From b786d577a2998c83fc5590247bd655afd105d73d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:46:56 -0700 Subject: [PATCH 01/73] cmux-tui: document the npx ENOTEMPTY cache failure and hint the fix in the launcher npx cmux@latest aborts inside npm with ENOTEMPTY when the ~/.npm/_npx cache holds an older cmux and npm upgrades the per-platform binary package in place. The abort happens before bin/cmux.js runs, so no launcher code can prevent it. Add a troubleshooting section with the cache-clear workaround, link it from the README, and extend the launcher's missing-platform-package error with the same hint since a stale npx cache also produces that state. --- cmux-tui/README.md | 2 ++ cmux-tui/dist/npm/cmux/bin/cmux.js | 4 +++- cmux-tui/docs/getting-started.md | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 8b21e0acf847..4c8833b8e57e 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -90,6 +90,8 @@ ssh -T dev@buildbox cmux relay --session agents The Unix-only `machine-agent` shares an existing local session through one outbound SSH registration with cmux.cloud. It prints a one-time pairing code and opens no listener. The final command is a low-level raw JSON-lines diagnostic. Use the machine rail or `cmux ssh` for the managed remote lifecycle. +If `npx cmux@latest` fails with an npm `ENOTEMPTY: directory not empty, rename` error, the npx package cache is stale; see [Troubleshooting npx installs](docs/getting-started.md#troubleshooting-npx-installs). + Use `--term ` to set `TERM` for child PTYs. Without it, children get `xterm-256color`; `CMUX_TUI_TERM` can override the terminal runtime default, with `CMUX_MUX_TERM` retained as a legacy fallback. ## Browser ownership diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index f8a0c63dfb51..abee99008e9e 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -35,7 +35,9 @@ try { } catch { console.error( `cmux: platform package ${pkg} is not installed. Reinstall cmux, ` + - `or set npm to install optional dependencies (--include=optional).` + `or set npm to install optional dependencies (--include=optional). ` + + `Under npx, a stale cache can also cause this (or an ENOTEMPTY rename ` + + `error during install): run \`rm -rf ~/.npm/_npx\` and retry.` ); process.exit(1); } diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index ff15e16f7c76..acf4debf0297 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -116,6 +116,26 @@ npx cmux machine-agent --session agents Run this command from an interactive terminal with `/dev/tty`; the agent fails closed without a controlling terminal, including on reconnects. The first registration prints the one-time code used by `+ ssh host` on cmux.cloud. +## Troubleshooting npx installs + +`npx cmux@latest` can fail inside npm before cmux runs: + +```text +npm error code ENOTEMPTY +npm error syscall rename +npm error path ~/.npm/_npx//node_modules/cmux-tui-darwin-arm64 +npm error ENOTEMPTY: directory not empty, rename ... +``` + +This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits packages with per-platform binary dependencies (cmux ships `cmux-tui-` optional dependencies) most often. Clear the npx cache and rerun: + +```bash +rm -rf ~/.npm/_npx +npx cmux@latest +``` + +A global install avoids the npx cache entirely: `npm install -g cmux`, then run `cmux` and upgrade with `npm install -g cmux@latest`. + ## Sessions and sockets The default socket path is: From 0477df2d7b9ee5b02bd1ffd929ab2c945082510c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:08:13 -0700 Subject: [PATCH 02/73] cmux-tui: runtime-download npm launcher and cmux update, immune to the npx ENOTEMPTY bug The cmux npm launcher no longer declares per-platform optionalDependencies. The shim downloads the cmux-tui- tarball from the npm registry on first run, verifies the registry's sha512 dist.integrity, extracts bin/ with a minimal ustar/pax reader, and caches binaries per version in a launcher cache outside npm's control. New shim-owned command: cmux update [--check] moves to the latest published version by talking only to the registry and the launcher cache, so routine upgrades never reify the npx cache and cannot hit npm's long-standing ENOTEMPTY rename bug (https://github.com/npm/cli/issues/4622). Resolve order: CMUX_TUI_BIN, an installed platform package whose version matches exactly (keeps offline installs and the CI offline smoke working), the launcher cache, registry download, then fallback to any installed or cached binary with a warning. The remote SSH bootstrap (npx --yes cmux@ install-self) goes through the same shim and gains the same immunity. Packaging: package_npm.py stops injecting launcher optionalDependencies and refuses a template that declares any; package_contract.py now requires the launcher to have no dependency fields. cmux-relay's launcher is unchanged. Verified locally against the real registry: first-run download and exec of 0.11.0, cached rerun, update --check and update 0.10.3 -> 0.11.0 with state and prune, offline resolution via an installed platform package with an unreachable registry, failure message with no fallback, and a stub-binary package_npm + validate_npm_tree run. --- cmux-tui/README.md | 2 +- cmux-tui/dist/npm/cmux/bin/cmux.js | 466 ++++++++++++++++++++-- cmux-tui/dist/npm/cmux/package.json | 9 +- cmux-tui/dist/scripts/package_contract.py | 16 +- cmux-tui/dist/scripts/package_npm.py | 9 +- cmux-tui/docs/getting-started.md | 23 +- 6 files changed, 475 insertions(+), 50 deletions(-) diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 4c8833b8e57e..438b0925d282 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -90,7 +90,7 @@ ssh -T dev@buildbox cmux relay --session agents The Unix-only `machine-agent` shares an existing local session through one outbound SSH registration with cmux.cloud. It prints a one-time pairing code and opens no listener. The final command is a low-level raw JSON-lines diagnostic. Use the machine rail or `cmux ssh` for the managed remote lifecycle. -If `npx cmux@latest` fails with an npm `ENOTEMPTY: directory not empty, rename` error, the npx package cache is stale; see [Troubleshooting npx installs](docs/getting-started.md#troubleshooting-npx-installs). +Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without touching npm's caches. If `npx cmux@latest` fails with an npm `ENOTEMPTY: directory not empty, rename` error, the npx package cache is stale; see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Use `--term ` to set `TERM` for child PTYs. Without it, children get `xterm-256color`; `CMUX_TUI_TERM` can override the terminal runtime default, with `CMUX_MUX_TERM` retained as a legacy fallback. diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index abee99008e9e..bd904ae403c2 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -2,54 +2,460 @@ "use strict"; // Launcher for `npx cmux` / a global `cmux` install. The actual TUI is a -// prebuilt Rust binary shipped in a per-platform optional dependency -// (cmux-tui-); npm installs only the one matching os+cpu. This shim -// resolves that binary and execs it, forwarding argv, stdio, exit code, and -// signals so cmux behaves exactly like the native binary. +// prebuilt Rust binary published as per-platform npm packages +// (cmux-tui-). This shim resolves that binary and execs it, +// forwarding argv, stdio, exit code, and signals. +// +// The platform packages are NOT optionalDependencies. npm's npx cache has a +// long-standing ENOTEMPTY reify bug that fires when `npx cmux@latest` +// upgrades a cached tree containing per-platform binary packages +// (https://github.com/npm/cli/issues/4622). Instead, the shim downloads the +// platform package tarball from the npm registry on first run, verifies the +// registry's sha512 integrity for it, and extracts the binaries into a +// versioned launcher cache outside npm's control. `cmux update` moves that +// cache to the latest published version without npm ever reifying anything, +// so routine upgrades cannot hit the npx cache bug. +// +// Resolve order for the binary: +// 1. CMUX_TUI_BIN (explicit override, development and debugging) +// 2. an installed platform package (require.resolve) whose version matches +// the wanted version exactly -- this keeps offline installs working: +// `npm install -g cmux cmux-tui-` never needs the network +// 3. the launcher cache entry for the wanted version +// 4. download the wanted version into the launcher cache +// 5. fall back to any installed platform package or newest cached version, +// with a warning, when the download fails +// +// Wanted version = max(shim's own package version, version recorded by +// `cmux update`), compared by semver so a nightly shim is not downgraded by +// an older stable `update` record. const { spawnSync } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const zlib = require("zlib"); const PACKAGE_BY_PLATFORM = { "darwin-arm64": "cmux-tui-darwin-arm64", "darwin-x64": "cmux-tui-darwin-x64", "linux-x64": "cmux-tui-linux-x64", "linux-arm64": "cmux-tui-linux-arm64", - "win32-x64": "cmux-tui-win32-x64", + // win32-x64 pending: ghostty vt headers fail bindgen under mingw clang. }; -const key = `${process.platform}-${process.arch}`; -const pkg = PACKAGE_BY_PLATFORM[key]; +const EXE = process.platform === "win32" ? ".exe" : ""; +const BIN_NAME = `cmux-tui${EXE}`; -if (!pkg) { - console.error( - `cmux: no prebuilt binary for ${key}. Supported: ${Object.keys(PACKAGE_BY_PLATFORM).join(", ")}.` - ); +function fail(message) { + console.error(`cmux: ${message}`); process.exit(1); } -const binName = process.platform === "win32" ? "cmux-tui.exe" : "cmux-tui"; +function platformPackage() { + const key = `${process.platform}-${process.arch}`; + const pkg = PACKAGE_BY_PLATFORM[key]; + if (!pkg) { + fail( + `no prebuilt binary for ${key}. Supported: ${Object.keys(PACKAGE_BY_PLATFORM).join(", ")}.` + ); + } + return pkg; +} + +function shimVersion() { + const version = require("../package.json").version; + if (process.env.CMUX_TUI_LAUNCHER_VERSION) { + return process.env.CMUX_TUI_LAUNCHER_VERSION; + } + return version; +} -let binPath; -try { - binPath = require.resolve(`${pkg}/bin/${binName}`); -} catch { - console.error( - `cmux: platform package ${pkg} is not installed. Reinstall cmux, ` + - `or set npm to install optional dependencies (--include=optional). ` + - `Under npx, a stale cache can also cause this (or an ENOTEMPTY rename ` + - `error during install): run \`rm -rf ~/.npm/_npx\` and retry.` +function isManagedPlaceholder(version) { + return version === "0.0.0-managed"; +} + +// Minimal semver comparison, enough for the version shapes this repo +// publishes (X.Y.Z, X.Y.Z-rc.N, X.Y.Z-nightly.YYYYMMDD.N). Returns +// negative/zero/positive like a comparator. Prerelease sorts before the +// release with the same triple. +function compareVersions(a, b) { + const parse = (v) => { + const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v); + if (!m) return null; + return { + nums: [Number(m[1]), Number(m[2]), Number(m[3])], + pre: m[4] ? m[4].split(".") : null, + }; + }; + const pa = parse(a); + const pb = parse(b); + if (!pa || !pb) return String(a).localeCompare(String(b)); + for (let i = 0; i < 3; i++) { + if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] - pb.nums[i]; + } + if (!pa.pre && !pb.pre) return 0; + if (!pa.pre) return 1; + if (!pb.pre) return -1; + for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { + const x = pa.pre[i]; + const y = pb.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xn = /^\d+$/.test(x); + const yn = /^\d+$/.test(y); + if (xn && yn) { + if (Number(x) !== Number(y)) return Number(x) - Number(y); + } else if (xn !== yn) { + return xn ? -1 : 1; + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +function cacheRoot() { + if (process.env.CMUX_TUI_LAUNCHER_CACHE) { + return process.env.CMUX_TUI_LAUNCHER_CACHE; + } + if (process.platform === "win32") { + const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + return path.join(base, "cmux-tui-launcher"); + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Caches", "cmux-tui-launcher"); + } + const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); + return path.join(base, "cmux-tui-launcher"); +} + +function statePath() { + return path.join(cacheRoot(), "state.json"); +} + +function readState() { + try { + const state = JSON.parse(fs.readFileSync(statePath(), "utf8")); + if (state && typeof state.version === "string") return state; + } catch {} + return null; +} + +function writeState(state) { + const target = statePath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.renameSync(tmp, target); +} + +function cachedBinDir(version) { + return path.join(cacheRoot(), "v", version, "bin"); +} + +function cachedBinary(version) { + const bin = path.join(cachedBinDir(version), BIN_NAME); + return fs.existsSync(bin) ? bin : null; +} + +function newestCachedVersion() { + let versions; + try { + versions = fs.readdirSync(path.join(cacheRoot(), "v")); + } catch { + return null; + } + versions = versions.filter((v) => cachedBinary(v)).sort(compareVersions); + return versions.length ? versions[versions.length - 1] : null; +} + +// Resolve an installed cmux-tui- package (global or local install). +// Returns { binPath, version } or null. +function installedPackage(pkg) { + try { + const packageJsonPath = require.resolve(`${pkg}/package.json`); + const binPath = path.join(path.dirname(packageJsonPath), "bin", BIN_NAME); + if (!fs.existsSync(binPath)) return null; + const version = require(packageJsonPath).version; + return { binPath, version }; + } catch { + return null; + } +} + +function registryBase() { + const raw = + process.env.CMUX_NPM_REGISTRY || + process.env.npm_config_registry || + "https://registry.npmjs.org"; + return raw.replace(/\/+$/, ""); +} + +async function fetchJson(url) { + const response = await fetch(url, { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +// Parse one pax extended header block into { path } overrides. +function parsePaxRecords(buffer) { + const records = {}; + let offset = 0; + while (offset < buffer.length) { + const space = buffer.indexOf(0x20, offset); + if (space === -1) break; + const length = Number(buffer.toString("utf8", offset, space)); + if (!Number.isFinite(length) || length <= 0) break; + const record = buffer.toString("utf8", space + 1, offset + length - 1); + const eq = record.indexOf("="); + if (eq !== -1) records[record.slice(0, eq)] = record.slice(eq + 1); + offset += length; + } + return records; +} + +// Minimal ustar/pax reader for npm registry tarballs: returns +// [{ name, data }] for regular files under package/bin/. +function extractBinEntries(tarBuffer) { + const entries = []; + let offset = 0; + let paxPath = null; + let gnuLongName = null; + while (offset + 512 <= tarBuffer.length) { + const header = tarBuffer.subarray(offset, offset + 512); + if (header.every((b) => b === 0)) break; + const rawName = header.toString("utf8", 0, 100).replace(/\0.*$/, ""); + const prefix = header.toString("utf8", 345, 500).replace(/\0.*$/, ""); + const size = parseInt(header.toString("utf8", 124, 136).replace(/\0.*$/, "").trim(), 8) || 0; + const typeflag = String.fromCharCode(header[156]); + const dataStart = offset + 512; + const data = tarBuffer.subarray(dataStart, dataStart + size); + offset = dataStart + Math.ceil(size / 512) * 512; + + if (typeflag === "x" || typeflag === "g") { + const records = parsePaxRecords(data); + if (typeflag === "x" && records.path) paxPath = records.path; + continue; + } + if (typeflag === "L") { + gnuLongName = data.toString("utf8").replace(/\0.*$/, ""); + continue; + } + let name = paxPath || gnuLongName || (prefix ? `${prefix}/${rawName}` : rawName); + paxPath = null; + gnuLongName = null; + if (typeflag !== "0" && typeflag !== "\0") continue; + if (!name.startsWith("package/bin/")) continue; + const base = name.slice("package/bin/".length); + // Flat bin/ payload only; refuse anything that could escape the dir. + if (!base || base.includes("/") || base.includes("\\") || base === "." || base === "..") { + continue; + } + entries.push({ name: base, data: Buffer.from(data) }); + } + return entries; +} + +function verifyIntegrity(buffer, integrity) { + const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity || ""); + if (!match) { + throw new Error(`registry did not provide a sha512 integrity value (got: ${integrity})`); + } + const actual = crypto.createHash("sha512").update(buffer).digest("base64"); + if (actual !== match[1]) { + throw new Error("tarball integrity check failed (sha512 mismatch)"); + } +} + +// Download pkg@version from the registry, verify integrity, extract bin/ +// into the launcher cache. Returns the binary path. +async function downloadVersion(pkg, version) { + const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`); + const tarballUrl = meta && meta.dist && meta.dist.tarball; + const integrity = meta && meta.dist && meta.dist.integrity; + if (!tarballUrl) { + throw new Error(`registry metadata for ${pkg}@${version} has no tarball URL`); + } + console.error(`cmux: downloading ${pkg}@${version}...`); + const response = await fetch(tarballUrl); + if (!response.ok) { + throw new Error(`GET ${tarballUrl} failed: ${response.status} ${response.statusText}`); + } + const tgz = Buffer.from(await response.arrayBuffer()); + verifyIntegrity(tgz, integrity); + const tar = zlib.gunzipSync(tgz); + const entries = extractBinEntries(tar); + if (!entries.some((entry) => entry.name === BIN_NAME)) { + throw new Error(`tarball for ${pkg}@${version} does not contain bin/${BIN_NAME}`); + } + + const finalDir = cachedBinDir(version); + const stagingDir = path.join( + cacheRoot(), + "tmp", + `${version}-${process.pid}-${Date.now().toString(36)}` ); - process.exit(1); + fs.mkdirSync(path.join(stagingDir, "bin"), { recursive: true }); + for (const entry of entries) { + fs.writeFileSync(path.join(stagingDir, "bin", entry.name), entry.data, { mode: 0o755 }); + } + fs.mkdirSync(path.dirname(finalDir), { recursive: true }); + try { + fs.renameSync(path.join(stagingDir, "bin"), finalDir); + } catch (error) { + // A concurrent launcher won the race; its extraction is byte-identical + // because both verified the same registry integrity. + if (!cachedBinary(version)) throw error; + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + const binPath = cachedBinary(version); + if (!binPath) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); + return binPath; } -const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" }); +function pruneCache(keepVersion) { + let versions; + const root = path.join(cacheRoot(), "v"); + try { + versions = fs.readdirSync(root); + } catch { + return; + } + for (const version of versions) { + if (version === keepVersion) continue; + // Best effort: on Windows a running binary cannot be deleted. + try { + fs.rmSync(path.join(root, version), { recursive: true, force: true }); + } catch {} + } + try { + fs.rmSync(path.join(cacheRoot(), "tmp"), { recursive: true, force: true }); + } catch {} +} -if (result.error) { - console.error(`cmux: failed to launch ${binPath}: ${result.error.message}`); - process.exit(1); +function wantedVersion(pkg) { + const pinned = shimVersion(); + const state = readState(); + if (isManagedPlaceholder(pinned)) { + if (state) return state.version; + const installed = installedPackage(pkg); + if (installed) return installed.version; + fail( + "this launcher is an unpublished development copy with no pinned " + + "version. Set CMUX_TUI_BIN to a built binary, or " + + "CMUX_TUI_LAUNCHER_VERSION to a published version." + ); + } + if (state && compareVersions(state.version, pinned) > 0) { + return state.version; + } + return pinned; +} + +async function resolveBinary(pkg) { + const override = process.env.CMUX_TUI_BIN; + if (override) { + if (!fs.existsSync(override)) fail(`CMUX_TUI_BIN does not exist: ${override}`); + return override; + } + + const wanted = wantedVersion(pkg); + const installed = installedPackage(pkg); + if (installed && installed.version === wanted) { + return installed.binPath; + } + const cached = cachedBinary(wanted); + if (cached) return cached; + + try { + return await downloadVersion(pkg, wanted); + } catch (error) { + if (installed) { + console.error( + `cmux: download of ${pkg}@${wanted} failed (${error.message}); ` + + `falling back to installed ${pkg}@${installed.version}.` + ); + return installed.binPath; + } + const newest = newestCachedVersion(); + if (newest) { + console.error( + `cmux: download of ${pkg}@${wanted} failed (${error.message}); ` + + `falling back to cached ${newest}.` + ); + return cachedBinary(newest); + } + fail( + `could not obtain the cmux-tui binary (${error.message}). ` + + `Check network access to ${registryBase()}, or install the platform ` + + `package directly: npm install -g ${pkg}` + ); + } } -if (result.signal) { - process.kill(process.pid, result.signal); - return; + +// `cmux update`: move the launcher to the latest published version without +// npm reifying anything, which is what makes upgrades immune to the npx +// cache ENOTEMPTY bug. The shim stays as-is; only the binary moves. +async function runUpdate(pkg, args) { + const checkOnly = args.includes("--check"); + const unknown = args.filter((a) => a !== "--check"); + if (unknown.length) { + fail(`unknown arguments for update: ${unknown.join(" ")}. Usage: cmux update [--check]`); + } + const current = wantedVersion(pkg); + const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); + const latest = latestMeta && latestMeta.version; + if (!latest) fail("could not determine the latest published cmux version"); + if (compareVersions(latest, current) <= 0) { + console.log(`cmux ${current} is up to date (latest is ${latest}).`); + return; + } + if (checkOnly) { + console.log(`cmux ${latest} is available (current: ${current}). Run: cmux update`); + return; + } + await downloadVersion(pkg, latest); + writeState({ + version: latest, + updatedAt: new Date().toISOString(), + }); + pruneCache(latest); + console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); +} + +async function main() { + const pkg = platformPackage(); + const args = process.argv.slice(2); + + // Owned by the shim, not the Rust CLI: `update` must work even when no + // binary is present, and must never go through npm. spec/cli.md has no + // top-level `update` verb, so nothing is shadowed. + if (args[0] === "update") { + try { + await runUpdate(pkg, args.slice(1)); + } catch (error) { + fail(`update failed: ${error.message}`); + } + return; + } + + const binPath = await resolveBinary(pkg); + const result = spawnSync(binPath, args, { stdio: "inherit" }); + if (result.error) { + fail(`failed to launch ${binPath}: ${result.error.message}`); + } + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + process.exit(result.status === null ? 1 : result.status); } -process.exit(result.status === null ? 1 : result.status); + +main().catch((error) => fail(error.message)); diff --git a/cmux-tui/dist/npm/cmux/package.json b/cmux-tui/dist/npm/cmux/package.json index 06e0efd8d41e..2da729879b81 100644 --- a/cmux-tui/dist/npm/cmux/package.json +++ b/cmux-tui/dist/npm/cmux/package.json @@ -17,12 +17,5 @@ }, "files": [ "bin/cmux.js" - ], - "optionalDependencies": { - "cmux-tui-darwin-arm64": "0.0.0-managed", - "cmux-tui-darwin-x64": "0.0.0-managed", - "cmux-tui-linux-x64": "0.0.0-managed", - "cmux-tui-linux-arm64": "0.0.0-managed", - "cmux-tui-win32-x64": "0.0.0-managed" - } + ] } diff --git a/cmux-tui/dist/scripts/package_contract.py b/cmux-tui/dist/scripts/package_contract.py index 6284d0331f80..1ea2a4374921 100644 --- a/cmux-tui/dist/scripts/package_contract.py +++ b/cmux-tui/dist/scripts/package_contract.py @@ -288,13 +288,15 @@ def validate_npm_tree( raise _error("cmux: files must contain only bin/cmux.js") if launcher_metadata.get("bin") != {"cmux": "bin/cmux.js"}: raise _error("cmux: bin mapping is incorrect") - expected_dependencies = {target.name: package_version for target in targets} - if launcher_metadata.get("optionalDependencies") != expected_dependencies: - raise _error( - "cmux: optionalDependencies mismatch: " - f"expected {expected_dependencies}, " - f"found {launcher_metadata.get('optionalDependencies')}" - ) + # The launcher must have no dependencies of any kind: the shim downloads + # the platform binary at runtime, and an empty npx cache tree is what + # keeps `npx cmux` upgrades away from npm's ENOTEMPTY reify bug. + for forbidden in ("dependencies", "optionalDependencies", "peerDependencies"): + if launcher_metadata.get(forbidden): + raise _error( + f"cmux: launcher must not declare {forbidden}, " + f"found {launcher_metadata.get(forbidden)}" + ) _require_executable(launcher_dir / "bin/cmux.js", "cmux launcher") relay_launcher_dir = packages_dir / "cmux-relay" diff --git a/cmux-tui/dist/scripts/package_npm.py b/cmux-tui/dist/scripts/package_npm.py index 0d4098f70714..7bf13f2eb516 100644 --- a/cmux-tui/dist/scripts/package_npm.py +++ b/cmux-tui/dist/scripts/package_npm.py @@ -187,9 +187,14 @@ def package_launcher(version: str, out_dir: Path, include_windows: bool) -> None package_json_path = launcher_dir / "package.json" package_json = json.loads(package_json_path.read_text()) package_json["version"] = version - targets = TARGETS if include_windows else [t for t in TARGETS if t["os"] != "win32"] + # The cmux launcher deliberately has NO optionalDependencies: the shim + # downloads the platform package at runtime with registry integrity + # verification. Keeping platform binary packages out of the npx cache + # tree is what protects `npx cmux` upgrades from npm's ENOTEMPTY reify + # bug (https://github.com/npm/cli/issues/4622). + if "optionalDependencies" in package_json or "dependencies" in package_json: + raise SystemExit("cmux launcher template must not declare dependencies") relay_targets = RELAY_TARGETS if include_windows else [t for t in RELAY_TARGETS if t["os"] != "win32"] - package_json["optionalDependencies"] = {target["package"]: version for target in targets} write_json(package_json_path, package_json) launcher_bin = launcher_dir / "bin" / "cmux.js" diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index acf4debf0297..a7e3e6264d1b 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -116,6 +116,25 @@ npx cmux machine-agent --session agents Run this command from an interactive terminal with `/dev/tty`; the agent fails closed without a controlling terminal, including on reconnects. The first registration prints the one-time code used by `+ ssh host` on cmux.cloud. +## Packaged installs and updates + +The `cmux` npm package is a small launcher with no dependencies. On first run it downloads the prebuilt `cmux-tui-` package for your platform from the npm registry, verifies the registry's sha512 integrity for the tarball, and caches the binaries in a versioned launcher cache (`~/Library/Caches/cmux-tui-launcher` on macOS, `$XDG_CACHE_HOME/cmux-tui-launcher` or `~/.cache/cmux-tui-launcher` on Linux). Later runs start instantly from that cache. + +Update with the launcher itself: + +```bash +npx cmux update # download the latest published version +npx cmux update --check # report whether a newer version exists +``` + +`cmux update` talks only to the npm registry and writes only the launcher cache. It never rewrites npm's `_npx` cache, so it cannot hit the npm `ENOTEMPTY` bug described below, and the updated binary is used on the next start. + +For offline or air-gapped machines, install the platform package next to the launcher; the launcher prefers a matching installed package and needs no network: + +```bash +npm install -g cmux cmux-tui-darwin-arm64 # pick your platform package +``` + ## Troubleshooting npx installs `npx cmux@latest` can fail inside npm before cmux runs: @@ -127,14 +146,14 @@ npm error path ~/.npm/_npx//node_modules/cmux-tui-darwin-arm64 npm error ENOTEMPTY: directory not empty, rename ... ``` -This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits packages with per-platform binary dependencies (cmux ships `cmux-tui-` optional dependencies) most often. Clear the npx cache and rerun: +This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Clear the npx cache and rerun: ```bash rm -rf ~/.npm/_npx npx cmux@latest ``` -A global install avoids the npx cache entirely: `npm install -g cmux`, then run `cmux` and upgrade with `npm install -g cmux@latest`. +Newer launchers keep binaries out of npm's cache entirely (see the previous section), and `npx cmux update` replaces `npx cmux@latest` as the routine upgrade path. ## Sessions and sockets From 2a4a9d915653a98bc8e124d7f80a8f102158620a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:39:39 -0700 Subject: [PATCH 03/73] fix(tui): harden runtime npm launcher errors and version inputs --- cmux-tui/dist/npm/cmux/bin/cmux.js | 53 +++++---- tests/test_tui_npm_launcher.py | 147 +++++++++++++++++++++++++ tests/test_tui_npm_package_artifact.py | 3 - tests/test_tui_package_contract.py | 3 - 4 files changed, 178 insertions(+), 28 deletions(-) create mode 100644 tests/test_tui_npm_launcher.py diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index bd904ae403c2..418a49386639 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -47,6 +47,8 @@ const PACKAGE_BY_PLATFORM = { const EXE = process.platform === "win32" ? ".exe" : ""; const BIN_NAME = `cmux-tui${EXE}`; +const PUBLISHED_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const MAX_TARBALL_BYTES = 256 * 1024 * 1024; function fail(message) { console.error(`cmux: ${message}`); @@ -72,6 +74,10 @@ function shimVersion() { return version; } +function validVersion(version) { + return typeof version === "string" && PUBLISHED_VERSION.test(version); +} + function isManagedPlaceholder(version) { return version === "0.0.0-managed"; } @@ -198,7 +204,7 @@ async function fetchJson(url) { headers: { accept: "application/json" }, }); if (!response.ok) { - throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`); + throw new Error("registry request failed"); } return response.json(); } @@ -280,19 +286,23 @@ async function downloadVersion(pkg, version) { const tarballUrl = meta && meta.dist && meta.dist.tarball; const integrity = meta && meta.dist && meta.dist.integrity; if (!tarballUrl) { - throw new Error(`registry metadata for ${pkg}@${version} has no tarball URL`); + throw new Error("registry metadata is incomplete"); } console.error(`cmux: downloading ${pkg}@${version}...`); const response = await fetch(tarballUrl); if (!response.ok) { - throw new Error(`GET ${tarballUrl} failed: ${response.status} ${response.statusText}`); + throw new Error("platform package download failed"); } - const tgz = Buffer.from(await response.arrayBuffer()); + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_TARBALL_BYTES) { + throw new Error("platform package is too large"); + } + const tgz = Buffer.from(arrayBuffer); verifyIntegrity(tgz, integrity); const tar = zlib.gunzipSync(tgz); const entries = extractBinEntries(tar); if (!entries.some((entry) => entry.name === BIN_NAME)) { - throw new Error(`tarball for ${pkg}@${version} does not contain bin/${BIN_NAME}`); + throw new Error("platform package does not contain the native binary"); } const finalDir = cachedBinDir(version); @@ -344,17 +354,19 @@ function wantedVersion(pkg) { const pinned = shimVersion(); const state = readState(); if (isManagedPlaceholder(pinned)) { - if (state) return state.version; + if (state && validVersion(state.version)) return state.version; const installed = installedPackage(pkg); - if (installed) return installed.version; + if (installed && validVersion(installed.version)) return installed.version; fail( - "this launcher is an unpublished development copy with no pinned " + - "version. Set CMUX_TUI_BIN to a built binary, or " + - "CMUX_TUI_LAUNCHER_VERSION to a published version." + "this launcher is an unpublished development copy without a pinned " + + "binary. Set a development binary override or install a published release." ); } + if (!validVersion(pinned)) { + fail("this launcher has an invalid release version"); + } if (state && compareVersions(state.version, pinned) > 0) { - return state.version; + return validVersion(state.version) ? state.version : pinned; } return pinned; } @@ -379,23 +391,20 @@ async function resolveBinary(pkg) { } catch (error) { if (installed) { console.error( - `cmux: download of ${pkg}@${wanted} failed (${error.message}); ` + - `falling back to installed ${pkg}@${installed.version}.` + `cmux: platform download failed; using the installed binary instead.` ); return installed.binPath; } const newest = newestCachedVersion(); if (newest) { console.error( - `cmux: download of ${pkg}@${wanted} failed (${error.message}); ` + - `falling back to cached ${newest}.` + `cmux: platform download failed; using a cached binary instead.` ); return cachedBinary(newest); } fail( - `could not obtain the cmux-tui binary (${error.message}). ` + - `Check network access to ${registryBase()}, or install the platform ` + - `package directly: npm install -g ${pkg}` + "could not obtain the native binary. Check network access or install " + + "the matching platform package directly." ); } } @@ -407,12 +416,12 @@ async function runUpdate(pkg, args) { const checkOnly = args.includes("--check"); const unknown = args.filter((a) => a !== "--check"); if (unknown.length) { - fail(`unknown arguments for update: ${unknown.join(" ")}. Usage: cmux update [--check]`); + fail("invalid update arguments. Usage: cmux update [--check]"); } const current = wantedVersion(pkg); const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); const latest = latestMeta && latestMeta.version; - if (!latest) fail("could not determine the latest published cmux version"); + if (!validVersion(latest)) fail("could not determine the latest published release"); if (compareVersions(latest, current) <= 0) { console.log(`cmux ${current} is up to date (latest is ${latest}).`); return; @@ -449,7 +458,7 @@ async function main() { const binPath = await resolveBinary(pkg); const result = spawnSync(binPath, args, { stdio: "inherit" }); if (result.error) { - fail(`failed to launch ${binPath}: ${result.error.message}`); + fail("failed to launch the native binary"); } if (result.signal) { process.kill(process.pid, result.signal); @@ -458,4 +467,4 @@ async function main() { process.exit(result.status === null ? 1 : result.status); } -main().catch((error) => fail(error.message)); +main().catch(() => fail("launcher failed before starting the native binary")); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py new file mode 100644 index 000000000000..688e85ddf956 --- /dev/null +++ b/tests/test_tui_npm_launcher.py @@ -0,0 +1,147 @@ +"""Behavior tests for the dependency-free npm launcher.""" + +from __future__ import annotations + +import base64 +import gzip +import hashlib +import http.server +import io +import json +import os +import stat +import subprocess +import tarfile +import threading +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LAUNCHER = ROOT / "cmux-tui/dist/npm/cmux/bin/cmux.js" + + +def make_tarball() -> bytes: + payload = b"#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n" + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode="w") as archive: + info = tarfile.TarInfo("package/bin/cmux-tui") + info.mode = 0o755 + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return gzip.compress(tar_buffer.getvalue()) + + +class RegistryHandler(http.server.BaseHTTPRequestHandler): + tarball = make_tarball() + metadata_requests = 0 + tarball_requests = 0 + status = 200 + + def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler + if self.path.endswith(( + "/cmux-tui-darwin-arm64/1.2.3", + "/cmux-tui-darwin-x64/1.2.3", + "/cmux-tui-linux-arm64/1.2.3", + "/cmux-tui-linux-x64/1.2.3", + )): + type(self).metadata_requests += 1 + body = json.dumps( + { + "dist": { + "tarball": f"http://127.0.0.1:{self.server.server_port}/tarball.tgz", + "integrity": "sha512-" + + base64.b64encode(hashlib.sha512(self.tarball).digest()).decode(), + } + } + ).encode() + elif self.path == "/tarball.tgz": + type(self).tarball_requests += 1 + body = self.tarball + else: + self.send_error(404) + return + if self.status != 200: + self.send_error(self.status) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args: object) -> None: + return + + +def run_launcher( + launcher: Path, + cache: Path, + registry: str, + *args: str, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": registry, + "NO_COLOR": "1", + } + ) + return subprocess.run( + ["node", str(launcher), *args], + check=False, + capture_output=True, + text=True, + env=env, + ) + + +def write_launcher(tmp_path: Path) -> Path: + package = tmp_path / "package" + (package / "bin").mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({"name": "cmux", "version": "1.2.3"}) + "\n" + ) + launcher = package / "bin/cmux.js" + launcher.write_bytes(LAUNCHER.read_bytes()) + launcher.chmod(0o755) + return launcher + + +def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, str]: + RegistryHandler.metadata_requests = 0 + RegistryHandler.tarball_requests = 0 + RegistryHandler.status = 200 + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RegistryHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, f"http://127.0.0.1:{server.server_port}" + + +def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> None: + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + try: + first = run_launcher(launcher, cache, registry, "--version") + second = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join(timeout=5) + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert first.stdout == second.stdout == "fake cmux-tui 1.2.3\n" + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 + cached = cache / "v/1.2.3/bin/cmux-tui" + assert cached.is_file() + assert cached.stat().st_mode & stat.S_IXUSR + + +def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: + launcher = write_launcher(tmp_path) + result = run_launcher(launcher, tmp_path / "cache", "http://127.0.0.1:1") + assert result.returncode != 0 + assert "could not obtain the native binary" in result.stderr + assert "127.0.0.1" not in result.stderr + assert "CMUX_" not in result.stderr diff --git a/tests/test_tui_npm_package_artifact.py b/tests/test_tui_npm_package_artifact.py index 8249e3ec9474..329e6ca66bd3 100644 --- a/tests/test_tui_npm_package_artifact.py +++ b/tests/test_tui_npm_package_artifact.py @@ -114,9 +114,6 @@ def make_package_fixture(packages: Path) -> None: "version": VERSION, "bin": {"cmux": "bin/cmux.js"}, "files": ["bin/cmux.js"], - "optionalDependencies": { - name: VERSION for name in TARGETS - }, } ) + "\n" diff --git a/tests/test_tui_package_contract.py b/tests/test_tui_package_contract.py index 314a65555645..7ad58ef9ddf3 100644 --- a/tests/test_tui_package_contract.py +++ b/tests/test_tui_package_contract.py @@ -195,9 +195,6 @@ def make_npm_packages(root: Path) -> None: "version": VERSION, "bin": {"cmux": "bin/cmux.js"}, "files": ["bin/cmux.js"], - "optionalDependencies": { - name: VERSION for name in NPM_TARGETS - }, } ) + "\n" From 4736ddd4ec30df7f4daf46e9a18bbfe0c9e5a0c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:43:50 -0700 Subject: [PATCH 04/73] docs(tui): clarify npm launcher update and cache recovery --- cmux-tui/README.ja.md | 22 ++++++++++++++++++ cmux-tui/README.md | 2 +- cmux-tui/docs/getting-started.ja.md | 36 +++++++++++++++++++++++++++++ cmux-tui/docs/getting-started.md | 18 +++++++++++---- 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 cmux-tui/README.ja.md create mode 100644 cmux-tui/docs/getting-started.ja.md diff --git a/cmux-tui/README.ja.md b/cmux-tui/README.ja.md new file mode 100644 index 000000000000..fec79ef42333 --- /dev/null +++ b/cmux-tui/README.ja.md @@ -0,0 +1,22 @@ +# cmux-tui + +tmux 風のターミナル TUI です。詳細な英語ドキュメントは +[README.md](README.md) を参照してください。 + +## npm パッケージのインストールと更新 + +`cmux` npm パッケージは依存関係を持たない小さなランチャーです。初回起動時に +現在のプラットフォーム用の `cmux-tui-` パッケージを npm レジストリから +ダウンロードし、sha512 整合性を確認してランチャー専用キャッシュに保存します。 + +```bash +npx cmux update +npx cmux update --check +``` + +`cmux update` はプラットフォーム用バイナリの通常更新に使います。npm ランチャー自体を +更新する場合は `npx cmux@latest` を使います。`npx` は cmux の起動前に npm の `_npx` +キャッシュへアクセスすることがあり、古いキャッシュでは `ENOTEMPTY` が発生する場合が +あります。詳しい復旧手順は +[パッケージのインストールと更新](docs/getting-started.ja.md) を参照してください。 + diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 438b0925d282..8c75107a7ee5 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -90,7 +90,7 @@ ssh -T dev@buildbox cmux relay --session agents The Unix-only `machine-agent` shares an existing local session through one outbound SSH registration with cmux.cloud. It prints a one-time pairing code and opens no listener. The final command is a low-level raw JSON-lines diagnostic. Use the machine rail or `cmux ssh` for the managed remote lifecycle. -Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without touching npm's caches. If `npx cmux@latest` fails with an npm `ENOTEMPTY: directory not empty, rename` error, the npx package cache is stale; see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). +Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without rewriting npm's caches. `npx` can still touch, or fail while touching, npm's `_npx` cache before cmux starts. Use `npx cmux update` for routine platform-binary upgrades and `npx cmux@latest` when updating the npm launcher. If the latter fails with `ENOTEMPTY: directory not empty, rename`, see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Japanese: [npm パッケージ](README.ja.md). Use `--term ` to set `TERM` for child PTYs. Without it, children get `xterm-256color`; `CMUX_TUI_TERM` can override the terminal runtime default, with `CMUX_MUX_TERM` retained as a legacy fallback. diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md new file mode 100644 index 000000000000..15b672666dcc --- /dev/null +++ b/cmux-tui/docs/getting-started.ja.md @@ -0,0 +1,36 @@ +# cmux-tui の開始 + +英語版の全ガイドは [getting-started.md](getting-started.md) にあります。 + +## パッケージのインストールと更新 + +`cmux` npm パッケージは依存関係を持たないランチャーです。初回起動時にプラット +フォーム用バイナリを取得し、レジストリの sha512 整合性を確認して、macOS では +`~/Library/Caches/cmux-tui-launcher`、Linux では `$XDG_CACHE_HOME/cmux-tui-launcher` +(未設定時は `~/.cache/cmux-tui-launcher`)にバージョン別で保存します。 + +```bash +npx cmux update # 最新のプラットフォーム用バイナリを取得 +npx cmux update --check # 更新の有無だけを確認 +``` + +`cmux update` はプラットフォーム用バイナリの通常更新に使います。npm ランチャー自体を +更新する場合は `npx cmux@latest` を使います。前者は npm の `_npx` キャッシュを書き換え +ませんが、後者は cmux 起動前にそのキャッシュへアクセスし、古い状態では +`ENOTEMPTY: directory not empty, rename` で失敗することがあります。 + +レジストリに接続できない場合は、ランチャーと同じバージョンのプラットフォーム用 +パッケージをローカルの tarball からインストールするか、npm キャッシュへ事前に保存して +ください。 + +## npx の ENOTEMPTY エラー + +`npx cmux@latest` が npm の処理中に失敗した場合は、npm のキャッシュ場所を確認して +`_npx` だけを削除します。 + +```bash +npm_cache="$(npm config get cache)" +rm -rf "$npm_cache/_npx" +npx cmux@latest +``` + diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index a7e3e6264d1b..8b20828b57b1 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -118,6 +118,8 @@ Run this command from an interactive terminal with `/dev/tty`; the agent fails c ## Packaged installs and updates +Japanese: [パッケージのインストールと更新](getting-started.ja.md) + The `cmux` npm package is a small launcher with no dependencies. On first run it downloads the prebuilt `cmux-tui-` package for your platform from the npm registry, verifies the registry's sha512 integrity for the tarball, and caches the binaries in a versioned launcher cache (`~/Library/Caches/cmux-tui-launcher` on macOS, `$XDG_CACHE_HOME/cmux-tui-launcher` or `~/.cache/cmux-tui-launcher` on Linux). Later runs start instantly from that cache. Update with the launcher itself: @@ -127,14 +129,19 @@ npx cmux update # download the latest published version npx cmux update --check # report whether a newer version exists ``` -`cmux update` talks only to the npm registry and writes only the launcher cache. It never rewrites npm's `_npx` cache, so it cannot hit the npm `ENOTEMPTY` bug described below, and the updated binary is used on the next start. +`cmux update` talks only to the npm registry and writes only the launcher cache. It does not rewrite npm's `_npx` cache, but `npx` can still touch that cache, or fail before cmux starts, while resolving the launcher. Use `cmux update` for routine platform-binary updates. Use `npx cmux@latest` when you need to update the npm launcher itself. For offline or air-gapped machines, install the platform package next to the launcher; the launcher prefers a matching installed package and needs no network: ```bash -npm install -g cmux cmux-tui-darwin-arm64 # pick your platform package +npm install -g cmux@0.11.0 cmux-tui-darwin-arm64@0.11.0 # pick your platform package and version ``` +For a machine without registry access, download both matching tarballs first +and install their local paths, or pre-populate npm's cache with those exact +versions before running the command. The launcher then uses the installed +platform package without resolving a different version. + ## Troubleshooting npx installs `npx cmux@latest` can fail inside npm before cmux runs: @@ -146,14 +153,15 @@ npm error path ~/.npm/_npx//node_modules/cmux-tui-darwin-arm64 npm error ENOTEMPTY: directory not empty, rename ... ``` -This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Clear the npx cache and rerun: +This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Derive npm's cache location, clear only its npx entries, and rerun: ```bash -rm -rf ~/.npm/_npx +npm_cache="$(npm config get cache)" +rm -rf "$npm_cache/_npx" npx cmux@latest ``` -Newer launchers keep binaries out of npm's cache entirely (see the previous section), and `npx cmux update` replaces `npx cmux@latest` as the routine upgrade path. +Newer launchers keep platform binaries out of npm's cache entirely (see the previous section). `npx cmux update` is the routine platform-binary upgrade path; `npx cmux@latest` remains the npm-launcher upgrade path. ## Sessions and sockets From e08716d0771515b418f24fd2d3e4115b1b1ad543 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:49:39 -0700 Subject: [PATCH 05/73] fix(tui): make npm cache updates bounded and version safe --- cmux-tui/dist/npm/cmux/bin/cmux.js | 58 +++++------------------------- tests/test_tui_npm_launcher.py | 18 ++++++++++ 2 files changed, 26 insertions(+), 50 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 418a49386639..3b2484f61996 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -23,8 +23,7 @@ // `npm install -g cmux cmux-tui-` never needs the network // 3. the launcher cache entry for the wanted version // 4. download the wanted version into the launcher cache -// 5. fall back to any installed platform package or newest cached version, -// with a warning, when the download fails +// 5. fail closed when the requested version cannot be obtained // // Wanted version = max(shim's own package version, version recorded by // `cmux update`), compared by semver so a nightly shim is not downgraded by @@ -42,13 +41,14 @@ const PACKAGE_BY_PLATFORM = { "darwin-x64": "cmux-tui-darwin-x64", "linux-x64": "cmux-tui-linux-x64", "linux-arm64": "cmux-tui-linux-arm64", - // win32-x64 pending: ghostty vt headers fail bindgen under mingw clang. + "win32-x64": "cmux-tui-win32-x64", }; const EXE = process.platform === "win32" ? ".exe" : ""; const BIN_NAME = `cmux-tui${EXE}`; const PUBLISHED_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const MAX_TARBALL_BYTES = 256 * 1024 * 1024; +const REGISTRY_TIMEOUT_MS = 30_000; function fail(message) { console.error(`cmux: ${message}`); @@ -166,17 +166,6 @@ function cachedBinary(version) { return fs.existsSync(bin) ? bin : null; } -function newestCachedVersion() { - let versions; - try { - versions = fs.readdirSync(path.join(cacheRoot(), "v")); - } catch { - return null; - } - versions = versions.filter((v) => cachedBinary(v)).sort(compareVersions); - return versions.length ? versions[versions.length - 1] : null; -} - // Resolve an installed cmux-tui- package (global or local install). // Returns { binPath, version } or null. function installedPackage(pkg) { @@ -202,6 +191,7 @@ function registryBase() { async function fetchJson(url) { const response = await fetch(url, { headers: { accept: "application/json" }, + signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), }); if (!response.ok) { throw new Error("registry request failed"); @@ -289,7 +279,9 @@ async function downloadVersion(pkg, version) { throw new Error("registry metadata is incomplete"); } console.error(`cmux: downloading ${pkg}@${version}...`); - const response = await fetch(tarballUrl); + const response = await fetch(tarballUrl, { + signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), + }); if (!response.ok) { throw new Error("platform package download failed"); } @@ -330,26 +322,6 @@ async function downloadVersion(pkg, version) { return binPath; } -function pruneCache(keepVersion) { - let versions; - const root = path.join(cacheRoot(), "v"); - try { - versions = fs.readdirSync(root); - } catch { - return; - } - for (const version of versions) { - if (version === keepVersion) continue; - // Best effort: on Windows a running binary cannot be deleted. - try { - fs.rmSync(path.join(root, version), { recursive: true, force: true }); - } catch {} - } - try { - fs.rmSync(path.join(cacheRoot(), "tmp"), { recursive: true, force: true }); - } catch {} -} - function wantedVersion(pkg) { const pinned = shimVersion(); const state = readState(); @@ -388,20 +360,7 @@ async function resolveBinary(pkg) { try { return await downloadVersion(pkg, wanted); - } catch (error) { - if (installed) { - console.error( - `cmux: platform download failed; using the installed binary instead.` - ); - return installed.binPath; - } - const newest = newestCachedVersion(); - if (newest) { - console.error( - `cmux: platform download failed; using a cached binary instead.` - ); - return cachedBinary(newest); - } + } catch { fail( "could not obtain the native binary. Check network access or install " + "the matching platform package directly." @@ -435,7 +394,6 @@ async function runUpdate(pkg, args) { version: latest, updatedAt: new Date().toISOString(), }); - pruneCache(latest); console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); } diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 688e85ddf956..37ace23ee95a 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -145,3 +145,21 @@ def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path assert "could not obtain the native binary" in result.stderr assert "127.0.0.1" not in result.stderr assert "CMUX_" not in result.stderr + + +def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> None: + launcher = write_launcher(tmp_path) + package = tmp_path / "node_modules/cmux-tui-darwin-arm64" + package.mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({"name": "cmux-tui-darwin-arm64", "version": "1.2.2"}) + "\n" + ) + binary = package / "bin/cmux-tui" + binary.parent.mkdir() + binary.write_text("#!/bin/sh\nprintf '%s\\n' 'wrong binary'\n") + binary.chmod(0o755) + + result = run_launcher(launcher, tmp_path / "cache", "http://127.0.0.1:1", "--version") + assert result.returncode != 0 + assert result.stdout == "" + assert "could not obtain the native binary" in result.stderr From 5cebd61f33e90fe8697c5f492762be76f8831ad4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:24:24 -0700 Subject: [PATCH 06/73] fix(tui): bound package extraction and cache eviction --- cmux-tui/dist/npm/cmux/bin/cmux.js | 131 ++++++++++++++++++++++++---- cmux-tui/docs/getting-started.ja.md | 23 +++-- cmux-tui/docs/getting-started.md | 22 ++++- 3 files changed, 149 insertions(+), 27 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 3b2484f61996..bd87e332db02 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -48,6 +48,7 @@ const EXE = process.platform === "win32" ? ".exe" : ""; const BIN_NAME = `cmux-tui${EXE}`; const PUBLISHED_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const MAX_TARBALL_BYTES = 256 * 1024 * 1024; +const MAX_METADATA_BYTES = 1024 * 1024; const REGISTRY_TIMEOUT_MS = 30_000; function fail(message) { @@ -166,6 +167,43 @@ function cachedBinary(version) { return fs.existsSync(bin) ? bin : null; } +function cacheLockPath() { + return path.join(cacheRoot(), ".update.lock"); +} + +function tryAcquireCacheLock() { + try { + fs.mkdirSync(cacheLockPath(), { recursive: false }); + return true; + } catch { + return false; + } +} + +function releaseCacheLock() { + try { + fs.rmdirSync(cacheLockPath()); + } catch {} +} + +function acquireVersionLease(version) { + const lease = path.join(cacheRoot(), "v", version, ".active"); + try { + fs.mkdirSync(path.dirname(lease), { recursive: true }); + fs.mkdirSync(lease, { recursive: false }); + return lease; + } catch { + return null; + } +} + +function releaseVersionLease(lease) { + if (!lease) return; + try { + fs.rmdirSync(lease); + } catch {} +} + // Resolve an installed cmux-tui- package (global or local install). // Returns { binPath, version } or null. function installedPackage(pkg) { @@ -196,7 +234,31 @@ async function fetchJson(url) { if (!response.ok) { throw new Error("registry request failed"); } - return response.json(); + return JSON.parse( + (await readResponseBody(response, MAX_METADATA_BYTES)).toString("utf8") + ); +} + +async function readResponseBody(response, limit) { + if (!response.body) throw new Error("registry response has no body"); + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel(); + throw new Error("registry response is too large"); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, total); } // Parse one pax extended header block into { path } overrides. @@ -285,13 +347,14 @@ async function downloadVersion(pkg, version) { if (!response.ok) { throw new Error("platform package download failed"); } - const arrayBuffer = await response.arrayBuffer(); - if (arrayBuffer.byteLength > MAX_TARBALL_BYTES) { - throw new Error("platform package is too large"); - } - const tgz = Buffer.from(arrayBuffer); + const tgz = await readResponseBody(response, MAX_TARBALL_BYTES); verifyIntegrity(tgz, integrity); - const tar = zlib.gunzipSync(tgz); + let tar; + try { + tar = zlib.gunzipSync(tgz, { maxOutputLength: MAX_TARBALL_BYTES }); + } catch { + throw new Error("platform package is invalid or too large"); + } const entries = extractBinEntries(tar); if (!entries.some((entry) => entry.name === BIN_NAME)) { throw new Error("platform package does not contain the native binary"); @@ -322,6 +385,24 @@ async function downloadVersion(pkg, version) { return binPath; } +function pruneCache(keepVersion) { + if (!tryAcquireCacheLock()) return; + const root = path.join(cacheRoot(), "v"); + try { + for (const version of fs.readdirSync(root)) { + if (version === keepVersion) continue; + if (fs.existsSync(path.join(root, version, ".active"))) continue; + try { + fs.rmSync(path.join(root, version), { recursive: true, force: true }); + } catch {} + } + } catch { + // Cache cleanup is best effort and must never hide a successful update. + } finally { + releaseCacheLock(); + } +} + function wantedVersion(pkg) { const pinned = shimVersion(); const state = readState(); @@ -343,14 +424,13 @@ function wantedVersion(pkg) { return pinned; } -async function resolveBinary(pkg) { +async function resolveBinary(pkg, wanted = wantedVersion(pkg)) { const override = process.env.CMUX_TUI_BIN; if (override) { if (!fs.existsSync(override)) fail(`CMUX_TUI_BIN does not exist: ${override}`); return override; } - const wanted = wantedVersion(pkg); const installed = installedPackage(pkg); if (installed && installed.version === wanted) { return installed.binPath; @@ -394,6 +474,7 @@ async function runUpdate(pkg, args) { version: latest, updatedAt: new Date().toISOString(), }); + pruneCache(latest); console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); } @@ -413,16 +494,30 @@ async function main() { return; } - const binPath = await resolveBinary(pkg); - const result = spawnSync(binPath, args, { stdio: "inherit" }); - if (result.error) { - fail("failed to launch the native binary"); - } - if (result.signal) { - process.kill(process.pid, result.signal); - return; + let lease = null; + let exitCode = 1; + try { + const wanted = wantedVersion(pkg); + const lockHeld = tryAcquireCacheLock(); + if (lockHeld) { + lease = acquireVersionLease(wanted); + releaseCacheLock(); + if (lease) process.once("exit", () => releaseVersionLease(lease)); + } + const binPath = await resolveBinary(pkg, wanted); + const result = spawnSync(binPath, args, { stdio: "inherit" }); + if (result.error) { + fail("failed to launch the native binary"); + } + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + exitCode = result.status === null ? 1 : result.status; + } finally { + releaseVersionLease(lease); } - process.exit(result.status === null ? 1 : result.status); + process.exit(exitCode); } main().catch(() => fail("launcher failed before starting the native binary")); diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 15b672666dcc..293d6667828c 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -19,9 +19,16 @@ npx cmux update --check # 更新の有無だけを確認 ませんが、後者は cmux 起動前にそのキャッシュへアクセスし、古い状態では `ENOTEMPTY: directory not empty, rename` で失敗することがあります。 -レジストリに接続できない場合は、ランチャーと同じバージョンのプラットフォーム用 -パッケージをローカルの tarball からインストールするか、npm キャッシュへ事前に保存して -ください。 +レジストリに接続できない場合は、ランチャーと同じバージョンの tarball をダウンロードし、 +ローカルパスからインストールしてください。ランチャーはインストール済みのプラット +フォーム用パッケージを使います。 + +```bash +npm install -g ./cmux-0.11.0.tgz ./cmux-tui-darwin-arm64-0.11.0.tgz +``` + +npm のダウンロードキャッシュはランチャーから読み取れません。別の方法として、ランチャー +キャッシュへ直接配置し、`CMUX_TUI_LAUNCHER_CACHE` でそのディレクトリを指定できます。 ## npx の ENOTEMPTY エラー @@ -30,7 +37,13 @@ npx cmux update --check # 更新の有無だけを確認 ```bash npm_cache="$(npm config get cache)" -rm -rf "$npm_cache/_npx" +target="$npm_cache/_npx" +printf '削除対象: %s\n' "$target" +case "$npm_cache" in + ""|/|"$HOME"|"$HOME/"*) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; +esac +read -r -p '続行する場合は yes と入力してください: ' confirm +[ "$confirm" = yes ] || exit 1 +rm -rf -- "$target" npx cmux@latest ``` - diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index 8b20828b57b1..a05d11549e2b 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -138,9 +138,16 @@ npm install -g cmux@0.11.0 cmux-tui-darwin-arm64@0.11.0 # pick your platform p ``` For a machine without registry access, download both matching tarballs first -and install their local paths, or pre-populate npm's cache with those exact -versions before running the command. The launcher then uses the installed -platform package without resolving a different version. +and install their local paths. The launcher then uses the installed platform +package without resolving a different version: + +```bash +npm install -g ./cmux-0.11.0.tgz ./cmux-tui-darwin-arm64-0.11.0.tgz +``` + +Alternatively, populate the launcher cache itself and set +`CMUX_TUI_LAUNCHER_CACHE` to that directory. npm's download cache is not read +by the launcher. ## Troubleshooting npx installs @@ -157,7 +164,14 @@ This is a long-standing npm bug in the `npx` package cache, not a cmux failure. ```bash npm_cache="$(npm config get cache)" -rm -rf "$npm_cache/_npx" +target="$npm_cache/_npx" +printf 'About to remove: %s\n' "$target" +case "$npm_cache" in + ""|/|"$HOME"|"$HOME/"*) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; +esac +read -r -p 'Type yes to continue: ' confirm +[ "$confirm" = yes ] || exit 1 +rm -rf -- "$target" npx cmux@latest ``` From dfae7e7c151a5eb841430a44cb4841ea4203fb0d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:25:51 -0700 Subject: [PATCH 07/73] fix(tui): track active launcher cache leases --- cmux-tui/dist/npm/cmux/bin/cmux.js | 22 ++++++++++++++++++++-- tests/test_tui_npm_launcher.py | 1 + 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index bd87e332db02..0980ecca26cd 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -191,6 +191,7 @@ function acquireVersionLease(version) { try { fs.mkdirSync(path.dirname(lease), { recursive: true }); fs.mkdirSync(lease, { recursive: false }); + fs.writeFileSync(path.join(lease, "pid"), `${process.pid}\n`); return lease; } catch { return null; @@ -200,10 +201,21 @@ function acquireVersionLease(version) { function releaseVersionLease(lease) { if (!lease) return; try { - fs.rmdirSync(lease); + fs.rmSync(lease, { recursive: true, force: true }); } catch {} } +function leaseIsActive(lease) { + try { + const pid = Number.parseInt(fs.readFileSync(path.join(lease, "pid"), "utf8"), 10); + if (!Number.isInteger(pid) || pid <= 0) return true; + process.kill(pid, 0); + return true; + } catch (error) { + return error && error.code !== "ESRCH"; + } +} + // Resolve an installed cmux-tui- package (global or local install). // Returns { binPath, version } or null. function installedPackage(pkg) { @@ -391,7 +403,13 @@ function pruneCache(keepVersion) { try { for (const version of fs.readdirSync(root)) { if (version === keepVersion) continue; - if (fs.existsSync(path.join(root, version, ".active"))) continue; + const lease = path.join(root, version, ".active"); + if (fs.existsSync(lease)) { + if (leaseIsActive(lease)) continue; + try { + fs.rmSync(lease, { recursive: true, force: true }); + } catch {} + } try { fs.rmSync(path.join(root, version), { recursive: true, force: true }); } catch {} diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 37ace23ee95a..2ddade4be2ed 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -136,6 +136,7 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No cached = cache / "v/1.2.3/bin/cmux-tui" assert cached.is_file() assert cached.stat().st_mode & stat.S_IXUSR + assert not (cache / "v/1.2.3/.active").exists() def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: From c51645be6254b58084ad92767d5f6f017ba74756 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:33:09 -0700 Subject: [PATCH 08/73] fix(tui): isolate npm cache by platform and preserve auth --- cmux-tui/dist/npm/cmux/bin/cmux.js | 41 +++++++++++++++++++++++------ cmux-tui/docs/getting-started.ja.md | 2 +- cmux-tui/docs/getting-started.md | 2 +- tests/test_tui_npm_launcher.py | 6 +++-- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 0980ecca26cd..dc0dfac7a7df 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -139,7 +139,11 @@ function cacheRoot() { } function statePath() { - return path.join(cacheRoot(), "state.json"); + return path.join(platformRoot(), "state.json"); +} + +function platformRoot() { + return path.join(cacheRoot(), `${process.platform}-${process.arch}`); } function readState() { @@ -159,7 +163,7 @@ function writeState(state) { } function cachedBinDir(version) { - return path.join(cacheRoot(), "v", version, "bin"); + return path.join(platformRoot(), "v", version, "bin"); } function cachedBinary(version) { @@ -168,7 +172,7 @@ function cachedBinary(version) { } function cacheLockPath() { - return path.join(cacheRoot(), ".update.lock"); + return path.join(platformRoot(), ".update.lock"); } function tryAcquireCacheLock() { @@ -187,7 +191,7 @@ function releaseCacheLock() { } function acquireVersionLease(version) { - const lease = path.join(cacheRoot(), "v", version, ".active"); + const lease = path.join(platformRoot(), "v", version, ".active"); try { fs.mkdirSync(path.dirname(lease), { recursive: true }); fs.mkdirSync(lease, { recursive: false }); @@ -238,9 +242,20 @@ function registryBase() { return raw.replace(/\/+$/, ""); } +function registryHeaders(url, accept) { + const headers = { accept }; + try { + const parsed = new URL(url); + const tokenKey = `npm_config_//${parsed.host}/:_authToken`; + const token = process.env[tokenKey] || process.env.npm_config_authToken; + if (token) headers.authorization = `Bearer ${token}`; + } catch {} + return headers; +} + async function fetchJson(url) { const response = await fetch(url, { - headers: { accept: "application/json" }, + headers: registryHeaders(url, "application/json"), signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), }); if (!response.ok) { @@ -354,6 +369,7 @@ async function downloadVersion(pkg, version) { } console.error(`cmux: downloading ${pkg}@${version}...`); const response = await fetch(tarballUrl, { + headers: registryHeaders(tarballUrl, "application/octet-stream"), signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), }); if (!response.ok) { @@ -374,7 +390,7 @@ async function downloadVersion(pkg, version) { const finalDir = cachedBinDir(version); const stagingDir = path.join( - cacheRoot(), + platformRoot(), "tmp", `${version}-${process.pid}-${Date.now().toString(36)}` ); @@ -385,10 +401,14 @@ async function downloadVersion(pkg, version) { fs.mkdirSync(path.dirname(finalDir), { recursive: true }); try { fs.renameSync(path.join(stagingDir, "bin"), finalDir); + fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); } catch (error) { // A concurrent launcher won the race; its extraction is byte-identical // because both verified the same registry integrity. if (!cachedBinary(version)) throw error; + try { + fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + } catch {} } finally { fs.rmSync(stagingDir, { recursive: true, force: true }); } @@ -399,10 +419,15 @@ async function downloadVersion(pkg, version) { function pruneCache(keepVersion) { if (!tryAcquireCacheLock()) return; - const root = path.join(cacheRoot(), "v"); + const root = path.join(platformRoot(), "v"); try { + const managed = fs + .readdirSync(root) + .filter((version) => fs.existsSync(path.join(root, version, "managed"))) + .sort(compareVersions); + const keep = new Set([keepVersion, ...managed.slice(-2)]); for (const version of fs.readdirSync(root)) { - if (version === keepVersion) continue; + if (keep.has(version)) continue; const lease = path.join(root, version, ".active"); if (fs.existsSync(lease)) { if (leaseIsActive(lease)) continue; diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 293d6667828c..5a3a4d54003d 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -40,7 +40,7 @@ npm_cache="$(npm config get cache)" target="$npm_cache/_npx" printf '削除対象: %s\n' "$target" case "$npm_cache" in - ""|/|"$HOME"|"$HOME/"*) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; + ""|/) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; esac read -r -p '続行する場合は yes と入力してください: ' confirm [ "$confirm" = yes ] || exit 1 diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index a05d11549e2b..bc1038e890f5 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -167,7 +167,7 @@ npm_cache="$(npm config get cache)" target="$npm_cache/_npx" printf 'About to remove: %s\n' "$target" case "$npm_cache" in - ""|/|"$HOME"|"$HOME/"*) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; + ""|/) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; esac read -r -p 'Type yes to continue: ' confirm [ "$confirm" = yes ] || exit 1 diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 2ddade4be2ed..547fd3dded7d 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -11,6 +11,7 @@ import os import stat import subprocess +import sys import tarfile import threading from pathlib import Path @@ -133,10 +134,11 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert first.stdout == second.stdout == "fake cmux-tui 1.2.3\n" assert RegistryHandler.metadata_requests == 1 assert RegistryHandler.tarball_requests == 1 - cached = cache / "v/1.2.3/bin/cmux-tui" + platform = "darwin-arm64" if sys.platform == "darwin" else "linux-x64" + cached = cache / platform / "v/1.2.3/bin/cmux-tui" assert cached.is_file() assert cached.stat().st_mode & stat.S_IXUSR - assert not (cache / "v/1.2.3/.active").exists() + assert not (cache / platform / "v/1.2.3/.active").exists() def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: From 1adf617b1be96f4a2ed4811dd171fd507a596dd5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:37:23 -0700 Subject: [PATCH 09/73] fix(tui): recover cache locks and scope npm auth --- cmux-tui/dist/npm/cmux/bin/cmux.js | 31 +++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index dc0dfac7a7df..59f75999ddc1 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -178,15 +178,38 @@ function cacheLockPath() { function tryAcquireCacheLock() { try { fs.mkdirSync(cacheLockPath(), { recursive: false }); + fs.writeFileSync(path.join(cacheLockPath(), "pid"), `${process.pid}\n`); return true; } catch { - return false; + try { + if (!fs.existsSync(path.join(cacheLockPath(), "pid"))) { + fs.rmSync(cacheLockPath(), { recursive: true, force: true }); + } + } catch {} + try { + const pid = Number.parseInt( + fs.readFileSync(path.join(cacheLockPath(), "pid"), "utf8"), + 10 + ); + process.kill(pid, 0); + return false; + } catch (error) { + if (!error || error.code !== "ESRCH") return false; + try { + fs.rmSync(cacheLockPath(), { recursive: true, force: true }); + fs.mkdirSync(cacheLockPath(), { recursive: false }); + fs.writeFileSync(path.join(cacheLockPath(), "pid"), `${process.pid}\n`); + return true; + } catch { + return false; + } + } } } function releaseCacheLock() { try { - fs.rmdirSync(cacheLockPath()); + fs.rmSync(cacheLockPath(), { recursive: true, force: true }); } catch {} } @@ -246,8 +269,10 @@ function registryHeaders(url, accept) { const headers = { accept }; try { const parsed = new URL(url); + const registry = new URL(registryBase()); + if (parsed.origin !== registry.origin) return headers; const tokenKey = `npm_config_//${parsed.host}/:_authToken`; - const token = process.env[tokenKey] || process.env.npm_config_authToken; + const token = process.env[tokenKey]; if (token) headers.authorization = `Bearer ${token}`; } catch {} return headers; From f58ba87776fef6fcbe1c6f778152db47629d4812 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:43:46 -0700 Subject: [PATCH 10/73] fix(tui): keep cached binaries leased during updates --- cmux-tui/dist/npm/cmux/bin/cmux.js | 62 ++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 59f75999ddc1..514645dd16d7 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -214,9 +214,13 @@ function releaseCacheLock() { } function acquireVersionLease(version) { - const lease = path.join(platformRoot(), "v", version, ".active"); + const leaseRoot = path.join(platformRoot(), "v", version, ".active"); + const lease = path.join( + leaseRoot, + `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ); try { - fs.mkdirSync(path.dirname(lease), { recursive: true }); + fs.mkdirSync(leaseRoot, { recursive: true }); fs.mkdirSync(lease, { recursive: false }); fs.writeFileSync(path.join(lease, "pid"), `${process.pid}\n`); return lease; @@ -229,6 +233,8 @@ function releaseVersionLease(lease) { if (!lease) return; try { fs.rmSync(lease, { recursive: true, force: true }); + const leaseRoot = path.dirname(lease); + if (path.basename(leaseRoot) === ".active") fs.rmdirSync(leaseRoot); } catch {} } @@ -243,6 +249,39 @@ function leaseIsActive(lease) { } } +function versionHasActiveLease(versionDir) { + const leaseRoot = path.join(versionDir, ".active"); + if (!fs.existsSync(leaseRoot)) return false; + try { + const entries = fs.readdirSync(leaseRoot, { withFileTypes: true }); + let active = false; + for (const entry of entries) { + const lease = path.join(leaseRoot, entry.name); + if (entry.isDirectory()) { + if (leaseIsActive(lease)) { + active = true; + } else { + fs.rmSync(lease, { recursive: true, force: true }); + } + } else if (entry.name === "pid") { + // Read leases written by older launchers, before leases became + // per-process directories. + active = leaseIsActive(leaseRoot); + } else { + // Unknown lease state is retained conservatively. + active = true; + } + } + if (!active && fs.readdirSync(leaseRoot).length === 0) { + fs.rmdirSync(leaseRoot); + } + return active; + } catch { + // Cleanup must never remove a version when lease state is unreadable. + return true; + } +} + // Resolve an installed cmux-tui- package (global or local install). // Returns { binPath, version } or null. function installedPackage(pkg) { @@ -453,13 +492,7 @@ function pruneCache(keepVersion) { const keep = new Set([keepVersion, ...managed.slice(-2)]); for (const version of fs.readdirSync(root)) { if (keep.has(version)) continue; - const lease = path.join(root, version, ".active"); - if (fs.existsSync(lease)) { - if (leaseIsActive(lease)) continue; - try { - fs.rmSync(lease, { recursive: true, force: true }); - } catch {} - } + if (versionHasActiveLease(path.join(root, version))) continue; try { fs.rmSync(path.join(root, version), { recursive: true, force: true }); } catch {} @@ -566,12 +599,11 @@ async function main() { let exitCode = 1; try { const wanted = wantedVersion(pkg); - const lockHeld = tryAcquireCacheLock(); - if (lockHeld) { - lease = acquireVersionLease(wanted); - releaseCacheLock(); - if (lease) process.once("exit", () => releaseVersionLease(lease)); - } + // Hold a per-process lease independently of the update lock. An update + // may already own that lock while pruning another version, and the + // binary must remain present through resolution and spawn. + lease = acquireVersionLease(wanted); + if (lease) process.once("exit", () => releaseVersionLease(lease)); const binPath = await resolveBinary(pkg, wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); if (result.error) { From 57eee3662a12c610368bf0dbfb933b732d15b56e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:52:48 -0700 Subject: [PATCH 11/73] test(tui): verify npm launcher in CI --- .github/workflows/ci.yml | 3 +++ .github/workflows/cmux-tui-sdks.yml | 3 +++ cmux-tui/dist/npm/cmux/bin/cmux.js | 17 ++++++++++++-- tests/test_tui_npm_launcher.py | 35 ++++++++++++++++++++++++++--- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c05bab6e95df..89102dd86635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,9 @@ jobs: - name: Validate TUI package artifact contract run: python3 tests/test_tui_package_contract.py + - name: Validate TUI npm launcher behavior + run: python3 tests/test_tui_npm_launcher.py + - name: Validate Claude launch environment policy generation run: python3 scripts/generate-claude-launch-environment-policy.py --check diff --git a/.github/workflows/cmux-tui-sdks.yml b/.github/workflows/cmux-tui-sdks.yml index 8101b3faaf1b..efcdb63df300 100644 --- a/.github/workflows/cmux-tui-sdks.yml +++ b/.github/workflows/cmux-tui-sdks.yml @@ -136,6 +136,9 @@ jobs: - name: Test TUI npm artifact transfer contract run: python3 tests/test_tui_npm_package_artifact.py + - name: Test TUI npm launcher behavior + run: python3 tests/test_tui_npm_launcher.py + - name: Check package versions run: python3 cmux-tui/bindings/check-versions.py --published-only diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 514645dd16d7..202265d908fc 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -468,8 +468,21 @@ async function downloadVersion(pkg, version) { fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); } catch (error) { // A concurrent launcher won the race; its extraction is byte-identical - // because both verified the same registry integrity. - if (!cachedBinary(version)) throw error; + // only when the existing binary matches the entry we verified above. + const expected = entries.find((entry) => entry.name === BIN_NAME); + const existing = cachedBinary(version); + if (!existing || !expected) throw error; + const existingDigest = crypto + .createHash("sha512") + .update(fs.readFileSync(existing)) + .digest(); + const expectedDigest = crypto.createHash("sha512").update(expected.data).digest(); + if ( + existingDigest.length !== expectedDigest.length || + !crypto.timingSafeEqual(existingDigest, expectedDigest) + ) { + throw new Error("cached platform binary failed integrity verification"); + } try { fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); } catch {} diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 547fd3dded7d..41f66d0c881b 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -9,10 +9,12 @@ import io import json import os +import platform import stat import subprocess import sys import tarfile +import tempfile import threading from pathlib import Path @@ -120,6 +122,8 @@ def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> None: + if sys.platform == "win32": + return launcher = write_launcher(tmp_path) cache = tmp_path / "cache" server, thread, registry = start_registry() @@ -134,14 +138,23 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert first.stdout == second.stdout == "fake cmux-tui 1.2.3\n" assert RegistryHandler.metadata_requests == 1 assert RegistryHandler.tarball_requests == 1 - platform = "darwin-arm64" if sys.platform == "darwin" else "linux-x64" - cached = cache / platform / "v/1.2.3/bin/cmux-tui" + arch = { + "aarch64": "arm64", + "arm64": "arm64", + "amd64": "x64", + "x86_64": "x64", + }.get(platform.machine().lower()) + assert arch is not None, platform.machine() + platform_key = f"{sys.platform}-{arch}" + cached = cache / platform_key / "v/1.2.3/bin/cmux-tui" assert cached.is_file() assert cached.stat().st_mode & stat.S_IXUSR - assert not (cache / platform / "v/1.2.3/.active").exists() + assert not (cache / platform_key / "v/1.2.3/.active").exists() def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: + if sys.platform == "win32": + return launcher = write_launcher(tmp_path) result = run_launcher(launcher, tmp_path / "cache", "http://127.0.0.1:1") assert result.returncode != 0 @@ -151,6 +164,8 @@ def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> None: + if sys.platform == "win32": + return launcher = write_launcher(tmp_path) package = tmp_path / "node_modules/cmux-tui-darwin-arm64" package.mkdir(parents=True) @@ -166,3 +181,17 @@ def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> assert result.returncode != 0 assert result.stdout == "" assert "could not obtain the native binary" in result.stderr + + +def main() -> None: + if sys.platform == "win32": + return + with tempfile.TemporaryDirectory(prefix="cmux-tui-launcher-test-") as directory: + root = Path(directory) + test_launcher_downloads_once_and_reuses_verified_cache(root / "download") + test_launcher_reports_network_failure_without_leaking_details(root / "failure") + test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") + + +if __name__ == "__main__": + main() From 5e493d58c77e52573b734b60d15df873edbcbd4b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:02:50 -0700 Subject: [PATCH 12/73] fix(tui): preserve launcher overrides and clean stale staging --- cmux-tui/dist/npm/cmux/bin/cmux.js | 48 ++++++++++++++++++++++++++++-- tests/test_tui_npm_launcher.py | 24 +++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 202265d908fc..236fa6ad1654 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -50,6 +50,7 @@ const PUBLISHED_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const MAX_TARBALL_BYTES = 256 * 1024 * 1024; const MAX_METADATA_BYTES = 1024 * 1024; const REGISTRY_TIMEOUT_MS = 30_000; +const STAGING_MAX_AGE_MS = 60 * 60 * 1000; function fail(message) { console.error(`cmux: ${message}`); @@ -282,6 +283,43 @@ function versionHasActiveLease(versionDir) { } } +function cleanupStaging() { + const root = path.join(platformRoot(), "tmp"); + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return; + } + const now = Date.now(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const staging = path.join(root, entry.name); + let stat; + try { + stat = fs.statSync(staging); + } catch { + continue; + } + const age = now - stat.mtimeMs; + const match = /-(\d+)-[a-z0-9]+$/.exec(entry.name); + let active = false; + if (match) { + const pid = Number.parseInt(match[1], 10); + try { + process.kill(pid, 0); + active = true; + } catch (error) { + active = !error || error.code !== "ESRCH"; + } + } + if (active && age < STAGING_MAX_AGE_MS) continue; + try { + fs.rmSync(staging, { recursive: true, force: true }); + } catch {} + } +} + // Resolve an installed cmux-tui- package (global or local install). // Returns { binPath, version } or null. function installedPackage(pkg) { @@ -538,12 +576,13 @@ function wantedVersion(pkg) { return pinned; } -async function resolveBinary(pkg, wanted = wantedVersion(pkg)) { +async function resolveBinary(pkg, wanted) { const override = process.env.CMUX_TUI_BIN; if (override) { if (!fs.existsSync(override)) fail(`CMUX_TUI_BIN does not exist: ${override}`); return override; } + if (wanted === undefined || wanted === null) wanted = wantedVersion(pkg); const installed = installedPackage(pkg); if (installed && installed.version === wanted) { @@ -601,6 +640,7 @@ async function main() { // top-level `update` verb, so nothing is shadowed. if (args[0] === "update") { try { + cleanupStaging(); await runUpdate(pkg, args.slice(1)); } catch (error) { fail(`update failed: ${error.message}`); @@ -611,11 +651,13 @@ async function main() { let lease = null; let exitCode = 1; try { - const wanted = wantedVersion(pkg); + cleanupStaging(); + const override = process.env.CMUX_TUI_BIN; + const wanted = override ? null : wantedVersion(pkg); // Hold a per-process lease independently of the update lock. An update // may already own that lock while pruning another version, and the // binary must remain present through resolution and spawn. - lease = acquireVersionLease(wanted); + lease = wanted ? acquireVersionLease(wanted) : null; if (lease) process.once("exit", () => releaseVersionLease(lease)); const binPath = await resolveBinary(pkg, wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 41f66d0c881b..104848a4a12a 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -81,6 +81,7 @@ def run_launcher( cache: Path, registry: str, *args: str, + env_extra: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env.update( @@ -90,6 +91,7 @@ def run_launcher( "NO_COLOR": "1", } ) + env.update(env_extra or {}) return subprocess.run( ["node", str(launcher), *args], check=False, @@ -99,11 +101,11 @@ def run_launcher( ) -def write_launcher(tmp_path: Path) -> Path: +def write_launcher(tmp_path: Path, version: str = "1.2.3") -> Path: package = tmp_path / "package" (package / "bin").mkdir(parents=True) (package / "package.json").write_text( - json.dumps({"name": "cmux", "version": "1.2.3"}) + "\n" + json.dumps({"name": "cmux", "version": version}) + "\n" ) launcher = package / "bin/cmux.js" launcher.write_bytes(LAUNCHER.read_bytes()) @@ -183,6 +185,23 @@ def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> assert "could not obtain the native binary" in result.stderr +def test_managed_launcher_honors_development_binary_override(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path, "0.0.0-managed") + binary = tmp_path / "dev-cmux-tui" + binary.write_text("#!/bin/sh\nprintf '%s\\n' 'development override'\n") + binary.chmod(0o755) + result = run_launcher( + launcher, + tmp_path / "cache", + "http://127.0.0.1:1", + env_extra={"CMUX_TUI_BIN": str(binary)}, + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "development override\n" + + def main() -> None: if sys.platform == "win32": return @@ -191,6 +210,7 @@ def main() -> None: test_launcher_downloads_once_and_reuses_verified_cache(root / "download") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") + test_managed_launcher_honors_development_binary_override(root / "override") if __name__ == "__main__": From b0dac56e15db0791cac5cf65475ee5012c8d7ef5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:11:47 -0700 Subject: [PATCH 13/73] fix(tui): hide override paths and stabilize launcher tests --- cmux-tui/dist/npm/cmux/bin/cmux.js | 2 +- tests/test_tui_npm_launcher.py | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 236fa6ad1654..b51e0699774b 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -579,7 +579,7 @@ function wantedVersion(pkg) { async function resolveBinary(pkg, wanted) { const override = process.env.CMUX_TUI_BIN; if (override) { - if (!fs.existsSync(override)) fail(`CMUX_TUI_BIN does not exist: ${override}`); + if (!fs.existsSync(override)) fail("configured native binary override does not exist"); return override; } if (wanted === undefined || wanted === null) wanted = wantedVersion(pkg); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 104848a4a12a..2e15873fcf50 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -31,7 +31,7 @@ def make_tarball() -> bytes: info.mode = 0o755 info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) - return gzip.compress(tar_buffer.getvalue()) + return gzip.compress(tar_buffer.getvalue(), mtime=0) class RegistryHandler(http.server.BaseHTTPRequestHandler): @@ -134,7 +134,7 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No second = run_launcher(launcher, cache, registry, "--version") finally: server.shutdown() - thread.join(timeout=5) + thread.join() assert first.returncode == 0, first.stderr assert second.returncode == 0, second.stderr assert first.stdout == second.stdout == "fake cmux-tui 1.2.3\n" @@ -202,6 +202,23 @@ def test_managed_launcher_honors_development_binary_override(tmp_path: Path) -> assert result.stdout == "development override\n" +def test_missing_binary_override_hides_path_and_variable(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path, "1.2.3") + missing = tmp_path / "missing-native" + result = run_launcher( + launcher, + tmp_path / "cache", + "http://127.0.0.1:1", + env_extra={"CMUX_TUI_BIN": str(missing)}, + ) + assert result.returncode != 0 + assert "configured native binary override does not exist" in result.stderr + assert str(missing) not in result.stderr + assert "CMUX_TUI_BIN" not in result.stderr + + def main() -> None: if sys.platform == "win32": return @@ -211,6 +228,7 @@ def main() -> None: test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_managed_launcher_honors_development_binary_override(root / "override") + test_missing_binary_override_hides_path_and_variable(root / "missing-override") if __name__ == "__main__": From b93448c4ce08e3991205cfee07d8772ab52713a2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:21:25 -0700 Subject: [PATCH 14/73] fix(tui): preserve npm registry credentials safely --- cmux-tui/dist/npm/cmux/bin/cmux.js | 30 ++++++++++++++++++++++++++++-- tests/test_tui_npm_launcher.py | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index b51e0699774b..428b91b24247 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -313,7 +313,8 @@ function cleanupStaging() { active = !error || error.code !== "ESRCH"; } } - if (active && age < STAGING_MAX_AGE_MS) continue; + if (active) continue; + if (match && age < STAGING_MAX_AGE_MS) continue; try { fs.rmSync(staging, { recursive: true, force: true }); } catch {} @@ -349,12 +350,37 @@ function registryHeaders(url, accept) { const registry = new URL(registryBase()); if (parsed.origin !== registry.origin) return headers; const tokenKey = `npm_config_//${parsed.host}/:_authToken`; - const token = process.env[tokenKey]; + const token = process.env[tokenKey] || npmrcAuthToken(parsed); if (token) headers.authorization = `Bearer ${token}`; } catch {} return headers; } +function npmrcAuthToken(url) { + const configPaths = new Set([ + process.env.npm_config_userconfig, + process.env.npm_config_globalconfig, + path.join(os.homedir(), ".npmrc"), + ]); + const host = url.host.toLowerCase(); + for (const configPath of configPaths) { + if (!configPath) continue; + let contents; + try { + contents = fs.readFileSync(configPath, "utf8"); + } catch { + continue; + } + for (const line of contents.split(/\r?\n/)) { + const match = /^\s*\/\/([^/]+)(\/[^:]+)?\/:_authToken\s*=\s*(.*?)\s*$/.exec(line); + if (!match || match[1].toLowerCase() !== host) continue; + const token = match[3].replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ""); + if (token) return token; + } + } + return null; +} + async function fetchJson(url) { const response = await fetch(url, { headers: registryHeaders(url, "application/json"), diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 2e15873fcf50..0321c2599ff0 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -38,9 +38,11 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() metadata_requests = 0 tarball_requests = 0 + authorization_headers: list[str | None] = [] status = 200 def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler + type(self).authorization_headers.append(self.headers.get("Authorization")) if self.path.endswith(( "/cmux-tui-darwin-arm64/1.2.3", "/cmux-tui-darwin-x64/1.2.3", @@ -116,6 +118,7 @@ def write_launcher(tmp_path: Path, version: str = "1.2.3") -> Path: def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, str]: RegistryHandler.metadata_requests = 0 RegistryHandler.tarball_requests = 0 + RegistryHandler.authorization_headers = [] RegistryHandler.status = 200 server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RegistryHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -165,6 +168,29 @@ def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path assert "CMUX_" not in result.stderr +def test_launcher_reads_registry_token_from_npmrc(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + npmrc = tmp_path / ".npmrc" + npmrc.write_text(f"//127.0.0.1:{server.server_port}/:_authToken=fixture-token\n") + try: + result = run_launcher( + launcher, + cache, + registry, + env_extra={"npm_config_userconfig": str(npmrc)}, + ) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all(value == "Bearer fixture-token" for value in RegistryHandler.authorization_headers) + + def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -226,6 +252,7 @@ def main() -> None: root = Path(directory) test_launcher_downloads_once_and_reuses_verified_cache(root / "download") test_launcher_reports_network_failure_without_leaking_details(root / "failure") + test_launcher_reads_registry_token_from_npmrc(root / "npmrc") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_managed_launcher_honors_development_binary_override(root / "override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") From 124dcf2093e6873f29d78cc9caa4004beaa4db7b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:28:40 -0700 Subject: [PATCH 15/73] fix(tui): bound cache growth after launcher downloads --- cmux-tui/dist/npm/cmux/bin/cmux.js | 1 + tests/test_tui_npm_launcher.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 428b91b24247..55b4cf2dda4a 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -686,6 +686,7 @@ async function main() { lease = wanted ? acquireVersionLease(wanted) : null; if (lease) process.once("exit", () => releaseVersionLease(lease)); const binPath = await resolveBinary(pkg, wanted); + if (wanted) pruneCache(wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); if (result.error) { fail("failed to launch the native binary"); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 0321c2599ff0..05bbc585342b 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -245,6 +245,37 @@ def test_missing_binary_override_hides_path_and_variable(tmp_path: Path) -> None assert "CMUX_TUI_BIN" not in result.stderr +def test_launcher_prunes_old_managed_cache_after_download(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + arch = { + "aarch64": "arm64", + "arm64": "arm64", + "amd64": "x64", + "x86_64": "x64", + }[platform.machine().lower()] + platform_root = cache / f"{sys.platform}-{arch}" / "v" + for version in ("0.9.0", "1.0.0", "1.1.0"): + binary = platform_root / version / "bin/cmux-tui" + binary.parent.mkdir(parents=True) + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o755) + binary.parent.parent.joinpath("managed").write_text("cmux\n") + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert not (platform_root / "0.9.0").exists() + assert not (platform_root / "1.0.0").exists() + assert (platform_root / "1.1.0").exists() + assert (platform_root / "1.2.3").exists() + + def main() -> None: if sys.platform == "win32": return @@ -256,6 +287,7 @@ def main() -> None: test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_managed_launcher_honors_development_binary_override(root / "override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") + test_launcher_prunes_old_managed_cache_after_download(root / "prune") if __name__ == "__main__": From 322d331119742ebbdaf142ea13e5ec4ea8b3f53d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:21:58 -0700 Subject: [PATCH 16/73] fix(tui): reject malformed npm tar entry sizes --- cmux-tui/dist/npm/cmux/bin/cmux.js | 21 +++++++++++++++-- tests/test_tui_npm_launcher.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 55b4cf2dda4a..b85ddf581d13 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -445,11 +445,28 @@ function extractBinEntries(tarBuffer) { if (header.every((b) => b === 0)) break; const rawName = header.toString("utf8", 0, 100).replace(/\0.*$/, ""); const prefix = header.toString("utf8", 345, 500).replace(/\0.*$/, ""); - const size = parseInt(header.toString("utf8", 124, 136).replace(/\0.*$/, "").trim(), 8) || 0; + const sizeText = header + .toString("utf8", 124, 136) + .replace(/\0.*$/, "") + .trim(); + if (sizeText && !/^[0-7]+$/.test(sizeText)) { + throw new Error("invalid tar entry size"); + } + const size = sizeText ? Number.parseInt(sizeText, 8) : 0; + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error("invalid tar entry size"); + } const typeflag = String.fromCharCode(header[156]); const dataStart = offset + 512; + if (size > tarBuffer.length - dataStart) { + throw new Error("truncated tar entry"); + } + const nextOffset = dataStart + Math.ceil(size / 512) * 512; + if (nextOffset <= offset || nextOffset > tarBuffer.length) { + throw new Error("invalid tar entry bounds"); + } const data = tarBuffer.subarray(dataStart, dataStart + size); - offset = dataStart + Math.ceil(size / 512) * 512; + offset = nextOffset; if (typeflag === "x" || typeflag === "g") { const records = parsePaxRecords(data); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 05bbc585342b..3804337959f0 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -34,6 +34,14 @@ def make_tarball() -> bytes: return gzip.compress(tar_buffer.getvalue(), mtime=0) +def make_negative_size_tarball() -> bytes: + tar = bytearray(gzip.decompress(make_tarball())) + # -1000 is -512 in octal. The launcher must reject it before the tar + # cursor can move backwards and parse the same header forever. + tar[124:136] = b"-1000" + b"\0" * 7 + return gzip.compress(bytes(tar), mtime=0) + + class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() metadata_requests = 0 @@ -84,6 +92,7 @@ def run_launcher( registry: str, *args: str, env_extra: dict[str, str] | None = None, + timeout_seconds: float | None = None, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env.update( @@ -100,6 +109,7 @@ def run_launcher( capture_output=True, text=True, env=env, + timeout=timeout_seconds, ) @@ -157,6 +167,33 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert not (cache / platform_key / "v/1.2.3/.active").exists() +def test_launcher_rejects_negative_tar_size_without_hanging(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + original_tarball = RegistryHandler.tarball + RegistryHandler.tarball = make_negative_size_tarball() + server, thread, registry = start_registry() + try: + try: + result = run_launcher( + launcher, + cache, + registry, + "--version", + timeout_seconds=2, + ) + except subprocess.TimeoutExpired as error: + raise AssertionError("malformed tar header caused the launcher to hang") from error + finally: + server.shutdown() + thread.join() + RegistryHandler.tarball = original_tarball + assert result.returncode != 0 + assert "could not obtain the native binary" in result.stderr + + def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -282,6 +319,7 @@ def main() -> None: with tempfile.TemporaryDirectory(prefix="cmux-tui-launcher-test-") as directory: root = Path(directory) test_launcher_downloads_once_and_reuses_verified_cache(root / "download") + test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") From 1a4a6a0e830886153dae3c77736be00edce2746c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:34:01 -0700 Subject: [PATCH 17/73] fix(tui): verify launcher cache and scope registry auth --- cmux-tui/dist/npm/cmux/bin/cmux.js | 91 +++++++++++++++++++++++++----- tests/test_tui_npm_launcher.py | 65 +++++++++++++++++++++ 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index b85ddf581d13..33c1b7c7902c 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -167,9 +167,33 @@ function cachedBinDir(version) { return path.join(platformRoot(), "v", version, "bin"); } +function cacheManifestPath(version) { + return path.join(platformRoot(), "v", version, "manifest.json"); +} + +function digestHex(buffer) { + return crypto.createHash("sha512").update(buffer).digest("hex"); +} + function cachedBinary(version) { const bin = path.join(cachedBinDir(version), BIN_NAME); - return fs.existsSync(bin) ? bin : null; + try { + const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); + const expected = manifest?.binaries?.[BIN_NAME]; + if ( + manifest?.version !== version || + typeof expected !== "string" || + !/^[a-f0-9]{128}$/.test(expected) + ) { + return null; + } + if (!fs.lstatSync(bin).isFile()) return null; + const actual = Buffer.from(digestHex(fs.readFileSync(bin)), "hex"); + const expectedBytes = Buffer.from(expected, "hex"); + return crypto.timingSafeEqual(actual, expectedBytes) ? bin : null; + } catch { + return null; + } } function cacheLockPath() { @@ -372,8 +396,13 @@ function npmrcAuthToken(url) { continue; } for (const line of contents.split(/\r?\n/)) { - const match = /^\s*\/\/([^/]+)(\/[^:]+)?\/:_authToken\s*=\s*(.*?)\s*$/.exec(line); + const match = /^\s*\/\/([^/]+)(\/[^:]*?)?\/:_authToken\s*=\s*(.*?)\s*$/.exec(line); if (!match || match[1].toLowerCase() !== host) continue; + const scope = match[2] || "/"; + if (scope !== "/") { + const prefix = scope.endsWith("/") ? scope : `${scope}/`; + if (url.pathname !== scope && !url.pathname.startsWith(prefix)) continue; + } const token = match[3].replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ""); if (token) return token; } @@ -503,6 +532,28 @@ function verifyIntegrity(buffer, integrity) { } } +function writeCacheManifest(pkg, version, integrity, entries) { + const target = cacheManifestPath(version); + const tmp = `${target}.${process.pid}.tmp`; + const binaries = {}; + for (const entry of entries) binaries[entry.name] = digestHex(entry.data); + fs.writeFileSync( + tmp, + JSON.stringify({ package: pkg, version, tarballIntegrity: integrity, binaries }, null, 2) + "\n", + { mode: 0o600 } + ); + fs.renameSync(tmp, target); +} + +function removeCachedPayload(version) { + const versionDir = path.dirname(cachedBinDir(version)); + for (const name of ["bin", "manifest.json", "managed"]) { + try { + fs.rmSync(path.join(versionDir, name), { recursive: true, force: true }); + } catch {} + } +} + // Download pkg@version from the registry, verify integrity, extract bin/ // into the launcher cache. Returns the binary path. async function downloadVersion(pkg, version) { @@ -546,27 +597,41 @@ async function downloadVersion(pkg, version) { fs.mkdirSync(path.dirname(finalDir), { recursive: true }); try { fs.renameSync(path.join(stagingDir, "bin"), finalDir); + writeCacheManifest(pkg, version, integrity, entries); fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); } catch (error) { // A concurrent launcher won the race; its extraction is byte-identical // only when the existing binary matches the entry we verified above. const expected = entries.find((entry) => entry.name === BIN_NAME); - const existing = cachedBinary(version); - if (!existing || !expected) throw error; + const existingPath = path.join(cachedBinDir(version), BIN_NAME); + let existingIsFile = false; + try { + existingIsFile = fs.lstatSync(existingPath).isFile(); + } catch {} + if (!existingIsFile || !expected) throw error; const existingDigest = crypto .createHash("sha512") - .update(fs.readFileSync(existing)) + .update(fs.readFileSync(existingPath)) .digest(); const expectedDigest = crypto.createHash("sha512").update(expected.data).digest(); - if ( - existingDigest.length !== expectedDigest.length || - !crypto.timingSafeEqual(existingDigest, expectedDigest) - ) { - throw new Error("cached platform binary failed integrity verification"); + const matchesExpected = + existingDigest.length === expectedDigest.length && + crypto.timingSafeEqual(existingDigest, expectedDigest); + if (!matchesExpected) { + try { + removeCachedPayload(version); + fs.renameSync(path.join(stagingDir, "bin"), finalDir); + writeCacheManifest(pkg, version, integrity, entries); + fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + } catch { + throw new Error("cached platform binary failed integrity verification"); + } + } else { + try { + writeCacheManifest(pkg, version, integrity, entries); + fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + } catch {} } - try { - fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); - } catch {} } finally { fs.rmSync(stagingDir, { recursive: true, force: true }); } diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 3804337959f0..5a56f829d1d0 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -194,6 +194,34 @@ def test_launcher_rejects_negative_tar_size_without_hanging(tmp_path: Path) -> N assert "could not obtain the native binary" in result.stderr +def test_launcher_refetches_a_tampered_cached_binary(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + try: + first = run_launcher(launcher, cache, registry, "--version") + arch = { + "aarch64": "arm64", + "arm64": "arm64", + "amd64": "x64", + "x86_64": "x64", + }[platform.machine().lower()] + binary = cache / f"{sys.platform}-{arch}/v/1.2.3/bin/cmux-tui" + binary.write_text("#!/bin/sh\nprintf '%s\\n' 'tampered binary'\n") + binary.chmod(0o755) + second = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert second.stdout == "fake cmux-tui 1.2.3\n" + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 2 + + def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -228,6 +256,41 @@ def test_launcher_reads_registry_token_from_npmrc(tmp_path: Path) -> None: assert all(value == "Bearer fixture-token" for value in RegistryHandler.authorization_headers) +def test_launcher_scopes_registry_token_to_npmrc_path(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + npmrc = tmp_path / ".npmrc" + npmrc.write_text(f"//127.0.0.1:{server.server_port}/private/:_authToken=fixture-token\n") + try: + result = run_launcher( + launcher, + cache, + registry, + env_extra={"npm_config_userconfig": str(npmrc)}, + timeout_seconds=3, + ) + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all(value is None for value in RegistryHandler.authorization_headers) + + RegistryHandler.authorization_headers = [] + scoped = run_launcher( + launcher, + cache / "private", + f"{registry}/private", + env_extra={"npm_config_userconfig": str(npmrc)}, + timeout_seconds=3, + ) + assert scoped.returncode == 0, scoped.stderr + assert RegistryHandler.authorization_headers == ["Bearer fixture-token", None] + finally: + server.shutdown() + thread.join() + + def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -320,8 +383,10 @@ def main() -> None: root = Path(directory) test_launcher_downloads_once_and_reuses_verified_cache(root / "download") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") + test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") + test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_managed_launcher_honors_development_binary_override(root / "override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") From f505be7ed932ac643d6e841a80720ba5832e9a73 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:15:42 -0700 Subject: [PATCH 18/73] test(tui): cover launcher runtime and cache lock races --- tests/test_tui_npm_launcher.py | 217 +++++++++++++++++++++++++++++++-- 1 file changed, 208 insertions(+), 9 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 5a56f829d1d0..5a4272624362 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -16,6 +16,7 @@ import tarfile import tempfile import threading +import time from pathlib import Path @@ -125,6 +126,66 @@ def write_launcher(tmp_path: Path, version: str = "1.2.3") -> Path: return launcher +def host_platform_key() -> str: + arch = { + "aarch64": "arm64", + "arm64": "arm64", + "amd64": "x64", + "x86_64": "x64", + }.get(platform.machine().lower()) + assert arch is not None, platform.machine() + return f"{sys.platform}-{arch}" + + +def write_cached_binary( + cache: Path, + version: str, + payload: str, + *, + managed: bool = False, +) -> Path: + platform_key = host_platform_key() + package = f"cmux-tui-{platform_key}" + binary = cache / platform_key / f"v/{version}/bin/cmux-tui" + data = payload.encode() + binary.parent.mkdir(parents=True) + binary.write_bytes(data) + binary.chmod(0o755) + version_dir = binary.parent.parent + (version_dir / "manifest.json").write_text( + json.dumps( + { + "package": package, + "version": version, + "tarballIntegrity": "sha512-fixture", + "binaries": {"cmux-tui": hashlib.sha512(data).hexdigest()}, + } + ) + + "\n" + ) + if managed: + (version_dir / "managed").write_text("cmux\n") + return binary + + +def write_runtime_capability_stub(tmp_path: Path) -> Path: + stub = tmp_path / "disable-node-network-apis.cjs" + stub.write_text( + "globalThis.fetch = undefined;\n" + "if (typeof AbortSignal === \"function\") AbortSignal.timeout = undefined;\n" + ) + return stub + + +def write_platform_stub(tmp_path: Path, platform_name: str = "freebsd") -> Path: + stub = tmp_path / "unsupported-platform.cjs" + stub.write_text( + "Object.defineProperty(process, \"platform\", " + f"{{ configurable: true, value: {json.dumps(platform_name)} }});\n" + ) + return stub + + def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, str]: RegistryHandler.metadata_requests = 0 RegistryHandler.tarball_requests = 0 @@ -141,10 +202,19 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No return launcher = write_launcher(tmp_path) cache = tmp_path / "cache" + runtime_stub = write_runtime_capability_stub(tmp_path) server, thread, registry = start_registry() try: first = run_launcher(launcher, cache, registry, "--version") - second = run_launcher(launcher, cache, registry, "--version") + # A verified cache hit must remain usable when the Node network APIs + # are unavailable. The capability guard belongs on the download path. + second = run_launcher( + launcher, + cache, + registry, + "--version", + env_extra={"NODE_OPTIONS": f"--require={runtime_stub}"}, + ) finally: server.shutdown() thread.join() @@ -153,20 +223,39 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert first.stdout == second.stdout == "fake cmux-tui 1.2.3\n" assert RegistryHandler.metadata_requests == 1 assert RegistryHandler.tarball_requests == 1 - arch = { - "aarch64": "arm64", - "arm64": "arm64", - "amd64": "x64", - "x86_64": "x64", - }.get(platform.machine().lower()) - assert arch is not None, platform.machine() - platform_key = f"{sys.platform}-{arch}" + platform_key = host_platform_key() cached = cache / platform_key / "v/1.2.3/bin/cmux-tui" assert cached.is_file() assert cached.stat().st_mode & stat.S_IXUSR assert not (cache / platform_key / "v/1.2.3/.active").exists() +def test_launcher_requires_network_runtime_capabilities(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + runtime_stub = write_runtime_capability_stub(tmp_path) + result = run_launcher( + launcher, + tmp_path / "cache", + "http://127.0.0.1:1", + "--version", + env_extra={"NODE_OPTIONS": f"--require={runtime_stub}"}, + ) + assert result.returncode != 0 + assert "requires Node.js 18 or newer" in result.stderr + assert "fetch" in result.stderr + assert "AbortSignal.timeout" in result.stderr + assert "127.0.0.1" not in result.stderr + + +def test_launcher_declares_node_engine_requirement() -> None: + metadata = json.loads( + (ROOT / "cmux-tui/dist/npm/cmux/package.json").read_text() + ) + assert metadata.get("engines", {}).get("node") == ">=18" + + def test_launcher_rejects_negative_tar_size_without_hanging(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -328,6 +417,27 @@ def test_managed_launcher_honors_development_binary_override(tmp_path: Path) -> assert result.stdout == "development override\n" +def test_binary_override_works_on_an_unsupported_platform(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + binary = tmp_path / "dev-cmux-tui" + binary.write_text("#!/bin/sh\nprintf '%s\\n' 'unsupported-platform override'\n") + binary.chmod(0o755) + platform_stub = write_platform_stub(tmp_path) + result = run_launcher( + launcher, + tmp_path / "cache", + "http://127.0.0.1:1", + env_extra={ + "CMUX_TUI_BIN": str(binary), + "NODE_OPTIONS": f"--require={platform_stub}", + }, + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "unsupported-platform override\n" + + def test_missing_binary_override_hides_path_and_variable(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -345,6 +455,91 @@ def test_missing_binary_override_hides_path_and_variable(tmp_path: Path) -> None assert "CMUX_TUI_BIN" not in result.stderr +def test_launcher_fails_closed_when_another_process_holds_cache_lock(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'cached while lock held'\n", + ) + lock = cache / host_platform_key() / ".update.lock" + lock.mkdir(parents=True) + owner = f"{os.getpid()}\nfixture-owner-token\n" + owner_path = lock / "owner" + owner_path.write_text(owner) + + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + ) + + assert result.returncode != 0 + assert result.stdout == "" + assert "could not reserve the native binary" in result.stderr + assert owner_path.read_text() == owner + + +def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Path) -> None: + if sys.platform == "win32": + return + old_launcher = write_launcher(tmp_path / "old", "1.0.0") + new_launcher = write_launcher(tmp_path / "new", "1.2.3") + cache = tmp_path / "cache" + started = tmp_path / "old-started" + old_payload = ( + "#!/bin/sh\n" + f"printf '%s' started > {json.dumps(str(started))}\n" + "sleep 2\n" + "printf '%s\\n' 'old binary'\n" + ) + write_cached_binary(cache, "1.0.0", old_payload, managed=True) + write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) + + env = os.environ.copy() + env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": "http://127.0.0.1:1", + "NO_COLOR": "1", + } + ) + old_process = subprocess.Popen( + ["node", str(old_launcher)], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + deadline = time.monotonic() + 3 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert started.exists(), "old launcher did not start its binary" + + new_result = run_launcher( + new_launcher, + cache, + "http://127.0.0.1:1", + "--version", + ) + assert new_result.returncode == 0, new_result.stderr + assert (cache / host_platform_key() / "v/1.0.0").exists() + finally: + try: + old_process.wait(timeout=5) + except subprocess.TimeoutExpired: + old_process.kill() + old_process.wait(timeout=5) + assert old_process.returncode == 0 + + def test_launcher_prunes_old_managed_cache_after_download(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -382,6 +577,7 @@ def main() -> None: with tempfile.TemporaryDirectory(prefix="cmux-tui-launcher-test-") as directory: root = Path(directory) test_launcher_downloads_once_and_reuses_verified_cache(root / "download") + test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") @@ -389,7 +585,10 @@ def main() -> None: test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_managed_launcher_honors_development_binary_override(root / "override") + test_binary_override_works_on_an_unsupported_platform(root / "unsupported-override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") + test_launcher_fails_closed_when_another_process_holds_cache_lock(root / "held-lock") + test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_launcher_prunes_old_managed_cache_after_download(root / "prune") From 8fce94d89e5c51352d4dba6be5ec6ce3d2181efa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:19:10 -0700 Subject: [PATCH 19/73] fix(tui): guard launcher runtime and cache leases --- cmux-tui/dist/npm/cmux/bin/cmux.js | 193 ++++++++++++++++++++++------- 1 file changed, 149 insertions(+), 44 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 33c1b7c7902c..215f78475df7 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -51,6 +51,8 @@ const MAX_TARBALL_BYTES = 256 * 1024 * 1024; const MAX_METADATA_BYTES = 1024 * 1024; const REGISTRY_TIMEOUT_MS = 30_000; const STAGING_MAX_AGE_MS = 60 * 60 * 1000; +const CACHE_LOCK_ATTEMPTS = 3; +const MIN_NODE_MAJOR = 18; function fail(message) { console.error(`cmux: ${message}`); @@ -200,58 +202,137 @@ function cacheLockPath() { return path.join(platformRoot(), ".update.lock"); } -function tryAcquireCacheLock() { +function cacheLockOwnerPath() { + return path.join(cacheLockPath(), "owner"); +} + +function newCacheLockOwner() { + const token = `${process.pid}-${Date.now().toString(36)}-${crypto + .randomBytes(16) + .toString("hex")}`; + return { + pid: process.pid, + token, + raw: `${process.pid}\n${token}\n`, + }; +} + +function readCacheLockOwner() { try { - fs.mkdirSync(cacheLockPath(), { recursive: false }); - fs.writeFileSync(path.join(cacheLockPath(), "pid"), `${process.pid}\n`); + const raw = fs.readFileSync(cacheLockOwnerPath(), "utf8"); + const lines = raw.trim().split(/\s+/); + const pid = Number.parseInt(lines[0], 10); + const token = lines[1]; + if (!Number.isInteger(pid) || pid <= 0 || !token) return null; + return { pid, token, raw }; + } catch { + return null; + } +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); return true; + } catch (error) { + return !error || error.code !== "ESRCH"; + } +} + +// Remove a lock only when its owner file still matches the observed token. +// The comparison prevents a stale-lock cleanup from deleting a newer owner. +function removeCacheLockIfOwned(owner, allowEmpty = false) { + let current; + try { + current = fs.readFileSync(cacheLockOwnerPath(), "utf8"); } catch { + if (!allowEmpty) return false; try { - if (!fs.existsSync(path.join(cacheLockPath(), "pid"))) { - fs.rmSync(cacheLockPath(), { recursive: true, force: true }); - } - } catch {} - try { - const pid = Number.parseInt( - fs.readFileSync(path.join(cacheLockPath(), "pid"), "utf8"), - 10 - ); - process.kill(pid, 0); + fs.rmSync(cacheLockPath(), { recursive: true, force: false }); + return true; + } catch { return false; - } catch (error) { - if (!error || error.code !== "ESRCH") return false; - try { - fs.rmSync(cacheLockPath(), { recursive: true, force: true }); - fs.mkdirSync(cacheLockPath(), { recursive: false }); - fs.writeFileSync(path.join(cacheLockPath(), "pid"), `${process.pid}\n`); - return true; - } catch { - return false; - } } } + if (current !== owner.raw) return false; + try { + fs.rmSync(cacheLockPath(), { recursive: true, force: false }); + return true; + } catch { + return false; + } } -function releaseCacheLock() { +function tryAcquireCacheLock() { + const lockPath = cacheLockPath(); try { - fs.rmSync(cacheLockPath(), { recursive: true, force: true }); - } catch {} + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + } catch { + return null; + } + + for (let attempt = 0; attempt < CACHE_LOCK_ATTEMPTS; attempt++) { + const owner = newCacheLockOwner(); + let created = false; + try { + fs.mkdirSync(lockPath, { recursive: false }); + created = true; + fs.writeFileSync(cacheLockOwnerPath(), owner.raw, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return owner; + } catch { + // If this attempt created the directory but could not publish its owner, + // clean up only that empty lock. Never remove an owner published by a + // different process. + if (created) { + removeCacheLockIfOwned(owner, true); + return null; + } + } + + const current = readCacheLockOwner(); + // Unknown or malformed lock state is retained conservatively. A later + // invocation can recover it after an operator inspects the cache. + if (!current || processIsAlive(current.pid)) return null; + if (!removeCacheLockIfOwned(current)) return null; + } + return null; +} + +function releaseCacheLock(owner) { + if (!owner) return; + removeCacheLockIfOwned(owner); } function acquireVersionLease(version) { const leaseRoot = path.join(platformRoot(), "v", version, ".active"); - const lease = path.join( - leaseRoot, - `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` - ); - try { - fs.mkdirSync(leaseRoot, { recursive: true }); - fs.mkdirSync(lease, { recursive: false }); - fs.writeFileSync(path.join(lease, "pid"), `${process.pid}\n`); - return lease; - } catch { - return null; + for (let attempt = 0; attempt < CACHE_LOCK_ATTEMPTS; attempt++) { + const lock = tryAcquireCacheLock(); + if (!lock) continue; + let lease = null; + try { + lease = path.join( + leaseRoot, + `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ); + fs.mkdirSync(leaseRoot, { recursive: true }); + fs.mkdirSync(lease, { recursive: false }); + fs.writeFileSync(path.join(lease, "pid"), `${process.pid}\n`); + return lease; + } catch { + if (lease) { + try { + fs.rmSync(lease, { recursive: true, force: true }); + } catch {} + } + } finally { + releaseCacheLock(lock); + } } + return null; } function releaseVersionLease(lease) { @@ -410,7 +491,21 @@ function npmrcAuthToken(url) { return null; } +function requireNetworkRuntime() { + const nodeMajor = Number.parseInt(String(process.versions.node).split(".", 1)[0], 10); + const hasFetch = typeof fetch === "function"; + const hasAbortTimeout = + typeof AbortSignal === "function" && typeof AbortSignal.timeout === "function"; + if (nodeMajor < MIN_NODE_MAJOR || !hasFetch || !hasAbortTimeout) { + fail( + "network operations require Node.js 18 or newer with global fetch and " + + "AbortSignal.timeout" + ); + } +} + async function fetchJson(url) { + requireNetworkRuntime(); const response = await fetch(url, { headers: registryHeaders(url, "application/json"), signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), @@ -641,7 +736,8 @@ async function downloadVersion(pkg, version) { } function pruneCache(keepVersion) { - if (!tryAcquireCacheLock()) return; + const lock = tryAcquireCacheLock(); + if (!lock) return false; const root = path.join(platformRoot(), "v"); try { const managed = fs @@ -656,10 +752,12 @@ function pruneCache(keepVersion) { fs.rmSync(path.join(root, version), { recursive: true, force: true }); } catch {} } + return true; } catch { // Cache cleanup is best effort and must never hide a successful update. + return false; } finally { - releaseCacheLock(); + releaseCacheLock(lock); } } @@ -740,13 +838,14 @@ async function runUpdate(pkg, args) { } async function main() { - const pkg = platformPackage(); const args = process.argv.slice(2); + const override = process.env.CMUX_TUI_BIN; // Owned by the shim, not the Rust CLI: `update` must work even when no // binary is present, and must never go through npm. spec/cli.md has no // top-level `update` verb, so nothing is shadowed. if (args[0] === "update") { + const pkg = platformPackage(); try { cleanupStaging(); await runUpdate(pkg, args.slice(1)); @@ -756,16 +855,22 @@ async function main() { return; } + // An explicit development binary is independent of the published platform + // matrix. Resolve it before checking process.platform so unsupported hosts + // can still run with CMUX_TUI_BIN. + const pkg = override ? null : platformPackage(); let lease = null; let exitCode = 1; try { cleanupStaging(); - const override = process.env.CMUX_TUI_BIN; const wanted = override ? null : wantedVersion(pkg); - // Hold a per-process lease independently of the update lock. An update - // may already own that lock while pruning another version, and the - // binary must remain present through resolution and spawn. + // Lease creation serializes with pruning. If another process owns the + // lock, fail closed rather than launching an unleased binary that a prune + // can remove while it is running. lease = wanted ? acquireVersionLease(wanted) : null; + if (wanted && !lease) { + fail("could not reserve the native binary for launch"); + } if (lease) process.once("exit", () => releaseVersionLease(lease)); const binPath = await resolveBinary(pkg, wanted); if (wanted) pruneCache(wanted); From 20949ceded178931b7e9b77e1dc9e1ab05c20019 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:27:57 -0700 Subject: [PATCH 20/73] fix(tui): make cache lock cleanup atomic --- cmux-tui/dist/npm/cmux/bin/cmux.js | 43 ++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 215f78475df7..dc3bdc0bfd39 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -240,26 +240,47 @@ function processIsAlive(pid) { } // Remove a lock only when its owner file still matches the observed token. -// The comparison prevents a stale-lock cleanup from deleting a newer owner. +// Rename the directory first, so the compare and delete cannot race a newer +// owner that acquires the path after stale-lock cleanup starts. function removeCacheLockIfOwned(owner, allowEmpty = false) { - let current; + const lockPath = cacheLockPath(); + let observed; try { - current = fs.readFileSync(cacheLockOwnerPath(), "utf8"); + observed = fs.readFileSync(cacheLockOwnerPath(), "utf8"); } catch { if (!allowEmpty) return false; + } + if (observed !== undefined && observed !== owner.raw) return false; + + const quarantine = `${lockPath}.reclaim-${process.pid}-${Date.now().toString(36)}-${crypto + .randomBytes(8) + .toString("hex")}`; + let removed = false; + try { + // rename is atomic within the cache directory. A competing stale-lock + // cleaner either loses the rename or sees the replacement owner. + fs.renameSync(lockPath, quarantine); + let actual; try { - fs.rmSync(cacheLockPath(), { recursive: true, force: false }); - return true; + actual = fs.readFileSync(path.join(quarantine, "owner"), "utf8"); } catch { - return false; + actual = undefined; } - } - if (current !== owner.raw) return false; - try { - fs.rmSync(cacheLockPath(), { recursive: true, force: false }); + if (actual !== owner.raw && !(allowEmpty && actual === undefined)) return false; + fs.rmSync(quarantine, { recursive: true, force: false }); + removed = true; return true; } catch { return false; + } finally { + if (!removed) { + // Restore the lock only when the path is still vacant. If a new owner + // won the path, leave its lock untouched and retain this quarantine for + // conservative operator cleanup. + try { + fs.renameSync(quarantine, lockPath); + } catch {} + } } } @@ -498,7 +519,7 @@ function requireNetworkRuntime() { typeof AbortSignal === "function" && typeof AbortSignal.timeout === "function"; if (nodeMajor < MIN_NODE_MAJOR || !hasFetch || !hasAbortTimeout) { fail( - "network operations require Node.js 18 or newer with global fetch and " + + "network access requires Node.js 18 or newer with global fetch and " + "AbortSignal.timeout" ); } From 12dcf5217c7075ba30cb9ab5229e7ae227032372 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:31:53 -0700 Subject: [PATCH 21/73] test(tui): fix concurrent launcher process setup --- tests/test_tui_npm_launcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 5a4272624362..4498ea584670 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -511,7 +511,6 @@ def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Pa ) old_process = subprocess.Popen( ["node", str(old_launcher)], - check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, From 2bba772754d017630e2e939d95de48fc403135e7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:36:00 -0700 Subject: [PATCH 22/73] fix(tui): recover interrupted cache lock acquisition --- cmux-tui/dist/npm/cmux/bin/cmux.js | 102 ++++++++++++++++++++++++----- tests/test_tui_npm_launcher.py | 56 ++++++++++++++++ 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index dc3bdc0bfd39..333fa7552bda 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -52,6 +52,10 @@ const MAX_METADATA_BYTES = 1024 * 1024; const REGISTRY_TIMEOUT_MS = 30_000; const STAGING_MAX_AGE_MS = 60 * 60 * 1000; const CACHE_LOCK_ATTEMPTS = 3; +// A process can be interrupted between creating the lock directory and +// publishing its owner file. Reclaim only an ownerless lock that has been +// quiet for long enough that the creator cannot still be in that window. +const CACHE_LOCK_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; const MIN_NODE_MAJOR = 18; function fail(message) { @@ -217,19 +221,28 @@ function newCacheLockOwner() { }; } -function readCacheLockOwner() { +function parseCacheLockOwner(raw) { + if (typeof raw !== "string") return null; + const lines = raw.trim().split(/\s+/); + const pid = Number.parseInt(lines[0], 10); + const token = lines[1]; + if (!Number.isInteger(pid) || pid <= 0 || !token) return null; + return { pid, token, raw }; +} + +function readCacheLockOwnerAt(ownerPath) { try { - const raw = fs.readFileSync(cacheLockOwnerPath(), "utf8"); - const lines = raw.trim().split(/\s+/); - const pid = Number.parseInt(lines[0], 10); - const token = lines[1]; - if (!Number.isInteger(pid) || pid <= 0 || !token) return null; - return { pid, token, raw }; + const raw = fs.readFileSync(ownerPath, "utf8"); + return parseCacheLockOwner(raw); } catch { return null; } } +function readCacheLockOwner() { + return readCacheLockOwnerAt(cacheLockOwnerPath()); +} + function processIsAlive(pid) { try { process.kill(pid, 0); @@ -239,6 +252,49 @@ function processIsAlive(pid) { } } +// A pending owner file is written before it is atomically renamed to `owner`. +// If the writer dies before the rename, a live pending owner proves that an +// apparently empty lock is still being initialized and must not be reclaimed. +function emptyCacheLockCanBeReclaimed() { + let entries; + try { + entries = fs.readdirSync(cacheLockPath(), { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name === "owner") { + let size; + try { + size = fs.statSync(path.join(cacheLockPath(), entry.name)).size; + } catch { + return false; + } + // An interrupted legacy direct write can leave an empty owner file. + if (!entry.isFile() || size !== 0) return false; + continue; + } + if (!entry.isFile() || !entry.name.startsWith(".owner-") || !entry.name.endsWith(".tmp")) { + return false; + } + const pending = readCacheLockOwnerAt(path.join(cacheLockPath(), entry.name)); + if (pending && processIsAlive(pending.pid)) return false; + } + return true; +} + +function emptyCacheLockIsStale() { + let stat; + try { + stat = fs.statSync(cacheLockPath()); + } catch { + return false; + } + if (!stat.isDirectory() || !Number.isFinite(stat.mtimeMs)) return false; + if (Date.now() - stat.mtimeMs < CACHE_LOCK_EMPTY_MAX_AGE_MS) return false; + return emptyCacheLockCanBeReclaimed(); +} + // Remove a lock only when its owner file still matches the observed token. // Rename the directory first, so the compare and delete cannot race a newer // owner that acquires the path after stale-lock cleanup starts. @@ -250,7 +306,9 @@ function removeCacheLockIfOwned(owner, allowEmpty = false) { } catch { if (!allowEmpty) return false; } - if (observed !== undefined && observed !== owner.raw) return false; + const observedEmpty = observed === undefined || observed === ""; + if (!observedEmpty && observed !== owner.raw) return false; + if (observedEmpty && !allowEmpty && owner.raw !== undefined) return false; const quarantine = `${lockPath}.reclaim-${process.pid}-${Date.now().toString(36)}-${crypto .randomBytes(8) @@ -266,7 +324,9 @@ function removeCacheLockIfOwned(owner, allowEmpty = false) { } catch { actual = undefined; } - if (actual !== owner.raw && !(allowEmpty && actual === undefined)) return false; + const actualEmpty = actual === undefined || actual === ""; + if (!actualEmpty && actual !== owner.raw) return false; + if (actualEmpty && !allowEmpty && owner.raw !== undefined) return false; fs.rmSync(quarantine, { recursive: true, force: false }); removed = true; return true; @@ -298,16 +358,20 @@ function tryAcquireCacheLock() { try { fs.mkdirSync(lockPath, { recursive: false }); created = true; - fs.writeFileSync(cacheLockOwnerPath(), owner.raw, { + // Publish the complete owner record with rename so readers never see a + // partially written PID/token pair. + const ownerTempPath = path.join(lockPath, `.owner-${owner.token}.tmp`); + fs.writeFileSync(ownerTempPath, owner.raw, { encoding: "utf8", flag: "wx", mode: 0o600, }); + fs.renameSync(ownerTempPath, cacheLockOwnerPath()); return owner; } catch { // If this attempt created the directory but could not publish its owner, - // clean up only that empty lock. Never remove an owner published by a - // different process. + // clean up only that lock. Never remove an owner published by a different + // process. if (created) { removeCacheLockIfOwned(owner, true); return null; @@ -315,10 +379,16 @@ function tryAcquireCacheLock() { } const current = readCacheLockOwner(); - // Unknown or malformed lock state is retained conservatively. A later - // invocation can recover it after an operator inspects the cache. - if (!current || processIsAlive(current.pid)) return null; - if (!removeCacheLockIfOwned(current)) return null; + if (current) { + if (processIsAlive(current.pid)) return null; + if (!removeCacheLockIfOwned(current)) return null; + continue; + } + // Unknown or malformed lock state is retained conservatively unless it is + // an ownerless directory left behind by an interrupted acquisition. The + // age gate plus atomic quarantine prevents deleting a fresh initializer. + if (!emptyCacheLockIsStale()) return null; + if (!removeCacheLockIfOwned({ raw: undefined }, true)) return null; } return null; } diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 4498ea584670..4eda3bd17dd8 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -484,6 +484,60 @@ def test_launcher_fails_closed_when_another_process_holds_cache_lock(tmp_path: P assert owner_path.read_text() == owner +def test_launcher_recovers_stale_empty_cache_lock(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'cached after interrupted lock'\n", + ) + lock = cache / host_platform_key() / ".update.lock" + lock.mkdir(parents=True) + stale = time.time() - 10 * 60 + os.utime(lock, (stale, stale)) + + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "cached after interrupted lock\n" + assert not lock.exists() + + +def test_launcher_keeps_fresh_empty_cache_lock(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'cached while lock initializes'\n", + ) + lock = cache / host_platform_key() / ".update.lock" + lock.mkdir(parents=True) + + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + ) + + assert result.returncode != 0 + assert result.stdout == "" + assert "could not reserve the native binary" in result.stderr + assert lock.is_dir() + assert not (lock / "owner").exists() + + def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -587,6 +641,8 @@ def main() -> None: test_binary_override_works_on_an_unsupported_platform(root / "unsupported-override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") test_launcher_fails_closed_when_another_process_holds_cache_lock(root / "held-lock") + test_launcher_recovers_stale_empty_cache_lock(root / "stale-empty-lock") + test_launcher_keeps_fresh_empty_cache_lock(root / "fresh-empty-lock") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_launcher_prunes_old_managed_cache_after_download(root / "prune") From 1987b8b4296870b45fe98051dbb4b3f11d394d6d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:43:39 -0700 Subject: [PATCH 23/73] fix(tui): publish cache leases atomically --- cmux-tui/dist/npm/cmux/bin/cmux.js | 81 +++++++++++++++++++++++++----- tests/test_tui_npm_launcher.py | 48 ++++++++++++++++++ 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 333fa7552bda..e1ff7a84ec19 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -56,6 +56,9 @@ const CACHE_LOCK_ATTEMPTS = 3; // publishing its owner file. Reclaim only an ownerless lock that has been // quiet for long enough that the creator cannot still be in that window. const CACHE_LOCK_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; +// Leases are published by renaming a fully initialized temporary directory. +// Keep the same bounded recovery window for legacy or interrupted leases. +const CACHE_LEASE_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; const MIN_NODE_MAJOR = 18; function fail(message) { @@ -404,19 +407,33 @@ function acquireVersionLease(version) { const lock = tryAcquireCacheLock(); if (!lock) continue; let lease = null; + let pendingLease = null; try { lease = path.join( leaseRoot, `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` ); + pendingLease = `${lease}.pending`; fs.mkdirSync(leaseRoot, { recursive: true }); - fs.mkdirSync(lease, { recursive: false }); - fs.writeFileSync(path.join(lease, "pid"), `${process.pid}\n`); + // Build the lease away from the directory scanned by prune. Publish it + // only after its PID record is complete, using an atomic directory + // rename so an interruption cannot expose an empty active lease. + fs.mkdirSync(pendingLease, { recursive: false }); + const pidTemp = path.join(pendingLease, ".pid.tmp"); + fs.writeFileSync(pidTemp, `${process.pid}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + fs.renameSync(pidTemp, path.join(pendingLease, "pid")); + fs.renameSync(pendingLease, lease); + pendingLease = null; return lease; } catch { - if (lease) { + for (const pathToRemove of [pendingLease, lease]) { + if (!pathToRemove) continue; try { - fs.rmSync(lease, { recursive: true, force: true }); + fs.rmSync(pathToRemove, { recursive: true, force: true }); } catch {} } } finally { @@ -435,17 +452,46 @@ function releaseVersionLease(lease) { } catch {} } -function leaseIsActive(lease) { +// Returns "live" or "dead" for a PID owner, "missing" or "malformed" for a +// recoverable interrupted record, and "unknown" for an unreadable record. +function leaseActivity(lease) { + let raw; + try { + raw = fs.readFileSync(path.join(lease, "pid"), "utf8"); + } catch (error) { + return error && (error.code === "ENOENT" || error.code === "EISDIR") + ? "missing" + : "unknown"; + } + const pid = Number.parseInt(raw, 10); + if (!Number.isInteger(pid) || pid <= 0) return "malformed"; try { - const pid = Number.parseInt(fs.readFileSync(path.join(lease, "pid"), "utf8"), 10); - if (!Number.isInteger(pid) || pid <= 0) return true; process.kill(pid, 0); - return true; + return "live"; } catch (error) { - return error && error.code !== "ESRCH"; + return error && error.code === "ESRCH" ? "dead" : "unknown"; + } +} + +function leaseIsStale(lease) { + try { + const stat = fs.statSync(lease); + return ( + Number.isFinite(stat.mtimeMs) && + Date.now() - stat.mtimeMs >= CACHE_LEASE_EMPTY_MAX_AGE_MS + ); + } catch { + return false; } } +function leaseCanBeReclaimed(lease) { + const activity = leaseActivity(lease); + if (activity === "live" || activity === "unknown") return false; + if (activity === "dead") return true; + return leaseIsStale(lease); +} + function versionHasActiveLease(versionDir) { const leaseRoot = path.join(versionDir, ".active"); if (!fs.existsSync(leaseRoot)) return false; @@ -455,15 +501,24 @@ function versionHasActiveLease(versionDir) { for (const entry of entries) { const lease = path.join(leaseRoot, entry.name); if (entry.isDirectory()) { - if (leaseIsActive(lease)) { - active = true; - } else { + if (leaseCanBeReclaimed(lease)) { fs.rmSync(lease, { recursive: true, force: true }); + } else { + active = true; } } else if (entry.name === "pid") { // Read leases written by older launchers, before leases became // per-process directories. - active = leaseIsActive(leaseRoot); + const activity = leaseActivity(leaseRoot); + if (activity === "live" || activity === "unknown") { + active = true; + } else if (activity === "dead" || leaseIsStale(leaseRoot)) { + fs.rmSync(path.join(leaseRoot, "pid"), { recursive: false, force: true }); + } else { + // A fresh malformed legacy record may belong to a process that is + // still publishing its PID. Retain the version until it is stale. + active = true; + } } else { // Unknown lease state is retained conservatively. active = true; diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 4eda3bd17dd8..321e83e06140 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -538,6 +538,52 @@ def test_launcher_keeps_fresh_empty_cache_lock(tmp_path: Path) -> None: assert not (lock / "owner").exists() +def test_launcher_reclaims_stale_empty_lease_during_prune(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) + lease_root = cache / host_platform_key() / "v/1.0.0/.active" + stale_lease = lease_root / "interrupted-lease" + stale_lease.mkdir(parents=True) + stale = time.time() - 10 * 60 + os.utime(stale_lease, (stale, stale)) + + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry) + finally: + server.shutdown() + thread.join() + + assert result.returncode == 0, result.stderr + assert not (cache / host_platform_key() / "v/1.0.0").exists() + + +def test_launcher_keeps_fresh_empty_lease_during_prune(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) + lease_root = cache / host_platform_key() / "v/1.0.0/.active" + fresh_lease = lease_root / "initializing-lease" + fresh_lease.mkdir(parents=True) + + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry) + finally: + server.shutdown() + thread.join() + + assert result.returncode == 0, result.stderr + assert fresh_lease.is_dir() + + def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -643,6 +689,8 @@ def main() -> None: test_launcher_fails_closed_when_another_process_holds_cache_lock(root / "held-lock") test_launcher_recovers_stale_empty_cache_lock(root / "stale-empty-lock") test_launcher_keeps_fresh_empty_cache_lock(root / "fresh-empty-lock") + test_launcher_reclaims_stale_empty_lease_during_prune(root / "stale-empty-lease") + test_launcher_keeps_fresh_empty_lease_during_prune(root / "fresh-empty-lease") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_launcher_prunes_old_managed_cache_after_download(root / "prune") From 0af1bec2fa355ab8b4277f30b76642721a8f6788 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:49:02 -0700 Subject: [PATCH 24/73] docs(tui): narrow npx cache recovery --- cmux-tui/docs/getting-started.ja.md | 26 ++++++++++++++++++++++---- cmux-tui/docs/getting-started.md | 24 +++++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 5a3a4d54003d..05ee71834247 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -32,18 +32,36 @@ npm のダウンロードキャッシュはランチャーから読み取れま ## npx の ENOTEMPTY エラー -`npx cmux@latest` が npm の処理中に失敗した場合は、npm のキャッシュ場所を確認して -`_npx` だけを削除します。 +`npx cmux@latest` が npm の処理中に失敗した場合は、実行中の `npx` プロセスを先に停止してください。 +以下のコマンドは `_npx` 直下のエントリを表示し、エラーに出たキャッシュハッシュのディレクトリだけを削除します。 ```bash npm_cache="$(npm config get cache)" target="$npm_cache/_npx" -printf '削除対象: %s\n' "$target" case "$npm_cache" in ""|/) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; esac +if [ ! -d "$target" ]; then + echo "npx キャッシュディレクトリがありません: $target" >&2 + exit 1 +fi +printf '利用可能な npx エントリ:\n' +find "$target" -mindepth 1 -maxdepth 1 -type d -print +read -r -p '削除する npx キャッシュハッシュを正確に入力してください: ' hash +case "$hash" in + ""|.|..|*[!A-Za-z0-9_-]*) + echo "無効な npx キャッシュハッシュのため中止します" >&2 + exit 1 + ;; +esac +entry="$target/$hash" +if [ ! -d "$entry" ]; then + echo "npx キャッシュエントリがありません: $hash" >&2 + exit 1 +fi +printf '削除対象(このエントリだけ): %s\n' "$entry" read -r -p '続行する場合は yes と入力してください: ' confirm [ "$confirm" = yes ] || exit 1 -rm -rf -- "$target" +rm -rf -- "$entry" npx cmux@latest ``` diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index bc1038e890f5..d61e46b46a19 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -160,18 +160,36 @@ npm error path ~/.npm/_npx//node_modules/cmux-tui-darwin-arm64 npm error ENOTEMPTY: directory not empty, rename ... ``` -This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Derive npm's cache location, clear only its npx entries, and rerun: +This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Stop running `npx` processes first. The command below lists the direct `_npx` entries, then removes only the exact cache hash shown in the error: ```bash npm_cache="$(npm config get cache)" target="$npm_cache/_npx" -printf 'About to remove: %s\n' "$target" case "$npm_cache" in ""|/) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; esac +if [ ! -d "$target" ]; then + echo "No npx cache directory: $target" >&2 + exit 1 +fi +printf 'Available npx entries:\n' +find "$target" -mindepth 1 -maxdepth 1 -type d -print +read -r -p 'Enter the exact npx cache hash to remove: ' hash +case "$hash" in + ""|.|..|*[!A-Za-z0-9_-]*) + echo "Refusing an invalid npx cache hash" >&2 + exit 1 + ;; +esac +entry="$target/$hash" +if [ ! -d "$entry" ]; then + echo "npx cache entry not found: $hash" >&2 + exit 1 +fi +printf 'About to remove only: %s\n' "$entry" read -r -p 'Type yes to continue: ' confirm [ "$confirm" = yes ] || exit 1 -rm -rf -- "$target" +rm -rf -- "$entry" npx cmux@latest ``` From 0d42daa2150552806a0e14e7d74f2490621df053 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:56:38 -0700 Subject: [PATCH 25/73] fix(tui): release cache leases on launch errors --- cmux-tui/dist/npm/cmux/bin/cmux.js | 37 ++++++++++++++++++++++++------ tests/test_tui_npm_launcher.py | 20 +++++++++++++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index e1ff7a84ec19..2251500274c6 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -61,9 +61,15 @@ const CACHE_LOCK_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; const CACHE_LEASE_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; const MIN_NODE_MAJOR = 18; +class LauncherError extends Error { + constructor(message) { + super(message); + this.name = "LauncherError"; + } +} + function fail(message) { - console.error(`cmux: ${message}`); - process.exit(1); + throw new LauncherError(message); } function platformPackage() { @@ -1007,6 +1013,7 @@ async function main() { const pkg = override ? null : platformPackage(); let lease = null; let exitCode = 1; + let childSignal = null; try { cleanupStaging(); const wanted = override ? null : wantedVersion(pkg); @@ -1025,14 +1032,30 @@ async function main() { fail("failed to launch the native binary"); } if (result.signal) { - process.kill(process.pid, result.signal); - return; + childSignal = result.signal; + } else { + exitCode = result.status === null ? 1 : result.status; } - exitCode = result.status === null ? 1 : result.status; } finally { releaseVersionLease(lease); } - process.exit(exitCode); + if (childSignal) { + process.exitCode = 1; + try { + process.kill(process.pid, childSignal); + } catch { + // Keep the non-zero fallback when the signal cannot be delivered. + } + return; + } + process.exitCode = exitCode; } -main().catch(() => fail("launcher failed before starting the native binary")); +main().catch((error) => { + const message = + error instanceof LauncherError + ? error.message + : "launcher failed before starting the native binary"; + console.error(`cmux: ${message}`); + process.exitCode = 1; +}); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 321e83e06140..40ba95a328a8 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -315,11 +315,28 @@ def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path if sys.platform == "win32": return launcher = write_launcher(tmp_path) - result = run_launcher(launcher, tmp_path / "cache", "http://127.0.0.1:1") + cache = tmp_path / "cache" + result = run_launcher(launcher, cache, "http://127.0.0.1:1") assert result.returncode != 0 assert "could not obtain the native binary" in result.stderr assert "127.0.0.1" not in result.stderr assert "CMUX_" not in result.stderr + assert not (cache / host_platform_key() / "v/1.2.3/.active").exists() + + +def test_launcher_releases_lease_when_native_launch_fails(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + binary = write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n") + binary.chmod(0o644) + + result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + + assert result.returncode != 0 + assert "failed to launch the native binary" in result.stderr + assert not (cache / host_platform_key() / "v/1.2.3/.active").exists() def test_launcher_reads_registry_token_from_npmrc(tmp_path: Path) -> None: @@ -680,6 +697,7 @@ def main() -> None: test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") + test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") From bb40a12d1ee6263b479bbbaa968d8ac0dc7977a7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:08:57 -0700 Subject: [PATCH 26/73] fix(tui): lease installed and updating cache binaries --- cmux-tui/dist/npm/cmux/bin/cmux.js | 30 +++++--- tests/test_tui_npm_launcher.py | 114 ++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 2251500274c6..f4b6573ee56c 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -980,12 +980,18 @@ async function runUpdate(pkg, args) { console.log(`cmux ${latest} is available (current: ${current}). Run: cmux update`); return; } - await downloadVersion(pkg, latest); - writeState({ - version: latest, - updatedAt: new Date().toISOString(), - }); - pruneCache(latest); + const lease = acquireVersionLease(latest); + if (!lease) fail("could not reserve the native binary for update"); + try { + await downloadVersion(pkg, latest); + writeState({ + version: latest, + updatedAt: new Date().toISOString(), + }); + pruneCache(latest); + } finally { + releaseVersionLease(lease); + } console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); } @@ -1017,16 +1023,20 @@ async function main() { try { cleanupStaging(); const wanted = override ? null : wantedVersion(pkg); + // A matching installed package is independent of the launcher cache. Do + // not require a writable cache or create a lease when it can run directly. + const installed = wanted ? installedPackage(pkg) : null; + const installedBin = installed && installed.version === wanted ? installed.binPath : null; // Lease creation serializes with pruning. If another process owns the // lock, fail closed rather than launching an unleased binary that a prune // can remove while it is running. - lease = wanted ? acquireVersionLease(wanted) : null; - if (wanted && !lease) { + lease = wanted && !installedBin ? acquireVersionLease(wanted) : null; + if (wanted && !installedBin && !lease) { fail("could not reserve the native binary for launch"); } if (lease) process.once("exit", () => releaseVersionLease(lease)); - const binPath = await resolveBinary(pkg, wanted); - if (wanted) pruneCache(wanted); + const binPath = installedBin || (await resolveBinary(pkg, wanted)); + if (lease) pruneCache(wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); if (result.error) { fail("failed to launch the native binary"); diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 40ba95a328a8..7f39f4f43350 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -45,6 +45,10 @@ def make_negative_size_tarball() -> bytes: class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() + latest_version = "1.2.3" + block_tarball = False + tarball_started = threading.Event() + tarball_release = threading.Event() metadata_requests = 0 tarball_requests = 0 authorization_headers: list[str | None] = [] @@ -52,7 +56,9 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) - if self.path.endswith(( + if self.path == "/cmux/latest": + body = json.dumps({"version": type(self).latest_version}).encode() + elif self.path.endswith(( "/cmux-tui-darwin-arm64/1.2.3", "/cmux-tui-darwin-x64/1.2.3", "/cmux-tui-linux-arm64/1.2.3", @@ -70,6 +76,9 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler ).encode() elif self.path == "/tarball.tgz": type(self).tarball_requests += 1 + if type(self).block_tarball: + type(self).tarball_started.set() + type(self).tarball_release.wait(timeout=10) body = self.tarball else: self.send_error(404) @@ -191,6 +200,10 @@ def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, RegistryHandler.tarball_requests = 0 RegistryHandler.authorization_headers = [] RegistryHandler.status = 200 + RegistryHandler.latest_version = "1.2.3" + RegistryHandler.block_tarball = False + RegistryHandler.tarball_started = threading.Event() + RegistryHandler.tarball_release = threading.Event() server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RegistryHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -417,6 +430,30 @@ def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> assert "could not obtain the native binary" in result.stderr +def test_launcher_runs_matching_installed_binary_without_cache_access(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + package_name = f"cmux-tui-{host_platform_key()}" + package = tmp_path / "node_modules" / package_name + package.mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({"name": package_name, "version": "1.2.3"}) + "\n" + ) + binary = package / "bin/cmux-tui" + binary.parent.mkdir() + binary.write_text("#!/bin/sh\nprintf '%s\\n' 'installed offline binary'\n") + binary.chmod(0o755) + cache_file = tmp_path / "cache-file" + cache_file.write_text("cache is intentionally unavailable\n") + + result = run_launcher(launcher, cache_file, "http://127.0.0.1:1", "--version") + + assert result.returncode == 0, result.stderr + assert result.stdout == "installed offline binary\n" + assert cache_file.read_text() == "cache is intentionally unavailable\n" + + def test_managed_launcher_honors_development_binary_override(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -656,6 +693,79 @@ def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Pa assert old_process.returncode == 0 +def test_update_lease_protects_download_from_concurrent_prune(tmp_path: Path) -> None: + if sys.platform == "win32": + return + update_launcher = write_launcher(tmp_path / "update", "1.0.0") + launch_launcher = write_launcher(tmp_path / "launch", "1.1.0") + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary( + cache, + "1.1.0", + "#!/bin/sh\nprintf '%s\\n' 'cached while update is downloading'\n", + managed=True, + ) + # This version is deliberately un-managed. A concurrent launcher's prune + # would delete it unless the update process publishes its lease first. + target = write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n", + ) + + server, thread, registry = start_registry() + RegistryHandler.block_tarball = True + env = os.environ.copy() + env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": registry, + "NO_COLOR": "1", + } + ) + update_process = subprocess.Popen( + ["node", str(update_launcher), "update"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + update_stdout = "" + update_stderr = "" + try: + assert RegistryHandler.tarball_started.wait(timeout=3), ( + "update did not start its download" + ) + active_root = cache / host_platform_key() / "v/1.2.3/.active" + assert any(entry.is_dir() for entry in active_root.iterdir()), ( + "update did not publish its target lease before downloading" + ) + launch_result = run_launcher( + launch_launcher, + cache, + registry, + "--version", + timeout_seconds=5, + ) + assert launch_result.returncode == 0, launch_result.stderr + assert launch_result.stdout == "cached while update is downloading\n" + assert target.is_file(), "concurrent prune removed the leased update target" + finally: + RegistryHandler.tarball_release.set() + try: + update_stdout, update_stderr = update_process.communicate(timeout=10) + except subprocess.TimeoutExpired: + update_process.kill() + update_stdout, update_stderr = update_process.communicate(timeout=5) + server.shutdown() + thread.join() + + assert update_process.returncode == 0, update_stderr or update_stdout + assert target.is_file() + assert (target.parent.parent / "managed").is_file() + + def test_launcher_prunes_old_managed_cache_after_download(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -701,6 +811,7 @@ def main() -> None: test_launcher_reads_registry_token_from_npmrc(root / "npmrc") test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") + test_launcher_runs_matching_installed_binary_without_cache_access(root / "installed-offline") test_managed_launcher_honors_development_binary_override(root / "override") test_binary_override_works_on_an_unsupported_platform(root / "unsupported-override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") @@ -710,6 +821,7 @@ def main() -> None: test_launcher_reclaims_stale_empty_lease_during_prune(root / "stale-empty-lease") test_launcher_keeps_fresh_empty_lease_during_prune(root / "fresh-empty-lease") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") + test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") test_launcher_prunes_old_managed_cache_after_download(root / "prune") From 208d8211480d332851f9d4ae6960b42ca7adf275 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:12:23 -0700 Subject: [PATCH 27/73] fix(tui): preserve runtime capability errors --- cmux-tui/dist/npm/cmux/bin/cmux.js | 4 ++++ tests/test_tui_npm_launcher.py | 1 + 2 files changed, 5 insertions(+) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index f4b6573ee56c..137848aaa6c6 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -949,6 +949,10 @@ async function resolveBinary(pkg, wanted) { const cached = cachedBinary(wanted); if (cached) return cached; + // Check the runtime before entering the generic download error boundary so + // an unsupported Node version gets a useful, actionable message instead of + // being flattened into a network failure. + requireNetworkRuntime(); try { return await downloadVersion(pkg, wanted); } catch { diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 7f39f4f43350..af6c3960810f 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -260,6 +260,7 @@ def test_launcher_requires_network_runtime_capabilities(tmp_path: Path) -> None: assert "fetch" in result.stderr assert "AbortSignal.timeout" in result.stderr assert "127.0.0.1" not in result.stderr + assert not (tmp_path / "cache" / host_platform_key() / "v/1.2.3/.active").exists() def test_launcher_declares_node_engine_requirement() -> None: From 59dc56445fe1ff205438b190fd1e9dedccd88cbe Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:27:25 -0700 Subject: [PATCH 28/73] fix(tui): bound cache to current and previous versions --- cmux-tui/dist/npm/cmux/bin/cmux.js | 8 +++++++- tests/test_tui_npm_launcher.py | 12 ++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 137848aaa6c6..ab9c5174946d 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -59,6 +59,9 @@ const CACHE_LOCK_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; // Leases are published by renaming a fully initialized temporary directory. // Keep the same bounded recovery window for legacy or interrupted leases. const CACHE_LEASE_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; +// Keep the requested version and one newest managed predecessor. This bounds +// disk use while retaining one rollback target after an update. +const MAX_PREVIOUS_MANAGED_VERSIONS = 1; const MIN_NODE_MAJOR = 18; class LauncherError extends Error { @@ -896,7 +899,10 @@ function pruneCache(keepVersion) { .readdirSync(root) .filter((version) => fs.existsSync(path.join(root, version, "managed"))) .sort(compareVersions); - const keep = new Set([keepVersion, ...managed.slice(-2)]); + const previous = managed + .filter((version) => version !== keepVersion) + .slice(-MAX_PREVIOUS_MANAGED_VERSIONS); + const keep = new Set([keepVersion, ...previous]); for (const version of fs.readdirSync(root)) { if (keep.has(version)) continue; if (versionHasActiveLease(path.join(root, version))) continue; diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index af6c3960810f..7b7dfaf2fa8f 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -767,7 +767,7 @@ def test_update_lease_protects_download_from_concurrent_prune(tmp_path: Path) -> assert (target.parent.parent / "managed").is_file() -def test_launcher_prunes_old_managed_cache_after_download(tmp_path: Path) -> None: +def test_launcher_keeps_current_and_one_previous_after_download(tmp_path: Path) -> None: if sys.platform == "win32": return launcher = write_launcher(tmp_path) @@ -792,10 +792,10 @@ def test_launcher_prunes_old_managed_cache_after_download(tmp_path: Path) -> Non server.shutdown() thread.join() assert result.returncode == 0, result.stderr - assert not (platform_root / "0.9.0").exists() - assert not (platform_root / "1.0.0").exists() - assert (platform_root / "1.1.0").exists() - assert (platform_root / "1.2.3").exists() + retained = { + entry.name for entry in platform_root.iterdir() if entry.is_dir() + } + assert retained == {"1.1.0", "1.2.3"} def main() -> None: @@ -823,7 +823,7 @@ def main() -> None: test_launcher_keeps_fresh_empty_lease_during_prune(root / "fresh-empty-lease") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") - test_launcher_prunes_old_managed_cache_after_download(root / "prune") + test_launcher_keeps_current_and_one_previous_after_download(root / "prune") if __name__ == "__main__": From ef32e41736b3353ed6cfb1525d9e62fd39765d7f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:14:35 -0700 Subject: [PATCH 29/73] test(tui): cover concurrent npm updates --- tests/test_tui_npm_launcher.py | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 7b7dfaf2fa8f..3c92dac12163 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -738,6 +738,10 @@ def test_update_lease_protects_download_from_concurrent_prune(tmp_path: Path) -> assert RegistryHandler.tarball_started.wait(timeout=3), ( "update did not start its download" ) + update_lock = cache / host_platform_key() / ".update-operation.lock" + assert (update_lock / "owner").is_file(), ( + "update did not hold the operation lock during its download" + ) active_root = cache / host_platform_key() / "v/1.2.3/.active" assert any(entry.is_dir() for entry in active_root.iterdir()), ( "update did not publish its target lease before downloading" @@ -767,6 +771,62 @@ def test_update_lease_protects_download_from_concurrent_prune(tmp_path: Path) -> assert (target.parent.parent / "managed").is_file() +def test_concurrent_updates_fail_closed_while_one_downloads(tmp_path: Path) -> None: + if sys.platform == "win32": + return + update_launcher = write_launcher(tmp_path / "first", "1.0.0") + concurrent_launcher = write_launcher(tmp_path / "second", "1.0.0") + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + + server, thread, registry = start_registry() + RegistryHandler.block_tarball = True + env = os.environ.copy() + env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": registry, + "NO_COLOR": "1", + } + ) + first_process = subprocess.Popen( + ["node", str(update_launcher), "update"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + first_stdout = "" + first_stderr = "" + try: + assert RegistryHandler.tarball_started.wait(timeout=3), ( + "first update did not start its download" + ) + second = run_launcher( + concurrent_launcher, + cache, + registry, + "update", + timeout_seconds=5, + ) + assert second.returncode != 0 + assert "could not reserve the native binary for update" in second.stderr + finally: + RegistryHandler.tarball_release.set() + try: + first_stdout, first_stderr = first_process.communicate(timeout=10) + except subprocess.TimeoutExpired: + first_process.kill() + first_stdout, first_stderr = first_process.communicate(timeout=5) + server.shutdown() + thread.join() + + assert first_process.returncode == 0, first_stderr or first_stdout + state = json.loads((cache / host_platform_key() / "state.json").read_text()) + assert state["version"] == "1.2.3" + assert not (cache / host_platform_key() / ".update-operation.lock").exists() + + def test_launcher_keeps_current_and_one_previous_after_download(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -823,6 +883,7 @@ def main() -> None: test_launcher_keeps_fresh_empty_lease_during_prune(root / "fresh-empty-lease") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") + test_concurrent_updates_fail_closed_while_one_downloads(root / "update-serialization") test_launcher_keeps_current_and_one_previous_after_download(root / "prune") From 133a1a7b1b55119b8d6c9924f72dcfcd23909ce4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:14:43 -0700 Subject: [PATCH 30/73] fix(tui): serialize npm updates and guard npx recovery --- cmux-tui/dist/npm/cmux/bin/cmux.js | 94 ++++++++++++++--------- cmux-tui/docs/getting-started.ja.md | 113 ++++++++++++++++++++++++++-- cmux-tui/docs/getting-started.md | 110 +++++++++++++++++++++++++-- 3 files changed, 266 insertions(+), 51 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index ab9c5174946d..552a681674b2 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -218,8 +218,15 @@ function cacheLockPath() { return path.join(platformRoot(), ".update.lock"); } -function cacheLockOwnerPath() { - return path.join(cacheLockPath(), "owner"); +// Keep update serialization separate from the cache lock. Launches must still +// publish version leases while an update is downloading, so prune can honor +// those leases instead of failing every launch for the whole network request. +function updateOperationLockPath() { + return path.join(platformRoot(), ".update-operation.lock"); +} + +function cacheLockOwnerPath(lockPath = cacheLockPath()) { + return path.join(lockPath, "owner"); } function newCacheLockOwner() { @@ -251,8 +258,8 @@ function readCacheLockOwnerAt(ownerPath) { } } -function readCacheLockOwner() { - return readCacheLockOwnerAt(cacheLockOwnerPath()); +function readCacheLockOwner(lockPath = cacheLockPath()) { + return readCacheLockOwnerAt(cacheLockOwnerPath(lockPath)); } function processIsAlive(pid) { @@ -267,10 +274,10 @@ function processIsAlive(pid) { // A pending owner file is written before it is atomically renamed to `owner`. // If the writer dies before the rename, a live pending owner proves that an // apparently empty lock is still being initialized and must not be reclaimed. -function emptyCacheLockCanBeReclaimed() { +function emptyCacheLockCanBeReclaimed(lockPath = cacheLockPath()) { let entries; try { - entries = fs.readdirSync(cacheLockPath(), { withFileTypes: true }); + entries = fs.readdirSync(lockPath, { withFileTypes: true }); } catch { return false; } @@ -278,7 +285,7 @@ function emptyCacheLockCanBeReclaimed() { if (entry.name === "owner") { let size; try { - size = fs.statSync(path.join(cacheLockPath(), entry.name)).size; + size = fs.statSync(path.join(lockPath, entry.name)).size; } catch { return false; } @@ -289,32 +296,31 @@ function emptyCacheLockCanBeReclaimed() { if (!entry.isFile() || !entry.name.startsWith(".owner-") || !entry.name.endsWith(".tmp")) { return false; } - const pending = readCacheLockOwnerAt(path.join(cacheLockPath(), entry.name)); + const pending = readCacheLockOwnerAt(path.join(lockPath, entry.name)); if (pending && processIsAlive(pending.pid)) return false; } return true; } -function emptyCacheLockIsStale() { +function emptyCacheLockIsStale(lockPath = cacheLockPath()) { let stat; try { - stat = fs.statSync(cacheLockPath()); + stat = fs.statSync(lockPath); } catch { return false; } if (!stat.isDirectory() || !Number.isFinite(stat.mtimeMs)) return false; if (Date.now() - stat.mtimeMs < CACHE_LOCK_EMPTY_MAX_AGE_MS) return false; - return emptyCacheLockCanBeReclaimed(); + return emptyCacheLockCanBeReclaimed(lockPath); } // Remove a lock only when its owner file still matches the observed token. // Rename the directory first, so the compare and delete cannot race a newer // owner that acquires the path after stale-lock cleanup starts. -function removeCacheLockIfOwned(owner, allowEmpty = false) { - const lockPath = cacheLockPath(); +function removeCacheLockIfOwned(owner, allowEmpty = false, lockPath = cacheLockPath()) { let observed; try { - observed = fs.readFileSync(cacheLockOwnerPath(), "utf8"); + observed = fs.readFileSync(cacheLockOwnerPath(lockPath), "utf8"); } catch { if (!allowEmpty) return false; } @@ -356,8 +362,7 @@ function removeCacheLockIfOwned(owner, allowEmpty = false) { } } -function tryAcquireCacheLock() { - const lockPath = cacheLockPath(); +function tryAcquireCacheLock(lockPath = cacheLockPath()) { try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { @@ -378,36 +383,36 @@ function tryAcquireCacheLock() { flag: "wx", mode: 0o600, }); - fs.renameSync(ownerTempPath, cacheLockOwnerPath()); + fs.renameSync(ownerTempPath, cacheLockOwnerPath(lockPath)); return owner; } catch { // If this attempt created the directory but could not publish its owner, // clean up only that lock. Never remove an owner published by a different // process. if (created) { - removeCacheLockIfOwned(owner, true); + removeCacheLockIfOwned(owner, true, lockPath); return null; } } - const current = readCacheLockOwner(); + const current = readCacheLockOwner(lockPath); if (current) { if (processIsAlive(current.pid)) return null; - if (!removeCacheLockIfOwned(current)) return null; + if (!removeCacheLockIfOwned(current, false, lockPath)) return null; continue; } // Unknown or malformed lock state is retained conservatively unless it is // an ownerless directory left behind by an interrupted acquisition. The // age gate plus atomic quarantine prevents deleting a fresh initializer. - if (!emptyCacheLockIsStale()) return null; - if (!removeCacheLockIfOwned({ raw: undefined }, true)) return null; + if (!emptyCacheLockIsStale(lockPath)) return null; + if (!removeCacheLockIfOwned({ raw: undefined }, true, lockPath)) return null; } return null; } -function releaseCacheLock(owner) { +function releaseCacheLock(owner, lockPath = cacheLockPath()) { if (!owner) return; - removeCacheLockIfOwned(owner); + removeCacheLockIfOwned(owner, false, lockPath); } function acquireVersionLease(version) { @@ -978,31 +983,50 @@ async function runUpdate(pkg, args) { if (unknown.length) { fail("invalid update arguments. Usage: cmux update [--check]"); } - const current = wantedVersion(pkg); - const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); - const latest = latestMeta && latestMeta.version; - if (!validVersion(latest)) fail("could not determine the latest published release"); - if (compareVersions(latest, current) <= 0) { - console.log(`cmux ${current} is up to date (latest is ${latest}).`); - return; - } if (checkOnly) { + const current = wantedVersion(pkg); + const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); + const latest = latestMeta && latestMeta.version; + if (!validVersion(latest)) fail("could not determine the latest published release"); + if (compareVersions(latest, current) <= 0) { + console.log(`cmux ${current} is up to date (latest is ${latest}).`); + return; + } console.log(`cmux ${latest} is available (current: ${current}). Run: cmux update`); return; } - const lease = acquireVersionLease(latest); - if (!lease) fail("could not reserve the native binary for update"); + + // Keep one update-wide lock from the version check through download, state + // publication, and pruning. This prevents two update processes from + // completing out of order and pinning state.json to an older version. + const updateLockPath = updateOperationLockPath(); + const updateLock = tryAcquireCacheLock(updateLockPath); + if (!updateLock) fail("could not reserve the native binary for update"); + let lease = null; try { + // Re-read the state after acquiring the lock. Another updater may have + // completed before this process obtained it. + const current = wantedVersion(pkg); + const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); + const latest = latestMeta && latestMeta.version; + if (!validVersion(latest)) fail("could not determine the latest published release"); + if (compareVersions(latest, current) <= 0) { + console.log(`cmux ${current} is up to date (latest is ${latest}).`); + return; + } + lease = acquireVersionLease(latest); + if (!lease) fail("could not reserve the native binary for update"); await downloadVersion(pkg, latest); writeState({ version: latest, updatedAt: new Date().toISOString(), }); pruneCache(latest); + console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); } finally { releaseVersionLease(lease); + releaseCacheLock(updateLock, updateLockPath); } - console.log(`cmux updated: ${current} -> ${latest}. The new version runs on the next start.`); } async function main() { diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 05ee71834247..171dc7961dbf 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -32,24 +32,79 @@ npm のダウンロードキャッシュはランチャーから読み取れま ## npx の ENOTEMPTY エラー -`npx cmux@latest` が npm の処理中に失敗した場合は、実行中の `npx` プロセスを先に停止してください。 -以下のコマンドは `_npx` 直下のエントリを表示し、エラーに出たキャッシュハッシュのディレクトリだけを削除します。 +`npx cmux@latest` が npm の処理中に失敗した場合は、すべての `npx` プロセスを先に停止してください。 +以下のコマンドは回復ロックを取得し、選択したエントリが使用中でないことを確認し、最新のエントリを拒否します。 +エラーに出たキャッシュハッシュだけを隔離ディレクトリへ移動し、使用中のキャッシュツリーを削除しません。 ```bash +set -eu + npm_cache="$(npm config get cache)" target="$npm_cache/_npx" case "$npm_cache" in - ""|/) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; + ""|/|.|./*|../*|*/./*|*/../*|*/.|*/..) echo "安全でない npm キャッシュパスのため中止します" >&2; exit 1 ;; + /*) ;; + *) echo "相対 npm キャッシュパスのため中止します" >&2; exit 1 ;; esac if [ ! -d "$target" ]; then echo "npx キャッシュディレクトリがありません: $target" >&2 exit 1 fi +if [ -L "$target" ]; then + echo "シンボリックリンクの npx キャッシュディレクトリのため中止します: $target" >&2 + exit 1 +fi + +lock="$npm_cache/.cmux-npx-recovery.lock" +if ! (umask 077 && mkdir "$lock" 2>/dev/null); then + echo "別の npx 回復処理が実行中か、このロックを手動で確認する必要があります: $lock" >&2 + exit 1 +fi +unlock() { + rmdir "$lock" 2>/dev/null || true +} +abort() { + unlock + exit 1 +} +trap unlock EXIT +trap abort HUP INT TERM + printf '利用可能な npx エントリ:\n' -find "$target" -mindepth 1 -maxdepth 1 -type d -print -read -r -p '削除する npx キャッシュハッシュを正確に入力してください: ' hash +newest_entry="" +newest_mtime="" +entry_mtime() { + value="$(stat -f %m "$1" 2>/dev/null || true)" + case "$value" in + ''|*[!0-9]*) ;; + *) printf '%s\n' "$value"; return 0 ;; + esac + value="$(stat -c %Y "$1" 2>/dev/null || true)" + case "$value" in + ''|*[!0-9]*) return 1 ;; + *) printf '%s\n' "$value"; return 0 ;; + esac +} +for candidate in "$target"/*; do + [ -d "$candidate" ] || continue + [ ! -L "$candidate" ] || { echo "シンボリックリンクの npx エントリのため中止します: $candidate" >&2; exit 1; } + name="${candidate##*/}" + case "$name" in + ''|*[!A-Za-z0-9_-]*) echo "予期しない npx エントリのため中止します: $candidate" >&2; exit 1 ;; + esac + mtime="$(entry_mtime "$candidate")" || { + echo "npx エントリの時刻を確認できません: $candidate" >&2 + exit 1 + } + printf '%s\n' "$candidate" + if [ -z "$newest_mtime" ] || [ "$mtime" -gt "$newest_mtime" ]; then + newest_entry="$candidate" + newest_mtime="$mtime" + fi +done +read -r -p '隔離する npx キャッシュハッシュを正確に入力してください: ' hash case "$hash" in - ""|.|..|*[!A-Za-z0-9_-]*) + ""|[-.]*|*[!A-Za-z0-9_-]*) echo "無効な npx キャッシュハッシュのため中止します" >&2 exit 1 ;; @@ -59,9 +114,51 @@ if [ ! -d "$entry" ]; then echo "npx キャッシュエントリがありません: $hash" >&2 exit 1 fi -printf '削除対象(このエントリだけ): %s\n' "$entry" +if [ -L "$entry" ]; then + echo "シンボリックリンクの npx キャッシュエントリのため中止します: $entry" >&2 + exit 1 +fi +entry_mtime="$(entry_mtime "$entry")" || { + echo "選択した npx エントリを確認できません: $entry" >&2 + exit 1 +} +if [ "$entry" = "$newest_entry" ] || [ "$entry_mtime" -ge "$newest_mtime" ]; then + echo "最新の npx エントリは移動しません。エラーに出た古いハッシュを使用してください" >&2 + exit 1 +fi +if ! command -v lsof >/dev/null 2>&1; then + echo "npx エントリが使用中か確認するため lsof が必要です" >&2 + exit 1 +fi +if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then + [ -z "$open_pids" ] || { + echo "プロセスが開いている npx エントリのため中止します: $open_pids" >&2 + exit 1 + } +else + lsof_status=$? + if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then + echo "npx エントリが非アクティブだと確認できません: $entry" >&2 + exit 1 + fi +fi +printf '隔離対象(このエントリだけ): %s\n' "$entry" read -r -p '続行する場合は yes と入力してください: ' confirm [ "$confirm" = yes ] || exit 1 -rm -rf -- "$entry" +quarantine="$npm_cache/.cmux-npx-quarantine" +if [ -L "$quarantine" ] || { [ -e "$quarantine" ] && [ ! -d "$quarantine" ]; }; then + echo "安全でない隔離パスのため中止します: $quarantine" >&2 + exit 1 +fi +mkdir -p "$quarantine" +destination="$quarantine/${hash}-$(date +%s)-$$" +[ ! -e "$destination" ] || { + echo "既存の隔離エントリを上書きするため中止します: $destination" >&2 + exit 1 +} +mv "$entry" "$destination" +printf '隔離先: %s\n' "$destination" npx cmux@latest ``` + +移動は隔離エントリが残っている間は元に戻せます。すべての `npx` プロセスを停止し、新しいランチャーが動くことを確認してから、通常のファイルマネージャーで隔離エントリを削除してください。 diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index d61e46b46a19..4526b70c9dc8 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -160,23 +160,77 @@ npm error path ~/.npm/_npx//node_modules/cmux-tui-darwin-arm64 npm error ENOTEMPTY: directory not empty, rename ... ``` -This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Stop running `npx` processes first. The command below lists the direct `_npx` entries, then removes only the exact cache hash shown in the error: +This is a long-standing npm bug in the `npx` package cache, not a cmux failure. It triggers when the cache holds an older cmux version and npm upgrades it in place, and it hits per-platform binary packages most often. cmux 0.11.0 and older shipped the platform binaries as optional dependencies of the launcher, so upgrading over a cached 0.11.0 can still fail this way once. Stop every `npx` process first. The command below takes a recovery lock, checks that the selected entry is not open, refuses the newest entry, and moves only the exact cache hash shown in the error to a quarantine directory. It never deletes an active cache tree: ```bash +set -eu + npm_cache="$(npm config get cache)" target="$npm_cache/_npx" case "$npm_cache" in - ""|/) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; + ""|/|.|./*|../*|*/./*|*/../*|*/.|*/..) echo "Refusing an unsafe npm cache path" >&2; exit 1 ;; + /*) ;; + *) echo "Refusing a relative npm cache path" >&2; exit 1 ;; esac if [ ! -d "$target" ]; then echo "No npx cache directory: $target" >&2 exit 1 fi +if [ -L "$target" ]; then + echo "Refusing a symlinked npx cache directory: $target" >&2 + exit 1 +fi + +lock="$npm_cache/.cmux-npx-recovery.lock" +if ! (umask 077 && mkdir "$lock" 2>/dev/null); then + echo "Another npx recovery is active, or this lock needs manual inspection: $lock" >&2 + exit 1 +fi +unlock() { + rmdir "$lock" 2>/dev/null || true +} +abort() { + unlock + exit 1 +} +trap unlock EXIT +trap abort HUP INT TERM + printf 'Available npx entries:\n' -find "$target" -mindepth 1 -maxdepth 1 -type d -print -read -r -p 'Enter the exact npx cache hash to remove: ' hash +newest_entry="" +newest_mtime="" +entry_mtime() { + value="$(stat -f %m "$1" 2>/dev/null || true)" + case "$value" in + ''|*[!0-9]*) ;; + *) printf '%s\n' "$value"; return 0 ;; + esac + value="$(stat -c %Y "$1" 2>/dev/null || true)" + case "$value" in + ''|*[!0-9]*) return 1 ;; + *) printf '%s\n' "$value"; return 0 ;; + esac +} +for candidate in "$target"/*; do + [ -d "$candidate" ] || continue + [ ! -L "$candidate" ] || { echo "Refusing a symlinked npx entry: $candidate" >&2; exit 1; } + name="${candidate##*/}" + case "$name" in + ''|*[!A-Za-z0-9_-]*) echo "Refusing an unexpected npx entry: $candidate" >&2; exit 1 ;; + esac + mtime="$(entry_mtime "$candidate")" || { + echo "Cannot inspect npx entry time: $candidate" >&2 + exit 1 + } + printf '%s\n' "$candidate" + if [ -z "$newest_mtime" ] || [ "$mtime" -gt "$newest_mtime" ]; then + newest_entry="$candidate" + newest_mtime="$mtime" + fi +done +read -r -p 'Enter the exact npx cache hash to quarantine: ' hash case "$hash" in - ""|.|..|*[!A-Za-z0-9_-]*) + ""|[-.]*|*[!A-Za-z0-9_-]*) echo "Refusing an invalid npx cache hash" >&2 exit 1 ;; @@ -186,14 +240,54 @@ if [ ! -d "$entry" ]; then echo "npx cache entry not found: $hash" >&2 exit 1 fi -printf 'About to remove only: %s\n' "$entry" +if [ -L "$entry" ]; then + echo "Refusing a symlinked npx cache entry: $entry" >&2 + exit 1 +fi +entry_mtime="$(entry_mtime "$entry")" || { + echo "Cannot inspect the selected npx entry: $entry" >&2 + exit 1 +} +if [ "$entry" = "$newest_entry" ] || [ "$entry_mtime" -ge "$newest_mtime" ]; then + echo "Refusing to move the newest npx entry; use the exact stale hash from the error" >&2 + exit 1 +fi +if ! command -v lsof >/dev/null 2>&1; then + echo "lsof is required to check whether the npx entry is active" >&2 + exit 1 +fi +if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then + [ -z "$open_pids" ] || { + echo "Refusing an npx entry opened by process(es): $open_pids" >&2 + exit 1 + } +else + lsof_status=$? + if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then + echo "Could not prove that the npx entry is inactive: $entry" >&2 + exit 1 + fi +fi +printf 'About to quarantine only: %s\n' "$entry" read -r -p 'Type yes to continue: ' confirm [ "$confirm" = yes ] || exit 1 -rm -rf -- "$entry" +quarantine="$npm_cache/.cmux-npx-quarantine" +if [ -L "$quarantine" ] || { [ -e "$quarantine" ] && [ ! -d "$quarantine" ]; }; then + echo "Refusing an unsafe quarantine path: $quarantine" >&2 + exit 1 +fi +mkdir -p "$quarantine" +destination="$quarantine/${hash}-$(date +%s)-$$" +[ ! -e "$destination" ] || { + echo "Refusing to overwrite an existing quarantine entry: $destination" >&2 + exit 1 +} +mv "$entry" "$destination" +printf 'Quarantined at: %s\n' "$destination" npx cmux@latest ``` -Newer launchers keep platform binaries out of npm's cache entirely (see the previous section). `npx cmux update` is the routine platform-binary upgrade path; `npx cmux@latest` remains the npm-launcher upgrade path. +The move is reversible while the quarantine entry remains. Leave it in place until all `npx` processes have stopped and the new launcher works, then remove it with your normal file manager. Newer launchers keep platform binaries out of npm's cache entirely (see the previous section). `npx cmux update` is the routine platform-binary upgrade path; `npx cmux@latest` remains the npm-launcher upgrade path. ## Sessions and sockets From c1110ee48fd66425edc97e23de1bae7a456d14bf Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:32:49 -0700 Subject: [PATCH 31/73] test(tui): cover cache lock and entry safety --- tests/test_tui_npm_launcher.py | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 3c92dac12163..a0f7fd787ec3 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -325,6 +325,49 @@ def test_launcher_refetches_a_tampered_cached_binary(tmp_path: Path) -> None: assert RegistryHandler.tarball_requests == 2 +def test_launcher_refetches_non_executable_cached_binary(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + try: + first = run_launcher(launcher, cache, registry, "--version") + binary = cache / f"{host_platform_key()}/v/1.2.3/bin/cmux-tui" + binary.chmod(0o644) + second = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert second.stdout == "fake cmux-tui 1.2.3\n" + assert binary.stat().st_mode & stat.S_IXUSR + assert RegistryHandler.tarball_requests == 2 + + +def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path, "1.2.3") + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) + unmanaged = write_cached_binary( + cache, + "9.9.9-dev", + "#!/bin/sh\nprintf '%s\\n' 'development binary'\n", + ) + write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) + + result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + + assert result.returncode == 0, result.stderr + assert unmanaged.is_file() + assert not unmanaged.parent.parent.joinpath("managed").exists() + + def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -539,6 +582,49 @@ def test_launcher_fails_closed_when_another_process_holds_cache_lock(tmp_path: P assert owner_path.read_text() == owner +def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'cached after short lock contention'\n", + ) + lock = cache / host_platform_key() / ".update.lock" + lock.mkdir(parents=True) + (lock / "owner").write_text(f"{os.getpid()}\nfixture-owner-token\n") + + process = subprocess.Popen( + ["node", str(launcher), "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={ + **os.environ, + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": "http://127.0.0.1:1", + "NO_COLOR": "1", + }, + ) + stdout = "" + stderr = "" + try: + time.sleep(0.2) + assert process.poll() is None, "launcher failed before the short lock was released" + (lock / "owner").unlink() + lock.rmdir() + stdout, stderr = process.communicate(timeout=5) + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + + assert process.returncode == 0, stderr or stdout + assert stdout == "cached after short lock contention\n" + + def test_launcher_recovers_stale_empty_cache_lock(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -867,6 +953,7 @@ def main() -> None: test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") + test_launcher_refetches_non_executable_cached_binary(root / "non-executable-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") @@ -877,6 +964,7 @@ def main() -> None: test_binary_override_works_on_an_unsupported_platform(root / "unsupported-override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") test_launcher_fails_closed_when_another_process_holds_cache_lock(root / "held-lock") + test_launcher_waits_for_short_cache_lock_contention(root / "short-lock") test_launcher_recovers_stale_empty_cache_lock(root / "stale-empty-lock") test_launcher_keeps_fresh_empty_cache_lock(root / "fresh-empty-lock") test_launcher_reclaims_stale_empty_lease_during_prune(root / "stale-empty-lease") @@ -884,6 +972,7 @@ def main() -> None: test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") test_concurrent_updates_fail_closed_while_one_downloads(root / "update-serialization") + test_prune_preserves_unmanaged_cache_version(root / "unmanaged-cache") test_launcher_keeps_current_and_one_previous_after_download(root / "prune") From f9ee458f0e5b5de449f685fc124429ac9d3573a8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:33:08 -0700 Subject: [PATCH 32/73] fix(tui): harden cache lock and entry validation --- cmux-tui/dist/npm/cmux/bin/cmux.js | 105 +++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 552a681674b2..12f3c720d6a6 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -52,6 +52,9 @@ const MAX_METADATA_BYTES = 1024 * 1024; const REGISTRY_TIMEOUT_MS = 30_000; const STAGING_MAX_AGE_MS = 60 * 60 * 1000; const CACHE_LOCK_ATTEMPTS = 3; +const CACHE_LOCK_RETRY_INITIAL_MS = 25; +const CACHE_LOCK_RETRY_MAX_MS = 250; +const CACHE_LOCK_WAIT_MAX_MS = 2_000; // A process can be interrupted between creating the lock directory and // publishing its owner file. Reclaim only an ownerless lock that has been // quiet for long enough that the creator cannot still be in that window. @@ -206,6 +209,9 @@ function cachedBinary(version) { return null; } if (!fs.lstatSync(bin).isFile()) return null; + if (process.platform !== "win32") { + fs.accessSync(bin, fs.constants.X_OK); + } const actual = Buffer.from(digestHex(fs.readFileSync(bin)), "hex"); const expectedBytes = Buffer.from(expected, "hex"); return crypto.timingSafeEqual(actual, expectedBytes) ? bin : null; @@ -225,6 +231,48 @@ function updateOperationLockPath() { return path.join(platformRoot(), ".update-operation.lock"); } +function waitForCacheLockRetry(delayMs, signal) { + if (signal?.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + let timer; + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + const onAbort = () => finish(false); + timer = setTimeout(() => finish(true), delayMs); + if (signal) { + if (signal.aborted) { + finish(false); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function lockWaitCancellation() { + const controller = new AbortController(); + const handlers = new Map(); + for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => controller.abort(); + handlers.set(signalName, handler); + process.once(signalName, handler); + } + return { + signal: controller.signal, + dispose() { + for (const [signalName, handler] of handlers) { + process.removeListener(signalName, handler); + } + }, + }; +} + function cacheLockOwnerPath(lockPath = cacheLockPath()) { return path.join(lockPath, "owner"); } @@ -415,7 +463,7 @@ function releaseCacheLock(owner, lockPath = cacheLockPath()) { removeCacheLockIfOwned(owner, false, lockPath); } -function acquireVersionLease(version) { +function tryAcquireVersionLease(version) { const leaseRoot = path.join(platformRoot(), "v", version, ".active"); for (let attempt = 0; attempt < CACHE_LOCK_ATTEMPTS; attempt++) { const lock = tryAcquireCacheLock(); @@ -457,6 +505,36 @@ function acquireVersionLease(version) { return null; } +// Lease creation is normally short, but another launcher can briefly own the +// cache lock while it publishes a lease or prunes old versions. Retry without +// blocking the event loop, and stop waiting at a bounded deadline or signal. +async function acquireVersionLease(version, signal) { + const deadline = Date.now() + CACHE_LOCK_WAIT_MAX_MS; + let delayMs = CACHE_LOCK_RETRY_INITIAL_MS; + while (!signal?.aborted) { + const lease = tryAcquireVersionLease(version); + if (lease) return lease; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return null; + const waited = await waitForCacheLockRetry( + Math.min(delayMs, remainingMs), + signal + ); + if (!waited) return null; + delayMs = Math.min(delayMs * 2, CACHE_LOCK_RETRY_MAX_MS); + } + return null; +} + +async function acquireVersionLeaseForProcess(version) { + const cancellation = lockWaitCancellation(); + try { + return await acquireVersionLease(version, cancellation.signal); + } finally { + cancellation.dispose(); + } +} + function releaseVersionLease(lease) { if (!lease) return; try { @@ -895,6 +973,17 @@ async function downloadVersion(pkg, version) { return binPath; } +function isManagedCacheVersion(versionRoot) { + try { + return ( + fs.lstatSync(versionRoot).isDirectory() && + fs.lstatSync(path.join(versionRoot, "managed")).isFile() + ); + } catch { + return false; + } +} + function pruneCache(keepVersion) { const lock = tryAcquireCacheLock(); if (!lock) return false; @@ -902,7 +991,7 @@ function pruneCache(keepVersion) { try { const managed = fs .readdirSync(root) - .filter((version) => fs.existsSync(path.join(root, version, "managed"))) + .filter((version) => isManagedCacheVersion(path.join(root, version))) .sort(compareVersions); const previous = managed .filter((version) => version !== keepVersion) @@ -910,9 +999,13 @@ function pruneCache(keepVersion) { const keep = new Set([keepVersion, ...previous]); for (const version of fs.readdirSync(root)) { if (keep.has(version)) continue; - if (versionHasActiveLease(path.join(root, version))) continue; + const versionRoot = path.join(root, version); + // Direct-cache and development entries have no managed marker. Keep + // them untouched so routine launches only prune launcher-owned data. + if (!isManagedCacheVersion(versionRoot)) continue; + if (versionHasActiveLease(versionRoot)) continue; try { - fs.rmSync(path.join(root, version), { recursive: true, force: true }); + fs.rmSync(versionRoot, { recursive: true, force: true }); } catch {} } return true; @@ -1014,7 +1107,7 @@ async function runUpdate(pkg, args) { console.log(`cmux ${current} is up to date (latest is ${latest}).`); return; } - lease = acquireVersionLease(latest); + lease = await acquireVersionLeaseForProcess(latest); if (!lease) fail("could not reserve the native binary for update"); await downloadVersion(pkg, latest); writeState({ @@ -1064,7 +1157,7 @@ async function main() { // Lease creation serializes with pruning. If another process owns the // lock, fail closed rather than launching an unleased binary that a prune // can remove while it is running. - lease = wanted && !installedBin ? acquireVersionLease(wanted) : null; + lease = wanted && !installedBin ? await acquireVersionLeaseForProcess(wanted) : null; if (wanted && !installedBin && !lease) { fail("could not reserve the native binary for launch"); } From e129a034317405015c3da999062160a5a0e792cb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:57:17 -0700 Subject: [PATCH 33/73] test(tui): cover executable mode repair --- tests/test_tui_npm_launcher.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index a0f7fd787ec3..73d7d46c3c9e 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -325,7 +325,7 @@ def test_launcher_refetches_a_tampered_cached_binary(tmp_path: Path) -> None: assert RegistryHandler.tarball_requests == 2 -def test_launcher_refetches_non_executable_cached_binary(tmp_path: Path) -> None: +def test_launcher_repairs_non_executable_cached_binary(tmp_path: Path) -> None: if sys.platform == "win32": return launcher = write_launcher(tmp_path) @@ -344,7 +344,8 @@ def test_launcher_refetches_non_executable_cached_binary(tmp_path: Path) -> None assert second.returncode == 0, second.stderr assert second.stdout == "fake cmux-tui 1.2.3\n" assert binary.stat().st_mode & stat.S_IXUSR - assert RegistryHandler.tarball_requests == 2 + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: @@ -359,6 +360,7 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: "9.9.9-dev", "#!/bin/sh\nprintf '%s\\n' 'development binary'\n", ) + unmanaged.chmod(0o644) write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") @@ -366,6 +368,7 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr assert unmanaged.is_file() assert not unmanaged.parent.parent.joinpath("managed").exists() + assert not (unmanaged.stat().st_mode & stat.S_IXUSR) def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: @@ -953,7 +956,7 @@ def main() -> None: test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") - test_launcher_refetches_non_executable_cached_binary(root / "non-executable-cache") + test_launcher_repairs_non_executable_cached_binary(root / "non-executable-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") From 627c5faa856d4b41ef096ca4eba90a13f520d548 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:57:20 -0700 Subject: [PATCH 34/73] fix(tui): repair trusted cache executable mode --- cmux-tui/dist/npm/cmux/bin/cmux.js | 89 +++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 12f3c720d6a6..a5f465cfdb1e 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -196,8 +196,63 @@ function digestHex(buffer) { return crypto.createHash("sha512").update(buffer).digest("hex"); } +function sameFileIdentity(left, right) { + // Windows does not expose stable dev/inode values through every Node + // version. Unix launchers have the descriptor identity needed for the + // symlink and replacement checks below. + return ( + process.platform === "win32" || + (left.dev === right.dev && left.ino === right.ino) + ); +} + +function openCachedBinary(bin) { + let fd; + try { + const linkStat = fs.lstatSync(bin); + if (!linkStat.isFile()) return null; + const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; + if (process.platform !== "win32" && typeof noFollow !== "number") return null; + fd = fs.openSync(bin, fs.constants.O_RDONLY | noFollow); + const openedStat = fs.fstatSync(fd); + if (!openedStat.isFile() || !sameFileIdentity(linkStat, openedStat)) { + fs.closeSync(fd); + fd = undefined; + return null; + } + return { fd, stat: openedStat }; + } catch (error) { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch {} + } + throw error; + } +} + +function readVerifiedCachedBinary(fd, expected) { + const before = fs.fstatSync(fd); + if (!before.isFile()) return null; + const data = fs.readFileSync(fd); + const after = fs.fstatSync(fd); + if (!after.isFile() || !sameFileIdentity(before, after) || after.size !== data.length) { + return null; + } + const actual = Buffer.from(digestHex(data), "hex"); + const expectedBytes = Buffer.from(expected, "hex"); + if (!crypto.timingSafeEqual(actual, expectedBytes)) return null; + return after; +} + +function cachedBinaryPathIsUnchanged(bin, expectedStat) { + const finalStat = fs.lstatSync(bin); + return finalStat.isFile() && sameFileIdentity(finalStat, expectedStat); +} + function cachedBinary(version) { const bin = path.join(cachedBinDir(version), BIN_NAME); + let opened = null; try { const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); const expected = manifest?.binaries?.[BIN_NAME]; @@ -208,15 +263,33 @@ function cachedBinary(version) { ) { return null; } - if (!fs.lstatSync(bin).isFile()) return null; + opened = openCachedBinary(bin); + if (!opened) return null; + let verifiedStat = readVerifiedCachedBinary(opened.fd, expected); + if (!verifiedStat) return null; if (process.platform !== "win32") { + if ((verifiedStat.mode & 0o111) === 0) { + const versionRoot = path.dirname(cachedBinDir(version)); + if (!isManagedCacheVersion(versionRoot)) return null; + // A trusted cache copy can lose its mode bits during transfer. Repair + // them only on the open descriptor of a managed entry, then reopen and + // revalidate the digest and path identity before accepting it. + fs.fchmodSync(opened.fd, 0o755); + fs.closeSync(opened.fd); + opened = null; + opened = openCachedBinary(bin); + if (!opened) return null; + verifiedStat = readVerifiedCachedBinary(opened.fd, expected); + if (!verifiedStat || (verifiedStat.mode & 0o111) === 0) return null; + } fs.accessSync(bin, fs.constants.X_OK); } - const actual = Buffer.from(digestHex(fs.readFileSync(bin)), "hex"); - const expectedBytes = Buffer.from(expected, "hex"); - return crypto.timingSafeEqual(actual, expectedBytes) ? bin : null; + if (!cachedBinaryPathIsUnchanged(bin, verifiedStat)) return null; + return bin; } catch { return null; + } finally { + if (opened) fs.closeSync(opened.fd); } } @@ -975,10 +1048,10 @@ async function downloadVersion(pkg, version) { function isManagedCacheVersion(versionRoot) { try { - return ( - fs.lstatSync(versionRoot).isDirectory() && - fs.lstatSync(path.join(versionRoot, "managed")).isFile() - ); + if (!fs.lstatSync(versionRoot).isDirectory()) return false; + const marker = path.join(versionRoot, "managed"); + if (!fs.lstatSync(marker).isFile()) return false; + return fs.readFileSync(marker, "utf8") === "cmux\n"; } catch { return false; } From c23df13292db20f4ba1d4cf3d677635af717e50e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:59:39 -0700 Subject: [PATCH 35/73] test(tui): keep launch failure coverage executable --- tests/test_tui_npm_launcher.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 73d7d46c3c9e..70c9ce34edd4 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -389,8 +389,12 @@ def test_launcher_releases_lease_when_native_launch_fails(tmp_path: Path) -> Non return launcher = write_launcher(tmp_path) cache = tmp_path / "cache" - binary = write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n") - binary.chmod(0o644) + write_cached_binary( + cache, + "1.2.3", + "#!/definitely/missing/interpreter\n", + managed=True, + ) result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") From f731b904ad63be44c1d7c0dd6ade3fed16bd7044 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:12:29 -0700 Subject: [PATCH 36/73] test(tui): wait on launcher exit signal --- tests/test_tui_npm_launcher.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 70c9ce34edd4..c8eb59c846de 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -123,6 +123,20 @@ def run_launcher( ) +def process_exited_within( + process: subprocess.Popen[str], timeout_seconds: float +) -> bool: + """Wait for the process-exit signal without polling a guessed delay.""" + exited = threading.Event() + + def wait_for_exit() -> None: + process.wait() + exited.set() + + threading.Thread(target=wait_for_exit, daemon=True).start() + return exited.wait(timeout=timeout_seconds) + + def write_launcher(tmp_path: Path, version: str = "1.2.3") -> Path: package = tmp_path / "package" (package / "bin").mkdir(parents=True) @@ -618,8 +632,9 @@ def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: stdout = "" stderr = "" try: - time.sleep(0.2) - assert process.poll() is None, "launcher failed before the short lock was released" + assert not process_exited_within( + process, timeout_seconds=0.2 + ), "launcher failed before the short lock was released" (lock / "owner").unlink() lock.rmdir() stdout, stderr = process.communicate(timeout=5) From bbfe2565916e01c4e64154efec5482c003b813cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:11:52 -0700 Subject: [PATCH 37/73] test(tui): cover stable and nightly launcher state --- tests/test_tui_npm_launcher.py | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index c8eb59c846de..b3729782b26a 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -385,6 +385,70 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: assert not (unmanaged.stat().st_mode & stat.S_IXUSR) +def test_launcher_keeps_stable_and_nightly_state_channels_separate( + tmp_path: Path, +) -> None: + if sys.platform == "win32": + return + + nightly_version = "1.2.3-nightly.20260827.1" + nightly_launcher = write_launcher(tmp_path / "nightly", nightly_version) + nightly_cache = tmp_path / "nightly-cache" + write_cached_binary( + nightly_cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", + managed=True, + ) + nightly_binary = write_cached_binary( + nightly_cache, + nightly_version, + "#!/bin/sh\nprintf '%s\\n' 'nightly binary'\n", + managed=True, + ) + nightly_state = nightly_cache / host_platform_key() / "state.json" + nightly_state.write_text(json.dumps({"version": "1.2.3"}) + "\n") + + nightly_result = run_launcher( + nightly_launcher, + nightly_cache, + "http://127.0.0.1:1", + "--version", + ) + + assert nightly_result.returncode == 0, nightly_result.stderr + assert nightly_result.stdout == "nightly binary\n" + assert nightly_binary.is_file() + + stable_launcher = write_launcher(tmp_path / "stable", "1.2.3") + stable_cache = tmp_path / "stable-cache" + stable_binary = write_cached_binary( + stable_cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", + managed=True, + ) + write_cached_binary( + stable_cache, + nightly_version, + "#!/bin/sh\nprintf '%s\\n' 'nightly binary'\n", + managed=True, + ) + stable_state = stable_cache / host_platform_key() / "state.json" + stable_state.write_text(json.dumps({"version": nightly_version}) + "\n") + + stable_result = run_launcher( + stable_launcher, + stable_cache, + "http://127.0.0.1:1", + "--version", + ) + + assert stable_result.returncode == 0, stable_result.stderr + assert stable_result.stdout == "stable binary\n" + assert stable_binary.is_file() + + def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -995,6 +1059,9 @@ def main() -> None: test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") test_concurrent_updates_fail_closed_while_one_downloads(root / "update-serialization") test_prune_preserves_unmanaged_cache_version(root / "unmanaged-cache") + test_launcher_keeps_stable_and_nightly_state_channels_separate( + root / "channel-state" + ) test_launcher_keeps_current_and_one_previous_after_download(root / "prune") From 2977105f3c302d42b76df205557bf13332bfc50f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:12:37 -0700 Subject: [PATCH 38/73] fix(tui): keep launcher update state channel-aware --- cmux-tui/dist/npm/cmux/bin/cmux.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index a5f465cfdb1e..c23962bde541 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -26,8 +26,8 @@ // 5. fail closed when the requested version cannot be obtained // // Wanted version = max(shim's own package version, version recorded by -// `cmux update`), compared by semver so a nightly shim is not downgraded by -// an older stable `update` record. +// `cmux update`) within the same release channel. A stable update record must +// never satisfy a nightly (or another prerelease-channel) shim. const { spawnSync } = require("child_process"); const crypto = require("crypto"); @@ -101,6 +101,18 @@ function validVersion(version) { return typeof version === "string" && PUBLISHED_VERSION.test(version); } +function versionChannel(version) { + if (typeof version !== "string") return null; + const match = /^\d+\.\d+\.\d+-([0-9A-Za-z]+)/.exec(version); + return match ? match[1].toLowerCase() : "stable"; +} + +function sameVersionChannel(left, right) { + const leftChannel = versionChannel(left); + const rightChannel = versionChannel(right); + return leftChannel !== null && leftChannel === rightChannel; +} + function isManagedPlaceholder(version) { return version === "0.0.0-managed"; } @@ -1105,7 +1117,11 @@ function wantedVersion(pkg) { if (!validVersion(pinned)) { fail("this launcher has an invalid release version"); } - if (state && compareVersions(state.version, pinned) > 0) { + if ( + state && + sameVersionChannel(state.version, pinned) && + compareVersions(state.version, pinned) > 0 + ) { return validVersion(state.version) ? state.version : pinned; } return pinned; From 2df9d811f5a1c7885b136a8cbc60c8cb3dec0188 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:22:13 -0700 Subject: [PATCH 39/73] test(tui): verify channel-specific update state --- tests/test_tui_npm_launcher.py | 62 ++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index b3729782b26a..dddd41575a9a 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -22,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[1] LAUNCHER = ROOT / "cmux-tui/dist/npm/cmux/bin/cmux.js" +NIGHTLY_VERSION = "1.2.3-nightly.20260827.1" def make_tarball() -> bytes: @@ -46,23 +47,35 @@ def make_negative_size_tarball() -> bytes: class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() latest_version = "1.2.3" + nightly_version = "1.2.3-nightly.20260827.1" block_tarball = False tarball_started = threading.Event() tarball_release = threading.Event() metadata_requests = 0 tarball_requests = 0 + latest_requests: list[str] = [] authorization_headers: list[str | None] = [] status = 200 def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) - if self.path == "/cmux/latest": - body = json.dumps({"version": type(self).latest_version}).encode() + if self.path in ("/cmux/latest", "/cmux/nightly"): + type(self).latest_requests.append(self.path) + version = ( + type(self).nightly_version + if self.path == "/cmux/nightly" + else type(self).latest_version + ) + body = json.dumps({"version": version}).encode() elif self.path.endswith(( "/cmux-tui-darwin-arm64/1.2.3", "/cmux-tui-darwin-x64/1.2.3", "/cmux-tui-linux-arm64/1.2.3", "/cmux-tui-linux-x64/1.2.3", + f"/cmux-tui-darwin-arm64/{NIGHTLY_VERSION}", + f"/cmux-tui-darwin-x64/{NIGHTLY_VERSION}", + f"/cmux-tui-linux-arm64/{NIGHTLY_VERSION}", + f"/cmux-tui-linux-x64/{NIGHTLY_VERSION}", )): type(self).metadata_requests += 1 body = json.dumps( @@ -215,9 +228,11 @@ def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, RegistryHandler.authorization_headers = [] RegistryHandler.status = 200 RegistryHandler.latest_version = "1.2.3" + RegistryHandler.nightly_version = NIGHTLY_VERSION RegistryHandler.block_tarball = False RegistryHandler.tarball_started = threading.Event() RegistryHandler.tarball_release = threading.Event() + RegistryHandler.latest_requests = [] server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RegistryHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -385,6 +400,46 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: assert not (unmanaged.stat().st_mode & stat.S_IXUSR) +def test_update_uses_channel_latest_and_persists_channel_state(tmp_path: Path) -> None: + if sys.platform == "win32": + return + + nightly_version = "1.2.3-nightly.20260826.1" + nightly_launcher = write_launcher(tmp_path / "nightly", nightly_version) + nightly_cache = tmp_path / "nightly-cache" + server, thread, registry = start_registry() + try: + result = run_launcher(nightly_launcher, nightly_cache, registry, "update") + finally: + server.shutdown() + thread.join() + + assert result.returncode == 0, result.stderr + assert RegistryHandler.latest_requests == ["/cmux/nightly"] + nightly_state = json.loads( + (nightly_cache / host_platform_key() / "state.json").read_text() + ) + assert nightly_state["version"] == RegistryHandler.nightly_version + assert nightly_state["channel"] == "nightly" + + stable_launcher = write_launcher(tmp_path / "stable", "1.2.2") + stable_cache = tmp_path / "stable-cache" + server, thread, registry = start_registry() + try: + result = run_launcher(stable_launcher, stable_cache, registry, "update") + finally: + server.shutdown() + thread.join() + + assert result.returncode == 0, result.stderr + assert RegistryHandler.latest_requests == ["/cmux/latest"] + stable_state = json.loads( + (stable_cache / host_platform_key() / "state.json").read_text() + ) + assert stable_state["version"] == RegistryHandler.latest_version + assert stable_state["channel"] == "stable" + + def test_launcher_keeps_stable_and_nightly_state_channels_separate( tmp_path: Path, ) -> None: @@ -1059,6 +1114,9 @@ def main() -> None: test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") test_concurrent_updates_fail_closed_while_one_downloads(root / "update-serialization") test_prune_preserves_unmanaged_cache_version(root / "unmanaged-cache") + test_update_uses_channel_latest_and_persists_channel_state( + root / "channel-update" + ) test_launcher_keeps_stable_and_nightly_state_channels_separate( root / "channel-state" ) From b339840509b4435f6faf4132c81c69851a07939d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:23:24 -0700 Subject: [PATCH 40/73] fix(tui): update within launcher release channel --- cmux-tui/dist/npm/cmux/bin/cmux.js | 48 ++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index c23962bde541..1d729f8875e3 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -107,10 +107,22 @@ function versionChannel(version) { return match ? match[1].toLowerCase() : "stable"; } -function sameVersionChannel(left, right) { - const leftChannel = versionChannel(left); - const rightChannel = versionChannel(right); - return leftChannel !== null && leftChannel === rightChannel; +function stateVersionChannel(state) { + if (!state || !validVersion(state.version)) return null; + const inferred = versionChannel(state.version); + if ( + typeof state.channel === "string" && + state.channel.toLowerCase() !== inferred + ) { + return null; + } + return inferred; +} + +function latestDistTag(version) { + const channel = versionChannel(version); + if (!channel) return null; + return channel === "stable" ? "latest" : channel; } function isManagedPlaceholder(version) { @@ -1119,10 +1131,10 @@ function wantedVersion(pkg) { } if ( state && - sameVersionChannel(state.version, pinned) && + stateVersionChannel(state) === versionChannel(pinned) && compareVersions(state.version, pinned) > 0 ) { - return validVersion(state.version) ? state.version : pinned; + return state.version; } return pinned; } @@ -1156,6 +1168,20 @@ async function resolveBinary(pkg, wanted) { } } +async function latestVersionForChannel(version) { + const channel = versionChannel(version); + const distTag = latestDistTag(version); + if (!channel || !distTag) { + fail("could not determine the launcher release channel"); + } + const latestMeta = await fetchJson(`${registryBase()}/cmux/${distTag}`); + const latest = latestMeta && latestMeta.version; + if (!validVersion(latest) || versionChannel(latest) !== channel) { + fail(`could not determine the latest published ${channel} release`); + } + return latest; +} + // `cmux update`: move the launcher to the latest published version without // npm reifying anything, which is what makes upgrades immune to the npx // cache ENOTEMPTY bug. The shim stays as-is; only the binary moves. @@ -1167,9 +1193,7 @@ async function runUpdate(pkg, args) { } if (checkOnly) { const current = wantedVersion(pkg); - const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); - const latest = latestMeta && latestMeta.version; - if (!validVersion(latest)) fail("could not determine the latest published release"); + const latest = await latestVersionForChannel(current); if (compareVersions(latest, current) <= 0) { console.log(`cmux ${current} is up to date (latest is ${latest}).`); return; @@ -1189,9 +1213,8 @@ async function runUpdate(pkg, args) { // Re-read the state after acquiring the lock. Another updater may have // completed before this process obtained it. const current = wantedVersion(pkg); - const latestMeta = await fetchJson(`${registryBase()}/cmux/latest`); - const latest = latestMeta && latestMeta.version; - if (!validVersion(latest)) fail("could not determine the latest published release"); + const channel = versionChannel(current); + const latest = await latestVersionForChannel(current); if (compareVersions(latest, current) <= 0) { console.log(`cmux ${current} is up to date (latest is ${latest}).`); return; @@ -1201,6 +1224,7 @@ async function runUpdate(pkg, args) { await downloadVersion(pkg, latest); writeState({ version: latest, + channel, updatedAt: new Date().toISOString(), }); pruneCache(latest); From 7ee68a03021b1a5f5792fec8168b41e9abd605d2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:29:19 -0700 Subject: [PATCH 41/73] docs(tui): recheck npx cache activity before quarantine --- cmux-tui/docs/getting-started.ja.md | 29 +++++++++++++++++++---------- cmux-tui/docs/getting-started.md | 29 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 171dc7961dbf..bd286e925fe0 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -130,18 +130,25 @@ if ! command -v lsof >/dev/null 2>&1; then echo "npx エントリが使用中か確認するため lsof が必要です" >&2 exit 1 fi -if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then - [ -z "$open_pids" ] || { - echo "プロセスが開いている npx エントリのため中止します: $open_pids" >&2 - exit 1 - } -else - lsof_status=$? - if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then - echo "npx エントリが非アクティブだと確認できません: $entry" >&2 +assert_entry_inactive() { + if [ ! -d "$entry" ] || [ -L "$entry" ]; then + echo "選択した npx エントリが変更されたか、シンボリックリンクになっています: $entry" >&2 exit 1 fi -fi + if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then + [ -z "$open_pids" ] || { + echo "プロセスが開いている npx エントリのため中止します: $open_pids" >&2 + exit 1 + } + else + lsof_status=$? + if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then + echo "npx エントリが非アクティブだと確認できません: $entry" >&2 + exit 1 + fi + fi +} +assert_entry_inactive printf '隔離対象(このエントリだけ): %s\n' "$entry" read -r -p '続行する場合は yes と入力してください: ' confirm [ "$confirm" = yes ] || exit 1 @@ -156,6 +163,8 @@ destination="$quarantine/${hash}-$(date +%s)-$$" echo "既存の隔離エントリを上書きするため中止します: $destination" >&2 exit 1 } +# 確認後、隔離へ原子的に移動する直前に再確認します。 +assert_entry_inactive mv "$entry" "$destination" printf '隔離先: %s\n' "$destination" npx cmux@latest diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index 4526b70c9dc8..d0e004cfc324 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -256,18 +256,25 @@ if ! command -v lsof >/dev/null 2>&1; then echo "lsof is required to check whether the npx entry is active" >&2 exit 1 fi -if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then - [ -z "$open_pids" ] || { - echo "Refusing an npx entry opened by process(es): $open_pids" >&2 - exit 1 - } -else - lsof_status=$? - if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then - echo "Could not prove that the npx entry is inactive: $entry" >&2 +assert_entry_inactive() { + if [ ! -d "$entry" ] || [ -L "$entry" ]; then + echo "The selected npx entry changed or became a symlink: $entry" >&2 exit 1 fi -fi + if open_pids="$(lsof -nP -t +D "$entry" 2>&1)"; then + [ -z "$open_pids" ] || { + echo "Refusing an npx entry opened by process(es): $open_pids" >&2 + exit 1 + } + else + lsof_status=$? + if [ "$lsof_status" -ne 1 ] || [ -n "$open_pids" ]; then + echo "Could not prove that the npx entry is inactive: $entry" >&2 + exit 1 + fi + fi +} +assert_entry_inactive printf 'About to quarantine only: %s\n' "$entry" read -r -p 'Type yes to continue: ' confirm [ "$confirm" = yes ] || exit 1 @@ -282,6 +289,8 @@ destination="$quarantine/${hash}-$(date +%s)-$$" echo "Refusing to overwrite an existing quarantine entry: $destination" >&2 exit 1 } +# Recheck after confirmation, immediately before the atomic quarantine move. +assert_entry_inactive mv "$entry" "$destination" printf 'Quarantined at: %s\n' "$destination" npx cmux@latest From 6e31cef1f569464186715203861d856f3ed4dcd0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:36:49 -0700 Subject: [PATCH 42/73] test(tui): cover read-only launcher cache --- tests/test_tui_npm_launcher.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index dddd41575a9a..604b2da1f1fd 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -272,6 +272,38 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert not (cache / platform_key / "v/1.2.3/.active").exists() +def test_launcher_runs_verified_binary_from_read_only_cache(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + binary = write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'read-only cached binary'\n", + managed=True, + ) + platform_root = cache / host_platform_key() + cache_dirs = [path for path in platform_root.rglob("*") if path.is_dir()] + cache_dirs.append(platform_root) + original_modes = { + directory: stat.S_IMODE(directory.stat().st_mode) for directory in cache_dirs + } + for directory in cache_dirs: + directory.chmod(original_modes[directory] & ~0o222) + try: + result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + finally: + for directory in reversed(cache_dirs): + directory.chmod(original_modes[directory]) + + assert result.returncode == 0, result.stderr + assert result.stdout == "read-only cached binary\n" + assert binary.is_file() + assert not (platform_root / ".update.lock").exists() + assert not (platform_root / "v/1.2.3/.active").exists() + + def test_launcher_requires_network_runtime_capabilities(tmp_path: Path) -> None: if sys.platform == "win32": return From 4c765df1f45d59cfdac5242a2f93b28b016266ee Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:38:21 -0700 Subject: [PATCH 43/73] fix(tui): launch verified binaries from read-only cache --- cmux-tui/dist/npm/cmux/bin/cmux.js | 51 +++++++++++++++++++++++++++-- cmux-tui/docs/getting-started.ja.md | 4 ++- cmux-tui/docs/getting-started.md | 4 ++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 1d729f8875e3..a8af5e5621a0 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -317,6 +317,33 @@ function cachedBinary(version) { } } +// A verified cache entry can be launched from a centrally provisioned +// read-only cache. Check every directory needed to publish a lease before +// deciding whether that read-only launch path is available. +function cacheVersionCanBeModified(version) { + const versionRoot = path.dirname(cachedBinDir(version)); + const leaseRoot = path.join(versionRoot, ".active"); + const directories = [ + platformRoot(), + path.dirname(versionRoot), + versionRoot, + ]; + try { + if (fs.existsSync(leaseRoot)) directories.push(leaseRoot); + } catch { + return false; + } + for (const directory of directories) { + try { + if (!fs.statSync(directory).isDirectory()) return false; + fs.accessSync(directory, fs.constants.W_OK); + } catch { + return false; + } + } + return true; +} + function cacheLockPath() { return path.join(platformRoot(), ".update.lock"); } @@ -1267,15 +1294,33 @@ async function main() { // not require a writable cache or create a lease when it can run directly. const installed = wanted ? installedPackage(pkg) : null; const installedBin = installed && installed.version === wanted ? installed.binPath : null; + // Resolve a verified cache hit before trying to create a lease. A + // read-only, pre-populated cache cannot publish `.active` or `.update.lock`; + // it is safe to launch that verified binary when pruning is skipped. + const cached = wanted && !installedBin ? cachedBinary(wanted) : null; + const readOnlyCached = Boolean( + cached && !cacheVersionCanBeModified(wanted) + ); // Lease creation serializes with pruning. If another process owns the // lock, fail closed rather than launching an unleased binary that a prune // can remove while it is running. - lease = wanted && !installedBin ? await acquireVersionLeaseForProcess(wanted) : null; - if (wanted && !installedBin && !lease) { + lease = + wanted && !installedBin && !readOnlyCached + ? await acquireVersionLeaseForProcess(wanted) + : null; + if (wanted && !installedBin && !readOnlyCached && !lease) { fail("could not reserve the native binary for launch"); } if (lease) process.once("exit", () => releaseVersionLease(lease)); - const binPath = installedBin || (await resolveBinary(pkg, wanted)); + let binPath = installedBin; + if (!binPath && readOnlyCached) { + // Revalidate the read-only path after all setup before spawning. No + // cache mutation or prune is attempted on this path. + binPath = cachedBinary(wanted); + if (!binPath) fail("the cached native binary changed before launch"); + } else if (!binPath) { + binPath = await resolveBinary(pkg, wanted); + } if (lease) pruneCache(wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); if (result.error) { diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index bd286e925fe0..5ea77e6bae3d 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -28,7 +28,9 @@ npm install -g ./cmux-0.11.0.tgz ./cmux-tui-darwin-arm64-0.11.0.tgz ``` npm のダウンロードキャッシュはランチャーから読み取れません。別の方法として、ランチャー -キャッシュへ直接配置し、`CMUX_TUI_LAUNCHER_CACHE` でそのディレクトリを指定できます。 +キャッシュへ直接配置し、`CMUX_TUI_LAUNCHER_CACHE` でそのディレクトリを指定できます。検証済みで +実行権限のあるバイナリは、読み取り専用のランチャーキャッシュからネットワークなしで起動できます。 +この場合ランチャーはリースの作成とプルーニングを行わないため、バイナリの実行権限を保ち、キャッシュ管理者が更新してください。 ## npx の ENOTEMPTY エラー diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index d0e004cfc324..d4db0cafec6c 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -147,7 +147,9 @@ npm install -g ./cmux-0.11.0.tgz ./cmux-tui-darwin-arm64-0.11.0.tgz Alternatively, populate the launcher cache itself and set `CMUX_TUI_LAUNCHER_CACHE` to that directory. npm's download cache is not read -by the launcher. +by the launcher. A verified executable in a read-only launcher cache can run +without network access; the launcher skips leases and pruning in that mode, so +keep the cached binary executable and let the cache administrator update it. ## Troubleshooting npx installs From 5f98522a9acbd5aa4a731e6392144535d00c7f75 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:43:47 -0700 Subject: [PATCH 44/73] fix(tui): require fully read-only cache for unleased launch --- cmux-tui/dist/npm/cmux/bin/cmux.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index a8af5e5621a0..1caa26dae1c5 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -318,9 +318,10 @@ function cachedBinary(version) { } // A verified cache entry can be launched from a centrally provisioned -// read-only cache. Check every directory needed to publish a lease before -// deciding whether that read-only launch path is available. -function cacheVersionCanBeModified(version) { +// read-only cache. Check every directory that could let a routine launcher +// publish a lease or prune the version before using that path. A partially +// writable cache stays on the leased path so pruning remains serialized. +function cacheVersionIsReadOnly(version) { const versionRoot = path.dirname(cachedBinDir(version)); const leaseRoot = path.join(versionRoot, ".active"); const directories = [ @@ -337,8 +338,16 @@ function cacheVersionCanBeModified(version) { try { if (!fs.statSync(directory).isDirectory()) return false; fs.accessSync(directory, fs.constants.W_OK); - } catch { + // Any writable directory could publish a lease or remove a version + // through its parent, so do not use an unleased launch path. return false; + } catch (error) { + // EACCES/EPERM is the expected result for a read-only provisioned tree. + // Unknown failures are not enough to prove that routine mutation is + // impossible, so fail closed instead. + if (!error || (error.code !== "EACCES" && error.code !== "EPERM")) { + return false; + } } } return true; From 0fa91ef1b18efd4f96022a1a28eec9024b27c2e9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:48:36 -0700 Subject: [PATCH 45/73] fix(tui): use read-only cache predicate --- cmux-tui/dist/npm/cmux/bin/cmux.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 1caa26dae1c5..0d2d9c6a9ce0 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -1308,7 +1308,7 @@ async function main() { // it is safe to launch that verified binary when pruning is skipped. const cached = wanted && !installedBin ? cachedBinary(wanted) : null; const readOnlyCached = Boolean( - cached && !cacheVersionCanBeModified(wanted) + cached && cacheVersionIsReadOnly(wanted) ); // Lease creation serializes with pruning. If another process owns the // lock, fail closed rather than launching an unleased binary that a prune From 19cd6be6ee291f92409fc6f17b950de73e893ec1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:15:35 -0700 Subject: [PATCH 46/73] test(tui): cover channel state and npm network config --- tests/test_tui_npm_launcher.py | 213 +++++++++++++++++++++++++-------- 1 file changed, 165 insertions(+), 48 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 604b2da1f1fd..6fb99100694e 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -47,7 +47,7 @@ def make_negative_size_tarball() -> bytes: class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() latest_version = "1.2.3" - nightly_version = "1.2.3-nightly.20260827.1" + nightly_version = "1.2.3-nightly.20260826.1" block_tarball = False tarball_started = threading.Event() tarball_release = threading.Event() @@ -213,6 +213,54 @@ def write_runtime_capability_stub(tmp_path: Path) -> Path: return stub +def write_fake_npm(tmp_path: Path) -> Path: + """Return a Node script that exercises the launcher npm transport path.""" + fake = tmp_path / "fake-npm.cjs" + fake.write_text( + """ +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); +const log = process.env.FAKE_NPM_LOG; +if (log) { + fs.appendFileSync(log, JSON.stringify({ + args, + proxy: process.env.npm_config_https_proxy || null, + cafile: process.env.npm_config_cafile || null, + certfile: process.env.npm_config_certfile || null, + keyfile: process.env.npm_config_keyfile || null, + }) + '\\n'); +} +if (args[0] === 'view') { + const field = args[2]; + if (field === 'version') { + process.stdout.write(JSON.stringify(process.env.FAKE_NPM_LATEST)); + process.exit(0); + } + if (field === 'dist') { + process.stdout.write(JSON.stringify({ + tarball: 'https://registry.invalid/unused.tgz', + integrity: process.env.FAKE_NPM_INTEGRITY, + })); + process.exit(0); + } +} +if (args[0] === 'pack') { + const destinationIndex = args.indexOf('--pack-destination'); + const destination = destinationIndex >= 0 ? args[destinationIndex + 1] : null; + if (!destination) process.exit(2); + const filename = 'cmux-tui-fixture.tgz'; + fs.copyFileSync(process.env.FAKE_NPM_TARBALL, path.join(destination, filename)); + process.stdout.write(JSON.stringify([{ filename }])); + process.exit(0); +} +process.exit(2); +""".lstrip() + ) + fake.chmod(0o755) + return fake + + def write_platform_stub(tmp_path: Path, platform_name: str = "freebsd") -> Path: stub = tmp_path / "unsupported-platform.cjs" stub.write_text( @@ -285,15 +333,27 @@ def test_launcher_runs_verified_binary_from_read_only_cache(tmp_path: Path) -> N ) platform_root = cache / host_platform_key() cache_dirs = [path for path in platform_root.rglob("*") if path.is_dir()] - cache_dirs.append(platform_root) + cache_dirs.extend((platform_root, cache)) original_modes = { directory: stat.S_IMODE(directory.stat().st_mode) for directory in cache_dirs } + trusted_files = [ + binary, + binary.parent.parent / "manifest.json", + binary.parent.parent / "managed", + ] + original_file_modes = { + file: stat.S_IMODE(file.stat().st_mode) for file in trusted_files if file.exists() + } for directory in cache_dirs: directory.chmod(original_modes[directory] & ~0o222) + for file in original_file_modes: + file.chmod(original_file_modes[file] & ~0o222) try: result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") finally: + for file in original_file_modes: + file.chmod(original_file_modes[file]) for directory in reversed(cache_dirs): directory.chmod(original_modes[directory]) @@ -449,7 +509,7 @@ def test_update_uses_channel_latest_and_persists_channel_state(tmp_path: Path) - assert result.returncode == 0, result.stderr assert RegistryHandler.latest_requests == ["/cmux/nightly"] nightly_state = json.loads( - (nightly_cache / host_platform_key() / "state.json").read_text() + (nightly_cache / host_platform_key() / "state/nightly.json").read_text() ) assert nightly_state["version"] == RegistryHandler.nightly_version assert nightly_state["channel"] == "nightly" @@ -466,7 +526,7 @@ def test_update_uses_channel_latest_and_persists_channel_state(tmp_path: Path) - assert result.returncode == 0, result.stderr assert RegistryHandler.latest_requests == ["/cmux/latest"] stable_state = json.loads( - (stable_cache / host_platform_key() / "state.json").read_text() + (stable_cache / host_platform_key() / "state/stable.json").read_text() ) assert stable_state["version"] == RegistryHandler.latest_version assert stable_state["channel"] == "stable" @@ -478,62 +538,66 @@ def test_launcher_keeps_stable_and_nightly_state_channels_separate( if sys.platform == "win32": return - nightly_version = "1.2.3-nightly.20260827.1" + nightly_version = "1.2.3-nightly.20260826.1" + cache = tmp_path / "shared-cache" nightly_launcher = write_launcher(tmp_path / "nightly", nightly_version) - nightly_cache = tmp_path / "nightly-cache" - write_cached_binary( - nightly_cache, - "1.2.3", - "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", - managed=True, - ) - nightly_binary = write_cached_binary( - nightly_cache, - nightly_version, - "#!/bin/sh\nprintf '%s\\n' 'nightly binary'\n", - managed=True, - ) - nightly_state = nightly_cache / host_platform_key() / "state.json" - nightly_state.write_text(json.dumps({"version": "1.2.3"}) + "\n") + stable_launcher = write_launcher(tmp_path / "stable", "1.2.2") - nightly_result = run_launcher( - nightly_launcher, - nightly_cache, - "http://127.0.0.1:1", - "--version", - ) + server, thread, registry = start_registry() + try: + nightly_update = run_launcher(nightly_launcher, cache, registry, "update") + finally: + server.shutdown() + thread.join() + assert nightly_update.returncode == 0, nightly_update.stderr + assert RegistryHandler.latest_requests == ["/cmux/nightly"] - assert nightly_result.returncode == 0, nightly_result.stderr - assert nightly_result.stdout == "nightly binary\n" - assert nightly_binary.is_file() + server, thread, registry = start_registry() + try: + stable_update = run_launcher(stable_launcher, cache, registry, "update") + finally: + server.shutdown() + thread.join() + assert stable_update.returncode == 0, stable_update.stderr + assert RegistryHandler.latest_requests == ["/cmux/latest"] - stable_launcher = write_launcher(tmp_path / "stable", "1.2.3") - stable_cache = tmp_path / "stable-cache" - stable_binary = write_cached_binary( - stable_cache, - "1.2.3", - "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", + platform_root = cache / host_platform_key() + nightly_state = json.loads((platform_root / "state/nightly.json").read_text()) + stable_state = json.loads((platform_root / "state/stable.json").read_text()) + assert nightly_state["version"] == RegistryHandler.nightly_version + assert nightly_state["channel"] == "nightly" + assert stable_state["version"] == RegistryHandler.latest_version + assert stable_state["channel"] == "stable" + + # Replace the downloaded fixture payloads with channel-specific markers, + # then launch older shims offline using their persisted channel state. + write_cached_binary( + cache, + RegistryHandler.nightly_version, + "#!/bin/sh\nprintf '%s\\n' 'nightly binary'\n", managed=True, ) write_cached_binary( - stable_cache, - nightly_version, - "#!/bin/sh\nprintf '%s\\n' 'nightly binary'\n", + cache, + RegistryHandler.latest_version, + "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", managed=True, ) - stable_state = stable_cache / host_platform_key() / "state.json" - stable_state.write_text(json.dumps({"version": nightly_version}) + "\n") - + # A legacy shared file from an older launcher must not cross-satisfy a + # stable shim with a nightly version. + (platform_root / "state.json").write_text( + json.dumps({"version": RegistryHandler.nightly_version}) + "\n" + ) + nightly_result = run_launcher( + nightly_launcher, cache, "http://127.0.0.1:1", "--version" + ) stable_result = run_launcher( - stable_launcher, - stable_cache, - "http://127.0.0.1:1", - "--version", + stable_launcher, cache, "http://127.0.0.1:1", "--version" ) - + assert nightly_result.returncode == 0, nightly_result.stderr + assert nightly_result.stdout == "nightly binary\n" assert stable_result.returncode == 0, stable_result.stderr assert stable_result.stdout == "stable binary\n" - assert stable_binary.is_file() def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: @@ -626,6 +690,56 @@ def test_launcher_scopes_registry_token_to_npmrc_path(tmp_path: Path) -> None: thread.join() +def test_launcher_uses_npm_for_proxy_and_tls_config(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + fake_npm = write_fake_npm(tmp_path) + tarball = tmp_path / "fixture.tgz" + tarball.write_bytes(make_tarball()) + integrity = "sha512-" + base64.b64encode(hashlib.sha512(tarball.read_bytes()).digest()).decode() + log = tmp_path / "npm.log" + cafile = tmp_path / "ca.pem" + certfile = tmp_path / "client.crt" + keyfile = tmp_path / "client.key" + for file in (cafile, certfile, keyfile): + file.write_text("fixture\n") + + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + env_extra={ + "npm_execpath": str(fake_npm), + "npm_config_https_proxy": "http://proxy.invalid:8080", + "npm_config_cafile": str(cafile), + "npm_config_certfile": str(certfile), + "npm_config_keyfile": str(keyfile), + "FAKE_NPM_LOG": str(log), + "FAKE_NPM_TARBALL": str(tarball), + "FAKE_NPM_INTEGRITY": integrity, + }, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "fake cmux-tui 1.2.3\n" + records = [json.loads(line) for line in log.read_text().splitlines()] + assert [record["args"][0] for record in records] == ["view", "pack"] + view_args, pack_args = (record["args"] for record in records) + assert f"cmux-tui-{host_platform_key()}@1.2.3" in view_args + assert f"cmux-tui-{host_platform_key()}@1.2.3" in pack_args + for args in (view_args, pack_args): + assert "--ignore-scripts" in args + assert "--registry" in args + assert "http://127.0.0.1:1" in args + assert all(record["proxy"] == "http://proxy.invalid:8080" for record in records) + assert all(record["cafile"] == str(cafile) for record in records) + assert all(record["certfile"] == str(certfile) for record in records) + assert all(record["keyfile"] == str(keyfile) for record in records) + + def test_launcher_does_not_run_a_mismatched_installed_binary(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1081,7 +1195,9 @@ def test_concurrent_updates_fail_closed_while_one_downloads(tmp_path: Path) -> N thread.join() assert first_process.returncode == 0, first_stderr or first_stdout - state = json.loads((cache / host_platform_key() / "state.json").read_text()) + state = json.loads( + (cache / host_platform_key() / "state/stable.json").read_text() + ) assert state["version"] == "1.2.3" assert not (cache / host_platform_key() / ".update-operation.lock").exists() @@ -1131,6 +1247,7 @@ def main() -> None: test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") + test_launcher_uses_npm_for_proxy_and_tls_config(root / "npm-network-config") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") test_launcher_runs_matching_installed_binary_without_cache_access(root / "installed-offline") test_managed_launcher_honors_development_binary_override(root / "override") From 34e9de9f227a10f651f40a221cb32a4c1633ba6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:23:03 -0700 Subject: [PATCH 47/73] fix(tui): honor npm network config and isolate launcher state --- cmux-tui/dist/npm/cmux/bin/cmux.js | 630 ++++++++++++++++++++++++++--- 1 file changed, 570 insertions(+), 60 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 0d2d9c6a9ce0..6d838dc2cb77 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -184,27 +184,86 @@ function cacheRoot() { return path.join(base, "cmux-tui-launcher"); } -function statePath() { - return path.join(platformRoot(), "state.json"); -} - function platformRoot() { return path.join(cacheRoot(), `${process.platform}-${process.arch}`); } -function readState() { +const STATE_CHANNEL = /^[a-z0-9]+$/; + +function statePath(channel) { + if (typeof channel !== "string" || !STATE_CHANNEL.test(channel)) return null; + return path.join(platformRoot(), "state", `${channel}.json`); +} + +function legacyStatePath() { + return path.join(platformRoot(), "state.json"); +} + +function readStateFile(target) { try { - const state = JSON.parse(fs.readFileSync(statePath(), "utf8")); - if (state && typeof state.version === "string") return state; + const state = JSON.parse(fs.readFileSync(target, "utf8")); + if (state && validVersion(state.version)) return state; } catch {} return null; } +function readLegacyState() { + return readStateFile(legacyStatePath()); +} + +function readState(channel) { + const target = statePath(channel); + if (!target) return null; + const state = readStateFile(target); + if (state && stateVersionChannel(state) === channel) return state; + // Migrate state written by older launchers lazily. A legacy record is only + // eligible for the channel encoded by its version, so a stable record can + // never satisfy a nightly launcher (or the reverse). + const legacy = readLegacyState(); + return legacy && stateVersionChannel(legacy) === channel ? legacy : null; +} + +function readUnambiguousManagedState() { + const candidates = []; + const legacy = readLegacyState(); + if (legacy && stateVersionChannel(legacy)) candidates.push(legacy); + const stateRoot = path.join(platformRoot(), "state"); + try { + for (const entry of fs.readdirSync(stateRoot, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const state = readStateFile(path.join(stateRoot, entry.name)); + if (state && stateVersionChannel(state)) candidates.push(state); + } + } catch {} + const unique = new Map( + candidates.map((state) => [ + `${stateVersionChannel(state)}:${state.version}`, + state, + ]) + ); + return unique.size === 1 ? unique.values().next().value : null; +} + function writeState(state) { - const target = statePath(); + const channel = + typeof state?.channel === "string" + ? state.channel.toLowerCase() + : versionChannel(state?.version); + if ( + !channel || + !STATE_CHANNEL.test(channel) || + stateVersionChannel({ ...state, channel }) !== channel + ) { + fail("cannot persist launcher state for an invalid release channel"); + } + const target = statePath(channel); + if (!target) fail("cannot persist launcher state for an invalid release channel"); fs.mkdirSync(path.dirname(target), { recursive: true }); const tmp = `${target}.${process.pid}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.writeFileSync( + tmp, + JSON.stringify({ ...state, channel }, null, 2) + "\n" + ); fs.renameSync(tmp, target); } @@ -255,6 +314,24 @@ function openCachedBinary(bin) { } } +function readCachedManifest(version) { + const bin = path.join(cachedBinDir(version), BIN_NAME); + try { + const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); + const expected = manifest?.binaries?.[BIN_NAME]; + if ( + manifest?.version !== version || + typeof expected !== "string" || + !/^[a-f0-9]{128}$/.test(expected) + ) { + return null; + } + return { bin, expected }; + } catch { + return null; + } +} + function readVerifiedCachedBinary(fd, expected) { const before = fs.fstatSync(fd); if (!before.isFile()) return null; @@ -266,7 +343,7 @@ function readVerifiedCachedBinary(fd, expected) { const actual = Buffer.from(digestHex(data), "hex"); const expectedBytes = Buffer.from(expected, "hex"); if (!crypto.timingSafeEqual(actual, expectedBytes)) return null; - return after; + return { stat: after, data }; } function cachedBinaryPathIsUnchanged(bin, expectedStat) { @@ -274,23 +351,16 @@ function cachedBinaryPathIsUnchanged(bin, expectedStat) { return finalStat.isFile() && sameFileIdentity(finalStat, expectedStat); } -function cachedBinary(version) { - const bin = path.join(cachedBinDir(version), BIN_NAME); +function cachedBinary(version, candidate = null) { + const resolvedCandidate = candidate || readCachedManifest(version); + if (!resolvedCandidate) return null; let opened = null; try { - const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); - const expected = manifest?.binaries?.[BIN_NAME]; - if ( - manifest?.version !== version || - typeof expected !== "string" || - !/^[a-f0-9]{128}$/.test(expected) - ) { - return null; - } - opened = openCachedBinary(bin); + opened = openCachedBinary(resolvedCandidate.bin); if (!opened) return null; - let verifiedStat = readVerifiedCachedBinary(opened.fd, expected); - if (!verifiedStat) return null; + let verified = readVerifiedCachedBinary(opened.fd, resolvedCandidate.expected); + if (!verified) return null; + let verifiedStat = verified.stat; if (process.platform !== "win32") { if ((verifiedStat.mode & 0o111) === 0) { const versionRoot = path.dirname(cachedBinDir(version)); @@ -301,15 +371,16 @@ function cachedBinary(version) { fs.fchmodSync(opened.fd, 0o755); fs.closeSync(opened.fd); opened = null; - opened = openCachedBinary(bin); + opened = openCachedBinary(resolvedCandidate.bin); if (!opened) return null; - verifiedStat = readVerifiedCachedBinary(opened.fd, expected); - if (!verifiedStat || (verifiedStat.mode & 0o111) === 0) return null; + verified = readVerifiedCachedBinary(opened.fd, resolvedCandidate.expected); + if (!verified || (verified.stat.mode & 0o111) === 0) return null; + verifiedStat = verified.stat; } - fs.accessSync(bin, fs.constants.X_OK); + fs.accessSync(resolvedCandidate.bin, fs.constants.X_OK); } - if (!cachedBinaryPathIsUnchanged(bin, verifiedStat)) return null; - return bin; + if (!cachedBinaryPathIsUnchanged(resolvedCandidate.bin, verifiedStat)) return null; + return resolvedCandidate.bin; } catch { return null; } finally { @@ -317,17 +388,99 @@ function cachedBinary(version) { } } +// Check only cheap metadata before lease setup. Full digest verification is +// performed once after the lease, or while making a private read-only snapshot. +function cachedBinaryCandidate(version) { + const candidate = readCachedManifest(version); + if (!candidate) return null; + try { + const stat = fs.lstatSync(candidate.bin); + if (!stat.isFile()) return null; + if (process.platform !== "win32") { + if ((stat.mode & 0o111) === 0) return null; + fs.accessSync(candidate.bin, fs.constants.X_OK); + } + return candidate; + } catch { + return null; + } +} + +function snapshotVerifiedCachedBinary(candidate) { + let opened = null; + let snapshotDirectory = null; + let snapshotFd; + let snapshot = null; + let complete = false; + try { + opened = openCachedBinary(candidate.bin); + if (!opened) return null; + const verified = readVerifiedCachedBinary(opened.fd, candidate.expected); + if (!verified) return null; + if ( + process.platform !== "win32" && + (verified.stat.mode & 0o111) === 0 + ) { + return null; + } + if (!cachedBinaryPathIsUnchanged(candidate.bin, verified.stat)) return null; + snapshotDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-tui-launch-")); + snapshot = path.join(snapshotDirectory, BIN_NAME); + snapshotFd = fs.openSync( + snapshot, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o700 + ); + fs.writeFileSync(snapshotFd, verified.data); + if (process.platform !== "win32") fs.fchmodSync(snapshotFd, 0o700); + fs.closeSync(snapshotFd); + snapshotFd = undefined; + if (process.platform !== "win32") fs.accessSync(snapshot, fs.constants.X_OK); + complete = true; + return { path: snapshot, directory: snapshotDirectory }; + } catch { + return null; + } finally { + if (opened) { + try { + fs.closeSync(opened.fd); + } catch {} + } + if (snapshotFd !== undefined) { + try { + fs.closeSync(snapshotFd); + } catch {} + } + if (snapshotDirectory && !complete) { + try { + fs.rmSync(snapshotDirectory, { recursive: true, force: true }); + } catch {} + } + } +} + +function removeLaunchSnapshot(snapshot) { + if (!snapshot) return; + try { + fs.rmSync(snapshot.directory, { recursive: true, force: true }); + } catch {} +} + // A verified cache entry can be launched from a centrally provisioned -// read-only cache. Check every directory that could let a routine launcher -// publish a lease or prune the version before using that path. A partially -// writable cache stays on the leased path so pruning remains serialized. +// read-only cache. Check every directory and trusted file that could let a +// routine launcher publish a lease or prune or replace the version before +// using that path. A partially writable cache stays on the leased path so +// pruning remains serialized. function cacheVersionIsReadOnly(version) { const versionRoot = path.dirname(cachedBinDir(version)); + const binDir = cachedBinDir(version); const leaseRoot = path.join(versionRoot, ".active"); const directories = [ + cacheRoot(), platformRoot(), path.dirname(versionRoot), versionRoot, + binDir, ]; try { if (fs.existsSync(leaseRoot)) directories.push(leaseRoot); @@ -350,6 +503,31 @@ function cacheVersionIsReadOnly(version) { } } } + const trustedFiles = [ + path.join(versionRoot, "manifest.json"), + path.join(binDir, BIN_NAME), + ]; + const marker = path.join(versionRoot, "managed"); + try { + if (fs.existsSync(marker)) trustedFiles.push(marker); + } catch { + return false; + } + for (const file of trustedFiles) { + try { + const stat = fs.lstatSync(file); + if (!stat.isFile()) return false; + if (process.platform !== "win32" && (stat.mode & 0o222) !== 0) { + return false; + } + fs.accessSync(file, fs.constants.W_OK); + return false; + } catch (error) { + if (!error || (error.code !== "EACCES" && error.code !== "EPERM")) { + return false; + } + } + } return true; } @@ -819,6 +997,307 @@ function registryBase() { return raw.replace(/\/+$/, ""); } +// Node's built-in fetch does not consume npm's proxy, CA, or client +// certificate settings. When one of those settings is present, delegate the +// registry operation to npm itself, which owns the supported config contract. +const NPM_NETWORK_CONFIG_KEYS = [ + "proxy", + "https-proxy", + "http-proxy", + "noproxy", + "cafile", + "ca", + "cert", + "key", + "certfile", + "keyfile", + "strict-ssl", +]; +let npmNetworkConfigPresent; + +function configValueIsPresent(value) { + if (value === undefined || value === null) return false; + const normalized = String(value).trim().toLowerCase(); + return normalized !== "" && normalized !== "null"; +} + +function npmConfigEnvironmentValue(key) { + const normalized = key.replace(/-/g, "_"); + for (const name of [ + `npm_config_${normalized}`, + `npm_config_${key}`, + `NPM_CONFIG_${normalized}`, + `NPM_CONFIG_${key}`, + ]) { + if (configValueIsPresent(process.env[name])) return process.env[name]; + } + return undefined; +} + +function npmEnvironmentHasScopedNetworkConfig() { + return Object.entries(process.env).some(([name, value]) => { + if (/^npm_config_ca\[\]$/i.test(name)) return configValueIsPresent(value); + if (!/^npm_config_\/\/[^/]+\/:/i.test(name)) return false; + const key = name.slice(name.lastIndexOf(":") + 1).toLowerCase(); + return ( + ["ca", "cafile", "cert", "certfile", "key", "keyfile"].includes(key) && + configValueIsPresent(value) + ); + }); +} + +function npmConfigFilePaths() { + const paths = new Set(); + const add = (candidate) => { + if (candidate) paths.add(path.resolve(candidate)); + }; + add(npmConfigEnvironmentValue("userconfig")); + add(npmConfigEnvironmentValue("globalconfig")); + + // npm reads a project .npmrc, then the user and global files. Walking to the + // nearest root also covers launchers started from a nested project folder. + let directory = process.cwd(); + while (true) { + add(path.join(directory, ".npmrc")); + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + add(path.join(os.homedir(), ".npmrc")); + const nodePrefix = path.dirname(path.dirname(process.execPath)); + add(path.join(nodePrefix, "etc", "npmrc")); + if (process.platform !== "win32") add("/etc/npmrc"); + return paths; +} + +function npmrcContainsNetworkConfig(contents) { + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + // Scoped client certificate settings use + // `//registry.example/:certfile=/path/to/cert.pem`. + const scoped = /:(certfile|keyfile)\s*=/i.exec(line); + if (scoped) { + const value = line.slice(scoped.index + scoped[0].length); + if (configValueIsPresent(value)) return true; + continue; + } + const match = /^(?:@[^:]+:)?([a-z-]+)(\[\])?\s*=\s*(.*)$/i.exec(line); + if (!match) continue; + const key = match[1].toLowerCase(); + if (NPM_NETWORK_CONFIG_KEYS.includes(key) && configValueIsPresent(match[3])) { + return true; + } + // `ca[]=` is npm's documented way to provide more than one CA value. + if (key === "ca" && match[2] === "[]" && configValueIsPresent(match[3])) { + return true; + } + } + return false; +} + +function registryIsLoopback() { + try { + const hostname = new URL(registryBase()).hostname.toLowerCase(); + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]" + ); + } catch { + return false; + } +} + +function hasNpmNetworkConfig() { + if (npmNetworkConfigPresent !== undefined) return npmNetworkConfigPresent; + npmNetworkConfigPresent = NPM_NETWORK_CONFIG_KEYS.some((key) => + configValueIsPresent(npmConfigEnvironmentValue(key)) + ); + if (!npmNetworkConfigPresent) { + npmNetworkConfigPresent = npmEnvironmentHasScopedNetworkConfig(); + } + if (!npmNetworkConfigPresent) { + for (const configPath of npmConfigFilePaths()) { + let contents; + try { + contents = fs.readFileSync(configPath, "utf8"); + } catch { + continue; + } + if (npmrcContainsNetworkConfig(contents)) { + npmNetworkConfigPresent = true; + break; + } + } + } + // npm's registry fetch honors the conventional proxy variables even when + // they are not duplicated into an .npmrc file. Keep ambient CI proxy + // variables from changing explicitly loopback fixture registries. + if (!npmNetworkConfigPresent) { + const proxyConfigured = [ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + ].some((name) => configValueIsPresent(process.env[name])); + npmNetworkConfigPresent = proxyConfigured && !registryIsLoopback(); + } + return npmNetworkConfigPresent; +} + +function npmChildEnvironment() { + const env = { ...process.env }; + // npm's config loader uses lower-case npm_config_* names on Unix. Preserve + // the user's upper-case spelling while making its meaning explicit to the + // delegated process. + for (const key of ["userconfig", "globalconfig", ...NPM_NETWORK_CONFIG_KEYS]) { + const value = npmConfigEnvironmentValue(key); + if (value === undefined) continue; + const normalized = key.replace(/-/g, "_"); + if (!configValueIsPresent(env[`npm_config_${normalized}`])) { + env[`npm_config_${normalized}`] = value; + } + } + for (const [name, value] of Object.entries(process.env)) { + const match = /^NPM_CONFIG_(\/\/.*)$/i.exec(name); + if (!match || !configValueIsPresent(value)) continue; + const lowerName = `npm_config_${match[1]}`; + if (!configValueIsPresent(env[lowerName])) env[lowerName] = value; + } + return env; +} + +function npmInvocation() { + const configured = process.env.npm_execpath; + if (configured) { + const candidate = path.isAbsolute(configured) + ? configured + : path.resolve(process.cwd(), configured); + try { + if (fs.statSync(candidate).isFile()) { + if (/\.(?:c?m?js)$/i.test(candidate)) { + return { command: process.execPath, prefix: [candidate] }; + } + return { command: candidate, prefix: [] }; + } + } catch {} + } + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + prefix: [], + }; +} + +function runNpm(args) { + const invocation = npmInvocation(); + const result = spawnSync(invocation.command, [...invocation.prefix, ...args], { + cwd: process.cwd(), + env: npmChildEnvironment(), + encoding: "buffer", + timeout: REGISTRY_TIMEOUT_MS, + killSignal: "SIGTERM", + maxBuffer: MAX_METADATA_BYTES, + windowsHide: true, + }); + if (result.error || result.status !== 0 || result.signal) { + throw new Error("npm registry request failed"); + } + const output = Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout || ""); + if (output.length > MAX_METADATA_BYTES) { + throw new Error("npm registry response is too large"); + } + return output; +} + +function parseNpmJson(output) { + try { + return JSON.parse(output.toString("utf8").trim()); + } catch { + throw new Error("npm registry response was invalid"); + } +} + +function npmPackageSpec(packageName, selector) { + const validSelector = + validVersion(selector) || /^[a-z0-9][a-z0-9._-]*$/i.test(selector); + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(packageName) || !validSelector) { + throw new Error("invalid npm package selector"); + } + return `${packageName}@${selector}`; +} + +function npmView(packageName, selector, field) { + const spec = npmPackageSpec(packageName, selector); + if (!/^[a-z][a-z0-9._-]*$/i.test(field)) { + throw new Error("invalid npm metadata field"); + } + return parseNpmJson( + runNpm([ + "view", + spec, + field, + "--json", + "--registry", + registryBase(), + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-online", + "--loglevel=error", + ]) + ); +} + +function npmPack(packageName, version) { + const spec = npmPackageSpec(packageName, version); + const destination = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-tui-npm-pack-")); + try { + const output = parseNpmJson( + runNpm([ + "pack", + spec, + "--json", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-online", + "--loglevel=error", + "--pack-destination", + destination, + "--registry", + registryBase(), + ]) + ); + const record = Array.isArray(output) ? output[0] : output; + const filename = record && record.filename; + if ( + typeof filename !== "string" || + !filename || + filename === "." || + filename === ".." || + filename.includes("/") || + filename.includes("\\") || + !filename.endsWith(".tgz") + ) { + throw new Error("npm pack did not return a safe tarball name"); + } + const tarball = path.join(destination, filename); + const stat = fs.lstatSync(tarball); + if (!stat.isFile() || stat.size > MAX_TARBALL_BYTES) { + throw new Error("npm pack returned an invalid tarball"); + } + return fs.readFileSync(tarball); + } finally { + try { + fs.rmSync(destination, { recursive: true, force: true }); + } catch {} + } +} + function registryHeaders(url, accept) { const headers = { accept }; try { @@ -875,8 +1354,11 @@ function requireNetworkRuntime() { } } -async function fetchJson(url) { +async function fetchJson(url, npmQuery = null) { requireNetworkRuntime(); + if (npmQuery && hasNpmNetworkConfig()) { + return npmView(npmQuery.packageName, npmQuery.selector, npmQuery.field); + } const response = await fetch(url, { headers: registryHeaders(url, "application/json"), signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), @@ -1023,21 +1505,34 @@ function removeCachedPayload(version) { // Download pkg@version from the registry, verify integrity, extract bin/ // into the launcher cache. Returns the binary path. async function downloadVersion(pkg, version) { - const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`); - const tarballUrl = meta && meta.dist && meta.dist.tarball; - const integrity = meta && meta.dist && meta.dist.integrity; - if (!tarballUrl) { + const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`, { + packageName: pkg, + selector: version, + field: "dist", + }); + // `npm view ... dist --json` returns the dist object directly, while the + // raw registry document wraps it under `dist`. Accept both shapes so the + // npm-configured and direct transports share one validation path. + const dist = meta && meta.dist ? meta.dist : meta; + const tarballUrl = dist && dist.tarball; + const integrity = dist && dist.integrity; + if (!integrity || (!tarballUrl && !hasNpmNetworkConfig())) { throw new Error("registry metadata is incomplete"); } console.error(`cmux: downloading ${pkg}@${version}...`); - const response = await fetch(tarballUrl, { - headers: registryHeaders(tarballUrl, "application/octet-stream"), - signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error("platform package download failed"); + let tgz; + if (hasNpmNetworkConfig()) { + tgz = npmPack(pkg, version); + } else { + const response = await fetch(tarballUrl, { + headers: registryHeaders(tarballUrl, "application/octet-stream"), + signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error("platform package download failed"); + } + tgz = await readResponseBody(response, MAX_TARBALL_BYTES); } - const tgz = await readResponseBody(response, MAX_TARBALL_BYTES); verifyIntegrity(tgz, integrity); let tar; try { @@ -1152,11 +1647,14 @@ function pruneCache(keepVersion) { function wantedVersion(pkg) { const pinned = shimVersion(); - const state = readState(); if (isManagedPlaceholder(pinned)) { - if (state && validVersion(state.version)) return state.version; const installed = installedPackage(pkg); - if (installed && validVersion(installed.version)) return installed.version; + const installedVersion = + installed && validVersion(installed.version) ? installed.version : null; + const channel = installedVersion ? versionChannel(installedVersion) : null; + const state = channel ? readState(channel) : readUnambiguousManagedState(); + if (state && validVersion(state.version)) return state.version; + if (installedVersion) return installedVersion; fail( "this launcher is an unpublished development copy without a pinned " + "binary. Set a development binary override or install a published release." @@ -1165,6 +1663,7 @@ function wantedVersion(pkg) { if (!validVersion(pinned)) { fail("this launcher has an invalid release version"); } + const state = readState(versionChannel(pinned)); if ( state && stateVersionChannel(state) === versionChannel(pinned) && @@ -1175,7 +1674,7 @@ function wantedVersion(pkg) { return pinned; } -async function resolveBinary(pkg, wanted) { +async function resolveBinary(pkg, wanted, cachedCandidate = null) { const override = process.env.CMUX_TUI_BIN; if (override) { if (!fs.existsSync(override)) fail("configured native binary override does not exist"); @@ -1187,7 +1686,7 @@ async function resolveBinary(pkg, wanted) { if (installed && installed.version === wanted) { return installed.binPath; } - const cached = cachedBinary(wanted); + const cached = cachedBinary(wanted, cachedCandidate); if (cached) return cached; // Check the runtime before entering the generic download error boundary so @@ -1210,8 +1709,13 @@ async function latestVersionForChannel(version) { if (!channel || !distTag) { fail("could not determine the launcher release channel"); } - const latestMeta = await fetchJson(`${registryBase()}/cmux/${distTag}`); - const latest = latestMeta && latestMeta.version; + const latestMeta = await fetchJson(`${registryBase()}/cmux/${distTag}`, { + packageName: "cmux", + selector: distTag, + field: "version", + }); + const latest = + typeof latestMeta === "string" ? latestMeta : latestMeta && latestMeta.version; if (!validVersion(latest) || versionChannel(latest) !== channel) { fail(`could not determine the latest published ${channel} release`); } @@ -1240,7 +1744,8 @@ async function runUpdate(pkg, args) { // Keep one update-wide lock from the version check through download, state // publication, and pruning. This prevents two update processes from - // completing out of order and pinning state.json to an older version. + // completing out of order and pinning a channel state file to an older + // version. const updateLockPath = updateOperationLockPath(); const updateLock = tryAcquireCacheLock(updateLockPath); if (!updateLock) fail("could not reserve the native binary for update"); @@ -1294,6 +1799,7 @@ async function main() { // can still run with CMUX_TUI_BIN. const pkg = override ? null : platformPackage(); let lease = null; + let launchSnapshot = null; let exitCode = 1; let childSignal = null; try { @@ -1306,9 +1812,10 @@ async function main() { // Resolve a verified cache hit before trying to create a lease. A // read-only, pre-populated cache cannot publish `.active` or `.update.lock`; // it is safe to launch that verified binary when pruning is skipped. - const cached = wanted && !installedBin ? cachedBinary(wanted) : null; + const cachedCandidate = + wanted && !installedBin ? cachedBinaryCandidate(wanted) : null; const readOnlyCached = Boolean( - cached && cacheVersionIsReadOnly(wanted) + cachedCandidate && cacheVersionIsReadOnly(wanted) ); // Lease creation serializes with pruning. If another process owns the // lock, fail closed rather than launching an unleased binary that a prune @@ -1323,12 +1830,14 @@ async function main() { if (lease) process.once("exit", () => releaseVersionLease(lease)); let binPath = installedBin; if (!binPath && readOnlyCached) { - // Revalidate the read-only path after all setup before spawning. No - // cache mutation or prune is attempted on this path. - binPath = cachedBinary(wanted); - if (!binPath) fail("the cached native binary changed before launch"); + // Revalidate and copy the read-only path after all setup. The private + // snapshot prevents a later replacement from changing the executable + // between verification and spawn. + launchSnapshot = snapshotVerifiedCachedBinary(cachedCandidate); + if (!launchSnapshot) fail("the cached native binary changed before launch"); + binPath = launchSnapshot.path; } else if (!binPath) { - binPath = await resolveBinary(pkg, wanted); + binPath = await resolveBinary(pkg, wanted, cachedCandidate); } if (lease) pruneCache(wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); @@ -1342,6 +1851,7 @@ async function main() { } } finally { releaseVersionLease(lease); + removeLaunchSnapshot(launchSnapshot); } if (childSignal) { process.exitCode = 1; From 7fb5b7b3a049bdb641e9ede6460f72cffb0aac36 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:29:23 -0700 Subject: [PATCH 48/73] test(tui): allow channel fixture rewrites --- tests/test_tui_npm_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 6fb99100694e..f3a1534ac633 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -184,7 +184,7 @@ def write_cached_binary( package = f"cmux-tui-{platform_key}" binary = cache / platform_key / f"v/{version}/bin/cmux-tui" data = payload.encode() - binary.parent.mkdir(parents=True) + binary.parent.mkdir(parents=True, exist_ok=True) binary.write_bytes(data) binary.chmod(0o755) version_dir = binary.parent.parent From bf90a7a9ac9f4abc08925b4c531069b92c78350a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:43:44 -0700 Subject: [PATCH 49/73] test(tui): cover npmrc registry and channel prune retention --- tests/test_tui_npm_launcher.py | 72 +++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index f3a1534ac633..f3a67e62d9b2 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -112,7 +112,7 @@ def log_message(self, *_args: object) -> None: def run_launcher( launcher: Path, cache: Path, - registry: str, + registry: str | None, *args: str, env_extra: dict[str, str] | None = None, timeout_seconds: float | None = None, @@ -121,10 +121,14 @@ def run_launcher( env.update( { "CMUX_TUI_LAUNCHER_CACHE": str(cache), - "CMUX_NPM_REGISTRY": registry, "NO_COLOR": "1", } ) + if registry is None: + for name in ("CMUX_NPM_REGISTRY", "npm_config_registry", "NPM_CONFIG_REGISTRY"): + env.pop(name, None) + else: + env["CMUX_NPM_REGISTRY"] = registry env.update(env_extra or {}) return subprocess.run( ["node", str(launcher), *args], @@ -492,6 +496,45 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: assert not (unmanaged.stat().st_mode & stat.S_IXUSR) +def test_prune_preserves_versions_selected_by_each_channel_state( + tmp_path: Path, +) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path, "1.2.3") + cache = tmp_path / "cache" + stable_old = "1.0.0" + stable_previous = "1.1.0" + stable_current = "1.2.3" + nightly_old = "1.0.0-nightly.20260820.1" + nightly_previous = "1.0.0-nightly.20260821.1" + for version in ( + stable_old, + stable_previous, + stable_current, + nightly_old, + nightly_previous, + ): + write_cached_binary(cache, version, "#!/bin/sh\nexit 0\n", managed=True) + + platform_root = cache / host_platform_key() + state_root = platform_root / "state" + state_root.mkdir(parents=True) + (state_root / "stable.json").write_text( + json.dumps({"version": stable_old, "channel": "stable"}) + "\n" + ) + (state_root / "nightly.json").write_text( + json.dumps({"version": nightly_old, "channel": "nightly"}) + "\n" + ) + + result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + + assert result.returncode == 0, result.stderr + assert result.stdout == "fake cmux-tui 1.2.3\n" + for version in (stable_old, nightly_old, stable_previous, nightly_previous): + assert (platform_root / f"v/{version}").is_dir() + + def test_update_uses_channel_latest_and_persists_channel_state(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -655,6 +698,31 @@ def test_launcher_reads_registry_token_from_npmrc(tmp_path: Path) -> None: assert all(value == "Bearer fixture-token" for value in RegistryHandler.authorization_headers) +def test_launcher_reads_registry_from_npmrc(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + npmrc = tmp_path / ".npmrc" + npmrc.write_text(f"registry={registry}\n") + try: + result = run_launcher( + launcher, + cache, + None, + "--version", + env_extra={"npm_config_userconfig": str(npmrc)}, + ) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert result.stdout == "fake cmux-tui 1.2.3\n" + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 + + def test_launcher_scopes_registry_token_to_npmrc_path(tmp_path: Path) -> None: if sys.platform == "win32": return From 9a2c2f4ecd2567f41d88074e0d29b1e179989ecc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:43:48 -0700 Subject: [PATCH 50/73] fix(tui): honor npmrc registry and retain channel states --- cmux-tui/dist/npm/cmux/bin/cmux.js | 156 +++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 9 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 6d838dc2cb77..fdaeb4351ded 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -989,12 +989,98 @@ function installedPackage(pkg) { } } +function normalizeRegistryValue(value) { + if (!configValueIsPresent(value)) return null; + let raw = String(value).trim(); + // npm accepts quoted ini values and environment substitutions in .npmrc. + if ( + raw.length >= 2 && + ((raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'"))) + ) { + raw = raw.slice(1, -1).trim(); + } + raw = raw.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ""); + if (!configValueIsPresent(raw)) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString().replace(/\/+$/, ""); + } catch { + return null; + } +} + +function npmrcRegistry(contents) { + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const match = /^registry\s*=\s*(.*?)\s*$/i.exec(line); + if (!match) continue; + // Keep URL fragments intact, but accept the inline comment form used by + // npm's ini parser when a comment is separated by whitespace. + const value = match[1].replace(/\s+[;#].*$/, "").trim(); + const registry = normalizeRegistryValue(value); + if (registry) return registry; + } + return null; +} + +function readNpmrcRegistry(configPath) { + try { + return npmrcRegistry(fs.readFileSync(configPath, "utf8")); + } catch { + return null; + } +} + +function npmRegistryFromConfigFiles() { + // npm's precedence is project, user, then global. A project file nearest to + // the launch cwd wins over broader project files, which also covers nested + // workspaces without requiring npm to be installed on PATH. + const projectPaths = []; + let directory = process.cwd(); + while (true) { + projectPaths.push(path.join(directory, ".npmrc")); + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + for (const configPath of projectPaths) { + const registry = readNpmrcRegistry(configPath); + if (registry) return registry; + } + + const userConfig = npmConfigEnvironmentValue("userconfig"); + const userPaths = userConfig + ? [userConfig] + : [path.join(os.homedir(), ".npmrc")]; + for (const configPath of userPaths) { + const registry = readNpmrcRegistry(configPath); + if (registry) return registry; + } + + const nodePrefix = path.dirname(path.dirname(process.execPath)); + const globalConfig = npmConfigEnvironmentValue("globalconfig"); + const globalPaths = globalConfig + ? [globalConfig] + : [path.join(nodePrefix, "etc", "npmrc")]; + if (process.platform !== "win32") globalPaths.push("/etc/npmrc"); + for (const configPath of globalPaths) { + const registry = readNpmrcRegistry(configPath); + if (registry) return registry; + } + return null; +} + function registryBase() { - const raw = - process.env.CMUX_NPM_REGISTRY || - process.env.npm_config_registry || - "https://registry.npmjs.org"; - return raw.replace(/\/+$/, ""); + const explicit = normalizeRegistryValue(process.env.CMUX_NPM_REGISTRY); + if (explicit) return explicit; + const environment = normalizeRegistryValue( + npmConfigEnvironmentValue("registry") + ); + if (environment) return environment; + return npmRegistryFromConfigFiles() || "https://registry.npmjs.org"; } // Node's built-in fetch does not consume npm's proxy, CA, or client @@ -1612,6 +1698,35 @@ function isManagedCacheVersion(versionRoot) { } } +function stateVersionsByChannel() { + const versions = new Map(); + const add = (state) => { + const channel = stateVersionChannel(state); + if (!channel) return; + let channelVersions = versions.get(channel); + if (!channelVersions) { + channelVersions = new Set(); + versions.set(channel, channelVersions); + } + channelVersions.add(state.version); + }; + add(readLegacyState()); + const stateRoot = path.join(platformRoot(), "state"); + let entries; + try { + entries = fs.readdirSync(stateRoot, { withFileTypes: true }); + } catch { + return versions; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + try { + add(readStateFile(path.join(stateRoot, entry.name))); + } catch {} + } + return versions; +} + function pruneCache(keepVersion) { const lock = tryAcquireCacheLock(); if (!lock) return false; @@ -1621,10 +1736,33 @@ function pruneCache(keepVersion) { .readdirSync(root) .filter((version) => isManagedCacheVersion(path.join(root, version))) .sort(compareVersions); - const previous = managed - .filter((version) => version !== keepVersion) - .slice(-MAX_PREVIOUS_MANAGED_VERSIONS); - const keep = new Set([keepVersion, ...previous]); + const keep = new Set([keepVersion]); + // Every channel state file is a durable promise that its selected binary + // remains available for an offline launch. Keep those versions even when + // another channel is being updated. + for (const channelVersions of stateVersionsByChannel().values()) { + for (const version of channelVersions) keep.add(version); + } + // Retain one rollback predecessor per release channel. A global + // predecessor is insufficient when stable and nightly caches coexist. + const managedByChannel = new Map(); + for (const version of managed) { + const channel = versionChannel(version); + if (!channel) continue; + let channelVersions = managedByChannel.get(channel); + if (!channelVersions) { + channelVersions = []; + managedByChannel.set(channel, channelVersions); + } + channelVersions.push(version); + } + for (const channelVersions of managedByChannel.values()) { + const predecessors = channelVersions + .filter((version) => version !== keepVersion) + .sort(compareVersions) + .slice(-MAX_PREVIOUS_MANAGED_VERSIONS); + for (const version of predecessors) keep.add(version); + } for (const version of fs.readdirSync(root)) { if (keep.has(version)) continue; const versionRoot = path.join(root, version); From 9880bdc1dfb78ca97232ecfefca410e30900d85d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:50:22 -0700 Subject: [PATCH 51/73] fix(tui): honor npm config precedence and auth --- cmux-tui/dist/npm/cmux/bin/cmux.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index fdaeb4351ded..0499f9254615 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -1012,6 +1012,7 @@ function normalizeRegistryValue(value) { } function npmrcRegistry(contents) { + let registry = null; for (const rawLine of contents.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#") || line.startsWith(";")) continue; @@ -1020,10 +1021,10 @@ function npmrcRegistry(contents) { // Keep URL fragments intact, but accept the inline comment form used by // npm's ini parser when a comment is separated by whitespace. const value = match[1].replace(/\s+[;#].*$/, "").trim(); - const registry = normalizeRegistryValue(value); - if (registry) return registry; + // npm's ini parser applies the last occurrence in a file. + registry = normalizeRegistryValue(value); } - return null; + return registry; } function readNpmrcRegistry(configPath) { @@ -1062,9 +1063,11 @@ function npmRegistryFromConfigFiles() { const nodePrefix = path.dirname(path.dirname(process.execPath)); const globalConfig = npmConfigEnvironmentValue("globalconfig"); + const configuredPrefix = npmConfigEnvironmentValue("prefix"); + const globalPrefix = configuredPrefix || process.env.PREFIX || nodePrefix; const globalPaths = globalConfig ? [globalConfig] - : [path.join(nodePrefix, "etc", "npmrc")]; + : [path.join(globalPrefix, "etc", "npmrc")]; if (process.platform !== "win32") globalPaths.push("/etc/npmrc"); for (const configPath of globalPaths) { const registry = readNpmrcRegistry(configPath); @@ -1390,19 +1393,20 @@ function registryHeaders(url, accept) { const parsed = new URL(url); const registry = new URL(registryBase()); if (parsed.origin !== registry.origin) return headers; - const tokenKey = `npm_config_//${parsed.host}/:_authToken`; - const token = process.env[tokenKey] || npmrcAuthToken(parsed); + const tokenKeys = [ + `npm_config_//${parsed.host}/:_authToken`, + `NPM_CONFIG_//${parsed.host}/:_authToken`, + ]; + const token = + tokenKeys.map((key) => process.env[key]).find(configValueIsPresent) || + npmrcAuthToken(parsed); if (token) headers.authorization = `Bearer ${token}`; } catch {} return headers; } function npmrcAuthToken(url) { - const configPaths = new Set([ - process.env.npm_config_userconfig, - process.env.npm_config_globalconfig, - path.join(os.homedir(), ".npmrc"), - ]); + const configPaths = npmConfigFilePaths(); const host = url.host.toLowerCase(); for (const configPath of configPaths) { if (!configPath) continue; From db4f80359912064f43e395693c97ca6a22d45ab5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:03:54 -0700 Subject: [PATCH 52/73] test(tui): cover reused cache lock PIDs --- tests/test_tui_npm_launcher.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index f3a67e62d9b2..368da2dd0a13 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -936,6 +936,38 @@ def test_launcher_fails_closed_when_another_process_holds_cache_lock(tmp_path: P assert owner_path.read_text() == owner +def test_launcher_reclaims_cache_lock_when_owner_pid_is_reused(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'cached after pid reuse'\n", + ) + lock = cache / host_platform_key() / ".update.lock" + lock.mkdir(parents=True) + # Keep the test process PID but use a different process-start identity. A + # stale timestamp also covers legacy records without that identity. + stale_created_at = int((time.time() - 11 * 60) * 1000) + (lock / "owner").write_text( + f"{os.getpid()}\nfixture-owner-token\nproc:old-start\n{stale_created_at}\n" + ) + os.utime(lock, (stale_created_at / 1000, stale_created_at / 1000)) + + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "cached after pid reuse\n" + assert not lock.exists() + + def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1322,6 +1354,9 @@ def main() -> None: test_binary_override_works_on_an_unsupported_platform(root / "unsupported-override") test_missing_binary_override_hides_path_and_variable(root / "missing-override") test_launcher_fails_closed_when_another_process_holds_cache_lock(root / "held-lock") + test_launcher_reclaims_cache_lock_when_owner_pid_is_reused( + root / "reused-lock" + ) test_launcher_waits_for_short_cache_lock_contention(root / "short-lock") test_launcher_recovers_stale_empty_cache_lock(root / "stale-empty-lock") test_launcher_keeps_fresh_empty_cache_lock(root / "fresh-empty-lock") From f327b17778f48e22d0db095202323491bcc59b1e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:04:19 -0700 Subject: [PATCH 53/73] fix(tui): identify cache lock owner processes --- cmux-tui/dist/npm/cmux/bin/cmux.js | 108 +++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 0499f9254615..9c3ac5b27b98 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -59,6 +59,11 @@ const CACHE_LOCK_WAIT_MAX_MS = 2_000; // publishing its owner file. Reclaim only an ownerless lock that has been // quiet for long enough that the creator cannot still be in that window. const CACHE_LOCK_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; +// Legacy owner records contain only a PID. If the PID is reused before a +// start identity can be read, bound the outage instead of treating that PID +// as live forever. New records carry a start identity and do not use this +// fallback while that identity remains available. +const CACHE_LOCK_OWNER_MAX_AGE_MS = 10 * 60 * 1000; // Leases are published by renaming a fully initialized temporary directory. // Keep the same bounded recovery window for legacy or interrupted leases. const CACHE_LEASE_EMPTY_MAX_AGE_MS = 5 * 60 * 1000; @@ -592,10 +597,14 @@ function newCacheLockOwner() { const token = `${process.pid}-${Date.now().toString(36)}-${crypto .randomBytes(16) .toString("hex")}`; + const startIdentity = processStartIdentity(process.pid); + const createdAt = Date.now(); return { pid: process.pid, token, - raw: `${process.pid}\n${token}\n`, + startIdentity, + createdAt, + raw: `${process.pid}\n${token}\n${startIdentity || "-"}\n${createdAt}\n`, }; } @@ -605,7 +614,15 @@ function parseCacheLockOwner(raw) { const pid = Number.parseInt(lines[0], 10); const token = lines[1]; if (!Number.isInteger(pid) || pid <= 0 || !token) return null; - return { pid, token, raw }; + const startIdentity = lines[2] && lines[2] !== "-" ? lines[2] : null; + const createdAt = Number(lines[3]); + return { + pid, + token, + startIdentity, + createdAt: Number.isFinite(createdAt) ? createdAt : null, + raw, + }; } function readCacheLockOwnerAt(ownerPath) { @@ -621,6 +638,59 @@ function readCacheLockOwner(lockPath = cacheLockPath()) { return readCacheLockOwnerAt(cacheLockOwnerPath(lockPath)); } +let selfProcessStartIdentity; +let selfProcessStartIdentityResolved = false; + +// Return a short, non-sensitive identity for a process start. Linux exposes a +// monotonic start tick in /proc; macOS and other Unix hosts provide `ps`'s +// start timestamp. Windows has no stable dependency-free query, so callers +// use the bounded owner-age fallback there. +function processStartIdentity(pid) { + if (!Number.isInteger(pid) || pid <= 0) return null; + if (pid === process.pid && selfProcessStartIdentityResolved) { + return selfProcessStartIdentity; + } + let identity = null; + if (process.platform !== "win32") { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const close = stat.lastIndexOf(") "); + if (close !== -1) { + const fields = stat.slice(close + 2).trim().split(/\s+/); + const startTick = fields[19]; + if (/^\d+$/.test(startTick || "")) identity = `proc:${startTick}`; + } + } catch {} + if (!identity) { + for (const psPath of ["/bin/ps", "/usr/bin/ps"]) { + try { + if (!fs.existsSync(psPath)) continue; + const result = spawnSync(psPath, ["-p", String(pid), "-o", "lstart="], { + encoding: "utf8", + timeout: 1_000, + maxBuffer: 4 * 1024, + windowsHide: true, + }); + const started = String(result.stdout || "").trim(); + if (result.status === 0 && started) { + identity = `ps:${crypto + .createHash("sha256") + .update(started) + .digest("hex") + .slice(0, 32)}`; + break; + } + } catch {} + } + } + } + if (pid === process.pid) { + selfProcessStartIdentity = identity; + selfProcessStartIdentityResolved = true; + } + return identity; +} + function processIsAlive(pid) { try { process.kill(pid, 0); @@ -630,6 +700,34 @@ function processIsAlive(pid) { } } +// Return true when the owner is the same process, false when the PID is dead +// or has been reused, and null when the host cannot expose a start identity. +function cacheLockOwnerIsCurrent(owner) { + if (!owner || !processIsAlive(owner.pid)) return false; + if (!owner.startIdentity) return null; + const current = processStartIdentity(owner.pid); + if (!current) return null; + return current === owner.startIdentity; +} + +function cacheLockOwnerIsStale(owner, lockPath = cacheLockPath()) { + const ownerCreatedAt = owner && Number(owner.createdAt); + let createdAt = Number.isFinite(ownerCreatedAt) ? ownerCreatedAt : null; + if (!createdAt) { + try { + createdAt = fs.statSync(cacheLockOwnerPath(lockPath)).mtimeMs; + } catch { + try { + createdAt = fs.statSync(lockPath).mtimeMs; + } catch { + return false; + } + } + } + const age = Date.now() - createdAt; + return Number.isFinite(age) && age >= CACHE_LOCK_OWNER_MAX_AGE_MS; +} + // A pending owner file is written before it is atomically renamed to `owner`. // If the writer dies before the rename, a live pending owner proves that an // apparently empty lock is still being initialized and must not be reclaimed. @@ -656,7 +754,7 @@ function emptyCacheLockCanBeReclaimed(lockPath = cacheLockPath()) { return false; } const pending = readCacheLockOwnerAt(path.join(lockPath, entry.name)); - if (pending && processIsAlive(pending.pid)) return false; + if (pending && cacheLockOwnerIsCurrent(pending) !== false) return false; } return true; } @@ -756,7 +854,9 @@ function tryAcquireCacheLock(lockPath = cacheLockPath()) { const current = readCacheLockOwner(lockPath); if (current) { - if (processIsAlive(current.pid)) return null; + const ownership = cacheLockOwnerIsCurrent(current); + if (ownership === true) return null; + if (ownership === null && !cacheLockOwnerIsStale(current, lockPath)) return null; if (!removeCacheLockIfOwned(current, false, lockPath)) return null; continue; } From 938ef1c214a3770e86d3a586fb85de9dd21f7d8b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:13:01 -0700 Subject: [PATCH 54/73] test(tui): cover reused cache lease PIDs --- tests/test_tui_npm_launcher.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 368da2dd0a13..58d784edf749 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -1090,6 +1090,32 @@ def test_launcher_reclaims_stale_empty_lease_during_prune(tmp_path: Path) -> Non assert not (cache / host_platform_key() / "v/1.0.0").exists() +def test_launcher_reclaims_cache_lease_when_owner_pid_is_reused(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + write_cached_binary(cache, "1.0.0", "#!/bin/sh\nexit 0\n", managed=True) + write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) + lease = cache / host_platform_key() / "v/1.0.0/.active/reused-lease" + lease.mkdir(parents=True) + stale_created_at = int((time.time() - 11 * 60) * 1000) + (lease / "pid").write_text( + f"{os.getpid()}\nfixture-owner-token\nproc:old-start\n{stale_created_at}\n" + ) + os.utime(lease, (stale_created_at / 1000, stale_created_at / 1000)) + + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry) + finally: + server.shutdown() + thread.join() + + assert result.returncode == 0, result.stderr + assert not (cache / host_platform_key() / "v/1.0.0").exists() + + def test_launcher_keeps_fresh_empty_lease_during_prune(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1361,6 +1387,9 @@ def main() -> None: test_launcher_recovers_stale_empty_cache_lock(root / "stale-empty-lock") test_launcher_keeps_fresh_empty_cache_lock(root / "fresh-empty-lock") test_launcher_reclaims_stale_empty_lease_during_prune(root / "stale-empty-lease") + test_launcher_reclaims_cache_lease_when_owner_pid_is_reused( + root / "reused-lease" + ) test_launcher_keeps_fresh_empty_lease_during_prune(root / "fresh-empty-lease") test_concurrent_launchers_preserve_an_active_lease_during_prune(root / "concurrent") test_update_lease_protects_download_from_concurrent_prune(root / "update-concurrent") From 61b5d5d6b5e2cfcc3f236da9223ac3f2d227556f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:13:54 -0700 Subject: [PATCH 55/73] fix(tui): identify cache lease owner processes --- cmux-tui/dist/npm/cmux/bin/cmux.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 9c3ac5b27b98..55001a790ba6 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -879,6 +879,7 @@ function tryAcquireVersionLease(version) { for (let attempt = 0; attempt < CACHE_LOCK_ATTEMPTS; attempt++) { const lock = tryAcquireCacheLock(); if (!lock) continue; + const leaseOwner = newCacheLockOwner(); let lease = null; let pendingLease = null; try { @@ -893,7 +894,7 @@ function tryAcquireVersionLease(version) { // rename so an interruption cannot expose an empty active lease. fs.mkdirSync(pendingLease, { recursive: false }); const pidTemp = path.join(pendingLease, ".pid.tmp"); - fs.writeFileSync(pidTemp, `${process.pid}\n`, { + fs.writeFileSync(pidTemp, leaseOwner.raw, { encoding: "utf8", flag: "wx", mode: 0o600, @@ -966,14 +967,21 @@ function leaseActivity(lease) { ? "missing" : "unknown"; } + const owner = parseCacheLockOwner(raw); + if (owner) { + const ownership = cacheLockOwnerIsCurrent(owner); + if (ownership === true) return "live"; + if (ownership === false) return "dead"; + // Legacy or unsupported hosts may not expose a start identity. Keep a + // fresh lease live, then reclaim it after the existing bounded lease age. + return leaseIsStale(lease) ? "dead" : "live"; + } + // Older launchers wrote only a PID. Preserve their safety behavior while + // bounding the PID-reuse outage by the lease age. const pid = Number.parseInt(raw, 10); if (!Number.isInteger(pid) || pid <= 0) return "malformed"; - try { - process.kill(pid, 0); - return "live"; - } catch (error) { - return error && error.code === "ESRCH" ? "dead" : "unknown"; - } + if (!processIsAlive(pid)) return "dead"; + return leaseIsStale(lease) ? "dead" : "live"; } function leaseIsStale(lease) { From c19b4fff1294529bd1d68de70b061aa28b994f4d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:25:14 -0700 Subject: [PATCH 56/73] fix(tui): snapshot cached binaries on Windows --- cmux-tui/dist/npm/cmux/bin/cmux.js | 119 ++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 55001a790ba6..3a22e047827e 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -285,12 +285,20 @@ function digestHex(buffer) { } function sameFileIdentity(left, right) { - // Windows does not expose stable dev/inode values through every Node - // version. Unix launchers have the descriptor identity needed for the - // symlink and replacement checks below. + // Node exposes the volume and file-index pair as dev/ino on supported + // Unix and Windows runtimes. If either side lacks a usable identity, fail + // closed. Windows callers that cannot prove path identity use a private + // snapshot from the already-open handle instead of spawning the path. + const usable = (stat) => { + if (!stat || stat.dev === undefined || stat.ino === undefined) return false; + if (typeof stat.ino === "bigint") return stat.ino !== 0n; + return Number.isSafeInteger(stat.ino) && stat.ino !== 0; + }; return ( - process.platform === "win32" || - (left.dev === right.dev && left.ino === right.ino) + usable(left) && + usable(right) && + left.dev === right.dev && + left.ino === right.ino ); } @@ -303,7 +311,12 @@ function openCachedBinary(bin) { if (process.platform !== "win32" && typeof noFollow !== "number") return null; fd = fs.openSync(bin, fs.constants.O_RDONLY | noFollow); const openedStat = fs.fstatSync(fd); - if (!openedStat.isFile() || !sameFileIdentity(linkStat, openedStat)) { + // Windows callers copy verified bytes from this handle into a private + // snapshot, so they do not rely on a later path lookup for identity. + if ( + !openedStat.isFile() || + (process.platform !== "win32" && !sameFileIdentity(linkStat, openedStat)) + ) { fs.closeSync(fd); fd = undefined; return null; @@ -342,7 +355,11 @@ function readVerifiedCachedBinary(fd, expected) { if (!before.isFile()) return null; const data = fs.readFileSync(fd); const after = fs.fstatSync(fd); - if (!after.isFile() || !sameFileIdentity(before, after) || after.size !== data.length) { + if ( + !after.isFile() || + (process.platform !== "win32" && !sameFileIdentity(before, after)) || + after.size !== data.length + ) { return null; } const actual = Buffer.from(digestHex(data), "hex"); @@ -428,7 +445,12 @@ function snapshotVerifiedCachedBinary(candidate) { ) { return null; } - if (!cachedBinaryPathIsUnchanged(candidate.bin, verified.stat)) return null; + if ( + process.platform !== "win32" && + !cachedBinaryPathIsUnchanged(candidate.bin, verified.stat) + ) { + return null; + } snapshotDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-tui-launch-")); snapshot = path.join(snapshotDirectory, BIN_NAME); snapshotFd = fs.openSync( @@ -1701,8 +1723,13 @@ function removeCachedPayload(version) { } // Download pkg@version from the registry, verify integrity, extract bin/ -// into the launcher cache. Returns the binary path. -async function downloadVersion(pkg, version) { +// into the launcher cache. Returns the binary path, or a verified candidate +// when the caller will create a private launch snapshot. +async function downloadVersion( + pkg, + version, + { verifyCache = true, returnCandidate = false } = {} +) { const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`, { packageName: pkg, selector: version, @@ -1794,9 +1821,30 @@ async function downloadVersion(pkg, version) { } finally { fs.rmSync(stagingDir, { recursive: true, force: true }); } - const binPath = cachedBinary(version); - if (!binPath) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); - return binPath; + const finalPath = path.join(finalDir, BIN_NAME); + if (!verifyCache) { + let stat; + try { + stat = fs.lstatSync(finalPath); + } catch { + stat = null; + } + if (!stat || !stat.isFile()) { + throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); + } + } else { + const binPath = cachedBinary(version); + if (!binPath) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); + } + if (returnCandidate) { + const expected = entries.find((entry) => entry.name === BIN_NAME); + if (!expected) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); + return { + path: finalPath, + candidate: { bin: finalPath, expected: digestHex(expected.data) }, + }; + } + return finalPath; } function isManagedCacheVersion(versionRoot) { @@ -1924,7 +1972,12 @@ function wantedVersion(pkg) { return pinned; } -async function resolveBinary(pkg, wanted, cachedCandidate = null) { +async function resolveBinary( + pkg, + wanted, + cachedCandidate = null, + { snapshotCache = false } = {} +) { const override = process.env.CMUX_TUI_BIN; if (override) { if (!fs.existsSync(override)) fail("configured native binary override does not exist"); @@ -1936,15 +1989,27 @@ async function resolveBinary(pkg, wanted, cachedCandidate = null) { if (installed && installed.version === wanted) { return installed.binPath; } - const cached = cachedBinary(wanted, cachedCandidate); - if (cached) return cached; + if (snapshotCache && cachedCandidate) { + const snapshot = snapshotVerifiedCachedBinary(cachedCandidate); + if (snapshot) return { path: snapshot.path, snapshot }; + } else { + const cached = cachedBinary(wanted, cachedCandidate); + if (cached) return cached; + } // Check the runtime before entering the generic download error boundary so // an unsupported Node version gets a useful, actionable message instead of // being flattened into a network failure. requireNetworkRuntime(); try { - return await downloadVersion(pkg, wanted); + const downloaded = await downloadVersion(pkg, wanted, { + verifyCache: !snapshotCache, + returnCandidate: snapshotCache, + }); + if (!snapshotCache) return downloaded; + const snapshot = snapshotVerifiedCachedBinary(downloaded.candidate); + if (!snapshot) fail("the cached native binary changed before launch"); + return { path: snapshot.path, snapshot }; } catch { fail( "could not obtain the native binary. Check network access or install " + @@ -2012,7 +2077,12 @@ async function runUpdate(pkg, args) { } lease = await acquireVersionLeaseForProcess(latest); if (!lease) fail("could not reserve the native binary for update"); - await downloadVersion(pkg, latest); + // Update never executes the returned path. On Windows, let the launch + // path use its private snapshot fallback when file IDs are unavailable; + // the tarball integrity check still validates every extracted byte here. + await downloadVersion(pkg, latest, { + verifyCache: process.platform !== "win32", + }); writeState({ version: latest, channel, @@ -2087,7 +2157,18 @@ async function main() { if (!launchSnapshot) fail("the cached native binary changed before launch"); binPath = launchSnapshot.path; } else if (!binPath) { - binPath = await resolveBinary(pkg, wanted, cachedCandidate); + const resolved = await resolveBinary(pkg, wanted, cachedCandidate, { + // Windows does not guarantee path identity through every supported + // Node filesystem build. Resolve cache hits into a private snapshot + // so the spawned path cannot be replaced after digest verification. + snapshotCache: process.platform === "win32", + }); + if (resolved && typeof resolved === "object" && resolved.snapshot) { + launchSnapshot = resolved.snapshot; + binPath = resolved.path; + } else { + binPath = resolved; + } } if (lease) pruneCache(wanted); const result = spawnSync(binPath, args, { stdio: "inherit" }); From 12fc6186b787f6284bf22fca823f1438a0ef1136 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:50:18 -0700 Subject: [PATCH 57/73] test(tui): reject tampered cache manifest and binary --- tests/test_tui_npm_launcher.py | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 58d784edf749..95791748a83d 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -450,6 +450,45 @@ def test_launcher_refetches_a_tampered_cached_binary(tmp_path: Path) -> None: assert RegistryHandler.tarball_requests == 2 +def test_launcher_refetches_tampered_manifest_and_binary(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry() + try: + first = run_launcher(launcher, cache, registry, "--version") + version_dir = cache / f"{host_platform_key()}/v/1.2.3" + manifest_path = version_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + tampered = b"#!/bin/sh\nprintf '%s\\n' 'tampered cache'\n" + manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( + hashlib.sha512(b"tampered tarball").digest() + ).decode() + manifest["binaries"]["cmux-tui"] = hashlib.sha512(tampered).hexdigest() + manifest_path.write_text(json.dumps(manifest) + "\n") + (version_dir / "bin/cmux-tui").write_bytes(tampered) + (version_dir / "bin/cmux-tui").chmod(0o755) + second = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert second.stdout == "fake cmux-tui 1.2.3\n" + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 2 + repaired = json.loads((version_dir / "manifest.json").read_text()) + expected_integrity = "sha512-" + base64.b64encode( + hashlib.sha512(RegistryHandler.tarball).digest() + ).decode() + assert repaired["tarballIntegrity"] == expected_integrity + expected_binary = b"#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n" + assert repaired["binaries"]["cmux-tui"] == hashlib.sha512( + expected_binary + ).hexdigest() + + def test_launcher_repairs_non_executable_cached_binary(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1368,6 +1407,9 @@ def main() -> None: test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") + test_launcher_refetches_tampered_manifest_and_binary( + root / "tampered-manifest-cache" + ) test_launcher_repairs_non_executable_cached_binary(root / "non-executable-cache") test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") From e5d58bf68958de9b09868d2d6966c89d90330d4c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:50:35 -0700 Subject: [PATCH 58/73] fix(tui): bind writable cache hits to registry integrity --- cmux-tui/dist/npm/cmux/bin/cmux.js | 204 +++++++++++++++++++---------- 1 file changed, 137 insertions(+), 67 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 3a22e047827e..6df96b05016d 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -332,19 +332,29 @@ function openCachedBinary(bin) { } } -function readCachedManifest(version) { +function readCachedManifest(version, pkg = null) { const bin = path.join(cachedBinDir(version), BIN_NAME); try { const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); const expected = manifest?.binaries?.[BIN_NAME]; if ( manifest?.version !== version || + (pkg && manifest?.package !== pkg) || + typeof manifest?.package !== "string" || + !/^[a-z0-9][a-z0-9._-]*$/i.test(manifest.package) || + typeof manifest?.tarballIntegrity !== "string" || typeof expected !== "string" || !/^[a-f0-9]{128}$/.test(expected) ) { return null; } - return { bin, expected }; + return { + bin, + expected, + package: manifest.package, + version: manifest.version, + tarballIntegrity: manifest.tarballIntegrity, + }; } catch { return null; } @@ -373,8 +383,8 @@ function cachedBinaryPathIsUnchanged(bin, expectedStat) { return finalStat.isFile() && sameFileIdentity(finalStat, expectedStat); } -function cachedBinary(version, candidate = null) { - const resolvedCandidate = candidate || readCachedManifest(version); +function cachedBinary(version, candidate = null, pkg = null) { + const resolvedCandidate = candidate || readCachedManifest(version, pkg); if (!resolvedCandidate) return null; let opened = null; try { @@ -412,15 +422,24 @@ function cachedBinary(version, candidate = null) { // Check only cheap metadata before lease setup. Full digest verification is // performed once after the lease, or while making a private read-only snapshot. -function cachedBinaryCandidate(version) { - const candidate = readCachedManifest(version); +function cachedBinaryCandidate(version, pkg = null) { + const candidate = readCachedManifest(version, pkg); if (!candidate) return null; try { const stat = fs.lstatSync(candidate.bin); if (!stat.isFile()) return null; if (process.platform !== "win32") { - if ((stat.mode & 0o111) === 0) return null; - fs.accessSync(candidate.bin, fs.constants.X_OK); + if ((stat.mode & 0o111) === 0) { + // Managed entries may have lost their mode bits during a cache copy. + // Keep them eligible for authenticated verification, where the mode + // can be repaired on the already-open descriptor. Unmanaged entries + // never receive a launcher-owned mode repair. + if (!isManagedCacheVersion(path.dirname(cachedBinDir(version)))) { + return null; + } + } else { + fs.accessSync(candidate.bin, fs.constants.X_OK); + } } return candidate; } catch { @@ -428,7 +447,11 @@ function cachedBinaryCandidate(version) { } } -function snapshotVerifiedCachedBinary(candidate) { +function snapshotVerifiedCachedBinary( + candidate, + expected = candidate.expected, + repairManagedMode = false +) { let opened = null; let snapshotDirectory = null; let snapshotFd; @@ -437,17 +460,29 @@ function snapshotVerifiedCachedBinary(candidate) { try { opened = openCachedBinary(candidate.bin); if (!opened) return null; - const verified = readVerifiedCachedBinary(opened.fd, candidate.expected); + let verified = readVerifiedCachedBinary(opened.fd, expected); if (!verified) return null; - if ( - process.platform !== "win32" && - (verified.stat.mode & 0o111) === 0 - ) { - return null; + if (process.platform !== "win32" && (verified.stat.mode & 0o111) === 0) { + const version = candidate.version; + const versionRoot = + typeof version === "string" ? path.dirname(cachedBinDir(version)) : null; + if (!repairManagedMode || !versionRoot || !isManagedCacheVersion(versionRoot)) { + return null; + } + // Repair only a launcher-owned managed entry, on the open descriptor. + // Reopen and revalidate after chmod so a replacement cannot inherit the + // repaired path into the launch snapshot. + fs.fchmodSync(opened.fd, 0o755); + fs.closeSync(opened.fd); + opened = null; + opened = openCachedBinary(candidate.bin); + if (!opened) return null; + verified = readVerifiedCachedBinary(opened.fd, expected); + if (!verified || (verified.stat.mode & 0o111) === 0) return null; } if ( process.platform !== "win32" && - !cachedBinaryPathIsUnchanged(candidate.bin, verified.stat) + !cachedBinaryPathIsUnchanged(candidate.bin, verified.stat) ) { return null; } @@ -1700,36 +1735,10 @@ function verifyIntegrity(buffer, integrity) { } } -function writeCacheManifest(pkg, version, integrity, entries) { - const target = cacheManifestPath(version); - const tmp = `${target}.${process.pid}.tmp`; - const binaries = {}; - for (const entry of entries) binaries[entry.name] = digestHex(entry.data); - fs.writeFileSync( - tmp, - JSON.stringify({ package: pkg, version, tarballIntegrity: integrity, binaries }, null, 2) + "\n", - { mode: 0o600 } - ); - fs.renameSync(tmp, target); -} - -function removeCachedPayload(version) { - const versionDir = path.dirname(cachedBinDir(version)); - for (const name of ["bin", "manifest.json", "managed"]) { - try { - fs.rmSync(path.join(versionDir, name), { recursive: true, force: true }); - } catch {} - } -} - -// Download pkg@version from the registry, verify integrity, extract bin/ -// into the launcher cache. Returns the binary path, or a verified candidate -// when the caller will create a private launch snapshot. -async function downloadVersion( - pkg, - version, - { verifyCache = true, returnCandidate = false } = {} -) { +// Fetch and verify one published platform package. The registry's dist +// integrity is the authentication root for the extracted binary; local cache +// metadata is only a consistency check and never supplies the expected digest. +async function fetchVerifiedPackage(pkg, version, purpose = "download") { const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`, { packageName: pkg, selector: version, @@ -1744,7 +1753,7 @@ async function downloadVersion( if (!integrity || (!tarballUrl && !hasNpmNetworkConfig())) { throw new Error("registry metadata is incomplete"); } - console.error(`cmux: downloading ${pkg}@${version}...`); + if (purpose) console.error(`cmux: ${purpose} ${pkg}@${version}...`); let tgz; if (hasNpmNetworkConfig()) { tgz = npmPack(pkg, version); @@ -1766,9 +1775,53 @@ async function downloadVersion( throw new Error("platform package is invalid or too large"); } const entries = extractBinEntries(tar); - if (!entries.some((entry) => entry.name === BIN_NAME)) { + const native = entries.find((entry) => entry.name === BIN_NAME); + if (!native) { throw new Error("platform package does not contain the native binary"); } + return { integrity, entries, native }; +} + +function cacheManifestMatchesPackage(candidate, pkg, version, verified) { + if (!candidate || !verified || candidate.package !== pkg) return false; + if (candidate.version !== version) return false; + if (candidate.tarballIntegrity !== verified.integrity) return false; + return candidate.expected === digestHex(verified.native.data); +} + +function writeCacheManifest(pkg, version, integrity, entries) { + const target = cacheManifestPath(version); + const tmp = `${target}.${process.pid}.tmp`; + const binaries = {}; + for (const entry of entries) binaries[entry.name] = digestHex(entry.data); + fs.writeFileSync( + tmp, + JSON.stringify({ package: pkg, version, tarballIntegrity: integrity, binaries }, null, 2) + "\n", + { mode: 0o600 } + ); + fs.renameSync(tmp, target); +} + +function removeCachedPayload(version) { + const versionDir = path.dirname(cachedBinDir(version)); + for (const name of ["bin", "manifest.json", "managed"]) { + try { + fs.rmSync(path.join(versionDir, name), { recursive: true, force: true }); + } catch {} + } +} + +// Download pkg@version from the registry, verify integrity, extract bin/ +// into the launcher cache. Returns the binary path, or a verified candidate +// when the caller will create a private launch snapshot. +async function downloadVersion( + pkg, + version, + { verifyCache = true, returnCandidate = false, verifiedPackage = null } = {} +) { + const verified = + verifiedPackage || (await fetchVerifiedPackage(pkg, version, "downloading")); + const { integrity, entries, native } = verified; const finalDir = cachedBinDir(version); const stagingDir = path.join( @@ -1833,15 +1886,19 @@ async function downloadVersion( throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); } } else { - const binPath = cachedBinary(version); + const binPath = cachedBinary(version, null, pkg); if (!binPath) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); } if (returnCandidate) { - const expected = entries.find((entry) => entry.name === BIN_NAME); - if (!expected) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); return { path: finalPath, - candidate: { bin: finalPath, expected: digestHex(expected.data) }, + candidate: { + bin: finalPath, + expected: digestHex(native.data), + package: pkg, + version, + tarballIntegrity: integrity, + }, }; } return finalPath; @@ -1989,24 +2046,37 @@ async function resolveBinary( if (installed && installed.version === wanted) { return installed.binPath; } - if (snapshotCache && cachedCandidate) { - const snapshot = snapshotVerifiedCachedBinary(cachedCandidate); - if (snapshot) return { path: snapshot.path, snapshot }; - } else { - const cached = cachedBinary(wanted, cachedCandidate); - if (cached) return cached; - } // Check the runtime before entering the generic download error boundary so // an unsupported Node version gets a useful, actionable message instead of // being flattened into a network failure. requireNetworkRuntime(); try { + let verifiedPackage = null; + if (cachedCandidate) { + // A writable cache is untrusted. Fetch the package metadata and tarball + // again, then derive the expected binary digest from those authenticated + // bytes. The local manifest can only confirm that the cache matches that + // registry result; it is never an integrity root. + verifiedPackage = await fetchVerifiedPackage(pkg, wanted, "verifying"); + if (cacheManifestMatchesPackage(cachedCandidate, pkg, wanted, verifiedPackage)) { + const expected = digestHex(verifiedPackage.native.data); + const snapshot = snapshotVerifiedCachedBinary( + cachedCandidate, + expected, + true + ); + if (snapshot) return { path: snapshot.path, snapshot }; + } + } const downloaded = await downloadVersion(pkg, wanted, { - verifyCache: !snapshotCache, - returnCandidate: snapshotCache, + // The caller receives a private snapshot below, so a second path lookup + // is unnecessary. Reuse the authenticated tarball when the cache was + // stale or its manifest was tampered with. + verifyCache: false, + returnCandidate: true, + verifiedPackage, }); - if (!snapshotCache) return downloaded; const snapshot = snapshotVerifiedCachedBinary(downloaded.candidate); if (!snapshot) fail("the cached native binary changed before launch"); return { path: snapshot.path, snapshot }; @@ -2133,7 +2203,7 @@ async function main() { // read-only, pre-populated cache cannot publish `.active` or `.update.lock`; // it is safe to launch that verified binary when pruning is skipped. const cachedCandidate = - wanted && !installedBin ? cachedBinaryCandidate(wanted) : null; + wanted && !installedBin ? cachedBinaryCandidate(wanted, pkg) : null; const readOnlyCached = Boolean( cachedCandidate && cacheVersionIsReadOnly(wanted) ); @@ -2158,10 +2228,10 @@ async function main() { binPath = launchSnapshot.path; } else if (!binPath) { const resolved = await resolveBinary(pkg, wanted, cachedCandidate, { - // Windows does not guarantee path identity through every supported - // Node filesystem build. Resolve cache hits into a private snapshot - // so the spawned path cannot be replaced after digest verification. - snapshotCache: process.platform === "win32", + // Resolve every cache hit into a private snapshot. This closes the + // replacement window after the authenticated registry check on Unix + // as well as the path-identity gap on Windows. + snapshotCache: true, }); if (resolved && typeof resolved === "object" && resolved.snapshot) { launchSnapshot = resolved.snapshot; From b5632c71813be40d559a5b851f710baf6913c9b6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:03:17 -0700 Subject: [PATCH 59/73] test(tui): authenticate cache fixtures and document offline use --- cmux-tui/README.ja.md | 4 +- cmux-tui/README.md | 2 +- cmux-tui/dist/npm/cmux/bin/cmux.js | 24 +++-- cmux-tui/docs/getting-started.ja.md | 4 + cmux-tui/docs/getting-started.md | 2 + tests/test_tui_npm_launcher.py | 139 ++++++++++++++++++++++------ 6 files changed, 132 insertions(+), 43 deletions(-) diff --git a/cmux-tui/README.ja.md b/cmux-tui/README.ja.md index fec79ef42333..8c16eddbbddd 100644 --- a/cmux-tui/README.ja.md +++ b/cmux-tui/README.ja.md @@ -9,6 +9,9 @@ tmux 風のターミナル TUI です。詳細な英語ドキュメントは 現在のプラットフォーム用の `cmux-tui-` パッケージを npm レジストリから ダウンロードし、sha512 整合性を確認してランチャー専用キャッシュに保存します。 +書き込み可能なランチャーキャッシュは起動前にレジストリの tarball と再検証します。 +管理者が用意した完全な読み取り専用キャッシュはオフラインで起動できます。 + ```bash npx cmux update npx cmux update --check @@ -19,4 +22,3 @@ npx cmux update --check キャッシュへアクセスすることがあり、古いキャッシュでは `ENOTEMPTY` が発生する場合が あります。詳しい復旧手順は [パッケージのインストールと更新](docs/getting-started.ja.md) を参照してください。 - diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 8c75107a7ee5..f7b65b17a7a6 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -90,7 +90,7 @@ ssh -T dev@buildbox cmux relay --session agents The Unix-only `machine-agent` shares an existing local session through one outbound SSH registration with cmux.cloud. It prints a one-time pairing code and opens no listener. The final command is a low-level raw JSON-lines diagnostic. Use the machine rail or `cmux ssh` for the managed remote lifecycle. -Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without rewriting npm's caches. `npx` can still touch, or fail while touching, npm's `_npx` cache before cmux starts. Use `npx cmux update` for routine platform-binary upgrades and `npx cmux@latest` when updating the npm launcher. If the latter fails with `ENOTEMPTY: directory not empty, rename`, see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Japanese: [npm パッケージ](README.ja.md). +Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without rewriting npm's caches. Writable launcher-cache hits revalidate the registry tarball before launch, while a fully read-only provisioned cache can run offline. `npx` can still touch, or fail while touching, npm's `_npx` cache before cmux starts. Use `npx cmux update` for routine platform-binary upgrades and `npx cmux@latest` when updating the npm launcher. If the latter fails with `ENOTEMPTY: directory not empty, rename`, see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Japanese: [npm パッケージ](README.ja.md). Use `--term ` to set `TERM` for child PTYs. Without it, children get `xterm-256color`; `CMUX_TUI_TERM` can override the terminal runtime default, with `CMUX_MUX_TERM` retained as a legacy fallback. diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 6df96b05016d..8bc7357fc953 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -343,6 +343,7 @@ function readCachedManifest(version, pkg = null) { typeof manifest?.package !== "string" || !/^[a-z0-9][a-z0-9._-]*$/i.test(manifest.package) || typeof manifest?.tarballIntegrity !== "string" || + !validSha512Integrity(manifest.tarballIntegrity) || typeof expected !== "string" || !/^[a-f0-9]{128}$/.test(expected) ) { @@ -482,7 +483,7 @@ function snapshotVerifiedCachedBinary( } if ( process.platform !== "win32" && - !cachedBinaryPathIsUnchanged(candidate.bin, verified.stat) + !cachedBinaryPathIsUnchanged(candidate.bin, verified.stat) ) { return null; } @@ -1735,6 +1736,10 @@ function verifyIntegrity(buffer, integrity) { } } +function validSha512Integrity(integrity) { + return /^sha512-[A-Za-z0-9+/]{86}={0,2}$/.test(integrity || ""); +} + // Fetch and verify one published platform package. The registry's dist // integrity is the authentication root for the extracted binary; local cache // metadata is only a consistency check and never supplies the expected digest. @@ -2029,12 +2034,7 @@ function wantedVersion(pkg) { return pinned; } -async function resolveBinary( - pkg, - wanted, - cachedCandidate = null, - { snapshotCache = false } = {} -) { +async function resolveBinary(pkg, wanted, cachedCandidate = null) { const override = process.env.CMUX_TUI_BIN; if (override) { if (!fs.existsSync(override)) fail("configured native binary override does not exist"); @@ -2227,12 +2227,10 @@ async function main() { if (!launchSnapshot) fail("the cached native binary changed before launch"); binPath = launchSnapshot.path; } else if (!binPath) { - const resolved = await resolveBinary(pkg, wanted, cachedCandidate, { - // Resolve every cache hit into a private snapshot. This closes the - // replacement window after the authenticated registry check on Unix - // as well as the path-identity gap on Windows. - snapshotCache: true, - }); + // Resolve every cache hit into a private snapshot. This closes the + // replacement window after the authenticated registry check on Unix + // as well as the path-identity gap on Windows. + const resolved = await resolveBinary(pkg, wanted, cachedCandidate); if (resolved && typeof resolved === "object" && resolved.snapshot) { launchSnapshot = resolved.snapshot; binPath = resolved.path; diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 5ea77e6bae3d..7c62fe0264ee 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -9,6 +9,10 @@ `~/Library/Caches/cmux-tui-launcher`、Linux では `$XDG_CACHE_HOME/cmux-tui-launcher` (未設定時は `~/.cache/cmux-tui-launcher`)にバージョン別で保存します。 +書き込み可能なキャッシュは起動前にレジストリの tarball と再検証するため、通常の +キャッシュヒットでもネットワークを使用します。完全な読み取り専用キャッシュは管理者が +用意したものとして扱い、バイナリとマニフェストを検証済みならオフラインで起動できます。 + ```bash npx cmux update # 最新のプラットフォーム用バイナリを取得 npx cmux update --check # 更新の有無だけを確認 diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index d4db0cafec6c..af0f10cf52f2 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -122,6 +122,8 @@ Japanese: [パッケージのインストールと更新](getting-started.ja.md) The `cmux` npm package is a small launcher with no dependencies. On first run it downloads the prebuilt `cmux-tui-` package for your platform from the npm registry, verifies the registry's sha512 integrity for the tarball, and caches the binaries in a versioned launcher cache (`~/Library/Caches/cmux-tui-launcher` on macOS, `$XDG_CACHE_HOME/cmux-tui-launcher` or `~/.cache/cmux-tui-launcher` on Linux). Later runs start instantly from that cache. +Writable cache entries are revalidated against the registry tarball before they run, so a normal cache hit can use the network. A fully read-only cache is treated as administrator-provisioned and can run offline after its binary and manifest have been verified. + Update with the launcher itself: ```bash diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 95791748a83d..a0d44bcfd0c5 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -17,6 +17,7 @@ import tempfile import threading import time +import urllib.parse from pathlib import Path @@ -25,8 +26,9 @@ NIGHTLY_VERSION = "1.2.3-nightly.20260827.1" -def make_tarball() -> bytes: - payload = b"#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n" +def make_tarball(payload: bytes | None = None) -> bytes: + if payload is None: + payload = b"#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n" tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode="w") as archive: info = tarfile.TarInfo("package/bin/cmux-tui") @@ -46,9 +48,11 @@ def make_negative_size_tarball() -> bytes: class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball = make_tarball() + tarballs: dict[str, bytes] = {} latest_version = "1.2.3" nightly_version = "1.2.3-nightly.20260826.1" block_tarball = False + block_tarball_versions: set[str] = set() tarball_started = threading.Event() tarball_release = threading.Event() metadata_requests = 0 @@ -78,21 +82,33 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler f"/cmux-tui-linux-x64/{NIGHTLY_VERSION}", )): type(self).metadata_requests += 1 + version = self.path.rsplit("/", 1)[-1] + tarball = type(self).tarballs.get(version, type(self).tarball) body = json.dumps( { "dist": { - "tarball": f"http://127.0.0.1:{self.server.server_port}/tarball.tgz", + "tarball": ( + f"http://127.0.0.1:{self.server.server_port}/tarball.tgz?" + f"version={urllib.parse.quote(version, safe='')}" + ), "integrity": "sha512-" - + base64.b64encode(hashlib.sha512(self.tarball).digest()).decode(), + + base64.b64encode(hashlib.sha512(tarball).digest()).decode(), } } ).encode() - elif self.path == "/tarball.tgz": + elif urllib.parse.urlsplit(self.path).path == "/tarball.tgz": type(self).tarball_requests += 1 - if type(self).block_tarball: + query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) + version = query.get("version", [None])[0] + tarball = type(self).tarballs.get(version, type(self).tarball) + blocked = type(self).block_tarball and ( + not type(self).block_tarball_versions + or version in type(self).block_tarball_versions + ) + if blocked: type(self).tarball_started.set() type(self).tarball_release.wait(timeout=10) - body = self.tarball + body = tarball else: self.send_error(404) return @@ -192,12 +208,16 @@ def write_cached_binary( binary.write_bytes(data) binary.chmod(0o755) version_dir = binary.parent.parent + tarball = make_tarball(data) + tarball_integrity = "sha512-" + base64.b64encode( + hashlib.sha512(tarball).digest() + ).decode() (version_dir / "manifest.json").write_text( json.dumps( { "package": package, "version": version, - "tarballIntegrity": "sha512-fixture", + "tarballIntegrity": tarball_integrity, "binaries": {"cmux-tui": hashlib.sha512(data).hexdigest()}, } ) @@ -208,6 +228,17 @@ def write_cached_binary( return binary +def make_cache_read_only(cache: Path) -> None: + """Mark a fixture cache immutable so the launcher may use its offline path.""" + entries = [cache, *cache.rglob("*")] + for entry in entries: + if entry.is_symlink(): + raise AssertionError(f"fixture cache unexpectedly contains a symlink: {entry}") + for entry in entries: + if entry.exists(): + entry.chmod(stat.S_IMODE(entry.stat().st_mode) & ~0o222) + + def write_runtime_capability_stub(tmp_path: Path) -> Path: stub = tmp_path / "disable-node-network-apis.cjs" stub.write_text( @@ -274,7 +305,11 @@ def write_platform_stub(tmp_path: Path, platform_name: str = "freebsd") -> Path: return stub -def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, str]: +def start_registry( + *, + tarballs: dict[str, bytes] | None = None, + block_tarball_versions: set[str] | None = None, +) -> tuple[http.server.ThreadingHTTPServer, threading.Thread, str]: RegistryHandler.metadata_requests = 0 RegistryHandler.tarball_requests = 0 RegistryHandler.authorization_headers = [] @@ -282,6 +317,8 @@ def start_registry() -> tuple[http.server.ThreadingHTTPServer, threading.Thread, RegistryHandler.latest_version = "1.2.3" RegistryHandler.nightly_version = NIGHTLY_VERSION RegistryHandler.block_tarball = False + RegistryHandler.tarballs = dict(tarballs or {}) + RegistryHandler.block_tarball_versions = set(block_tarball_versions or set()) RegistryHandler.tarball_started = threading.Event() RegistryHandler.tarball_release = threading.Event() RegistryHandler.latest_requests = [] @@ -300,6 +337,9 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No server, thread, registry = start_registry() try: first = run_launcher(launcher, cache, registry, "--version") + # Writable cache hits require a fresh registry verification. Mark this + # provisioned fixture read-only to exercise the documented offline path. + make_cache_read_only(cache) # A verified cache hit must remain usable when the Node network APIs # are unavailable. The capability guard belongs on the download path. second = run_launcher( @@ -508,8 +548,8 @@ def test_launcher_repairs_non_executable_cached_binary(tmp_path: Path) -> None: assert second.returncode == 0, second.stderr assert second.stdout == "fake cmux-tui 1.2.3\n" assert binary.stat().st_mode & stat.S_IXUSR - assert RegistryHandler.metadata_requests == 1 - assert RegistryHandler.tarball_requests == 1 + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 2 def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: @@ -527,7 +567,12 @@ def test_prune_preserves_unmanaged_cache_version(tmp_path: Path) -> None: unmanaged.chmod(0o644) write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) - result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() assert result.returncode == 0, result.stderr assert unmanaged.is_file() @@ -566,7 +611,12 @@ def test_prune_preserves_versions_selected_by_each_channel_state( json.dumps({"version": nightly_old, "channel": "nightly"}) + "\n" ) - result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() assert result.returncode == 0, result.stderr assert result.stdout == "fake cmux-tui 1.2.3\n" @@ -670,6 +720,7 @@ def test_launcher_keeps_stable_and_nightly_state_channels_separate( (platform_root / "state.json").write_text( json.dumps({"version": RegistryHandler.nightly_version}) + "\n" ) + make_cache_read_only(cache) nightly_result = run_launcher( nightly_launcher, cache, "http://127.0.0.1:1", "--version" ) @@ -707,7 +758,15 @@ def test_launcher_releases_lease_when_native_launch_fails(tmp_path: Path) -> Non managed=True, ) - result = run_launcher(launcher, cache, "http://127.0.0.1:1", "--version") + bad_payload = b"#!/definitely/missing/interpreter\n" + server, thread, registry = start_registry( + tarballs={"1.2.3": make_tarball(bad_payload)} + ) + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() assert result.returncode != 0 assert "failed to launch the native binary" in result.stderr @@ -995,12 +1054,15 @@ def test_launcher_reclaims_cache_lock_when_owner_pid_is_reused(tmp_path: Path) - ) os.utime(lock, (stale_created_at / 1000, stale_created_at / 1000)) - result = run_launcher( - launcher, - cache, - "http://127.0.0.1:1", - "--version", + payload = b"#!/bin/sh\nprintf '%s\\n' 'cached after pid reuse'\n" + server, thread, registry = start_registry( + tarballs={"1.2.3": make_tarball(payload)} ) + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() assert result.returncode == 0, result.stderr assert result.stdout == "cached after pid reuse\n" @@ -1021,6 +1083,10 @@ def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: lock.mkdir(parents=True) (lock / "owner").write_text(f"{os.getpid()}\nfixture-owner-token\n") + payload = b"#!/bin/sh\nprintf '%s\\n' 'cached after short lock contention'\n" + server, thread, registry = start_registry( + tarballs={"1.2.3": make_tarball(payload)} + ) process = subprocess.Popen( ["node", str(launcher), "--version"], stdout=subprocess.PIPE, @@ -1029,7 +1095,7 @@ def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: env={ **os.environ, "CMUX_TUI_LAUNCHER_CACHE": str(cache), - "CMUX_NPM_REGISTRY": "http://127.0.0.1:1", + "CMUX_NPM_REGISTRY": registry, "NO_COLOR": "1", }, ) @@ -1046,6 +1112,8 @@ def test_launcher_waits_for_short_cache_lock_contention(tmp_path: Path) -> None: if process.poll() is None: process.kill() process.communicate(timeout=5) + server.shutdown() + thread.join() assert process.returncode == 0, stderr or stdout assert stdout == "cached after short lock contention\n" @@ -1066,12 +1134,15 @@ def test_launcher_recovers_stale_empty_cache_lock(tmp_path: Path) -> None: stale = time.time() - 10 * 60 os.utime(lock, (stale, stale)) - result = run_launcher( - launcher, - cache, - "http://127.0.0.1:1", - "--version", + payload = b"#!/bin/sh\nprintf '%s\\n' 'cached after interrupted lock'\n" + server, thread, registry = start_registry( + tarballs={"1.2.3": make_tarball(payload)} ) + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() assert result.returncode == 0, result.stderr assert result.stdout == "cached after interrupted lock\n" @@ -1194,11 +1265,17 @@ def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Pa write_cached_binary(cache, "1.1.0", "#!/bin/sh\nexit 0\n", managed=True) write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) + server, thread, registry = start_registry( + tarballs={ + "1.0.0": make_tarball(old_payload.encode()), + "1.2.3": make_tarball(b"#!/bin/sh\nexit 0\n"), + } + ) env = os.environ.copy() env.update( { "CMUX_TUI_LAUNCHER_CACHE": str(cache), - "CMUX_NPM_REGISTRY": "http://127.0.0.1:1", + "CMUX_NPM_REGISTRY": registry, "NO_COLOR": "1", } ) @@ -1218,7 +1295,7 @@ def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Pa new_result = run_launcher( new_launcher, cache, - "http://127.0.0.1:1", + registry, "--version", ) assert new_result.returncode == 0, new_result.stderr @@ -1229,6 +1306,8 @@ def test_concurrent_launchers_preserve_an_active_lease_during_prune(tmp_path: Pa except subprocess.TimeoutExpired: old_process.kill() old_process.wait(timeout=5) + server.shutdown() + thread.join() assert old_process.returncode == 0 @@ -1253,7 +1332,11 @@ def test_update_lease_protects_download_from_concurrent_prune(tmp_path: Path) -> "#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n", ) - server, thread, registry = start_registry() + launch_payload = b"#!/bin/sh\nprintf '%s\\n' 'cached while update is downloading'\n" + server, thread, registry = start_registry( + tarballs={"1.1.0": make_tarball(launch_payload)}, + block_tarball_versions={"1.2.3"}, + ) RegistryHandler.block_tarball = True env = os.environ.copy() env.update( From c8dbdf4a2abde412ed3bdc73607ea491715e956f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:06 -0700 Subject: [PATCH 60/73] test(tui): serve every cached fixture version --- tests/test_tui_npm_launcher.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index a0d44bcfd0c5..87af63ba6318 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -10,6 +10,7 @@ import json import os import platform +import re import stat import subprocess import sys @@ -63,6 +64,12 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) + metadata_path = urllib.parse.urlsplit(self.path).path + metadata_match = re.fullmatch( + r"/cmux-tui-[A-Za-z0-9._-]+/" + r"([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)", + metadata_path, + ) if self.path in ("/cmux/latest", "/cmux/nightly"): type(self).latest_requests.append(self.path) version = ( @@ -71,18 +78,9 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler else type(self).latest_version ) body = json.dumps({"version": version}).encode() - elif self.path.endswith(( - "/cmux-tui-darwin-arm64/1.2.3", - "/cmux-tui-darwin-x64/1.2.3", - "/cmux-tui-linux-arm64/1.2.3", - "/cmux-tui-linux-x64/1.2.3", - f"/cmux-tui-darwin-arm64/{NIGHTLY_VERSION}", - f"/cmux-tui-darwin-x64/{NIGHTLY_VERSION}", - f"/cmux-tui-linux-arm64/{NIGHTLY_VERSION}", - f"/cmux-tui-linux-x64/{NIGHTLY_VERSION}", - )): + elif metadata_match: type(self).metadata_requests += 1 - version = self.path.rsplit("/", 1)[-1] + version = metadata_match.group(1) tarball = type(self).tarballs.get(version, type(self).tarball) body = json.dumps( { From e2b2c603b131522a8f0a6ce35fa903bddf2cad41 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:31:27 -0700 Subject: [PATCH 61/73] test(tui): support prefixed registry paths --- tests/test_tui_npm_launcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 87af63ba6318..a943e343711f 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -65,9 +65,9 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) metadata_path = urllib.parse.urlsplit(self.path).path - metadata_match = re.fullmatch( + metadata_match = re.search( r"/cmux-tui-[A-Za-z0-9._-]+/" - r"([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)", + r"([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$", metadata_path, ) if self.path in ("/cmux/latest", "/cmux/nightly"): From 1eb60ddddbe8a4da6126bac5643e086ae026a9fe Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:46:00 -0700 Subject: [PATCH 62/73] test(tui): cover Windows launcher cache path --- tests/test_tui_npm_launcher.py | 172 +++++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 6 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index a943e343711f..be30ab780a6b 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -27,12 +27,16 @@ NIGHTLY_VERSION = "1.2.3-nightly.20260827.1" -def make_tarball(payload: bytes | None = None) -> bytes: +def make_tarball( + payload: bytes | None = None, + *, + binary_name: str = "cmux-tui", +) -> bytes: if payload is None: payload = b"#!/bin/sh\nprintf '%s\\n' 'fake cmux-tui 1.2.3'\n" tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode="w") as archive: - info = tarfile.TarInfo("package/bin/cmux-tui") + info = tarfile.TarInfo(f"package/bin/{binary_name}") info.mode = 0o755 info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) @@ -294,12 +298,26 @@ def write_fake_npm(tmp_path: Path) -> Path: return fake -def write_platform_stub(tmp_path: Path, platform_name: str = "freebsd") -> Path: +def write_platform_stub( + tmp_path: Path, + platform_name: str = "freebsd", + arch_name: str | None = None, +) -> Path: stub = tmp_path / "unsupported-platform.cjs" - stub.write_text( + # Load the host path implementation before overriding process metadata. + # Node normally selects this module during startup, but preloading it here + # keeps the portable branch test's filesystem paths native to the host. + contents = ( + 'require("path");\n' "Object.defineProperty(process, \"platform\", " f"{{ configurable: true, value: {json.dumps(platform_name)} }});\n" ) + if arch_name is not None: + contents += ( + "Object.defineProperty(process, \"arch\", " + f"{{ configurable: true, value: {json.dumps(arch_name)} }});\n" + ) + stub.write_text(contents) return stub @@ -731,6 +749,145 @@ def test_launcher_keeps_stable_and_nightly_state_channels_separate( assert stable_result.stdout == "stable binary\n" +def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( + tmp_path: Path, +) -> None: + """Exercise the Windows launcher branches on every supported CI host. + + Native Windows runs this path with the system ``cmd.exe``. Unix runners + preload a small process metadata shim so the same launcher code selects + ``win32-x64`` and ``cmux-tui.exe`` while executing a real host executable. + This keeps the Windows-specific cache, snapshot, lock, and update behavior + covered even when the surrounding workflow has no Windows Python job. + """ + tmp_path.mkdir(parents=True, exist_ok=True) + if sys.platform == "win32": + assert host_platform_key() == "win32-x64" + command_path = os.environ.get("ComSpec") or os.environ.get("COMSPEC") + if not command_path: + command_path = str( + Path(os.environ.get("SystemRoot", r"C:\\Windows")) + / "System32" + / "cmd.exe" + ) + executable = Path(command_path) + child_args = ("/d", "/c", "echo", "windows-cache-snapshot") + env_extra: dict[str, str] = {} + else: + executable = Path("/bin/sh") + child_args = ("-c", "printf '%s\\n' windows-cache-snapshot") + platform_stub = write_platform_stub(tmp_path, "win32", "x64") + env_extra = {"NODE_OPTIONS": f"--require={platform_stub}"} + + assert executable.is_file(), executable + payload = executable.read_bytes() + tarball = make_tarball(payload, binary_name="cmux-tui.exe") + launcher = write_launcher(tmp_path / "launcher", "1.0.0") + cache = tmp_path / "cache" + server, thread, registry = start_registry(tarballs={"1.2.3": tarball}) + platform_root = cache / "win32-x64" + update_process = None + update_stdout = "" + update_stderr = "" + try: + # Hold the tarball response so the test can observe the update-wide + # lock and target lease before any bytes are published. + RegistryHandler.block_tarball = True + RegistryHandler.block_tarball_versions = {"1.2.3"} + update_env = os.environ.copy() + update_env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": registry, + "NO_COLOR": "1", + **env_extra, + } + ) + update_process = subprocess.Popen( + ["node", str(write_launcher(tmp_path / "update", "1.0.0")), "update"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=update_env, + ) + assert RegistryHandler.tarball_started.wait(timeout=5), ( + "Windows update did not start its tarball request" + ) + update_lock = platform_root / ".update-operation.lock" + assert (update_lock / "owner").is_file(), ( + "Windows update did not hold the operation lock" + ) + active_root = platform_root / "v/1.2.3/.active" + assert any(entry.is_dir() for entry in active_root.iterdir()), ( + "Windows update did not publish its target lease" + ) + RegistryHandler.tarball_release.set() + update_stdout, update_stderr = update_process.communicate(timeout=10) + finally: + RegistryHandler.tarball_release.set() + if update_process is not None and update_process.poll() is None: + update_process.kill() + update_process.communicate(timeout=5) + server.shutdown() + thread.join() + + assert update_process is not None + assert update_process.returncode == 0, update_stderr or update_stdout + assert RegistryHandler.latest_requests == ["/cmux/latest"] + binary = platform_root / "v/1.2.3/bin/cmux-tui.exe" + assert binary.is_file() + assert binary.read_bytes() == payload + state = json.loads((platform_root / "state/stable.json").read_text()) + assert state["version"] == "1.2.3" + assert state["channel"] == "stable" + assert not (platform_root / ".update-operation.lock").exists() + assert not (platform_root / ".update.lock").exists() + assert not (platform_root / "v/1.2.3/.active").exists() + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 + + first = run_launcher( + launcher, + cache, + registry, + *child_args, + env_extra=env_extra, + ) + assert first.returncode == 0, first.stderr + assert "windows-cache-snapshot" in first.stdout + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 2 + + # A writable cache hit is authenticated by a fresh registry response. A + # matching local manifest cannot bless a replaced executable or tarball. + version_dir = binary.parent.parent + manifest_path = version_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + tampered = b"tampered Windows executable" + manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( + hashlib.sha512(b"tampered tarball").digest() + ).decode() + manifest["binaries"]["cmux-tui.exe"] = hashlib.sha512(tampered).hexdigest() + manifest_path.write_text(json.dumps(manifest) + "\n") + binary.write_bytes(tampered) + + second = run_launcher( + launcher, + cache, + registry, + *child_args, + env_extra=env_extra, + ) + assert second.returncode == 0, second.stderr + assert "windows-cache-snapshot" in second.stdout + assert binary.read_bytes() == payload + assert RegistryHandler.metadata_requests == 3 + assert RegistryHandler.tarball_requests == 3 + assert not (platform_root / ".update-operation.lock").exists() + assert not (platform_root / ".update.lock").exists() + assert not (platform_root / "v/1.2.3/.active").exists() + + def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1480,10 +1637,13 @@ def test_launcher_keeps_current_and_one_previous_after_download(tmp_path: Path) def main() -> None: - if sys.platform == "win32": - return with tempfile.TemporaryDirectory(prefix="cmux-tui-launcher-test-") as directory: root = Path(directory) + test_launcher_windows_path_covers_exe_snapshot_lock_and_update( + root / "windows" + ) + if sys.platform == "win32": + return test_launcher_downloads_once_and_reuses_verified_cache(root / "download") test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") From 49e8181457a23a78963a12783f9248d85176d687 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:50:56 -0700 Subject: [PATCH 63/73] test(tui): keep Windows fixture registry alive --- tests/test_tui_npm_launcher.py | 86 +++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index be30ab780a6b..c136bd9efd91 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -846,46 +846,54 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( assert RegistryHandler.metadata_requests == 1 assert RegistryHandler.tarball_requests == 1 - first = run_launcher( - launcher, - cache, - registry, - *child_args, - env_extra=env_extra, - ) - assert first.returncode == 0, first.stderr - assert "windows-cache-snapshot" in first.stdout - assert RegistryHandler.metadata_requests == 2 - assert RegistryHandler.tarball_requests == 2 - - # A writable cache hit is authenticated by a fresh registry response. A - # matching local manifest cannot bless a replaced executable or tarball. - version_dir = binary.parent.parent - manifest_path = version_dir / "manifest.json" - manifest = json.loads(manifest_path.read_text()) - tampered = b"tampered Windows executable" - manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( - hashlib.sha512(b"tampered tarball").digest() - ).decode() - manifest["binaries"]["cmux-tui.exe"] = hashlib.sha512(tampered).hexdigest() - manifest_path.write_text(json.dumps(manifest) + "\n") - binary.write_bytes(tampered) + # The update check above intentionally blocks the registry thread. Start a + # fresh local fixture for the launch checks after that process is cleaned up. + server, thread, registry = start_registry(tarballs={"1.2.3": tarball}) + try: + first = run_launcher( + launcher, + cache, + registry, + *child_args, + env_extra=env_extra, + ) + assert first.returncode == 0, first.stderr + assert "windows-cache-snapshot" in first.stdout + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 + + # A writable cache hit is authenticated by a fresh registry response. + # A matching local manifest cannot bless a replaced executable or + # tarball. + version_dir = binary.parent.parent + manifest_path = version_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + tampered = b"tampered Windows executable" + manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( + hashlib.sha512(b"tampered tarball").digest() + ).decode() + manifest["binaries"]["cmux-tui.exe"] = hashlib.sha512(tampered).hexdigest() + manifest_path.write_text(json.dumps(manifest) + "\n") + binary.write_bytes(tampered) - second = run_launcher( - launcher, - cache, - registry, - *child_args, - env_extra=env_extra, - ) - assert second.returncode == 0, second.stderr - assert "windows-cache-snapshot" in second.stdout - assert binary.read_bytes() == payload - assert RegistryHandler.metadata_requests == 3 - assert RegistryHandler.tarball_requests == 3 - assert not (platform_root / ".update-operation.lock").exists() - assert not (platform_root / ".update.lock").exists() - assert not (platform_root / "v/1.2.3/.active").exists() + second = run_launcher( + launcher, + cache, + registry, + *child_args, + env_extra=env_extra, + ) + assert second.returncode == 0, second.stderr + assert "windows-cache-snapshot" in second.stdout + assert binary.read_bytes() == payload + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 2 + assert not (platform_root / ".update-operation.lock").exists() + assert not (platform_root / ".update.lock").exists() + assert not (platform_root / "v/1.2.3/.active").exists() + finally: + server.shutdown() + thread.join() def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: From 259944ef9eae713b9b4b598d80faed1c5b015f3b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:01:11 -0700 Subject: [PATCH 64/73] test(tui): isolate Windows fixture transport --- tests/test_tui_npm_launcher.py | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index c136bd9efd91..ce250f1ca040 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -133,9 +133,12 @@ def run_launcher( registry: str | None, *args: str, env_extra: dict[str, str] | None = None, + env_remove: set[str] | None = None, timeout_seconds: float | None = None, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() + for name in env_remove or set(): + env.pop(name, None) env.update( { "CMUX_TUI_LAUNCHER_CACHE": str(cache), @@ -158,6 +161,54 @@ def run_launcher( ) +NPM_NETWORK_ENV_KEYS = { + "npm_config_proxy", + "npm_config_https-proxy", + "npm_config_https_proxy", + "npm_config_http-proxy", + "npm_config_http_proxy", + "npm_config_noproxy", + "npm_config_cafile", + "npm_config_ca", + "npm_config_ca[]", + "npm_config_cert", + "npm_config_key", + "npm_config_certfile", + "npm_config_keyfile", + "npm_config_strict-ssl", + "npm_config_strict_ssl", +} + + +def isolated_npm_environment( + tmp_path: Path, + env_extra: dict[str, str], +) -> tuple[dict[str, str], set[str]]: + """Keep ambient npm proxy and TLS settings out of loopback fixtures.""" + empty_npmrc = tmp_path / "empty.npmrc" + empty_npmrc.write_text("") + isolated_home = tmp_path / "home" + isolated_home.mkdir(parents=True, exist_ok=True) + env_remove = { + name + for name in os.environ + if name.lower().startswith("npm_config_//") + or name.lower() in NPM_NETWORK_ENV_KEYS + } + isolated = dict(env_extra) + isolated.update( + { + "HOME": str(isolated_home), + "USERPROFILE": str(isolated_home), + "npm_config_userconfig": str(empty_npmrc), + "NPM_CONFIG_USERCONFIG": str(empty_npmrc), + "npm_config_globalconfig": str(empty_npmrc), + "NPM_CONFIG_GLOBALCONFIG": str(empty_npmrc), + } + ) + return isolated, env_remove + + def process_exited_within( process: subprocess.Popen[str], timeout_seconds: float ) -> bool: @@ -778,6 +829,7 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( child_args = ("-c", "printf '%s\\n' windows-cache-snapshot") platform_stub = write_platform_stub(tmp_path, "win32", "x64") env_extra = {"NODE_OPTIONS": f"--require={platform_stub}"} + env_extra, env_remove = isolated_npm_environment(tmp_path, env_extra) assert executable.is_file(), executable payload = executable.read_bytes() @@ -795,6 +847,8 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( RegistryHandler.block_tarball = True RegistryHandler.block_tarball_versions = {"1.2.3"} update_env = os.environ.copy() + for name in env_remove: + update_env.pop(name, None) update_env.update( { "CMUX_TUI_LAUNCHER_CACHE": str(cache), @@ -856,6 +910,7 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( registry, *child_args, env_extra=env_extra, + env_remove=env_remove, ) assert first.returncode == 0, first.stderr assert "windows-cache-snapshot" in first.stdout @@ -882,6 +937,7 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( registry, *child_args, env_extra=env_extra, + env_remove=env_remove, ) assert second.returncode == 0, second.stderr assert "windows-cache-snapshot" in second.stdout From b6bf7814373a130d93d3905c5cbb4460b294fa35 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:05:32 -0700 Subject: [PATCH 65/73] test(tui): serve npm packument fixtures --- tests/test_tui_npm_launcher.py | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index ce250f1ca040..b02ffd393d30 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -64,11 +64,13 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): tarball_requests = 0 latest_requests: list[str] = [] authorization_headers: list[str | None] = [] + request_paths: list[str] = [] status = 200 def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) metadata_path = urllib.parse.urlsplit(self.path).path + type(self).request_paths.append(metadata_path) metadata_match = re.search( r"/cmux-tui-[A-Za-z0-9._-]+/" r"([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$", @@ -98,6 +100,46 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler } } ).encode() + elif metadata_path != "/tarball.tgz" and re.fullmatch( + r"/[A-Za-z0-9._-]+", metadata_path + ): + # npm's configured transport requests a package packument before + # selecting a version. Return the same fixture tarballs as the raw + # `/package/version` endpoint so ambient npm config cannot escape + # this deterministic loopback registry. + type(self).metadata_requests += 1 + package_name = metadata_path[1:] + versions = { + type(self).latest_version, + type(self).nightly_version, + *type(self).tarballs.keys(), + } + version_records = {} + for version in versions: + tarball = type(self).tarballs.get(version, type(self).tarball) + dist = { + "tarball": ( + f"http://127.0.0.1:{self.server.server_port}/tarball.tgz?" + f"version={urllib.parse.quote(version, safe='')}" + ), + "integrity": "sha512-" + + base64.b64encode(hashlib.sha512(tarball).digest()).decode(), + } + version_records[version] = { + "name": package_name, + "version": version, + "dist": dist, + } + body = json.dumps( + { + "name": package_name, + "dist-tags": { + "latest": type(self).latest_version, + "nightly": type(self).nightly_version, + }, + "versions": version_records, + } + ).encode() elif urllib.parse.urlsplit(self.path).path == "/tarball.tgz": type(self).tarball_requests += 1 query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) @@ -380,6 +422,7 @@ def start_registry( RegistryHandler.metadata_requests = 0 RegistryHandler.tarball_requests = 0 RegistryHandler.authorization_headers = [] + RegistryHandler.request_paths = [] RegistryHandler.status = 200 RegistryHandler.latest_version = "1.2.3" RegistryHandler.nightly_version = NIGHTLY_VERSION From 2fcc18137bda3b4d83c40c5fcf6f307dde3af421 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:08:19 -0700 Subject: [PATCH 66/73] test(tui): expose Windows fixture request paths --- tests/test_tui_npm_launcher.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index b02ffd393d30..832bcd55c75f 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -955,7 +955,9 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( env_extra=env_extra, env_remove=env_remove, ) - assert first.returncode == 0, first.stderr + assert first.returncode == 0, ( + f"{first.stderr}\nregistry requests: {RegistryHandler.request_paths!r}" + ) assert "windows-cache-snapshot" in first.stdout assert RegistryHandler.metadata_requests == 1 assert RegistryHandler.tarball_requests == 1 From 59452c7c2011e6b39642725833aa4a722bb18d26 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:19:44 -0700 Subject: [PATCH 67/73] test(tui): use deterministic npm transport for Windows coverage --- tests/test_tui_npm_launcher.py | 229 ++++++++++++++++++--------------- 1 file changed, 126 insertions(+), 103 deletions(-) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 832bcd55c75f..127f1694905a 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -236,6 +236,15 @@ def isolated_npm_environment( for name in os.environ if name.lower().startswith("npm_config_//") or name.lower() in NPM_NETWORK_ENV_KEYS + or name.lower() + in { + "node_options", + "node_path", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + } } isolated = dict(env_extra) isolated.update( @@ -348,6 +357,13 @@ def write_fake_npm(tmp_path: Path) -> Path: fake = tmp_path / "fake-npm.cjs" fake.write_text( """ +const fixtureHostPlatform = process.env.FAKE_NPM_HOST_PLATFORM; +if (fixtureHostPlatform) { + Object.defineProperty(process, 'platform', { + configurable: true, + value: fixtureHostPlatform, + }); +} const fs = require('fs'); const path = require('path'); const args = process.argv.slice(2); @@ -877,60 +893,62 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( assert executable.is_file(), executable payload = executable.read_bytes() tarball = make_tarball(payload, binary_name="cmux-tui.exe") + fixture_tarball = tmp_path / "fixture.tgz" + fixture_tarball.write_bytes(tarball) + fixture_integrity = "sha512-" + base64.b64encode( + hashlib.sha512(tarball).digest() + ).decode() + fake_npm = write_fake_npm(tmp_path) + npm_log = tmp_path / "npm.log" + env_extra.update( + { + # Force the launcher's supported npm transport while keeping every + # response and tarball byte inside this test's fixture directory. + "CMUX_NPM_REGISTRY": "http://127.0.0.1:1", + "npm_execpath": str(fake_npm), + "npm_config_https_proxy": "fixture-proxy", + "FAKE_NPM_LATEST": "1.2.3", + "FAKE_NPM_TARBALL": str(fixture_tarball), + "FAKE_NPM_INTEGRITY": fixture_integrity, + "FAKE_NPM_LOG": str(npm_log), + "FAKE_NPM_HOST_PLATFORM": sys.platform, + } + ) launcher = write_launcher(tmp_path / "launcher", "1.0.0") + update_launcher = write_launcher(tmp_path / "update", "1.0.0") cache = tmp_path / "cache" - server, thread, registry = start_registry(tarballs={"1.2.3": tarball}) platform_root = cache / "win32-x64" - update_process = None - update_stdout = "" - update_stderr = "" - try: - # Hold the tarball response so the test can observe the update-wide - # lock and target lease before any bytes are published. - RegistryHandler.block_tarball = True - RegistryHandler.block_tarball_versions = {"1.2.3"} - update_env = os.environ.copy() - for name in env_remove: - update_env.pop(name, None) - update_env.update( - { - "CMUX_TUI_LAUNCHER_CACHE": str(cache), - "CMUX_NPM_REGISTRY": registry, - "NO_COLOR": "1", - **env_extra, - } - ) - update_process = subprocess.Popen( - ["node", str(write_launcher(tmp_path / "update", "1.0.0")), "update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=update_env, - ) - assert RegistryHandler.tarball_started.wait(timeout=5), ( - "Windows update did not start its tarball request" - ) - update_lock = platform_root / ".update-operation.lock" - assert (update_lock / "owner").is_file(), ( - "Windows update did not hold the operation lock" - ) - active_root = platform_root / "v/1.2.3/.active" - assert any(entry.is_dir() for entry in active_root.iterdir()), ( - "Windows update did not publish its target lease" - ) - RegistryHandler.tarball_release.set() - update_stdout, update_stderr = update_process.communicate(timeout=10) - finally: - RegistryHandler.tarball_release.set() - if update_process is not None and update_process.poll() is None: - update_process.kill() - update_process.communicate(timeout=5) - server.shutdown() - thread.join() - assert update_process is not None - assert update_process.returncode == 0, update_stderr or update_stdout - assert RegistryHandler.latest_requests == ["/cmux/latest"] + # A competing update must retain its lock and fail closed. This exercises + # the Windows lock identity path without relying on timing or a network. + update_lock = platform_root / ".update-operation.lock" + update_lock.mkdir(parents=True) + owner = f"{os.getpid()}\nfixture-owner-token\n-\n{int(time.time() * 1000)}\n" + owner_path = update_lock / "owner" + owner_path.write_text(owner) + blocked = run_launcher( + update_launcher, + cache, + None, + "update", + env_extra=env_extra, + env_remove=env_remove, + ) + assert blocked.returncode != 0 + assert "could not reserve the native binary for update" in blocked.stderr + assert owner_path.read_text() == owner + owner_path.unlink() + update_lock.rmdir() + + update = run_launcher( + update_launcher, + cache, + None, + "update", + env_extra=env_extra, + env_remove=env_remove, + ) + assert update.returncode == 0, update.stderr binary = platform_root / "v/1.2.3/bin/cmux-tui.exe" assert binary.is_file() assert binary.read_bytes() == payload @@ -940,61 +958,66 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( assert not (platform_root / ".update-operation.lock").exists() assert not (platform_root / ".update.lock").exists() assert not (platform_root / "v/1.2.3/.active").exists() - assert RegistryHandler.metadata_requests == 1 - assert RegistryHandler.tarball_requests == 1 - # The update check above intentionally blocks the registry thread. Start a - # fresh local fixture for the launch checks after that process is cleaned up. - server, thread, registry = start_registry(tarballs={"1.2.3": tarball}) - try: - first = run_launcher( - launcher, - cache, - registry, - *child_args, - env_extra=env_extra, - env_remove=env_remove, - ) - assert first.returncode == 0, ( - f"{first.stderr}\nregistry requests: {RegistryHandler.request_paths!r}" - ) - assert "windows-cache-snapshot" in first.stdout - assert RegistryHandler.metadata_requests == 1 - assert RegistryHandler.tarball_requests == 1 - - # A writable cache hit is authenticated by a fresh registry response. - # A matching local manifest cannot bless a replaced executable or - # tarball. - version_dir = binary.parent.parent - manifest_path = version_dir / "manifest.json" - manifest = json.loads(manifest_path.read_text()) - tampered = b"tampered Windows executable" - manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( - hashlib.sha512(b"tampered tarball").digest() - ).decode() - manifest["binaries"]["cmux-tui.exe"] = hashlib.sha512(tampered).hexdigest() - manifest_path.write_text(json.dumps(manifest) + "\n") - binary.write_bytes(tampered) + first = run_launcher( + launcher, + cache, + None, + *child_args, + env_extra=env_extra, + env_remove=env_remove, + ) + assert first.returncode == 0, first.stderr + assert "windows-cache-snapshot" in first.stdout - second = run_launcher( - launcher, - cache, - registry, - *child_args, - env_extra=env_extra, - env_remove=env_remove, - ) - assert second.returncode == 0, second.stderr - assert "windows-cache-snapshot" in second.stdout - assert binary.read_bytes() == payload - assert RegistryHandler.metadata_requests == 2 - assert RegistryHandler.tarball_requests == 2 - assert not (platform_root / ".update-operation.lock").exists() - assert not (platform_root / ".update.lock").exists() - assert not (platform_root / "v/1.2.3/.active").exists() - finally: - server.shutdown() - thread.join() + # A writable cache hit is authenticated by a fresh fixture response. A + # matching local manifest cannot bless a replaced executable or tarball. + version_dir = binary.parent.parent + manifest_path = version_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + tampered = b"tampered Windows executable" + manifest["tarballIntegrity"] = "sha512-" + base64.b64encode( + hashlib.sha512(b"tampered tarball").digest() + ).decode() + manifest["binaries"]["cmux-tui.exe"] = hashlib.sha512(tampered).hexdigest() + manifest_path.write_text(json.dumps(manifest) + "\n") + binary.write_bytes(tampered) + + second = run_launcher( + launcher, + cache, + None, + *child_args, + env_extra=env_extra, + env_remove=env_remove, + ) + assert second.returncode == 0, second.stderr + assert "windows-cache-snapshot" in second.stdout + assert binary.read_bytes() == payload + assert not (platform_root / ".update-operation.lock").exists() + assert not (platform_root / ".update.lock").exists() + assert not (platform_root / "v/1.2.3/.active").exists() + + records = [json.loads(line) for line in npm_log.read_text().splitlines()] + assert [record["args"][0] for record in records] == [ + "view", + "view", + "pack", + "view", + "pack", + "view", + "pack", + ] + assert records[0]["args"][2] == "version" + assert records[1]["args"][2] == "dist" + expected_spec = "cmux-tui-win32-x64@1.2.3" + assert all(expected_spec in record["args"] for record in records) + assert all( + record["args"][record["args"].index("--registry") + 1] + == "http://127.0.0.1:1" + for record in records + ) + assert all(record["proxy"] == "fixture-proxy" for record in records) def test_launcher_reports_network_failure_without_leaking_details(tmp_path: Path) -> None: From 5cda05ac8e7cf7968e126cbbec3a81a8f2a39661 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:53:00 -0700 Subject: [PATCH 68/73] fix(tui): use authenticated metadata for cache hits --- cmux-tui/README.ja.md | 6 +- cmux-tui/README.md | 2 +- cmux-tui/dist/npm/cmux/bin/cmux.js | 382 ++++++++++++++++++++------- cmux-tui/dist/scripts/package_npm.py | 12 + cmux-tui/docs/getting-started.ja.md | 8 +- cmux-tui/docs/getting-started.md | 2 +- tests/test_tui_npm_launcher.py | 234 ++++++++++++++-- 7 files changed, 525 insertions(+), 121 deletions(-) diff --git a/cmux-tui/README.ja.md b/cmux-tui/README.ja.md index 8c16eddbbddd..5878d7eb205c 100644 --- a/cmux-tui/README.ja.md +++ b/cmux-tui/README.ja.md @@ -9,8 +9,10 @@ tmux 風のターミナル TUI です。詳細な英語ドキュメントは 現在のプラットフォーム用の `cmux-tui-` パッケージを npm レジストリから ダウンロードし、sha512 整合性を確認してランチャー専用キャッシュに保存します。 -書き込み可能なランチャーキャッシュは起動前にレジストリの tarball と再検証します。 -管理者が用意した完全な読み取り専用キャッシュはオフラインで起動できます。 +書き込み可能なランチャーキャッシュは起動前に認証済みパッケージメタデータを取得し、 +公開元のバイナリダイジェストと照合します。レジストリがダイジェストを提供しない場合は、 +書き込み可能なキャッシュヒットごとに tarball 全体を検証します。管理者が用意した完全な読み取り専用 +キャッシュは、バイナリとマニフェストを検証済みならオフラインで起動できます。 ```bash npx cmux update diff --git a/cmux-tui/README.md b/cmux-tui/README.md index f7b65b17a7a6..7eec30c06d84 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -90,7 +90,7 @@ ssh -T dev@buildbox cmux relay --session agents The Unix-only `machine-agent` shares an existing local session through one outbound SSH registration with cmux.cloud. It prints a one-time pairing code and opens no listener. The final command is a low-level raw JSON-lines diagnostic. Use the machine rail or `cmux ssh` for the managed remote lifecycle. -Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without rewriting npm's caches. Writable launcher-cache hits revalidate the registry tarball before launch, while a fully read-only provisioned cache can run offline. `npx` can still touch, or fail while touching, npm's `_npx` cache before cmux starts. Use `npx cmux update` for routine platform-binary upgrades and `npx cmux@latest` when updating the npm launcher. If the latter fails with `ENOTEMPTY: directory not empty, rename`, see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Japanese: [npm パッケージ](README.ja.md). +Upgrade a packaged install with `npx cmux update`; it downloads the latest verified binary without rewriting npm's caches. Writable launcher-cache hits fetch fresh authenticated package metadata and compare the publisher's binary digest before launch. A registry without that digest falls back to full tarball verification for each writable hit. A fully read-only provisioned cache can run offline after its binary and manifest have been verified. `npx` can still touch, or fail while touching, npm's `_npx` cache before cmux starts. Use `npx cmux update` for routine platform-binary upgrades and `npx cmux@latest` when updating the npm launcher. If the latter fails with `ENOTEMPTY: directory not empty, rename`, see [Packaged installs and updates](docs/getting-started.md#packaged-installs-and-updates). Japanese: [npm パッケージ](README.ja.md). Use `--term ` to set `TERM` for child PTYs. Without it, children get `xterm-256color`; `CMUX_TUI_TERM` can override the terminal runtime default, with `CMUX_MUX_TERM` retained as a legacy fallback. diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 8bc7357fc953..69487ad6b126 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -21,8 +21,10 @@ // 2. an installed platform package (require.resolve) whose version matches // the wanted version exactly -- this keeps offline installs working: // `npm install -g cmux cmux-tui-` never needs the network -// 3. the launcher cache entry for the wanted version -// 4. download the wanted version into the launcher cache +// 3. the launcher cache entry for the wanted version, revalidated against +// authenticated package metadata when the cache is writable +// 4. download the wanted version into the launcher cache when the cache is +// missing, stale, or lacks a published binary digest // 5. fail closed when the requested version cannot be obtained // // Wanted version = max(shim's own package version, version recorded by @@ -1202,40 +1204,7 @@ function readNpmrcRegistry(configPath) { } function npmRegistryFromConfigFiles() { - // npm's precedence is project, user, then global. A project file nearest to - // the launch cwd wins over broader project files, which also covers nested - // workspaces without requiring npm to be installed on PATH. - const projectPaths = []; - let directory = process.cwd(); - while (true) { - projectPaths.push(path.join(directory, ".npmrc")); - const parent = path.dirname(directory); - if (parent === directory) break; - directory = parent; - } - for (const configPath of projectPaths) { - const registry = readNpmrcRegistry(configPath); - if (registry) return registry; - } - - const userConfig = npmConfigEnvironmentValue("userconfig"); - const userPaths = userConfig - ? [userConfig] - : [path.join(os.homedir(), ".npmrc")]; - for (const configPath of userPaths) { - const registry = readNpmrcRegistry(configPath); - if (registry) return registry; - } - - const nodePrefix = path.dirname(path.dirname(process.execPath)); - const globalConfig = npmConfigEnvironmentValue("globalconfig"); - const configuredPrefix = npmConfigEnvironmentValue("prefix"); - const globalPrefix = configuredPrefix || process.env.PREFIX || nodePrefix; - const globalPaths = globalConfig - ? [globalConfig] - : [path.join(globalPrefix, "etc", "npmrc")]; - if (process.platform !== "win32") globalPaths.push("/etc/npmrc"); - for (const configPath of globalPaths) { + for (const configPath of npmConfigFilePaths()) { const registry = readNpmrcRegistry(configPath); if (registry) return registry; } @@ -1306,22 +1275,50 @@ function npmConfigFilePaths() { const add = (candidate) => { if (candidate) paths.add(path.resolve(candidate)); }; - add(npmConfigEnvironmentValue("userconfig")); - add(npmConfigEnvironmentValue("globalconfig")); - // npm reads a project .npmrc, then the user and global files. Walking to the - // nearest root also covers launchers started from a nested project folder. - let directory = process.cwd(); + // npm's file precedence is project, user, then global. Find the nearest + // local prefix (a package.json or node_modules directory), then read only + // that prefix's .npmrc. Do not treat every ancestor as a project file: + // walking through the user's home directory would defeat an explicit + // `npm_config_userconfig` selection by loading `~/.npmrc` twice. + let directory = path.resolve(process.cwd()); + let localPrefix = null; while (true) { - add(path.join(directory, ".npmrc")); + try { + if ( + fs.lstatSync(path.join(directory, "package.json")).isFile() || + fs.lstatSync(path.join(directory, "node_modules")).isDirectory() + ) { + localPrefix = directory; + break; + } + } catch {} const parent = path.dirname(directory); if (parent === directory) break; directory = parent; } - add(path.join(os.homedir(), ".npmrc")); - const nodePrefix = path.dirname(path.dirname(process.execPath)); - add(path.join(nodePrefix, "etc", "npmrc")); - if (process.platform !== "win32") add("/etc/npmrc"); + const projectConfig = path.join(localPrefix || process.cwd(), ".npmrc"); + const userConfig = npmConfigEnvironmentValue("userconfig"); + if (!userConfig || path.resolve(projectConfig) !== path.resolve(userConfig)) { + add(projectConfig); + } + + if (userConfig) { + add(userConfig); + } else { + add(path.join(os.homedir(), ".npmrc")); + } + + const globalConfig = npmConfigEnvironmentValue("globalconfig"); + if (globalConfig) { + add(globalConfig); + } else { + const nodePrefix = path.dirname(path.dirname(process.execPath)); + const configuredPrefix = npmConfigEnvironmentValue("prefix"); + const globalPrefix = configuredPrefix || process.env.PREFIX || nodePrefix; + add(path.join(globalPrefix, "etc", "npmrc")); + if (process.platform !== "win32") add("/etc/npmrc"); + } return paths; } @@ -1507,6 +1504,14 @@ function npmView(packageName, selector, field) { ); } +function npmViewOptional(packageName, selector, field) { + try { + return npmView(packageName, selector, field); + } catch { + return null; + } +} + function npmPack(packageName, version) { const spec = npmPackageSpec(packageName, version); const destination = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-tui-npm-pack-")); @@ -1553,50 +1558,147 @@ function npmPack(packageName, version) { } } -function registryHeaders(url, accept) { - const headers = { accept }; - try { - const parsed = new URL(url); - const registry = new URL(registryBase()); - if (parsed.origin !== registry.origin) return headers; - const tokenKeys = [ - `npm_config_//${parsed.host}/:_authToken`, - `NPM_CONFIG_//${parsed.host}/:_authToken`, - ]; - const token = - tokenKeys.map((key) => process.env[key]).find(configValueIsPresent) || - npmrcAuthToken(parsed); - if (token) headers.authorization = `Bearer ${token}`; - } catch {} - return headers; +const NPM_AUTH_CONFIG_KEYS = new Set([ + "_authtoken", + "_auth", + "username", + "_password", +]); + +function expandNpmConfigValue(value) { + return String(value) + .trim() + .replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ""); +} + +function authScopeMatches(url, host, scope) { + if (host.toLowerCase() !== url.host.toLowerCase()) return false; + const normalizedScope = scope || "/"; + if (normalizedScope === "/") return true; + const prefix = normalizedScope.endsWith("/") + ? normalizedScope + : `${normalizedScope}/`; + return url.pathname === normalizedScope || url.pathname.startsWith(prefix); } -function npmrcAuthToken(url) { - const configPaths = npmConfigFilePaths(); - const host = url.host.toLowerCase(); - for (const configPath of configPaths) { - if (!configPath) continue; +function parseScopedNpmAuthLine(line) { + const match = /^\s*\/\/([^/]+)(\/[^:]*?)?\/:([^=\s]+)\s*=\s*(.*?)\s*$/.exec( + line + ); + if (!match) return null; + const key = match[3].toLowerCase(); + if (!NPM_AUTH_CONFIG_KEYS.has(key)) return null; + return { + host: match[1], + scope: match[2] || "/", + key, + value: expandNpmConfigValue(match[4]), + }; +} + +function parseScopedNpmAuthEnvironment(name, value) { + const match = /^npm_config_\/\/([^/]+)(\/[^:]*?)?\/:([^=]+)$/i.exec(name); + if (!match) return null; + const key = match[3].toLowerCase(); + if (!NPM_AUTH_CONFIG_KEYS.has(key) || !configValueIsPresent(value)) return null; + return { + host: match[1], + scope: match[2] || "/", + key, + value: expandNpmConfigValue(value), + }; +} + +function npmrcAuthValues(url, contents) { + const values = {}; + for (const line of contents.split(/\r?\n/)) { + const entry = parseScopedNpmAuthLine(line); + if (!entry || !authScopeMatches(url, entry.host, entry.scope)) continue; + if (configValueIsPresent(entry.value)) values[entry.key] = entry.value; + } + return values; +} + +function npmEnvironmentAuthValues(url) { + const values = {}; + for (const [name, value] of Object.entries(process.env)) { + const entry = parseScopedNpmAuthEnvironment(name, value); + if (!entry || !authScopeMatches(url, entry.host, entry.scope)) continue; + values[entry.key] = entry.value; + } + return values; +} + +function npmAuthSources(url) { + // Environment-scoped npm settings override every config file. Keep each + // source intact: npm requires username and _password to come from the same + // config layer, so merging partial credentials across files could create a + // credential that npm itself would reject or send to the wrong registry. + const sources = []; + const environmentValues = npmEnvironmentAuthValues(url); + if (Object.keys(environmentValues).length) sources.push(environmentValues); + for (const configPath of npmConfigFilePaths()) { let contents; try { contents = fs.readFileSync(configPath, "utf8"); } catch { continue; } - for (const line of contents.split(/\r?\n/)) { - const match = /^\s*\/\/([^/]+)(\/[^:]*?)?\/:_authToken\s*=\s*(.*?)\s*$/.exec(line); - if (!match || match[1].toLowerCase() !== host) continue; - const scope = match[2] || "/"; - if (scope !== "/") { - const prefix = scope.endsWith("/") ? scope : `${scope}/`; - if (url.pathname !== scope && !url.pathname.startsWith(prefix)) continue; - } - const token = match[3].replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ""); - if (token) return token; + const fileValues = npmrcAuthValues(url, contents); + if (Object.keys(fileValues).length) sources.push(fileValues); + } + return sources; +} + +function decodeNpmBase64(value) { + const encoded = expandNpmConfigValue(value); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null; + const padded = encoded + "=".repeat((4 - (encoded.length % 4)) % 4); + try { + const decoded = Buffer.from(padded, "base64").toString("utf8"); + return decoded && Buffer.from(decoded, "utf8").toString("base64") === padded + ? decoded + : null; + } catch { + return null; + } +} + +function npmAuthHeader(url) { + for (const values of npmAuthSources(url)) { + const token = values._authtoken; + if (configValueIsPresent(token)) return `Bearer ${token}`; + + let pair = null; + if (configValueIsPresent(values._auth)) { + const decoded = decodeNpmBase64(values._auth); + if (decoded && decoded.includes(":")) pair = decoded; } + if ( + !pair && + configValueIsPresent(values.username) && + configValueIsPresent(values._password) + ) { + const password = decodeNpmBase64(values._password); + if (password !== null) pair = `${values.username}:${password}`; + } + if (pair) return `Basic ${Buffer.from(pair, "utf8").toString("base64")}`; } return null; } +function registryHeaders(url, accept) { + const headers = { accept }; + try { + const parsed = new URL(url); + const registry = new URL(registryBase()); + if (parsed.origin !== registry.origin) return headers; + const authorization = npmAuthHeader(parsed); + if (authorization) headers.authorization = authorization; + } catch {} + return headers; +} + function requireNetworkRuntime() { const nodeMajor = Number.parseInt(String(process.versions.node).split(".", 1)[0], 10); const hasFetch = typeof fetch === "function"; @@ -1740,10 +1842,32 @@ function validSha512Integrity(integrity) { return /^sha512-[A-Za-z0-9+/]{86}={0,2}$/.test(integrity || ""); } -// Fetch and verify one published platform package. The registry's dist -// integrity is the authentication root for the extracted binary; local cache -// metadata is only a consistency check and never supplies the expected digest. -async function fetchVerifiedPackage(pkg, version, purpose = "download") { +function sha512IntegrityToHex(integrity) { + if (typeof integrity !== "string") return null; + if (/^[a-f0-9]{128}$/i.test(integrity)) return integrity.toLowerCase(); + if (!validSha512Integrity(integrity)) return null; + const encoded = integrity.slice("sha512-".length); + try { + const bytes = Buffer.from(encoded, "base64"); + if (bytes.length !== 64) return null; + return bytes.toString("hex"); + } catch { + return null; + } +} + +function packageBinaryIntegrity(dist, meta) { + // `cmuxBinaryIntegrity` is a publisher-provided field in the package + // metadata. It is covered by the registry response and lets a normal cache + // hit verify the extracted binary without downloading the tarball again. + // Accept the short `binaryIntegrity` spelling for older private registries. + const value = + (dist && (dist.cmuxBinaryIntegrity || dist.binaryIntegrity)) || + (meta && (meta.cmuxBinaryIntegrity || meta.binaryIntegrity)); + return sha512IntegrityToHex(value); +} + +async function fetchPackageMetadata(pkg, version) { const meta = await fetchJson(`${registryBase()}/${pkg}/${version}`, { packageName: pkg, selector: version, @@ -1755,9 +1879,37 @@ async function fetchVerifiedPackage(pkg, version, purpose = "download") { const dist = meta && meta.dist ? meta.dist : meta; const tarballUrl = dist && dist.tarball; const integrity = dist && dist.integrity; - if (!integrity || (!tarballUrl && !hasNpmNetworkConfig())) { + if (!validSha512Integrity(integrity) || (!tarballUrl && !hasNpmNetworkConfig())) { throw new Error("registry metadata is incomplete"); } + let binaryIntegrity = packageBinaryIntegrity(dist, meta); + // npm's `view ... dist` projection omits package-level custom fields. Ask + // for the publisher's binary digest separately when npm owns the transport; + // private registries may expose it inside `dist` or the full JSON response. + if (!binaryIntegrity && hasNpmNetworkConfig()) { + binaryIntegrity = sha512IntegrityToHex( + npmViewOptional(pkg, version, "cmuxBinaryIntegrity") + ); + } + return { + integrity, + tarballUrl, + binaryIntegrity, + }; +} + +// Fetch and verify one published platform package. The registry's dist +// integrity authenticates the tarball, and the publisher's binary integrity +// authenticates the extracted executable. Local cache metadata is only a +// consistency check and never supplies an integrity root. +async function fetchVerifiedPackage( + pkg, + version, + purpose = "download", + metadata = null +) { + const verifiedMetadata = metadata || (await fetchPackageMetadata(pkg, version)); + const { integrity, tarballUrl, binaryIntegrity } = verifiedMetadata; if (purpose) console.error(`cmux: ${purpose} ${pkg}@${version}...`); let tgz; if (hasNpmNetworkConfig()) { @@ -1784,14 +1936,25 @@ async function fetchVerifiedPackage(pkg, version, purpose = "download") { if (!native) { throw new Error("platform package does not contain the native binary"); } - return { integrity, entries, native }; + const nativeDigest = digestHex(native.data); + if (binaryIntegrity && nativeDigest !== binaryIntegrity) { + throw new Error("platform package binary integrity check failed"); + } + return { + integrity, + entries, + native, + binaryIntegrity: binaryIntegrity || nativeDigest, + }; } -function cacheManifestMatchesPackage(candidate, pkg, version, verified) { - if (!candidate || !verified || candidate.package !== pkg) return false; +function cacheManifestMatchesMetadata(candidate, pkg, version, metadata) { + if (!candidate || !metadata || candidate.package !== pkg) return false; if (candidate.version !== version) return false; - if (candidate.tarballIntegrity !== verified.integrity) return false; - return candidate.expected === digestHex(verified.native.data); + if (candidate.tarballIntegrity !== metadata.integrity) return false; + return Boolean( + metadata.binaryIntegrity && candidate.expected === metadata.binaryIntegrity + ); } function writeCacheManifest(pkg, version, integrity, entries) { @@ -1822,10 +1985,16 @@ function removeCachedPayload(version) { async function downloadVersion( pkg, version, - { verifyCache = true, returnCandidate = false, verifiedPackage = null } = {} + { + verifyCache = true, + returnCandidate = false, + verifiedPackage = null, + metadata = null, + } = {} ) { const verified = - verifiedPackage || (await fetchVerifiedPackage(pkg, version, "downloading")); + verifiedPackage || + (await fetchVerifiedPackage(pkg, version, "downloading", metadata)); const { integrity, entries, native } = verified; const finalDir = cachedBinDir(version); @@ -2052,30 +2221,45 @@ async function resolveBinary(pkg, wanted, cachedCandidate = null) { // being flattened into a network failure. requireNetworkRuntime(); try { - let verifiedPackage = null; + let metadata = null; if (cachedCandidate) { - // A writable cache is untrusted. Fetch the package metadata and tarball - // again, then derive the expected binary digest from those authenticated - // bytes. The local manifest can only confirm that the cache matches that - // registry result; it is never an integrity root. - verifiedPackage = await fetchVerifiedPackage(pkg, wanted, "verifying"); - if (cacheManifestMatchesPackage(cachedCandidate, pkg, wanted, verifiedPackage)) { - const expected = digestHex(verifiedPackage.native.data); + // A writable cache is untrusted. Fetch fresh package metadata and use + // the publisher's authenticated binary digest when it is available. + // The local manifest can only confirm that the cache matches that + // registry result; it is never an integrity root. Registries that do + // not expose the binary digest take the legacy full-tarball path below. + console.error(`cmux: verifying ${pkg}@${wanted}...`); + metadata = await fetchPackageMetadata(pkg, wanted); + if (cacheManifestMatchesMetadata(cachedCandidate, pkg, wanted, metadata)) { const snapshot = snapshotVerifiedCachedBinary( cachedCandidate, - expected, + metadata.binaryIntegrity, true ); if (snapshot) return { path: snapshot.path, snapshot }; } } + let verifiedPackage = null; + if (!metadata || !metadata.binaryIntegrity || cachedCandidate) { + // A missing or stale cache must be authenticated from the tarball. If + // metadata already proved the cache stale, reuse it to avoid a second + // metadata request before downloading. + verifiedPackage = await fetchVerifiedPackage( + pkg, + wanted, + "downloading", + metadata + ); + } const downloaded = await downloadVersion(pkg, wanted, { // The caller receives a private snapshot below, so a second path lookup // is unnecessary. Reuse the authenticated tarball when the cache was - // stale or its manifest was tampered with. + // stale or its manifest was tampered with. For a cache miss there is no + // metadata preflight, so downloadVersion performs the one full fetch. verifyCache: false, returnCandidate: true, verifiedPackage, + metadata: verifiedPackage ? null : metadata, }); const snapshot = snapshotVerifiedCachedBinary(downloaded.candidate); if (!snapshot) fail("the cached native binary changed before launch"); diff --git a/cmux-tui/dist/scripts/package_npm.py b/cmux-tui/dist/scripts/package_npm.py index 7bf13f2eb516..bfb5ccb92193 100644 --- a/cmux-tui/dist/scripts/package_npm.py +++ b/cmux-tui/dist/scripts/package_npm.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import base64 +import hashlib import json import re import shutil @@ -94,6 +96,11 @@ def copy_executable(src: Path, dst: Path) -> None: dst.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) +def binary_integrity(path: Path) -> str: + digest = hashlib.sha512(path.read_bytes()).digest() + return "sha512-" + base64.b64encode(digest).decode("ascii") + + def recreate_dir(path: Path) -> None: if path.exists(): if not path.is_dir(): @@ -136,6 +143,11 @@ def package_platforms(binaries_dir: Path, version: str, out_dir: Path, include_w "license": "MIT", "os": [target["os"]], "cpu": [target["cpu"]], + # The launcher uses this authenticated package metadata to + # verify a writable cache hit without downloading the tarball + # again. The value is also checked against the extracted file + # during a fresh download. + "cmuxBinaryIntegrity": binary_integrity(src), "files": [f"bin/cmux-tui{ext}", f"bin/cmux-tui-hook{ext}"], }, ) diff --git a/cmux-tui/docs/getting-started.ja.md b/cmux-tui/docs/getting-started.ja.md index 7c62fe0264ee..6303fed9b910 100644 --- a/cmux-tui/docs/getting-started.ja.md +++ b/cmux-tui/docs/getting-started.ja.md @@ -9,9 +9,11 @@ `~/Library/Caches/cmux-tui-launcher`、Linux では `$XDG_CACHE_HOME/cmux-tui-launcher` (未設定時は `~/.cache/cmux-tui-launcher`)にバージョン別で保存します。 -書き込み可能なキャッシュは起動前にレジストリの tarball と再検証するため、通常の -キャッシュヒットでもネットワークを使用します。完全な読み取り専用キャッシュは管理者が -用意したものとして扱い、バイナリとマニフェストを検証済みならオフラインで起動できます。 +書き込み可能なキャッシュは起動前に認証済みパッケージメタデータを取得し、公開元のバイナリ +ダイジェストと照合するため、通常のキャッシュヒットでもネットワークを使用します。レジストリが +ダイジェストを提供しない場合は、書き込み可能なキャッシュヒットごとに tarball 全体を検証します。 +完全な読み取り専用キャッシュは管理者が用意したものとして扱い、バイナリとマニフェストを検証済み +ならオフラインで起動できます。 ```bash npx cmux update # 最新のプラットフォーム用バイナリを取得 diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index af0f10cf52f2..94c63a2a0689 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -122,7 +122,7 @@ Japanese: [パッケージのインストールと更新](getting-started.ja.md) The `cmux` npm package is a small launcher with no dependencies. On first run it downloads the prebuilt `cmux-tui-` package for your platform from the npm registry, verifies the registry's sha512 integrity for the tarball, and caches the binaries in a versioned launcher cache (`~/Library/Caches/cmux-tui-launcher` on macOS, `$XDG_CACHE_HOME/cmux-tui-launcher` or `~/.cache/cmux-tui-launcher` on Linux). Later runs start instantly from that cache. -Writable cache entries are revalidated against the registry tarball before they run, so a normal cache hit can use the network. A fully read-only cache is treated as administrator-provisioned and can run offline after its binary and manifest have been verified. +Writable cache entries fetch fresh authenticated package metadata and compare the publisher's binary digest before they run, so a normal cache hit can use the network without downloading the tarball again. If the registry does not provide that digest, the launcher falls back to full tarball verification for each writable hit. A fully read-only cache is treated as administrator-provisioned and can run offline after its binary and manifest have been verified. Update with the launcher itself: diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 127f1694905a..311803f544d4 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -67,6 +67,25 @@ class RegistryHandler(http.server.BaseHTTPRequestHandler): request_paths: list[str] = [] status = 200 + @staticmethod + def binary_integrity(tarball: bytes) -> str | None: + """Return the fixture binary SRI carried by package metadata.""" + try: + with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as archive: + member = next( + member + for member in archive.getmembers() + if member.isfile() and member.name in {"package/bin/cmux-tui", "package/bin/cmux-tui.exe"} + ) + payload = archive.extractfile(member) + if payload is None: + return None + return "sha512-" + base64.b64encode( + hashlib.sha512(payload.read()).digest() + ).decode() + except (OSError, StopIteration, tarfile.TarError): + return None + def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).authorization_headers.append(self.headers.get("Authorization")) metadata_path = urllib.parse.urlsplit(self.path).path @@ -88,18 +107,21 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler type(self).metadata_requests += 1 version = metadata_match.group(1) tarball = type(self).tarballs.get(version, type(self).tarball) - body = json.dumps( - { - "dist": { - "tarball": ( - f"http://127.0.0.1:{self.server.server_port}/tarball.tgz?" - f"version={urllib.parse.quote(version, safe='')}" - ), - "integrity": "sha512-" - + base64.b64encode(hashlib.sha512(tarball).digest()).decode(), - } - } - ).encode() + binary_integrity = type(self).binary_integrity(tarball) + dist = { + "tarball": ( + f"http://127.0.0.1:{self.server.server_port}/tarball.tgz?" + f"version={urllib.parse.quote(version, safe='')}" + ), + "integrity": "sha512-" + + base64.b64encode(hashlib.sha512(tarball).digest()).decode(), + } + if binary_integrity: + dist["cmuxBinaryIntegrity"] = binary_integrity + metadata = {"dist": dist} + if binary_integrity: + metadata["cmuxBinaryIntegrity"] = binary_integrity + body = json.dumps(metadata).encode() elif metadata_path != "/tarball.tgz" and re.fullmatch( r"/[A-Za-z0-9._-]+", metadata_path ): @@ -125,11 +147,16 @@ def do_GET(self) -> None: # noqa: N802, required by BaseHTTPRequestHandler "integrity": "sha512-" + base64.b64encode(hashlib.sha512(tarball).digest()).decode(), } + binary_integrity = type(self).binary_integrity(tarball) + if binary_integrity: + dist["cmuxBinaryIntegrity"] = binary_integrity version_records[version] = { "name": package_name, "version": version, "dist": dist, } + if binary_integrity: + version_records[version]["cmuxBinaryIntegrity"] = binary_integrity body = json.dumps( { "name": package_name, @@ -177,6 +204,7 @@ def run_launcher( env_extra: dict[str, str] | None = None, env_remove: set[str] | None = None, timeout_seconds: float | None = None, + cwd: Path | None = None, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() for name in env_remove or set(): @@ -199,6 +227,7 @@ def run_launcher( capture_output=True, text=True, env=env, + cwd=cwd, timeout=timeout_seconds, ) @@ -384,10 +413,15 @@ def write_fake_npm(tmp_path: Path) -> Path: process.exit(0); } if (field === 'dist') { - process.stdout.write(JSON.stringify({ + const dist = { tarball: 'https://registry.invalid/unused.tgz', integrity: process.env.FAKE_NPM_INTEGRITY, - })); + }; + process.stdout.write(JSON.stringify(dist)); + process.exit(0); + } + if (field === 'cmuxBinaryIntegrity') { + process.stdout.write(JSON.stringify(process.env.FAKE_NPM_BINARY_INTEGRITY || null)); process.exit(0); } } @@ -490,6 +524,42 @@ def test_launcher_downloads_once_and_reuses_verified_cache(tmp_path: Path) -> No assert not (cache / platform_key / "v/1.2.3/.active").exists() +def test_launcher_uses_authenticated_metadata_for_writable_cache_hit( + tmp_path: Path, +) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + server, thread, registry = start_registry(block_tarball_versions={"1.2.3"}) + try: + first = run_launcher(launcher, cache, registry, "--version") + RegistryHandler.block_tarball = True + try: + second = run_launcher( + launcher, + cache, + registry, + "--version", + timeout_seconds=3, + ) + except subprocess.TimeoutExpired as error: + raise AssertionError( + "a clean writable cache hit attempted a full tarball download" + ) from error + finally: + RegistryHandler.tarball_release.set() + server.shutdown() + thread.join() + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert second.stdout == "fake cmux-tui 1.2.3\n" + assert RegistryHandler.metadata_requests == 2 + assert RegistryHandler.tarball_requests == 1 + assert not RegistryHandler.tarball_started.is_set() + + def test_launcher_runs_verified_binary_from_read_only_cache(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -898,6 +968,9 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( fixture_integrity = "sha512-" + base64.b64encode( hashlib.sha512(tarball).digest() ).decode() + fixture_binary_integrity = "sha512-" + base64.b64encode( + hashlib.sha512(payload).digest() + ).decode() fake_npm = write_fake_npm(tmp_path) npm_log = tmp_path / "npm.log" env_extra.update( @@ -910,6 +983,7 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( "FAKE_NPM_LATEST": "1.2.3", "FAKE_NPM_TARBALL": str(fixture_tarball), "FAKE_NPM_INTEGRITY": fixture_integrity, + "FAKE_NPM_BINARY_INTEGRITY": fixture_binary_integrity, "FAKE_NPM_LOG": str(npm_log), "FAKE_NPM_HOST_PLATFORM": sys.platform, } @@ -1002,14 +1076,17 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( assert [record["args"][0] for record in records] == [ "view", "view", - "pack", "view", "pack", "view", + "view", + "view", + "view", "pack", ] assert records[0]["args"][2] == "version" assert records[1]["args"][2] == "dist" + assert records[2]["args"][2] == "cmuxBinaryIntegrity" expected_spec = "cmux-tui-win32-x64@1.2.3" assert all(expected_spec in record["args"] for record in records) assert all( @@ -1083,6 +1160,126 @@ def test_launcher_reads_registry_token_from_npmrc(tmp_path: Path) -> None: assert all(value == "Bearer fixture-token" for value in RegistryHandler.authorization_headers) +def test_launcher_honors_explicit_userconfig_and_project_precedence( + tmp_path: Path, +) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + project = tmp_path / "project" + project.mkdir() + home = tmp_path / "home" + home.mkdir() + server, thread, registry = start_registry() + port = server.server_port + (home / ".npmrc").write_text( + f"//127.0.0.1:{port}/:_authToken=home-token\n" + ) + explicit = tmp_path / "explicit.npmrc" + explicit.write_text(f"//127.0.0.1:{port}/:_authToken=user-token\n") + (project / ".npmrc").write_text( + f"//127.0.0.1:{port}/:_authToken=project-token\n" + ) + env_extra = { + "HOME": str(home), + "USERPROFILE": str(home), + "npm_config_userconfig": str(explicit), + "NPM_CONFIG_USERCONFIG": str(explicit), + "npm_config_globalconfig": str(tmp_path / "empty-global.npmrc"), + "NPM_CONFIG_GLOBALCONFIG": str(tmp_path / "empty-global.npmrc"), + } + (tmp_path / "empty-global.npmrc").write_text("") + try: + result = run_launcher( + launcher, + cache, + registry, + "--version", + env_extra=env_extra, + cwd=project, + ) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all( + value == "Bearer project-token" + for value in RegistryHandler.authorization_headers + ) + + # An explicit userconfig replaces the default home file when no project + # file is present. This prevents credentials from a broader scope leaking + # into a launch that selected a dedicated config file. + (project / ".npmrc").unlink() + cache = tmp_path / "explicit-cache" + server, thread, registry = start_registry() + explicit.write_text( + f"//127.0.0.1:{server.server_port}/:_authToken=user-token\n" + ) + try: + result = run_launcher( + launcher, + cache, + registry, + "--version", + env_extra=env_extra, + cwd=project, + ) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all(value == "Bearer user-token" for value in RegistryHandler.authorization_headers) + + +def test_launcher_reads_basic_auth_from_npmrc(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + server, thread, registry = start_registry() + auth = base64.b64encode(b"fixture-user:fixture-pass").decode() + npmrc = tmp_path / "basic-auth.npmrc" + npmrc.write_text(f"//127.0.0.1:{server.server_port}/:_auth={auth}\n") + try: + result = run_launcher( + launcher, + tmp_path / "cache-auth", + registry, + env_extra={"npm_config_userconfig": str(npmrc)}, + ) + finally: + server.shutdown() + thread.join() + expected = "Basic " + base64.b64encode(b"fixture-user:fixture-pass").decode() + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all(value == expected for value in RegistryHandler.authorization_headers) + + # npm stores username/password credentials with a base64-encoded password. + server, thread, registry = start_registry() + password = base64.b64encode(b"fixture-pass").decode() + npmrc.write_text( + f"//127.0.0.1:{server.server_port}/:username=fixture-user\n" + f"//127.0.0.1:{server.server_port}/:_password={password}\n" + ) + try: + result = run_launcher( + launcher, + tmp_path / "cache-user-password", + registry, + env_extra={"npm_config_userconfig": str(npmrc)}, + ) + finally: + server.shutdown() + thread.join() + assert result.returncode == 0, result.stderr + assert RegistryHandler.authorization_headers + assert all(value == expected for value in RegistryHandler.authorization_headers) + + def test_launcher_reads_registry_from_npmrc(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -1777,6 +1974,9 @@ def main() -> None: if sys.platform == "win32": return test_launcher_downloads_once_and_reuses_verified_cache(root / "download") + test_launcher_uses_authenticated_metadata_for_writable_cache_hit( + root / "metadata-cache" + ) test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") @@ -1787,6 +1987,10 @@ def main() -> None: test_launcher_reports_network_failure_without_leaking_details(root / "failure") test_launcher_releases_lease_when_native_launch_fails(root / "launch-failure") test_launcher_reads_registry_token_from_npmrc(root / "npmrc") + test_launcher_honors_explicit_userconfig_and_project_precedence( + root / "npmrc-precedence" + ) + test_launcher_reads_basic_auth_from_npmrc(root / "npmrc-basic-auth") test_launcher_scopes_registry_token_to_npmrc_path(root / "npmrc-scope") test_launcher_uses_npm_for_proxy_and_tls_config(root / "npm-network-config") test_launcher_does_not_run_a_mismatched_installed_binary(root / "mismatch") From ed1557856c7512da4b2d484b862c8e8dc380b3da Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:04:40 -0700 Subject: [PATCH 69/73] test(tui): cover read-only root and duplicate binaries --- tests/test_tui_npm_launcher.py | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index 311803f544d4..dd238c2688c0 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -43,6 +43,23 @@ def make_tarball( return gzip.compress(tar_buffer.getvalue(), mtime=0) +def make_duplicate_tarball( + first: bytes, + second: bytes, + *, + binary_name: str = "cmux-tui", +) -> bytes: + """Build a tarball with two files that have the same package/bin path.""" + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode="w") as archive: + for payload in (first, second): + info = tarfile.TarInfo(f"package/bin/{binary_name}") + info.mode = 0o755 + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return gzip.compress(tar_buffer.getvalue(), mtime=0) + + def make_negative_size_tarball() -> bytes: tar = bytearray(gzip.decompress(make_tarball())) # -1000 is -512 in octal. The launcher must reject it before the tar @@ -381,6 +398,20 @@ def write_runtime_capability_stub(tmp_path: Path) -> Path: return stub +def write_root_access_stub(tmp_path: Path) -> Path: + """Make fs.accessSync(W_OK) look root-like without changing file modes.""" + stub = tmp_path / "root-access.cjs" + stub.write_text( + "const fs = require('fs');\n" + "const accessSync = fs.accessSync.bind(fs);\n" + "fs.accessSync = (target, mode, ...args) => {\n" + " if (mode === fs.constants.W_OK) return;\n" + " return accessSync(target, mode, ...args);\n" + "};\n" + ) + return stub + + def write_fake_npm(tmp_path: Path) -> Path: """Return a Node script that exercises the launcher npm transport path.""" fake = tmp_path / "fake-npm.cjs" @@ -604,6 +635,32 @@ def test_launcher_runs_verified_binary_from_read_only_cache(tmp_path: Path) -> N assert not (platform_root / "v/1.2.3/.active").exists() +def test_launcher_runs_read_only_cache_when_access_reports_root(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + binary = write_cached_binary( + cache, + "1.2.3", + "#!/bin/sh\nprintf '%s\\n' 'root-style read-only binary'\n", + managed=True, + ) + make_cache_read_only(cache) + access_stub = write_root_access_stub(tmp_path) + result = run_launcher( + launcher, + cache, + "http://127.0.0.1:1", + "--version", + env_extra={"NODE_OPTIONS": f"--require={access_stub}"}, + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "root-style read-only binary\n" + assert binary.is_file() + assert not (cache / host_platform_key() / ".update.lock").exists() + + def test_launcher_requires_network_runtime_capabilities(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -658,6 +715,28 @@ def test_launcher_rejects_negative_tar_size_without_hanging(tmp_path: Path) -> N assert "could not obtain the native binary" in result.stderr +def test_launcher_rejects_duplicate_native_binary_entries(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + original_tarball = RegistryHandler.tarball + RegistryHandler.tarball = make_duplicate_tarball( + b"#!/bin/sh\nexit 0\n", + b"#!/bin/sh\nprintf '%s\\n' 'unvalidated duplicate'\n", + ) + server, thread, registry = start_registry() + try: + result = run_launcher(launcher, cache, registry, "--version") + finally: + server.shutdown() + thread.join() + RegistryHandler.tarball = original_tarball + assert result.returncode != 0 + assert "could not obtain the native binary" in result.stderr + assert not (cache / host_platform_key() / "v/1.2.3").exists() + + def test_launcher_refetches_a_tampered_cached_binary(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -986,6 +1065,12 @@ def test_launcher_windows_path_covers_exe_snapshot_lock_and_update( "FAKE_NPM_BINARY_INTEGRITY": fixture_binary_integrity, "FAKE_NPM_LOG": str(npm_log), "FAKE_NPM_HOST_PLATFORM": sys.platform, + # The platform stub makes Node report win32 on Unix. Set all + # supported temporary-directory variables so os.tmpdir() still + # points at this fixture tree when npm pack stages its tarball. + "TMPDIR": str(tmp_path), + "TMP": str(tmp_path), + "TEMP": str(tmp_path), } ) launcher = write_launcher(tmp_path / "launcher", "1.0.0") @@ -1977,8 +2062,12 @@ def main() -> None: test_launcher_uses_authenticated_metadata_for_writable_cache_hit( root / "metadata-cache" ) + test_launcher_runs_read_only_cache_when_access_reports_root( + root / "root-read-only" + ) test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") + test_launcher_rejects_duplicate_native_binary_entries(root / "duplicate-bin") test_launcher_refetches_a_tampered_cached_binary(root / "tampered-cache") test_launcher_refetches_tampered_manifest_and_binary( root / "tampered-manifest-cache" From 8a6150bcb7cc0fbec032468ca3d356a7bd5422ad Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:04:44 -0700 Subject: [PATCH 70/73] fix(tui): reject duplicate binaries and detect root read-only cache --- cmux-tui/dist/npm/cmux/bin/cmux.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 69487ad6b126..55fc5c8e9475 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -554,7 +554,15 @@ function cacheVersionIsReadOnly(version) { } for (const directory of directories) { try { - if (!fs.statSync(directory).isDirectory()) return false; + const stat = fs.statSync(directory); + if (!stat.isDirectory()) return false; + // `fs.accessSync(W_OK)` reports success for uid 0 even when an + // administrator deliberately provisioned this tree without any write + // permission bits. Treat the Unix mode as the read-only contract before + // consulting access, so root can use the documented offline path too. + if (process.platform !== "win32" && (stat.mode & 0o222) === 0) { + continue; + } fs.accessSync(directory, fs.constants.W_OK); // Any writable directory could publish a lease or remove a version // through its parent, so do not use an unleased launch path. @@ -582,8 +590,11 @@ function cacheVersionIsReadOnly(version) { try { const stat = fs.lstatSync(file); if (!stat.isFile()) return false; - if (process.platform !== "win32" && (stat.mode & 0o222) !== 0) { - return false; + if (process.platform !== "win32") { + // See the directory check above. Mode bits are the only reliable + // signal for a root process, whose access(2) call otherwise succeeds + // even for a deliberately immutable provisioned file. + if ((stat.mode & 0o222) === 0) continue; } fs.accessSync(file, fs.constants.W_OK); return false; @@ -1772,6 +1783,7 @@ function parsePaxRecords(buffer) { // [{ name, data }] for regular files under package/bin/. function extractBinEntries(tarBuffer) { const entries = []; + const names = new Set(); let offset = 0; let paxPath = null; let gnuLongName = null; @@ -1822,6 +1834,11 @@ function extractBinEntries(tarBuffer) { if (!base || base.includes("/") || base.includes("\\") || base === "." || base === "..") { continue; } + // A later duplicate would overwrite the first file during extraction. + // Reject duplicate names so every authenticated tar entry maps to exactly + // one cache file and the publisher's binary digest cannot be bypassed. + if (names.has(base)) throw new Error("platform package contains duplicate bin entries"); + names.add(base); entries.push({ name: base, data: Buffer.from(data) }); } return entries; From 382f98ff94cc0c0039cf07c86c067603104f5933 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:45:49 -0700 Subject: [PATCH 71/73] test(tui): reject symlinked cache paths --- tests/test_tui_npm_launcher.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py index dd238c2688c0..75295a4d3c9e 100644 --- a/tests/test_tui_npm_launcher.py +++ b/tests/test_tui_npm_launcher.py @@ -661,6 +661,40 @@ def test_launcher_runs_read_only_cache_when_access_reports_root(tmp_path: Path) assert not (cache / host_platform_key() / ".update.lock").exists() +def test_launcher_rejects_symlinked_cache_version_before_writing(tmp_path: Path) -> None: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + cache = tmp_path / "cache" + outside = tmp_path / "outside" + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_text("unchanged\n") + version = cache / host_platform_key() / "v/1.2.3" + version.parent.mkdir(parents=True) + version.symlink_to(outside, target_is_directory=True) + + server, thread, registry = start_registry() + try: + result = run_launcher( + launcher, + cache, + registry, + "--version", + timeout_seconds=4, + ) + finally: + server.shutdown() + thread.join() + + assert result.returncode != 0 + assert "could not reserve the native binary for launch" in result.stderr + assert sentinel.read_text() == "unchanged\n" + assert list(outside.iterdir()) == [sentinel] + assert RegistryHandler.metadata_requests == 0 + assert RegistryHandler.tarball_requests == 0 + + def test_launcher_requires_network_runtime_capabilities(tmp_path: Path) -> None: if sys.platform == "win32": return @@ -2065,6 +2099,9 @@ def main() -> None: test_launcher_runs_read_only_cache_when_access_reports_root( root / "root-read-only" ) + test_launcher_rejects_symlinked_cache_version_before_writing( + root / "symlinked-cache" + ) test_launcher_requires_network_runtime_capabilities(root / "runtime") test_launcher_rejects_negative_tar_size_without_hanging(root / "negative-size") test_launcher_rejects_duplicate_native_binary_entries(root / "duplicate-bin") From deeafe38a0ff88c53e299566dcb2afc54e84fada Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:45:54 -0700 Subject: [PATCH 72/73] fix(tui): reject symlinked cache write paths --- cmux-tui/dist/npm/cmux/bin/cmux.js | 187 +++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 21 deletions(-) diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index 55fc5c8e9475..4ffbb95736f7 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -195,6 +195,117 @@ function platformRoot() { return path.join(cacheRoot(), `${process.platform}-${process.arch}`); } +// Cache contents are writable by the invoking user and are not a trust root. +// Reject symlinked directory components inside the configured cache boundary +// before any lease, state, or payload write. Node has no portable openat(2) +// API, so every creation is checked both before and after mkdir. +function cacheDirectoryPathIsSafe(target, mustExist = false) { + const root = path.resolve(cacheRoot()); + const resolvedTarget = path.resolve(target); + const relative = path.relative(root, resolvedTarget); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return false; + } + + const components = [root]; + let current = root; + if (relative) { + for (const component of relative.split(path.sep)) { + current = path.join(current, component); + components.push(current); + } + } + + let missing = false; + for (const component of components) { + if (missing) continue; + let stat; + try { + stat = fs.lstatSync(component); + } catch (error) { + if (error && error.code === "ENOENT") { + missing = true; + continue; + } + return false; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) return false; + } + return !mustExist || !missing; +} + +function ensureSafeCacheDirectory(target) { + if (!cacheDirectoryPathIsSafe(target)) { + throw new Error("launcher cache path contains an unsafe directory"); + } + fs.mkdirSync(target, { recursive: true, mode: 0o700 }); + if (!cacheDirectoryPathIsSafe(target, true)) { + throw new Error("launcher cache path changed during directory creation"); + } +} + +function cacheNoFollowFlag() { + if (process.platform === "win32") return 0; + const flag = fs.constants.O_NOFOLLOW; + return typeof flag === "number" ? flag : null; +} + +// Create a new cache file without following a file symlink. The parent is +// checked separately because Node does not expose a portable openat(2) API. +function writeNewCacheFile(target, data, mode = 0o600) { + const parent = path.dirname(target); + if (!cacheDirectoryPathIsSafe(parent, true)) { + throw new Error("launcher cache file parent is unsafe"); + } + const noFollow = cacheNoFollowFlag(); + if (process.platform !== "win32" && noFollow === null) { + throw new Error("launcher cannot enforce no-follow cache writes"); + } + let fd; + try { + fd = fs.openSync( + target, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, + mode + ); + fs.writeFileSync(fd, data); + if (process.platform !== "win32") fs.fchmodSync(fd, mode); + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch {} + } + } +} + +// Atomically replace a cache file through a fresh, exclusive temporary file. +// Renaming replaces a destination symlink itself and never follows it. +function replaceCacheFile(target, data, mode = 0o600) { + const parent = path.dirname(target); + if (!cacheDirectoryPathIsSafe(parent, true)) { + throw new Error("launcher cache file parent is unsafe"); + } + const temp = `${target}.${process.pid}-${crypto + .randomBytes(8) + .toString("hex")}.tmp`; + try { + writeNewCacheFile(temp, data, mode); + if (!cacheDirectoryPathIsSafe(parent, true)) { + throw new Error("launcher cache file parent changed"); + } + fs.renameSync(temp, target); + } finally { + try { + fs.rmSync(temp, { force: true }); + } catch {} + } +} + const STATE_CHANNEL = /^[a-z0-9]+$/; function statePath(channel) { @@ -208,6 +319,8 @@ function legacyStatePath() { function readStateFile(target) { try { + if (!cacheDirectoryPathIsSafe(path.dirname(target), true)) return null; + if (!fs.lstatSync(target).isFile()) return null; const state = JSON.parse(fs.readFileSync(target, "utf8")); if (state && validVersion(state.version)) return state; } catch {} @@ -265,13 +378,11 @@ function writeState(state) { } const target = statePath(channel); if (!target) fail("cannot persist launcher state for an invalid release channel"); - fs.mkdirSync(path.dirname(target), { recursive: true }); - const tmp = `${target}.${process.pid}.tmp`; - fs.writeFileSync( - tmp, + ensureSafeCacheDirectory(path.dirname(target)); + replaceCacheFile( + target, JSON.stringify({ ...state, channel }, null, 2) + "\n" ); - fs.renameSync(tmp, target); } function cachedBinDir(version) { @@ -337,7 +448,10 @@ function openCachedBinary(bin) { function readCachedManifest(version, pkg = null) { const bin = path.join(cachedBinDir(version), BIN_NAME); try { - const manifest = JSON.parse(fs.readFileSync(cacheManifestPath(version), "utf8")); + const manifestPath = cacheManifestPath(version); + if (!cacheDirectoryPathIsSafe(path.dirname(manifestPath), true)) return null; + if (!fs.lstatSync(manifestPath).isFile()) return null; + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const expected = manifest?.binaries?.[BIN_NAME]; if ( manifest?.version !== version || @@ -540,6 +654,12 @@ function cacheVersionIsReadOnly(version) { const versionRoot = path.dirname(cachedBinDir(version)); const binDir = cachedBinDir(version); const leaseRoot = path.join(versionRoot, ".active"); + if ( + !cacheDirectoryPathIsSafe(versionRoot, true) || + !cacheDirectoryPathIsSafe(binDir, true) + ) { + return false; + } const directories = [ cacheRoot(), platformRoot(), @@ -892,7 +1012,8 @@ function removeCacheLockIfOwned(owner, allowEmpty = false, lockPath = cacheLockP function tryAcquireCacheLock(lockPath = cacheLockPath()) { try { - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + ensureSafeCacheDirectory(path.dirname(lockPath)); + if (!cacheDirectoryPathIsSafe(lockPath)) return null; } catch { return null; } @@ -903,6 +1024,9 @@ function tryAcquireCacheLock(lockPath = cacheLockPath()) { try { fs.mkdirSync(lockPath, { recursive: false }); created = true; + if (!cacheDirectoryPathIsSafe(lockPath, true)) { + throw new Error("launcher cache lock path is unsafe"); + } // Publish the complete owner record with rename so readers never see a // partially written PID/token pair. const ownerTempPath = path.join(lockPath, `.owner-${owner.token}.tmp`); @@ -923,6 +1047,8 @@ function tryAcquireCacheLock(lockPath = cacheLockPath()) { } } + if (!cacheDirectoryPathIsSafe(lockPath, true)) return null; + const current = readCacheLockOwner(lockPath); if (current) { const ownership = cacheLockOwnerIsCurrent(current); @@ -959,11 +1085,14 @@ function tryAcquireVersionLease(version) { `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` ); pendingLease = `${lease}.pending`; - fs.mkdirSync(leaseRoot, { recursive: true }); + ensureSafeCacheDirectory(leaseRoot); // Build the lease away from the directory scanned by prune. Publish it // only after its PID record is complete, using an atomic directory // rename so an interruption cannot expose an empty active lease. fs.mkdirSync(pendingLease, { recursive: false }); + if (!cacheDirectoryPathIsSafe(pendingLease, true)) { + throw new Error("launcher cache lease path is unsafe"); + } const pidTemp = path.join(pendingLease, ".pid.tmp"); fs.writeFileSync(pidTemp, leaseOwner.raw, { encoding: "utf8", @@ -1120,6 +1249,7 @@ function cleanupStaging() { const root = path.join(platformRoot(), "tmp"); let entries; try { + if (!cacheDirectoryPathIsSafe(root, true)) return; entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; @@ -1130,7 +1260,8 @@ function cleanupStaging() { const staging = path.join(root, entry.name); let stat; try { - stat = fs.statSync(staging); + stat = fs.lstatSync(staging); + if (stat.isSymbolicLink() || !stat.isDirectory()) continue; } catch { continue; } @@ -1976,19 +2107,24 @@ function cacheManifestMatchesMetadata(candidate, pkg, version, metadata) { function writeCacheManifest(pkg, version, integrity, entries) { const target = cacheManifestPath(version); - const tmp = `${target}.${process.pid}.tmp`; + if (!cacheDirectoryPathIsSafe(path.dirname(target), true)) { + throw new Error("launcher cache manifest path is unsafe"); + } const binaries = {}; for (const entry of entries) binaries[entry.name] = digestHex(entry.data); - fs.writeFileSync( - tmp, - JSON.stringify({ package: pkg, version, tarballIntegrity: integrity, binaries }, null, 2) + "\n", - { mode: 0o600 } + replaceCacheFile( + target, + JSON.stringify( + { package: pkg, version, tarballIntegrity: integrity, binaries }, + null, + 2 + ) + "\n" ); - fs.renameSync(tmp, target); } function removeCachedPayload(version) { const versionDir = path.dirname(cachedBinDir(version)); + if (!cacheDirectoryPathIsSafe(versionDir, true)) return; for (const name of ["bin", "manifest.json", "managed"]) { try { fs.rmSync(path.join(versionDir, name), { recursive: true, force: true }); @@ -2020,15 +2156,23 @@ async function downloadVersion( "tmp", `${version}-${process.pid}-${Date.now().toString(36)}` ); - fs.mkdirSync(path.join(stagingDir, "bin"), { recursive: true }); + ensureSafeCacheDirectory(path.join(stagingDir, "bin")); for (const entry of entries) { - fs.writeFileSync(path.join(stagingDir, "bin", entry.name), entry.data, { mode: 0o755 }); + writeNewCacheFile( + path.join(stagingDir, "bin", entry.name), + entry.data, + 0o755 + ); + } + const versionDir = path.dirname(finalDir); + ensureSafeCacheDirectory(versionDir); + if (!cacheDirectoryPathIsSafe(finalDir)) { + throw new Error("launcher cache binary path is unsafe"); } - fs.mkdirSync(path.dirname(finalDir), { recursive: true }); try { fs.renameSync(path.join(stagingDir, "bin"), finalDir); writeCacheManifest(pkg, version, integrity, entries); - fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + replaceCacheFile(path.join(versionDir, "managed"), "cmux\n"); } catch (error) { // A concurrent launcher won the race; its extraction is byte-identical // only when the existing binary matches the entry we verified above. @@ -2052,14 +2196,14 @@ async function downloadVersion( removeCachedPayload(version); fs.renameSync(path.join(stagingDir, "bin"), finalDir); writeCacheManifest(pkg, version, integrity, entries); - fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + replaceCacheFile(path.join(versionDir, "managed"), "cmux\n"); } catch { throw new Error("cached platform binary failed integrity verification"); } } else { try { writeCacheManifest(pkg, version, integrity, entries); - fs.writeFileSync(path.join(path.dirname(finalDir), "managed"), "cmux\n"); + replaceCacheFile(path.join(versionDir, "managed"), "cmux\n"); } catch {} } } finally { @@ -2140,6 +2284,7 @@ function pruneCache(keepVersion) { if (!lock) return false; const root = path.join(platformRoot(), "v"); try { + if (!cacheDirectoryPathIsSafe(root, true)) return false; const managed = fs .readdirSync(root) .filter((version) => isManagedCacheVersion(path.join(root, version))) From c03d307fab4771093a054f782ce01e997d41b70e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:59:27 -0700 Subject: [PATCH 73/73] ci: keep TUI launcher coverage in SDK workflow --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89102dd86635..c05bab6e95df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,9 +158,6 @@ jobs: - name: Validate TUI package artifact contract run: python3 tests/test_tui_package_contract.py - - name: Validate TUI npm launcher behavior - run: python3 tests/test_tui_npm_launcher.py - - name: Validate Claude launch environment policy generation run: python3 scripts/generate-claude-launch-environment-policy.py --check