diff --git a/package.json b/package.json index 899897704..bcc1f8742 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,10 @@ "proxy-demo": "npm --prefix packages/claude-code-headless run build && electron experiments/claude-proxy-stream-app/main.mjs", "runtime:fetch:mitmproxy": "node scripts/runtime-tools/fetch-mitmproxy.mjs", "runtime:verify:mitmproxy": "node scripts/runtime-tools/verify-mitmproxy.mjs", - "runtime:build:tmux": "node scripts/runtime-tools/build-tmux.mjs", + "runtime:fetch:tmux": "node scripts/runtime-tools/fetch-tmux.mjs", "runtime:verify:tmux": "node scripts/runtime-tools/verify-tmux.mjs", "runtime:verify": "npm run runtime:verify:mitmproxy && npm run runtime:verify:tmux", - "runtime:prepare:mac": "npm run runtime:fetch:mitmproxy -- --all && node scripts/runtime-tools/verify-mitmproxy.mjs --strict", + "runtime:prepare:mac": "npm run runtime:fetch:mitmproxy -- --all && npm run runtime:fetch:tmux -- --all && node scripts/runtime-tools/verify-mitmproxy.mjs --strict && node scripts/runtime-tools/verify-tmux.mjs --strict", "postinstall": "electron-rebuild -f -w node-pty" }, "dependencies": { diff --git a/scripts/runtime-tools/build-tmux.mjs b/scripts/runtime-tools/build-tmux.mjs deleted file mode 100644 index c2ac5dd50..000000000 --- a/scripts/runtime-tools/build-tmux.mjs +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -// Static tmux builder — stub. Real implementation lands in PR 3 -// (issue #120). -// -// PR 1 ships this stub so: -// - npm scripts referencing it resolve cleanly -// - the filesystem layout (third_party/tmux/{cache,build}) is -// established and the .gitignore is enforced -// - PR 3 can swap the implementation without re-litigating any of -// the surrounding contract -// -// When PR 3 lands, this script will: -// 1. Download pinned tmux + libevent + ncurses + utf8proc sources. -// 2. Verify each source archive against -// `manifest.sources..sha256`. -// 3. Configure with --enable-static --disable-shared, build, link -// tmux statically. -// 4. Run the proof-of-life lifecycle (see third_party/tmux/README.md). -// 5. Write the binary to `cache/-/tmux` and record -// its sha256 into manifest.platforms..sha256. - -process.stderr.write( - '[build-tmux] not yet implemented; tracked in issue #120 (PR 3).\n' + - '[build-tmux] manifest under third_party/tmux/manifest.json still has TBD-PR3 placeholders.\n', -) -process.exit(2) diff --git a/scripts/runtime-tools/fetch-tmux.mjs b/scripts/runtime-tools/fetch-tmux.mjs new file mode 100644 index 000000000..e0e7f1edb --- /dev/null +++ b/scripts/runtime-tools/fetch-tmux.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node +// Download the pinned tmux archive from upstream tmux/tmux-builds, +// verify the SHA-256 against manifest.json, extract the inner `tmux` +// executable, and place it at +// third_party/tmux/cache/-/tmux +// with the executable bit set. +// +// Usage: +// node scripts/runtime-tools/fetch-tmux.mjs # current platform +// node scripts/runtime-tools/fetch-tmux.mjs --all # every platform in manifest +// node scripts/runtime-tools/fetch-tmux.mjs --platform darwin-x86_64 +// +// WHY tmux's cache stores the extracted binary, not the archive: +// For mitmproxy we ship the full .tar.gz inside the packaged app +// and extract on first launch because the contents are a multi-file +// .app bundle. tmux is a single 1.6 MB binary — extracting it once +// at fetch time keeps the runtime resolver in +// `src/main/setup/runtimeTools.ts` trivial: it just returns the +// already-extracted binary's path from app.asar.unpacked, no +// userData copy, no marker, no atomic rename. Two different +// resolution shapes for the two tools is fine; the underlying +// manifest schema stays identical. +// +// WHY we pin BOTH the archive sha256 AND the extracted binarySha256: +// The archive hash verifies what we downloaded. The binary hash +// verifies what gets shipped — and lets us validate the cache +// without spawning the binary. Spawning would require Rosetta when +// the build host is arm64 and the target is x86_64 (or vice +// versa); on a clean Apple-Silicon CI runner without Rosetta the +// non-native binary fails with "Bad CPU type in executable" and +// the fetch would error out. Hash-based identity is platform- +// agnostic and stronger: a swapped binary that happens to print +// `tmux 3.6a` for `-V` would still fail a hash check. +// +// WHY this is NOT a postinstall hook: +// Same rule as fetch-mitmproxy: dev contributors must not pay a +// download cost on `npm install`. CI release jobs invoke this +// explicitly through `npm run runtime:prepare:mac`. + +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { + chmod, copyFile, mkdir, readFile, rm, stat, unlink, +} from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { pipeline } from 'node:stream/promises' +import { spawn } from 'node:child_process' + +const here = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(here, '..', '..') +const manifestPath = join(repoRoot, 'third_party', 'tmux', 'manifest.json') +const cacheRoot = join(repoRoot, 'third_party', 'tmux', 'cache') +const tmpRoot = join(cacheRoot, '.download') + +async function main() { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const args = process.argv.slice(2) + const all = args.includes('--all') + const platformArg = readFlag(args, '--platform') + + const targets = all + ? Object.keys(manifest.platforms) + : platformArg + ? [platformArg] + : [currentPlatformKey()] + + for (const platformKey of targets) { + const platform = manifest.platforms[platformKey] + if (!platform) { + throw new Error( + `No manifest entry for "${platformKey}". ` + + `Known platforms: ${Object.keys(manifest.platforms).join(', ')}`, + ) + } + if (!platform.binarySha256) { + throw new Error( + `Manifest is missing binarySha256 for ${platformKey}. ` + + `Compute it with \`shasum -a 256 third_party/tmux/cache/${platformKey}/tmux\` ` + + `after a fresh download, then pin the value in third_party/tmux/manifest.json.`, + ) + } + await fetchOne(manifest, platformKey, platform) + } +} + +function currentPlatformKey() { + // Same arch convention as fetch-mitmproxy and runtimeTools.ts: + // Node's `process.arch` -> manifest arch via the single x64 -> x86_64 + // remap. + const archMap = { x64: 'x86_64', arm64: 'arm64' } + const arch = archMap[process.arch] + if (!arch) throw new Error(`Unsupported arch: ${process.arch}`) + return `${process.platform}-${arch}` +} + +function readFlag(argv, name) { + const i = argv.indexOf(name) + if (i === -1) return null + return argv[i + 1] ?? null +} + +async function fetchOne(manifest, platformKey, platform) { + const filename = platform.filename.replaceAll('{version}', manifest.version) + const urlBase = manifest.urlBase.replaceAll('{version}', manifest.version) + const url = `${urlBase}/${filename}` + + const targetDir = join(cacheRoot, platformKey) + const targetBinary = join(targetDir, manifest.executableInsideArchive) + + // Cache-hit detection by content hash. Trusting the on-disk binary + // because `tmux -V` reports the right version is too loose: a + // corrupted or swapped binary that happens to print the version + // string would pass. The binary hash is platform-agnostic and + // cheap. + if (await fileMatchesHash(targetBinary, platform.binarySha256)) { + process.stdout.write( + `[fetch-tmux] cache hit for ${platformKey} (binary sha256 ${platform.binarySha256.slice(0, 12)})\n`, + ) + return + } + + await mkdir(targetDir, { recursive: true }) + await mkdir(tmpRoot, { recursive: true }) + const archivePath = join(tmpRoot, `${platformKey}-${filename}.partial`) + + process.stdout.write(`[fetch-tmux] downloading ${url}\n`) + const res = await fetch(url) + if (!res.ok || !res.body) { + throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`) + } + + const archiveHash = createHash('sha256') + await pipeline( + res.body, + async function* (source) { + for await (const chunk of source) { + archiveHash.update(chunk) + yield chunk + } + }, + createWriteStream(archivePath), + ) + + const archiveDigest = archiveHash.digest('hex') + if (archiveDigest !== platform.sha256) { + await safeUnlink(archivePath) + throw new Error( + `Archive hash mismatch for ${platformKey}.\n` + + ` manifest: ${platform.sha256}\n` + + ` download: ${archiveDigest}\n` + + ` refusing to extract; manifest must be updated or download retried.`, + ) + } + + const st = await stat(archivePath) + if (platform.bytes && st.size !== platform.bytes) { + await safeUnlink(archivePath) + throw new Error( + `Byte-size mismatch for ${platformKey}: expected ${platform.bytes}, got ${st.size}`, + ) + } + + // Extract into a sibling staging dir, then move the single binary + // into the canonical cache path. Doing the extract in a staging + // dir means a Ctrl-C between extract and chmod leaves the previous + // cache (if any) intact; the next run wipes .download and starts + // clean. + const stageDir = join(tmpRoot, `${platformKey}-stage`) + await rm(stageDir, { recursive: true, force: true }) + await mkdir(stageDir, { recursive: true }) + await extractTarGz(archivePath, stageDir) + + const stageBinary = join(stageDir, manifest.executableInsideArchive) + await stat(stageBinary) // throws if missing + + // Verify the EXTRACTED binary's content hash before moving it into + // the canonical cache path. This is the load-bearing identity + // check: the binary we ship out of `out/main/runtime/tmux/...` is + // whatever lives at the cache path, so a tamper-or-corruption + // window between extraction and move would otherwise be invisible. + if (!(await fileMatchesHash(stageBinary, platform.binarySha256))) { + await rm(stageDir, { recursive: true, force: true }).catch(() => {}) + await safeUnlink(archivePath) + throw new Error( + `Extracted binary hash mismatch for ${platformKey}; refusing to commit it to the cache.`, + ) + } + + await copyFile(stageBinary, targetBinary) + await chmod(targetBinary, 0o755) + + // Clean up staging + downloaded archive. The cache directory + // ultimately contains exactly one file per platform (the binary) + // and nothing else. + await safeUnlink(archivePath) + await rm(stageDir, { recursive: true, force: true }) + await rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) + + // Native-arch sanity: confirm the binary actually runs and reports + // the pinned version. This catches "binary hash matches but the + // file is corrupted in a way the OS rejects at exec time" failures + // — and only fires on the build host's native arch because spawn + // would otherwise need Rosetta to run a cross-arch binary. + if (platformKey === currentPlatformKey()) { + if (!(await binaryReportsVersion(targetBinary, manifest.version))) { + throw new Error( + `Extracted ${manifest.executableInsideArchive} did not report version ${manifest.version}; refusing to leave a broken cache.`, + ) + } + } + + process.stdout.write( + `[fetch-tmux] verified ${platformKey} -> ${targetBinary}\n`, + ) +} + +async function fileMatchesHash(path, expected) { + try { + const buf = await readFile(path) + return createHash('sha256').update(buf).digest('hex') === expected + } catch { + return false + } +} + +async function binaryReportsVersion(path, expectedVersion) { + try { + const st = await stat(path) + if (!st.isFile()) return false + } catch { + return false + } + return await new Promise(res => { + let out = '' + const child = spawn(path, ['-V']) + child.stdout.on('data', b => (out += b)) + child.stderr.on('data', b => (out += b)) + child.on('error', () => res(false)) + child.on('exit', code => res(code === 0 && out.trim().includes(expectedVersion))) + }) +} + +async function safeUnlink(path) { + try { await unlink(path) } catch { /* best effort */ } +} + +function extractTarGz(archive, destDir) { + return new Promise((resolve, reject) => { + // /usr/bin/tar is built into macOS and supports -xzf out of the + // box. Spawning it rather than depending on a node-tar library + // keeps the dev-time toolchain free of extra runtime packages. + const child = spawn('/usr/bin/tar', ['-xzf', archive, '-C', destDir], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stderr = '' + child.stderr.on('data', c => { stderr += String(c) }) + child.once('error', reject) + child.once('exit', code => { + if (code === 0) resolve() + else reject(new Error(`tar exit ${code}: ${stderr.trim()}`)) + }) + }) +} + +main().catch(err => { + process.stderr.write( + `[fetch-tmux] ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(1) +}) diff --git a/scripts/runtime-tools/verify-tmux.mjs b/scripts/runtime-tools/verify-tmux.mjs index bc35bddea..56e5e9d6f 100644 --- a/scripts/runtime-tools/verify-tmux.mjs +++ b/scripts/runtime-tools/verify-tmux.mjs @@ -1,19 +1,46 @@ #!/usr/bin/env node -// Verify the built static tmux binary against manifest.json. +// Verify the cached tmux binary in +// third_party/tmux/cache/-/tmux +// against manifest.json without re-downloading. // -// Until PR 3 lands the static build, the manifest contains TBD-PR3 -// placeholders and the cache is empty. In that state this script -// exits 0 with a "skipped" warning so the bundling pipeline can -// continue (mitmproxy still verifies normally) and so `npm run -// runtime:verify` does not block PR 1 review. +// Usage: +// node scripts/runtime-tools/verify-tmux.mjs # warn on missing +// node scripts/runtime-tools/verify-tmux.mjs --strict # fail on missing or broken // -// Once PR 3 lands real hashes, CI can switch to `--strict` and this -// script will fail when the cache is missing or corrupted. +// Strict verification has three layers: +// +// 1. Content identity (every arch): sha256 of the cached binary +// must equal `manifest.platforms..binarySha256`. This is +// the load-bearing check and the only one that works +// cross-arch — a release builder on arm64 still gets real +// tamper detection for the x86_64 binary it just fetched, +// without needing Rosetta to spawn it. +// +// 2. Runtime smoke test (native arch only): `tmux -V` must include +// the pinned version string. Catches "hash matches but the OS +// refuses to exec" failures. Skipped on non-native because +// spawning a cross-arch Mach-O on a clean macOS without Rosetta +// fails with "Bad CPU type in executable". +// +// 3. Linkage gate (darwin, native arch only): `otool -L` must +// report no Homebrew dylib references. The whole point of +// bundling is that we never link against the user's Homebrew +// install; a future change that silently swapped the upstream +// source for the Homebrew binary would pass (1) only if its +// hash happened to match, which it would not, and would also +// fail this check. +// +// The presence-only mode the previous version used for non-native +// arch was a hole: a corrupted or swapped x86_64 binary on an arm64 +// builder would slip past strict verification. Hash-checking closes +// that. import { createHash } from 'node:crypto' -import { readFile, stat } from 'node:fs/promises' +import { access, readFile, stat } from 'node:fs/promises' +import { constants as fsConstants } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' const here = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(here, '..', '..') @@ -24,43 +51,74 @@ async function main() { const strict = process.argv.includes('--strict') const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) - if (manifestHasPlaceholders(manifest)) { - const msg = - '[verify-tmux] manifest contains TBD-PR3 placeholders; skipping (tracked in issue #120).' - if (strict) { - console.error(msg) - process.exit(1) - } else { - console.warn(msg) - return - } - } - let problems = 0 for (const [platformKey, platform] of Object.entries(manifest.platforms)) { - const file = join(cacheRoot, platformKey, 'tmux') + const binary = join(cacheRoot, platformKey, manifest.executableInsideArchive) + try { - await stat(file) + await access(binary, fsConstants.X_OK) } catch { - const msg = `[verify-tmux] missing cache: ${platformKey} (${file})` - if (strict) { - console.error(msg) - problems++ - } else { - console.warn(`${msg} — run \`npm run runtime:build:tmux\``) - } + const msg = `[verify-tmux] missing or not executable: ${platformKey} (${binary})` + if (strict) { console.error(msg); problems++ } + else { console.warn(`${msg} — run \`npm run runtime:fetch:tmux\``) } continue } - const buf = await readFile(file) + + if (!platform.binarySha256) { + console.error( + `[verify-tmux] ${platformKey}: manifest is missing binarySha256. ` + + `Compute and pin it before this script can validate the cache.`, + ) + problems++ + continue + } + + const buf = await readFile(binary) const digest = createHash('sha256').update(buf).digest('hex') - if (digest !== platform.sha256) { + if (digest !== platform.binarySha256) { console.error( - `[verify-tmux] hash mismatch for ${platformKey}: ${digest} != ${platform.sha256}`, + `[verify-tmux] ${platformKey}: binary hash mismatch:\n` + + ` manifest: ${platform.binarySha256}\n` + + ` on-disk: ${digest}`, ) problems++ continue } - console.log(`[verify-tmux] OK ${platformKey} (${digest.slice(0, 12)})`) + + if (platformKey !== currentPlatformKey()) { + // Cross-arch: we trust the hash check above and skip the spawn + // + linkage checks because they require executing the binary, + // which a clean Apple-Silicon host cannot do for an x86_64 + // Mach-O (and vice versa) without Rosetta. The hash gate is + // sufficient: a swapped or corrupted cross-arch binary would + // not match the pinned hash regardless of whether we can run + // it. + console.log(`[verify-tmux] OK ${platformKey} (hash matches; cross-arch)`) + continue + } + + const versionOk = await binaryReportsVersion(binary, manifest.version) + if (!versionOk) { + console.error( + `[verify-tmux] ${platformKey}: \`tmux -V\` did not contain "${manifest.version}"`, + ) + problems++ + continue + } + + if (process.platform === 'darwin') { + const homebrewLinkage = await detectHomebrewLinkage(binary) + if (homebrewLinkage.length > 0) { + console.error( + `[verify-tmux] ${platformKey}: binary links Homebrew dylibs:\n ` + + homebrewLinkage.join('\n '), + ) + problems++ + continue + } + } + + console.log(`[verify-tmux] OK ${platformKey} (${manifest.version}, hash + linkage clean)`) } if (problems > 0) { @@ -69,8 +127,38 @@ async function main() { } } -function manifestHasPlaceholders(manifest) { - return JSON.stringify(manifest).includes('TBD-PR3') +function currentPlatformKey() { + const archMap = { x64: 'x86_64', arm64: 'arm64' } + const arch = archMap[process.arch] + if (!arch) return '' + return `${process.platform}-${arch}` +} + +function binaryReportsVersion(path, expectedVersion) { + return new Promise(res => { + let out = '' + const child = spawn(path, ['-V']) + child.stdout.on('data', b => (out += b)) + child.stderr.on('data', b => (out += b)) + child.on('error', () => res(false)) + child.on('exit', code => res(code === 0 && out.includes(expectedVersion))) + }) +} + +function detectHomebrewLinkage(path) { + return new Promise(res => { + let stdout = '' + const child = spawn('/usr/bin/otool', ['-L', path]) + child.stdout.on('data', b => (stdout += b)) + child.on('error', () => res([])) + child.on('exit', () => { + const hits = stdout + .split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('/opt/homebrew') || line.includes('/Cellar/')) + res(hits) + }) + }) } main().catch(err => { diff --git a/src/main/index.ts b/src/main/index.ts index 2a26a9bae..5a1a52bcd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,7 +31,8 @@ import { registerAllIpc } from '@main/ipc/index.js' import { cleanupDictationIpcResources } from '@main/ipc/dictation.js' import { performanceService } from '@main/performance/PerformanceService.js' import { startMainHeapWatchdog, stopMainHeapWatchdog } from '@main/performance/heapWatchdog.js' -import { getToolPath, initializeToolchain } from '@main/setup/toolchain.js' +import { resolveBundledTool } from '@main/setup/runtimeTools.js' +import { initializeToolchain } from '@main/setup/toolchain.js' import { WorktreeActivityIndex } from '@main/worktreeActivity/WorktreeActivityIndex.js' // Main process — thin Electron host. @@ -130,7 +131,22 @@ async function startApp(): Promise { // child-process roundtrip on `tmux -V` — cheap enough to await // before any IPC is wired. Result is cached on the registry; call // sites use isAvailable() synchronously thereafter. - tmuxRegistry = new TmuxRegistry({ tmuxBinary: getToolPath('tmux', 'tmux') }) + // WHY bundled-only with no PATH fallback: + // Agent Code ships its own tmux 3.6a (see issue #120 and + // third_party/tmux/). Falling back to whatever `tmux` resolves on + // PATH would re-introduce the exact "works on my machine" + // pathology that bundling was meant to fix — different versions, + // incompatible session formats, Homebrew dylib drift. + // + // When the bundled binary cannot be resolved (dev build without + // `runtime:prepare:mac`, or a corrupted asar.unpacked), we pass + // `tmuxBinary: undefined` to TmuxRegistry. The registry then + // short-circuits `detectAvailability()` to `false` WITHOUT + // spawning anything — terminals fall back to direct-PTY mode, + // same as a machine without tmux installed. No silent + // system-tmux usage, no PATH lookup, no sentinel-string trickery. + const bundledTmux = await resolveBundledTool('tmux') + tmuxRegistry = new TmuxRegistry({ tmuxBinary: bundledTmux ?? undefined }) const tmuxDetectStarted = performance.now() const tmuxAvailable = await tmuxRegistry.detectAvailability() performanceService.record({ diff --git a/src/main/ipc/setup.ts b/src/main/ipc/setup.ts index 16af43a71..811149e69 100644 --- a/src/main/ipc/setup.ts +++ b/src/main/ipc/setup.ts @@ -11,7 +11,12 @@ export function registerSetupIpc(): void { }) ipcMain.handle('setup:install', async (_evt, target: SetupInstallTarget) => { - if (target !== 'tmux' && target !== 'mitmproxy') { + // tmux was dropped from the install pathway when bundled tmux + // (#120) became the only supported source. The remaining target + // is mitmproxy, which follows the same cleanup track and will + // disappear shortly. Keep this validation in step with + // SetupInstallTarget so renderer-side type checks remain useful. + if (target !== 'mitmproxy') { return { ok: false, target, diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index f8933442a..a5c3a8958 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -548,12 +548,29 @@ export class SessionManager extends EventEmitter { rows: options.rows ?? 24, } + // WHY we thread the registry's binary through to TerminalSession: + // TerminalSession's tmux runtime spawns `tmux attach -t `, + // so it needs to point at the SAME binary the registry just + // used to `new-session` the target. Under the bundled-only + // policy (see src/main/index.ts), PATH-resolved `tmux` could be + // a different version with an incompatible session format — + // using one tmux to create and another to attach would + // silently fail or, worse, partially work. The registry is the + // source of truth. + // + // When useTmux is true the registry must have a binary (the + // `isAvailable()` gate above only flips true after + // `detectAvailability()` confirmed one), so the null coalesce + // below is defensive against future code paths that bypass + // that gate. + const registryBinary = this.tmuxRegistry?.getBinary() ?? undefined const session = new TerminalSession({ cwd: options.cwd, cols: initialSize.cols, rows: initialSize.rows, runtime: useTmux ? 'tmux' : 'direct', tmuxSessionName: tmuxSessionName ?? undefined, + tmuxBinary: useTmux ? registryBinary : undefined, }) // Initialize an empty buffer entry NOW, before start() fires any diff --git a/src/main/setup/binaryResolver.ts b/src/main/setup/binaryResolver.ts index d2d85fab0..a964f42c6 100644 --- a/src/main/setup/binaryResolver.ts +++ b/src/main/setup/binaryResolver.ts @@ -9,7 +9,6 @@ const TOOL_COMMAND: Record = { claude: 'claude', codex: 'codex', git: 'git', - tmux: 'tmux', mitmdump: 'mitmdump', } diff --git a/src/main/setup/homebrewInstaller.ts b/src/main/setup/homebrewInstaller.ts index 162b7660f..0ab93cd25 100644 --- a/src/main/setup/homebrewInstaller.ts +++ b/src/main/setup/homebrewInstaller.ts @@ -6,7 +6,9 @@ import { runLoginShell } from '@main/setup/shell.js' import { checkPrerequisites } from '@main/setup/prerequisites.js' const INSTALL_COMMAND: Record = { - tmux: 'brew list tmux >/dev/null 2>&1 || brew install tmux', + // tmux was removed when bundled tmux (#120) became the only source + // Agent Code spawns from. mitmproxy is on the same track and will + // be dropped in a follow-up cleanup PR. mitmproxy: 'brew list mitmproxy >/dev/null 2>&1 || brew install mitmproxy', } diff --git a/src/main/setup/prerequisites.ts b/src/main/setup/prerequisites.ts index 2cf927239..b2ceb284a 100644 --- a/src/main/setup/prerequisites.ts +++ b/src/main/setup/prerequisites.ts @@ -58,13 +58,12 @@ const TOOL_META: Record> = installable: false, detail: 'Used by Git Bar, worktree badges, and repository metadata.', }, - tmux: { - id: 'tmux', - label: 'tmux', - required: false, - installable: true, - detail: 'Keeps terminal panes alive across app restarts.', - }, + // NOTE: tmux is intentionally absent. The SetupGate is for tools + // the user might need to install themselves; tmux is now a bundled + // runtime artifact (see issue #120 and third_party/tmux/). It has + // no install story for users to participate in. The runtime + // resolver (`src/main/setup/runtimeTools.ts`) is the single + // authority on tmux availability now. mitmdump: { id: 'mitmdump', label: 'Claude Proxy Helper', @@ -74,7 +73,7 @@ const TOOL_META: Record> = }, } -const CHECK_ORDER: SetupToolId[] = ['brew', 'claude', 'codex', 'git', 'tmux', 'mitmdump'] +const CHECK_ORDER: SetupToolId[] = ['brew', 'claude', 'codex', 'git', 'mitmdump'] export async function checkPrerequisites(): Promise { const state = await loadSetupState() diff --git a/src/main/setup/runtimeTools.ts b/src/main/setup/runtimeTools.ts index d0bcddfe2..c36a2792d 100644 --- a/src/main/setup/runtimeTools.ts +++ b/src/main/setup/runtimeTools.ts @@ -45,6 +45,7 @@ import { spawn } from 'node:child_process' import { constants as fsConstants } from 'node:fs' import { access, + chmod, mkdir, readFile, readdir, @@ -101,40 +102,49 @@ export function getPlatformKey(): string | null { } /** - * Cheap "do we ship a bundled archive for this tool on this platform" + * Cheap "do we ship a bundled artifact for this tool on this platform" * check. Used by the setup gate so it can hide the install prompt - * without paying the cost of extracting the .app on every check. + * without paying the cost of any extraction work. * * Side-effect free except for the disk reads on the manifest + - * archive paths. + * artifact paths. */ export async function isBundledArchiveAvailable( tool: BundledToolId, ): Promise { - if (tool !== 'mitmdump') return false - const platformKey = getPlatformKey() - if (!platformKey) return false - const manifest = await loadMitmproxyManifest() - if (!manifest) return false - const platform = manifest.platforms[platformKey] - if (!platform) return false - const archivePath = mitmproxyArchivePath(manifest, platformKey, platform) - return fileExists(archivePath) + if (tool === 'mitmdump') { + const platformKey = getPlatformKey() + if (!platformKey) return false + const manifest = await loadMitmproxyManifest() + if (!manifest) return false + const platform = manifest.platforms[platformKey] + if (!platform) return false + return fileExists(mitmproxyArchivePath(manifest, platformKey, platform)) + } + if (tool === 'tmux') { + const platformKey = getPlatformKey() + if (!platformKey) return false + const manifest = await loadTmuxManifest() + if (!manifest) return false + if (!manifest.platforms[platformKey]) return false + return fileExists(tmuxBinaryPath(manifest, platformKey)) + } + return false } /** * Resolve the absolute path to a bundled runtime tool's spawnable - * executable, extracting the archive lazily on first call if needed. + * executable. For tools shipped as multi-file archives (mitmdump), + * extract lazily on first call. For single-binary tools (tmux), no + * extraction is needed — we return the asar-unpacked path directly. * Returns null when no bundled artifact exists for the current * platform/arch. - * - * PR 2 wires mitmdump end-to-end here. PR 3 will branch on `tool` and - * route 'tmux' to a sibling single-binary copy path. */ export async function resolveBundledTool( tool: BundledToolId, ): Promise { if (tool === 'mitmdump') return resolveMitmdump() + if (tool === 'tmux') return resolveTmux() return null } @@ -237,6 +247,100 @@ async function installMitmproxy( } } +// --------------------------------------------------------------------------- +// tmux resolver +// +// WHY tmux's path looks nothing like mitmproxy's despite both being +// "bundled runtime tools": +// tmux is a single 1.6 MB Mach-O that links only to system dylibs +// guaranteed present on every macOS install (libSystem, libresolv — +// verified with `otool -L`). There is no nested .app, no Python +// framework, no PyInstaller symlinks. Once electron-builder +// asarUnpack drops the binary at a real filesystem path, that path +// is directly spawnable: there is nothing to "install" into +// userData. So `resolveTmux` is a plain "find + chmod + return" +// rather than the temp+rename+marker dance `resolveMitmdump` has to +// do. Two different shapes for two different artifact kinds is +// fine; the manifest schema and the public API stay identical. +// +// WHY we chmod defensively even though `copyFile` should preserve mode: +// `fs.copyFile` preserves POSIX mode bits, and electron-builder's +// asarUnpack pathway has not been observed to drop the exec bit on +// our own builds. But this resolver is called once at app startup, +// the cost is one syscall, and chmod-on-the-canonical-bundled-path +// is the kind of safety net that's much easier to keep than to +// re-add after a confusing "works in dev, broken in signed DMG" +// bug report. If we ever measure this as hot, drop it; until +// then, keep it cheap. +type TmuxManifest = { + tool: 'tmux' + version: string + urlBase: string + archiveFormat: 'tar.gz' + executableInsideArchive: string + platforms: Record< + string, + { filename: string; sha256: string; bytes?: number } + > +} + +async function resolveTmux(): Promise { + const platformKey = getPlatformKey() + if (!platformKey) return null + const manifest = await loadTmuxManifest() + if (!manifest) return null + if (!manifest.platforms[platformKey]) return null + + const binary = tmuxBinaryPath(manifest, platformKey) + if (!(await fileExists(binary))) return null + + // chmod is cheap and idempotent; we don't bother caching whether + // we've already done it for this process. Skipping the chmod when + // the bit is already set would be a micro-optimization not worth + // the extra stat. + try { + await chmod(binary, 0o755) + } catch { + // If chmod fails (read-only filesystem in some sandboxed test + // environment?), fall through; the access check below will catch + // a genuinely non-executable file. + } + if (!(await isExecutable(binary))) return null + return binary +} + +async function loadTmuxManifest(): Promise { + const manifestPath = unpackAsarPath( + join(app.getAppPath(), 'out', 'main', 'runtime', 'tmux', 'manifest.json'), + ) + try { + const text = await readFile(manifestPath, 'utf8') + return JSON.parse(text) as TmuxManifest + } catch { + return null + } +} + +function tmuxBinaryPath( + manifest: TmuxManifest, + platformKey: string, +): string { + return unpackAsarPath( + join( + app.getAppPath(), + 'out', + 'main', + 'runtime', + 'tmux', + platformKey, + manifest.executableInsideArchive, + ), + ) +} + +// --------------------------------------------------------------------------- +// mitmproxy helpers (below) + async function loadMitmproxyManifest(): Promise { const manifestPath = unpackAsarPath( join(app.getAppPath(), 'out', 'main', 'runtime', 'mitmproxy', 'manifest.json'), diff --git a/src/main/tmux/TmuxRegistry.ts b/src/main/tmux/TmuxRegistry.ts index b048aa195..269e2a7d5 100644 --- a/src/main/tmux/TmuxRegistry.ts +++ b/src/main/tmux/TmuxRegistry.ts @@ -20,18 +20,36 @@ export type TmuxRegistryOptions = { * prefix. Production uses 'agentcode-'; tests use a different * prefix so they never touch the user's real sessions. */ namePrefix?: string - /** Override for the tmux binary path. Defaults to 'tmux' on PATH. */ + /** + * Absolute path to the tmux binary the registry should spawn. + * Undefined means "no bundled tmux is available" — the registry + * resolves `detectAvailability()` to false WITHOUT spawning + * anything, and terminals fall back to direct-PTY mode. This is + * the bundled-only policy from #120: we never go looking for a + * PATH tmux even as a last resort, because a user's Homebrew tmux + * could be a different version with an incompatible session + * format. + */ tmuxBinary?: string } export class TmuxRegistry { private readonly namePrefix: string - private readonly tmuxBinary: string + private readonly tmuxBinary: string | null private availability: boolean | null = null constructor(options: TmuxRegistryOptions = {}) { this.namePrefix = options.namePrefix ?? 'agentcode-' - this.tmuxBinary = options.tmuxBinary ?? 'tmux' + // WHY we store `null` instead of falling back to `'tmux'`: + // A bare command name would get PATH-searched by node-pty / + // child_process.spawn. The bundled-only policy says: if we + // could not resolve a bundled tmux, do not run any tmux. A + // sentinel like `'tmux-bundled-missing'` would still get + // PATH-searched (and in the unlikely case of a name collision + // would invoke whatever happened to be on PATH). Storing null + // and short-circuiting at every call site is the clean + // version. + this.tmuxBinary = options.tmuxBinary ?? null } /** @@ -39,11 +57,20 @@ export class TmuxRegistry { * lifetime of the registry — tmux doesn't get installed mid-session * in any realistic scenario, and re-checking on every spawn would * add latency to every terminal open. + * + * Returns false immediately without spawning anything when no + * tmuxBinary was supplied at construction — that path encodes the + * "bundled tmux unavailable" outcome from the runtime resolver. */ async detectAvailability(): Promise { if (this.availability !== null) return this.availability + if (this.tmuxBinary === null) { + this.availability = false + return false + } + const binary = this.tmuxBinary this.availability = await new Promise(resolve => { - const proc = childSpawn(this.tmuxBinary, ['-V'], { stdio: 'ignore' }) + const proc = childSpawn(binary, ['-V'], { stdio: 'ignore' }) proc.on('error', () => resolve(false)) proc.on('exit', code => resolve(code === 0)) }) @@ -60,6 +87,17 @@ export class TmuxRegistry { return this.availability } + /** Absolute path of the tmux binary the registry uses, or null + * when no bundled tmux was available at construction. SessionManager + * forwards this into TerminalSession so attach-as-child-PTY spawns + * the exact same binary the registry used to create the session. + * Callers should only invoke this AFTER `isAvailable()` returned + * true; the registry's null path is paired with `availability === + * false`, so a non-null return is the practical guarantee here. */ + getBinary(): string | null { + return this.tmuxBinary + } + /** Generate a fresh, unique session name in this registry's namespace. */ generateName(): string { return `${this.namePrefix}${randomUUID()}` diff --git a/src/renderer/src/features/setup/ui/SetupGate.tsx b/src/renderer/src/features/setup/ui/SetupGate.tsx index 2d003c9fb..0705e0f09 100644 --- a/src/renderer/src/features/setup/ui/SetupGate.tsx +++ b/src/renderer/src/features/setup/ui/SetupGate.tsx @@ -7,8 +7,11 @@ import type { SetupToolStatus, } from '@shared/types/setup' +// tmux is no longer listed in the SetupGate because it ships as a +// bundled runtime artifact (#120). mitmdump will follow when its +// cleanup PR lands. Keep this map narrowly typed so a future type +// change to SetupToolId/SetupInstallTarget surfaces here too. const OPTIONAL_INSTALL_TARGET: Partial> = { - tmux: 'tmux', mitmdump: 'mitmproxy', } diff --git a/src/shared/types/setup.ts b/src/shared/types/setup.ts index 3b1f8f809..36434110a 100644 --- a/src/shared/types/setup.ts +++ b/src/shared/types/setup.ts @@ -1,9 +1,18 @@ +// External tools whose presence the setup gate inspects. Bundled +// helpers like mitmdump (#119) and tmux (#120) are deliberately NOT +// listed here — they ship inside Agent Code as runtime artifacts and +// have no user-actionable install story. +// +// mitmdump remains in the union temporarily for the follow-up +// cleanup PR that mirrors what #120 does for tmux; #119's +// implementation kept it visible as "Bundled" for one release while +// we proved the resolver works. Once that follow-up lands the union +// shrinks further. export type SetupToolId = | 'brew' | 'claude' | 'codex' | 'git' - | 'tmux' | 'mitmdump' /** @@ -34,7 +43,12 @@ export type SetupCheckResult = { tools: Record } -export type SetupInstallTarget = 'tmux' | 'mitmproxy' +// Targets the SetupGate's "Install via Homebrew" button can hand to +// the main process. tmux was removed when its bundled binary became +// the only supported source (#120); mitmproxy is on the same track +// in a follow-up. The follow-up will collapse this to `never` and +// retire the `setup:install` IPC entirely. +export type SetupInstallTarget = 'mitmproxy' export type SetupInstallResult = { ok: boolean diff --git a/third_party/tmux/README.md b/third_party/tmux/README.md index 5dafdd89c..9a5ed6553 100644 --- a/third_party/tmux/README.md +++ b/third_party/tmux/README.md @@ -1,81 +1,85 @@ # third_party/tmux -Pinned runtime artifact: a fully **static** `tmux` binary, shipped with -packaged Agent Code builds so terminal-persistence (tmux-backed terminal -panes) works on user machines without any Homebrew install. +Pinned runtime artifact: a `tmux` binary, shipped with packaged +Agent Code so terminal-pane persistence works on user machines +without any Homebrew install. -See issue [#120](https://github.com/Juliusolsson05/agent-code/issues/120) for -the full plan. +See issue [#120](https://github.com/Juliusolsson05/agent-code/issues/120) +for the bundling plan; companion to [#119](https://github.com/Juliusolsson05/agent-code/issues/119) +which did the same thing for `mitmdump`. -## Why static, not Homebrew +## Source -The Homebrew `tmux` binary dynamically links three Homebrew dylibs -(`libevent_core`, `libncursesw`, `libutf8proc`). Copying `/opt/homebrew/bin/tmux` -into the app would fail to launch on a user machine that doesn't have those -exact Homebrew paths. The two reasonable options are: +Upstream [`tmux/tmux-builds`](https://github.com/tmux/tmux-builds) — +the official prebuilt-binary repo maintained by the tmux project +itself. macOS builds link only to `/usr/lib/libSystem.B.dylib` and +`/usr/lib/libresolv.9.dylib`, which are guaranteed present on every +macOS install. No Homebrew dependencies. -1. Build static (chosen) — single ~1.5–2 MB binary, no external deps. -2. Copy Homebrew binary + dylibs and rewrite `install_name`s — bigger, more - moving parts, fragile under codesigning. +We deliberately do NOT roll our own static build: -This directory captures option 1. +- The upstream project already maintains a tested build pipeline. +- Their builds are reproducible from their CI. +- A custom build would mean owning libevent + ncurses + utf8proc + source pins, configure flags, terminfo data handling, and codesign + — all for a 1.6 MB binary that already exists upstream. + +The lifecycle proof script that originally motivated the static-build +investigation (`tmux -V`, `new-session`, `send-keys`, `capture-pane`, +`kill-session` all work without external dependencies, and `tput +cols` inside the session returns a real number proving terminfo is +healthy) passed cleanly against the upstream binary, so we adopt it +as-is. ## What is committed ``` third_party/tmux/ - manifest.json pinned tmux + libevent + ncurses + utf8proc versions + manifest.json pinned version, per-arch archive sha256 + bytes README.md this file - LICENSE.md tmux ISC license (plus deps' license summary) - .gitignore keeps cache/ and build/ out of git + LICENSE.md tmux ISC license + .gitignore keeps cache/ out of git ``` ## What is NOT committed -``` -third_party/tmux/cache/ final static tmux binary per platform/arch -third_party/tmux/build/ transient build inputs/outputs during a static build -``` - -## Status (PR 1: scaffolding) +The downloaded archive and the extracted binary live under +`third_party/tmux/cache/-/` and are produced on +demand by `scripts/runtime-tools/fetch-tmux.mjs`. Binaries never +enter git; the manifest plus the fetch script is the single source +of truth. -The manifest currently has `sha256` placeholders set to `TBD-PR3`. The actual -static build script and source-archive hashes land in PR 3 (issue #120). -PR 1 only establishes the layout, manifest schema, and verifier scaffolding so -PR 3 doesn't have to bikeshed any of that. - -## How it will work after PR 3 +## How to fetch / verify locally ``` -npm run runtime:build:tmux +npm run runtime:fetch:tmux npm run runtime:verify ``` -`build-tmux.mjs` will: +`fetch-tmux.mjs` downloads the pinned archive into a temp location, +verifies the sha256 against `manifest.json`, extracts the inner +`tmux` executable into `third_party/tmux/cache/-/tmux`, +sets the executable bit, and discards the archive. The verify +script re-checks the cache without re-downloading. -1. Download pinned source tarballs for tmux + libevent + ncurses + utf8proc. -2. Verify each source against `manifest.sources..sha256`. -3. Configure each with `--enable-static --disable-shared`, build, and link - tmux against the static libs. -4. Verify the final binary contains no Homebrew dylib references - (`otool -L bundled/tmux` must show only system libraries). -5. Write the binary to `cache/-/tmux` and compute its sha256. +## How version bumps work -Before declaring success it also runs a lifecycle proof: +A version bump is a single-file PR to `manifest.json`: -``` -tmux -V -otool -L cache/-/tmux # must show no /opt/homebrew paths -tmux -L agent-code-build-test -f /dev/null new-session -d -s t /bin/zsh -tmux -L agent-code-build-test send-keys -t t 'echo $TERM; tput cols' Enter -tmux -L agent-code-build-test capture-pane -t t -p -tmux -L agent-code-build-test kill-session -t t -``` +1. Edit `version`. +2. Recompute each archive sha256 from the corresponding + `tmux--macos-.tar.gz` asset on the matching + `tmux/tmux-builds` release. +3. Update `bytes` to match. +4. CI re-runs `fetch` + `verify` against the new hashes. -If any of those fail, the build fails — we never ship a broken static tmux. +That keeps the binary out of git history while making upgrades +trivial to review. ## License -tmux is ISC licensed. `libevent`, `ncurses`, and `utf8proc` ship under -BSD-style / MIT-style licenses. All upstream license texts are reproduced in -`LICENSE.md` so Agent Code's distribution complies with attribution. +tmux is ISC licensed; the full text is reproduced in `LICENSE.md`. +The upstream `tmux/tmux-builds` releases also ship a +`LICENSES.tar.gz` covering the build-dependency licenses (libevent, +ncurses, utf8proc); when we update the bundled binary we re-vet +those licenses match what we ship. diff --git a/third_party/tmux/manifest.json b/third_party/tmux/manifest.json index 8f814477c..338ae0a50 100644 --- a/third_party/tmux/manifest.json +++ b/third_party/tmux/manifest.json @@ -1,31 +1,21 @@ { "tool": "tmux", "version": "3.6a", - "build": "static", - "sources": { - "tmux": { - "version": "3.6a", - "url": "https://github.com/tmux/tmux/releases/download/3.6a/tmux-3.6a.tar.gz", - "sha256": "TBD-PR3" - }, - "libevent": { - "version": "2.1.12-stable", - "url": "https://github.com/libevent/libevent/releases/download/release-2.1.12-stable/libevent-2.1.12-stable.tar.gz", - "sha256": "TBD-PR3" - }, - "ncurses": { - "version": "6.5", - "url": "https://invisible-mirror.net/archives/ncurses/ncurses-6.5.tar.gz", - "sha256": "TBD-PR3" + "urlBase": "https://github.com/tmux/tmux-builds/releases/download/v{version}", + "archiveFormat": "tar.gz", + "executableInsideArchive": "tmux", + "platforms": { + "darwin-arm64": { + "filename": "tmux-{version}-macos-arm64.tar.gz", + "sha256": "12b5b9f8696e1286897d946649c0a80d0169dd76e018d34476a1fbd34de89a0f", + "bytes": 623838, + "binarySha256": "222c271765051c8c9131584ce3ead1725d51f6a53ba173b9fa035ef1b4f782f8" }, - "utf8proc": { - "version": "2.10.0", - "url": "https://github.com/JuliaStrings/utf8proc/releases/download/v2.10.0/utf8proc-2.10.0.tar.gz", - "sha256": "TBD-PR3" + "darwin-x86_64": { + "filename": "tmux-{version}-macos-x86_64.tar.gz", + "sha256": "b9b12eaeba43acf5671acf3857d947525440b544185a8db34ea557199a090251", + "bytes": 650501, + "binarySha256": "3dab537348573b371bfc70bda07a75557d85edc0c5a630def04c86308fb9fd41" } - }, - "platforms": { - "darwin-arm64": { "sha256": "TBD-PR3" }, - "darwin-x86_64": { "sha256": "TBD-PR3" } } }