Shell 1.12.1: a catalog install keeps the tarball's file modes

parseTar threw away each entry's mode, so installModule wrote every
downloaded file 0644 — including the prebuilt helpers modules ship under
bin/. spawn() then failed with EACCES: on the 1.10.2 rollout (AB,
2026-10-04) that silently killed both LTC capture (Timers 1.7.0) and SPL
metering (0.5.0) until the binaries were chmod +x'd by hand.
carryOverExecutables (1.9.2) never applied: it only carries files the
download does NOT include, and these downloads include the helpers.

parseTar now keeps the header's mode field (permission bits only,
defaulting to 0644) and both writers pass it on: installModule's staging
and the archive-based shell update. The unbundled-shell test's mock repo
now ships an executable bin/ helper and asserts it still spawns-worthy
after a catalog install, alongside the existing carry-over check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-07 11:45:07 -04:00
co-authored by Claude Fable 5
parent c9e03398a6
commit 34a6def839
3 changed files with 41 additions and 13 deletions
+18 -11
View File
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break; // end-of-archive blocks
// The mode matters: modules ship prebuilt helpers under bin/, and writing
// one out without its execute bits leaves spawn() with EACCES and the
// feature silently dead. Permission bits only — setuid/sticky from an
// archive are not wanted here.
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
}
const name = pendingName || shortName;
pendingName = null;
if (type === '0') files.set(name, Buffer.from(data));
if (type === '0') files.set(name, { data: Buffer.from(data), mode });
}
return { files, comment };
}
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
}
const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) {
for (const [p, entry] of files) {
const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
}
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
}
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
const job = (async () => {
try {
const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json'));
const pkg = safeJson(files.get('package.json')?.data);
const modules = new Map();
for (const [p, data] of files) {
for (const [p, file] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], data);
entry.files.set(m[2], file);
modules.set(m[1], entry);
}
for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json'));
const man = safeJson(entry.files.get('module.json')?.data);
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id);
continue;
@@ -1322,12 +1327,14 @@ async function installModule(id) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set();
for (const [rel, data] of remoteEntry.files) {
for (const [rel, { data, mode }] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
shipped.add(rel);
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
// The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
fs.writeFileSync(target, data, { mode });
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in
@@ -1439,11 +1446,11 @@ async function updateShell() {
} else {
const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) {
for (const [rel, { data, mode }] of remote.files) {
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
fs.writeFileSync(target, data, { mode });
}
const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {