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/README.ja.md b/cmux-tui/README.ja.md new file mode 100644 index 000000000000..5878d7eb205c --- /dev/null +++ b/cmux-tui/README.ja.md @@ -0,0 +1,26 @@ +# cmux-tui + +tmux 風のターミナル TUI です。詳細な英語ドキュメントは +[README.md](README.md) を参照してください。 + +## npm パッケージのインストールと更新 + +`cmux` npm パッケージは依存関係を持たない小さなランチャーです。初回起動時に +現在のプラットフォーム用の `cmux-tui-` パッケージを npm レジストリから +ダウンロードし、sha512 整合性を確認してランチャー専用キャッシュに保存します。 + +書き込み可能なランチャーキャッシュは起動前に認証済みパッケージメタデータを取得し、 +公開元のバイナリダイジェストと照合します。レジストリがダイジェストを提供しない場合は、 +書き込み可能なキャッシュヒットごとに tarball 全体を検証します。管理者が用意した完全な読み取り専用 +キャッシュは、バイナリとマニフェストを検証済みならオフラインで起動できます。 + +```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 8b21e0acf847..7eec30c06d84 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. +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. ## Browser ownership diff --git a/cmux-tui/dist/npm/cmux/bin/cmux.js b/cmux-tui/dist/npm/cmux/bin/cmux.js index f8a0c63dfb51..4ffbb95736f7 100755 --- a/cmux-tui/dist/npm/cmux/bin/cmux.js +++ b/cmux-tui/dist/npm/cmux/bin/cmux.js @@ -2,12 +2,41 @@ "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, 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 +// `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"); +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", @@ -17,37 +46,2575 @@ const PACKAGE_BY_PLATFORM = { "win32-x64": "cmux-tui-win32-x64", }; -const key = `${process.platform}-${process.arch}`; -const pkg = PACKAGE_BY_PLATFORM[key]; +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; +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. +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; +// 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; -if (!pkg) { - console.error( - `cmux: no prebuilt binary for ${key}. Supported: ${Object.keys(PACKAGE_BY_PLATFORM).join(", ")}.` +class LauncherError extends Error { + constructor(message) { + super(message); + this.name = "LauncherError"; + } +} + +function fail(message) { + throw new LauncherError(message); +} + +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; +} + +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 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) { + 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 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) { + 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 { + 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 {} + 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 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"); + ensureSafeCacheDirectory(path.dirname(target)); + replaceCacheFile( + target, + JSON.stringify({ ...state, channel }, null, 2) + "\n" + ); +} + +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 sameFileIdentity(left, right) { + // 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 ( + usable(left) && + usable(right) && + 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); + // 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; + } + return { fd, stat: openedStat }; + } catch (error) { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch {} + } + throw error; + } +} + +function readCachedManifest(version, pkg = null) { + const bin = path.join(cachedBinDir(version), BIN_NAME); + try { + 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 || + (pkg && manifest?.package !== pkg) || + 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) + ) { + return null; + } + return { + bin, + expected, + package: manifest.package, + version: manifest.version, + tarballIntegrity: manifest.tarballIntegrity, + }; + } catch { + return null; + } +} + +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() || + (process.platform !== "win32" && !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 { stat: after, data }; +} + +function cachedBinaryPathIsUnchanged(bin, expectedStat) { + const finalStat = fs.lstatSync(bin); + return finalStat.isFile() && sameFileIdentity(finalStat, expectedStat); +} + +function cachedBinary(version, candidate = null, pkg = null) { + const resolvedCandidate = candidate || readCachedManifest(version, pkg); + if (!resolvedCandidate) return null; + let opened = null; + try { + opened = openCachedBinary(resolvedCandidate.bin); + if (!opened) 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)); + 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(resolvedCandidate.bin); + if (!opened) return null; + verified = readVerifiedCachedBinary(opened.fd, resolvedCandidate.expected); + if (!verified || (verified.stat.mode & 0o111) === 0) return null; + verifiedStat = verified.stat; + } + fs.accessSync(resolvedCandidate.bin, fs.constants.X_OK); + } + if (!cachedBinaryPathIsUnchanged(resolvedCandidate.bin, verifiedStat)) return null; + return resolvedCandidate.bin; + } catch { + return null; + } finally { + if (opened) fs.closeSync(opened.fd); + } +} + +// 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, 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) { + // 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 { + return null; + } +} + +function snapshotVerifiedCachedBinary( + candidate, + expected = candidate.expected, + repairManagedMode = false +) { + let opened = null; + let snapshotDirectory = null; + let snapshotFd; + let snapshot = null; + let complete = false; + try { + opened = openCachedBinary(candidate.bin); + if (!opened) return null; + let verified = readVerifiedCachedBinary(opened.fd, expected); + if (!verified) 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) + ) { + 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 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"); + if ( + !cacheDirectoryPathIsSafe(versionRoot, true) || + !cacheDirectoryPathIsSafe(binDir, true) + ) { + return false; + } + const directories = [ + cacheRoot(), + platformRoot(), + path.dirname(versionRoot), + versionRoot, + binDir, + ]; + try { + if (fs.existsSync(leaseRoot)) directories.push(leaseRoot); + } catch { + return false; + } + for (const directory of directories) { + try { + 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. + 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; + } + } + } + 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") { + // 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; + } catch (error) { + if (!error || (error.code !== "EACCES" && error.code !== "EPERM")) { + return false; + } + } + } + return true; +} + +function cacheLockPath() { + return path.join(platformRoot(), ".update.lock"); +} + +// 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 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"); +} + +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, + startIdentity, + createdAt, + raw: `${process.pid}\n${token}\n${startIdentity || "-"}\n${createdAt}\n`, + }; +} + +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; + 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) { + try { + const raw = fs.readFileSync(ownerPath, "utf8"); + return parseCacheLockOwner(raw); + } catch { + return null; + } +} + +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); + return true; + } catch (error) { + return !error || error.code !== "ESRCH"; + } +} + +// 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. +function emptyCacheLockCanBeReclaimed(lockPath = cacheLockPath()) { + let entries; + try { + entries = fs.readdirSync(lockPath, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name === "owner") { + let size; + try { + size = fs.statSync(path.join(lockPath, 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(lockPath, entry.name)); + if (pending && cacheLockOwnerIsCurrent(pending) !== false) return false; + } + return true; +} + +function emptyCacheLockIsStale(lockPath = cacheLockPath()) { + let stat; + try { + 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(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, lockPath = cacheLockPath()) { + let observed; + try { + observed = fs.readFileSync(cacheLockOwnerPath(lockPath), "utf8"); + } catch { + if (!allowEmpty) 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) + .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 { + actual = fs.readFileSync(path.join(quarantine, "owner"), "utf8"); + } catch { + actual = undefined; + } + 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; + } 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 {} + } + } +} + +function tryAcquireCacheLock(lockPath = cacheLockPath()) { + try { + ensureSafeCacheDirectory(path.dirname(lockPath)); + if (!cacheDirectoryPathIsSafe(lockPath)) return null; + } 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; + 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`); + fs.writeFileSync(ownerTempPath, owner.raw, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + 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, lockPath); + return null; + } + } + + if (!cacheDirectoryPathIsSafe(lockPath, true)) return null; + + const current = readCacheLockOwner(lockPath); + if (current) { + 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; + } + // 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(lockPath)) return null; + if (!removeCacheLockIfOwned({ raw: undefined }, true, lockPath)) return null; + } + return null; +} + +function releaseCacheLock(owner, lockPath = cacheLockPath()) { + if (!owner) return; + removeCacheLockIfOwned(owner, false, lockPath); +} + +function tryAcquireVersionLease(version) { + const leaseRoot = path.join(platformRoot(), "v", version, ".active"); + 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 { + lease = path.join( + leaseRoot, + `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + ); + pendingLease = `${lease}.pending`; + 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", + flag: "wx", + mode: 0o600, + }); + fs.renameSync(pidTemp, path.join(pendingLease, "pid")); + fs.renameSync(pendingLease, lease); + pendingLease = null; + return lease; + } catch { + for (const pathToRemove of [pendingLease, lease]) { + if (!pathToRemove) continue; + try { + fs.rmSync(pathToRemove, { recursive: true, force: true }); + } catch {} + } + } finally { + releaseCacheLock(lock); + } + } + 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 { + fs.rmSync(lease, { recursive: true, force: true }); + const leaseRoot = path.dirname(lease); + if (path.basename(leaseRoot) === ".active") fs.rmdirSync(leaseRoot); + } catch {} +} + +// 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 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"; + if (!processIsAlive(pid)) return "dead"; + return leaseIsStale(lease) ? "dead" : "live"; +} + +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; + 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 (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. + 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; + } + } + 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; + } +} + +function cleanupStaging() { + const root = path.join(platformRoot(), "tmp"); + let entries; + try { + if (!cacheDirectoryPathIsSafe(root, true)) return; + 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.lstatSync(staging); + if (stat.isSymbolicLink() || !stat.isDirectory()) continue; + } 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) continue; + if (match && 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) { + 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 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) { + let registry = null; + 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(); + // npm's ini parser applies the last occurrence in a file. + registry = normalizeRegistryValue(value); + } + return registry; +} + +function readNpmrcRegistry(configPath) { + try { + return npmrcRegistry(fs.readFileSync(configPath, "utf8")); + } catch { + return null; + } +} + +function npmRegistryFromConfigFiles() { + for (const configPath of npmConfigFilePaths()) { + const registry = readNpmrcRegistry(configPath); + if (registry) return registry; + } + return null; +} + +function registryBase() { + const explicit = normalizeRegistryValue(process.env.CMUX_NPM_REGISTRY); + if (explicit) return explicit; + const environment = normalizeRegistryValue( + npmConfigEnvironmentValue("registry") ); - process.exit(1); + if (environment) return environment; + return npmRegistryFromConfigFiles() || "https://registry.npmjs.org"; +} + +// 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; } -const binName = process.platform === "win32" ? "cmux-tui.exe" : "cmux-tui"; +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)); + }; + + // 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) { + 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; + } + 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; +} + +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; +} -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).` +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 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-")); + 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 {} + } +} + +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 parseScopedNpmAuthLine(line) { + const match = /^\s*\/\/([^/]+)(\/[^:]*?)?\/:([^=\s]+)\s*=\s*(.*?)\s*$/.exec( + line ); - process.exit(1); + 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), + }; } -const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" }); +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; +} -if (result.error) { - console.error(`cmux: failed to launch ${binPath}: ${result.error.message}`); - process.exit(1); +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; } -if (result.signal) { - process.kill(process.pid, result.signal); - return; + +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; + } + const fileValues = npmrcAuthValues(url, contents); + if (Object.keys(fileValues).length) sources.push(fileValues); + } + return sources; } -process.exit(result.status === null ? 1 : result.status); + +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"; + const hasAbortTimeout = + typeof AbortSignal === "function" && typeof AbortSignal.timeout === "function"; + if (nodeMajor < MIN_NODE_MAJOR || !hasFetch || !hasAbortTimeout) { + fail( + "network access requires Node.js 18 or newer with global fetch and " + + "AbortSignal.timeout" + ); + } +} + +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), + }); + if (!response.ok) { + throw new Error("registry request failed"); + } + 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. +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 = []; + const names = new Set(); + 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 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 = nextOffset; + + 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; + } + // 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; +} + +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)"); + } +} + +function validSha512Integrity(integrity) { + return /^sha512-[A-Za-z0-9+/]{86}={0,2}$/.test(integrity || ""); +} + +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, + 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 (!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()) { + 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); + } + verifyIntegrity(tgz, integrity); + 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); + const native = entries.find((entry) => entry.name === BIN_NAME); + if (!native) { + throw new Error("platform package does not contain the native binary"); + } + 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 cacheManifestMatchesMetadata(candidate, pkg, version, metadata) { + if (!candidate || !metadata || candidate.package !== pkg) return false; + if (candidate.version !== version) return false; + if (candidate.tarballIntegrity !== metadata.integrity) return false; + return Boolean( + metadata.binaryIntegrity && candidate.expected === metadata.binaryIntegrity + ); +} + +function writeCacheManifest(pkg, version, integrity, entries) { + const target = cacheManifestPath(version); + 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); + replaceCacheFile( + target, + JSON.stringify( + { package: pkg, version, tarballIntegrity: integrity, binaries }, + null, + 2 + ) + "\n" + ); +} + +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 }); + } 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, + metadata = null, + } = {} +) { + const verified = + verifiedPackage || + (await fetchVerifiedPackage(pkg, version, "downloading", metadata)); + const { integrity, entries, native } = verified; + + const finalDir = cachedBinDir(version); + const stagingDir = path.join( + platformRoot(), + "tmp", + `${version}-${process.pid}-${Date.now().toString(36)}` + ); + ensureSafeCacheDirectory(path.join(stagingDir, "bin")); + for (const entry of entries) { + 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"); + } + try { + fs.renameSync(path.join(stagingDir, "bin"), finalDir); + writeCacheManifest(pkg, version, integrity, entries); + 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. + const expected = entries.find((entry) => entry.name === BIN_NAME); + 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(existingPath)) + .digest(); + const expectedDigest = crypto.createHash("sha512").update(expected.data).digest(); + 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); + replaceCacheFile(path.join(versionDir, "managed"), "cmux\n"); + } catch { + throw new Error("cached platform binary failed integrity verification"); + } + } else { + try { + writeCacheManifest(pkg, version, integrity, entries); + replaceCacheFile(path.join(versionDir, "managed"), "cmux\n"); + } catch {} + } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + 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, null, pkg); + if (!binPath) throw new Error(`extraction did not produce ${finalDir}/${BIN_NAME}`); + } + if (returnCandidate) { + return { + path: finalPath, + candidate: { + bin: finalPath, + expected: digestHex(native.data), + package: pkg, + version, + tarballIntegrity: integrity, + }, + }; + } + return finalPath; +} + +function isManagedCacheVersion(versionRoot) { + try { + 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; + } +} + +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; + 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))) + .sort(compareVersions); + 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); + // 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(versionRoot, { recursive: true, force: true }); + } catch {} + } + return true; + } catch { + // Cache cleanup is best effort and must never hide a successful update. + return false; + } finally { + releaseCacheLock(lock); + } +} + +function wantedVersion(pkg) { + const pinned = shimVersion(); + if (isManagedPlaceholder(pinned)) { + const installed = installedPackage(pkg); + 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." + ); + } + if (!validVersion(pinned)) { + fail("this launcher has an invalid release version"); + } + const state = readState(versionChannel(pinned)); + if ( + state && + stateVersionChannel(state) === versionChannel(pinned) && + compareVersions(state.version, pinned) > 0 + ) { + return state.version; + } + return pinned; +} + +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"); + return override; + } + if (wanted === undefined || wanted === null) wanted = wantedVersion(pkg); + + const installed = installedPackage(pkg); + if (installed && installed.version === wanted) { + return installed.binPath; + } + + // 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 metadata = null; + if (cachedCandidate) { + // 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, + 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. 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"); + return { path: snapshot.path, snapshot }; + } catch { + fail( + "could not obtain the native binary. Check network access or install " + + "the matching platform package directly." + ); + } +} + +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}`, { + 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`); + } + 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. +async function runUpdate(pkg, args) { + const checkOnly = args.includes("--check"); + const unknown = args.filter((a) => a !== "--check"); + if (unknown.length) { + fail("invalid update arguments. Usage: cmux update [--check]"); + } + if (checkOnly) { + const current = wantedVersion(pkg); + const latest = await latestVersionForChannel(current); + 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; + } + + // 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 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"); + 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 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; + } + lease = await acquireVersionLeaseForProcess(latest); + if (!lease) fail("could not reserve the native binary for update"); + // 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, + 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); + } +} + +async function main() { + 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)); + } catch (error) { + fail(`update failed: ${error.message}`); + } + 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 launchSnapshot = null; + let exitCode = 1; + let childSignal = null; + 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; + // 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 cachedCandidate = + wanted && !installedBin ? cachedBinaryCandidate(wanted, pkg) : null; + const readOnlyCached = Boolean( + 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 + // can remove while it is running. + 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)); + let binPath = installedBin; + if (!binPath && readOnlyCached) { + // 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) { + // 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; + } else { + binPath = resolved; + } + } + if (lease) pruneCache(wanted); + const result = spawnSync(binPath, args, { stdio: "inherit" }); + if (result.error) { + fail("failed to launch the native binary"); + } + if (result.signal) { + childSignal = result.signal; + } else { + exitCode = result.status === null ? 1 : result.status; + } + } finally { + releaseVersionLease(lease); + removeLaunchSnapshot(launchSnapshot); + } + 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((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/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..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}"], }, ) @@ -187,9 +199,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.ja.md b/cmux-tui/docs/getting-started.ja.md new file mode 100644 index 000000000000..6303fed9b910 --- /dev/null +++ b/cmux-tui/docs/getting-started.ja.md @@ -0,0 +1,181 @@ +# 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`)にバージョン別で保存します。 + +書き込み可能なキャッシュは起動前に認証済みパッケージメタデータを取得し、公開元のバイナリ +ダイジェストと照合するため、通常のキャッシュヒットでもネットワークを使用します。レジストリが +ダイジェストを提供しない場合は、書き込み可能なキャッシュヒットごとに tarball 全体を検証します。 +完全な読み取り専用キャッシュは管理者が用意したものとして扱い、バイナリとマニフェストを検証済み +ならオフラインで起動できます。 + +```bash +npx cmux update # 最新のプラットフォーム用バイナリを取得 +npx cmux update --check # 更新の有無だけを確認 +``` + +`cmux update` はプラットフォーム用バイナリの通常更新に使います。npm ランチャー自体を +更新する場合は `npx cmux@latest` を使います。前者は npm の `_npx` キャッシュを書き換え +ませんが、後者は cmux 起動前にそのキャッシュへアクセスし、古い状態では +`ENOTEMPTY: directory not empty, rename` で失敗することがあります。 + +レジストリに接続できない場合は、ランチャーと同じバージョンの 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 エラー + +`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 ;; +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' +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_-]*) + echo "無効な npx キャッシュハッシュのため中止します" >&2 + exit 1 + ;; +esac +entry="$target/$hash" +if [ ! -d "$entry" ]; then + echo "npx キャッシュエントリがありません: $hash" >&2 + exit 1 +fi +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 +assert_entry_inactive() { + if [ ! -d "$entry" ] || [ -L "$entry" ]; then + echo "選択した npx エントリが変更されたか、シンボリックリンクになっています: $entry" >&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 +} +assert_entry_inactive +printf '隔離対象(このエントリだけ): %s\n' "$entry" +read -r -p '続行する場合は yes と入力してください: ' confirm +[ "$confirm" = yes ] || exit 1 +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 +} +# 確認後、隔離へ原子的に移動する直前に再確認します。 +assert_entry_inactive +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 ff15e16f7c76..94c63a2a0689 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -116,6 +116,192 @@ 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 + +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 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: + +```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 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@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. 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. 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 + +`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 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 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' +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_-]*) + 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 +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 +assert_entry_inactive() { + if [ ! -d "$entry" ] || [ -L "$entry" ]; then + echo "The selected npx entry changed or became a symlink: $entry" >&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 +} +assert_entry_inactive +printf 'About to quarantine only: %s\n' "$entry" +read -r -p 'Type yes to continue: ' confirm +[ "$confirm" = yes ] || exit 1 +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 +} +# Recheck after confirmation, immediately before the atomic quarantine move. +assert_entry_inactive +mv "$entry" "$destination" +printf 'Quarantined at: %s\n' "$destination" +npx cmux@latest +``` + +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 The default socket path is: diff --git a/tests/test_tui_npm_launcher.py b/tests/test_tui_npm_launcher.py new file mode 100644 index 000000000000..75295a4d3c9e --- /dev/null +++ b/tests/test_tui_npm_launcher.py @@ -0,0 +1,2153 @@ +"""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 platform +import re +import stat +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import urllib.parse +from pathlib import Path + + +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( + 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(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_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 + # 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() + 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 + tarball_requests = 0 + latest_requests: list[str] = [] + authorization_headers: list[str | None] = [] + 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 + 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.-]+)?)$", + metadata_path, + ) + 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 metadata_match: + type(self).metadata_requests += 1 + version = metadata_match.group(1) + tarball = type(self).tarballs.get(version, type(self).tarball) + 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 + ): + # 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(), + } + 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, + "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) + 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 = 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 | None, + *args: str, + 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(): + env.pop(name, None) + env.update( + { + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "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], + check=False, + capture_output=True, + text=True, + env=env, + cwd=cwd, + timeout=timeout_seconds, + ) + + +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 + or name.lower() + in { + "node_options", + "node_path", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + } + } + 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: + """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) + (package / "package.json").write_text( + json.dumps({"name": "cmux", "version": version}) + "\n" + ) + launcher = package / "bin/cmux.js" + launcher.write_bytes(LAUNCHER.read_bytes()) + launcher.chmod(0o755) + 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, exist_ok=True) + 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": tarball_integrity, + "binaries": {"cmux-tui": hashlib.sha512(data).hexdigest()}, + } + ) + + "\n" + ) + if managed: + (version_dir / "managed").write_text("cmux\n") + 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( + "globalThis.fetch = undefined;\n" + "if (typeof AbortSignal === \"function\") AbortSignal.timeout = undefined;\n" + ) + 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" + 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); +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') { + 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); + } +} +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", + arch_name: str | None = None, +) -> Path: + stub = tmp_path / "unsupported-platform.cjs" + # 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 + + +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 = [] + RegistryHandler.request_paths = [] + RegistryHandler.status = 200 + 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 = [] + 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: + if sys.platform == "win32": + 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") + # 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( + launcher, + cache, + registry, + "--version", + env_extra={"NODE_OPTIONS": f"--require={runtime_stub}"}, + ) + finally: + server.shutdown() + 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" + assert RegistryHandler.metadata_requests == 1 + assert RegistryHandler.tarball_requests == 1 + 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_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 + 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.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]) + + 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_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_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 + 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 + assert not (tmp_path / "cache" / host_platform_key() / "v/1.2.3/.active").exists() + + +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 + 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_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 + 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_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 + 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.metadata_requests == 2 + 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", + ) + unmanaged.chmod(0o644) + write_cached_binary(cache, "1.2.3", "#!/bin/sh\nexit 0\n", managed=True) + + 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() + assert not unmanaged.parent.parent.joinpath("managed").exists() + 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" + ) + + 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" + 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 + + 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/nightly.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/stable.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: + if sys.platform == "win32": + return + + nightly_version = "1.2.3-nightly.20260826.1" + cache = tmp_path / "shared-cache" + nightly_launcher = write_launcher(tmp_path / "nightly", nightly_version) + stable_launcher = write_launcher(tmp_path / "stable", "1.2.2") + + 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"] + + 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"] + + 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( + cache, + RegistryHandler.latest_version, + "#!/bin/sh\nprintf '%s\\n' 'stable binary'\n", + managed=True, + ) + # 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" + ) + make_cache_read_only(cache) + nightly_result = run_launcher( + nightly_launcher, cache, "http://127.0.0.1:1", "--version" + ) + stable_result = run_launcher( + 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" + + +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}"} + env_extra, env_remove = isolated_npm_environment(tmp_path, env_extra) + + 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() + 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( + { + # 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_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") + update_launcher = write_launcher(tmp_path / "update", "1.0.0") + cache = tmp_path / "cache" + platform_root = cache / "win32-x64" + + # 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 + 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() + + 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 + + # 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", + "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( + 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: + if sys.platform == "win32": + return + launcher = write_launcher(tmp_path) + 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" + write_cached_binary( + cache, + "1.2.3", + "#!/definitely/missing/interpreter\n", + managed=True, + ) + + 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 + assert not (cache / host_platform_key() / "v/1.2.3/.active").exists() + + +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_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 + 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 + 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_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 + 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 + + +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 + 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 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 + 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 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_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)) + + 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" + assert not lock.exists() + + +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") + + 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, + stderr=subprocess.PIPE, + text=True, + env={ + **os.environ, + "CMUX_TUI_LAUNCHER_CACHE": str(cache), + "CMUX_NPM_REGISTRY": registry, + "NO_COLOR": "1", + }, + ) + stdout = "" + stderr = "" + try: + 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) + finally: + 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" + + +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)) + + 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" + 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_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_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 + 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 + 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) + + 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": registry, + "NO_COLOR": "1", + } + ) + old_process = subprocess.Popen( + ["node", str(old_launcher)], + 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, + registry, + "--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) + server.shutdown() + thread.join() + 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", + ) + + 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( + { + "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" + ) + 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" + ) + 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_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/stable.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 + 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 + retained = { + entry.name for entry in platform_root.iterdir() if entry.is_dir() + } + assert retained == {"1.1.0", "1.2.3"} + + +def main() -> None: + 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_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_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") + 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") + 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") + 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") + 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") + 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") + 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" + ) + test_launcher_keeps_current_and_one_previous_after_download(root / "prune") + + +if __name__ == "__main__": + main() 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"