Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 0 additions & 26 deletions scripts/runtime-tools/build-tmux.mjs

This file was deleted.

271 changes: 271 additions & 0 deletions scripts/runtime-tools/fetch-tmux.mjs
Original file line number Diff line number Diff line change
@@ -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/<platform>-<arch>/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)
})
Loading