diff --git a/docs/users/features/code-review.md b/docs/users/features/code-review.md index 33de06032d0..0c3901a1b90 100644 --- a/docs/users/features/code-review.md +++ b/docs/users/features/code-review.md @@ -252,6 +252,8 @@ Confirmed findings are canonicalized into `.qwen/tmp/qwen-review--findin The command validates on write: a duplicate id, a finding with no failure scenario, an empty locations array, or an unknown severity is an error rather than a silently mangled entry. +Where do the images come from? For terminal-rendering claims, `qwen review capture-tui` drives the code under review in a **private tmux server** (it cannot touch your own tmux sessions), captures the pane bytes as `.ans`, and renders a `.png` via `freeze` when installed — degrading explicitly (`png` → `ans-only` → refused) and recording which rung it reached, because a verifier must say whether its verdict stands on pixels, bytes, or prose. + ## Evidence Images in PR Comments GitHub's API cannot attach images to review comments, so `/review` can host evidence images (TUI screenshots, rendered-output comparisons) in a repository you designate and embed them by URL: diff --git a/packages/cli/src/commands/review.test.ts b/packages/cli/src/commands/review.test.ts index 6bad1023cc5..62d40e1894f 100644 --- a/packages/cli/src/commands/review.test.ts +++ b/packages/cli/src/commands/review.test.ts @@ -48,6 +48,7 @@ describe('reviewCommand', () => { 'comment-body', 'fetch-pr', 'capture-local', + 'capture-tui', 'plan-diff', 'repo-context', 'pr-context', diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 2eb6a46ee45..c52e3f09606 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -16,6 +16,7 @@ import { findingsCommand } from './review/findings.js'; import { recoverFindingsCommand } from './review/recover-findings.js'; import { fetchPrCommand } from './review/fetch-pr.js'; import { captureLocalCommand } from './review/capture-local.js'; +import { captureTuiCommand } from './review/capture-tui.js'; import { planDiffCommand } from './review/plan-diff.js'; import { repoContextCommand } from './review/repo-context.js'; import { prContextCommand } from './review/pr-context.js'; @@ -65,6 +66,7 @@ export const reviewCommand: CommandModule = { .command(commentBodyCommand) .command(fetchPrCommand) .command(captureLocalCommand) + .command(captureTuiCommand) .command(planDiffCommand) .command(repoContextCommand) .command(prContextCommand) @@ -98,7 +100,7 @@ export const reviewCommand: CommandModule = { .command(cleanupCommand) .demandCommand( 1, - 'Specify a subcommand: run, parse-args, match-remote, meta, issue-context, fetch-diff, comment-body, fetch-pr, capture-local, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, emit-workflow, build-test, base-tree, scratch-tree, test-delta, drive, ab-drive, mock-provider, extract-step, script-lint, dedup-candidates, revert-hunk, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, recover-findings, publish-assets, compose-review, save-artifact, submit, or cleanup.', + 'Specify a subcommand: run, parse-args, match-remote, meta, issue-context, fetch-diff, comment-body, fetch-pr, capture-local, capture-tui, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, emit-workflow, build-test, base-tree, scratch-tree, test-delta, drive, ab-drive, mock-provider, extract-step, script-lint, dedup-candidates, revert-hunk, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, recover-findings, publish-assets, compose-review, save-artifact, submit, or cleanup.', ) .version(false), handler: () => { diff --git a/packages/cli/src/commands/review/agent-prompt.test.ts b/packages/cli/src/commands/review/agent-prompt.test.ts index def2fdd3540..b99254e2040 100644 --- a/packages/cli/src/commands/review/agent-prompt.test.ts +++ b/packages/cli/src/commands/review/agent-prompt.test.ts @@ -4230,6 +4230,13 @@ describe('verify and reverse-audit briefs — the Step 4/5 methodology, in code' expect(p).toContain('go read the claimed source first'); }); + it('wires capture-tui into the verify brief — the producer its rendering claims cite', () => { + // The brief teaches terminal-rendering claims to run `review capture-tui`; + // if the block drops out of the prompt the command exists with no consumer. + const p = buildRoleBrief(PLAN, 'verify'); + expect(p).toContain('"${QWEN_CODE_CLI:-qwen}" review capture-tui'); + }); + it('the verify brief carries the #9789 do-not-refute list and the constructible rejection bar', () => { // The recall leak the finder-side RECALL rule closes has a verifier half: // "silence is better than noise" read as a confidence bar lets Step 4 drop diff --git a/packages/cli/src/commands/review/capture-tui.test.ts b/packages/cli/src/commands/review/capture-tui.test.ts new file mode 100644 index 00000000000..02c99b1c08d --- /dev/null +++ b/packages/cli/src/commands/review/capture-tui.test.ts @@ -0,0 +1,6563 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + constants as fsConstants, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, + symlinkSync, + lstatSync, +} from 'node:fs'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { + ARTIFACT_OPEN_FLAGS, + isSameSocket, + captureTuiCommand, + freezeRender, + hostStateFor, + MATCH_BUDGET_MS, + holderInit, + probeBudget, + probes, + REAP_SIGNALS, + runCaptureTui, + tmuxControl, +} from './capture-tui.js'; +import { + captureServerName, + tmuxSupportsCaptureN, + tmuxPadsWithCaptureN, +} from './lib/tui-capture.js'; + +const tmuxVersionProbe = spawnSync('tmux', ['-V'], { + encoding: 'utf8', + // Same belt as production probeOutput: a hanging shimmed binary here + // blocks the whole file at import time with no red test naming the cause. + timeout: 10_000, + killSignal: 'SIGKILL', +}); +// The suite needs capture-pane -N (tmux 3.1+); on an older tmux every +// capture would refuse with "too old", which is a skip-shaped outcome, not +// a red suite. +const hasTmux = + tmuxVersionProbe.status === 0 && + tmuxSupportsCaptureN(tmuxVersionProbe.stdout ?? '') !== false; +// --help, not --version: freeze <=0.1.6 has no --version flag and would be +// misdiagnosed as absent (mirrors the production probe). +const hasFreeze = + spawnSync('freeze', ['--help'], { timeout: 10_000, killSignal: 'SIGKILL' }) + .status === 0; +// The server-death and signal probes need pgrep; without it they would parse +// pid 0 and fail red on healthy code. error === undefined distinguishes +// "binary absent" from "no match" (a --version gate would misfire on BSD +// pgrep, which has none). +const hasPgrep = + spawnSync('pgrep', ['-f', 'no-such-process-anywhere'], { + timeout: 10_000, + killSignal: 'SIGKILL', + }).error === undefined; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Capture the stdio written during `fn` — the refusal REASON is part of + * the contract, not just the exit code: two different refusal paths share + * the exit-3/no-artifacts shape, and only the reason tells them apart; and + * an agent consumer parses the refusal JSON from stdout, not stderr. */ +async function withStdio( + fn: () => Promise, +): Promise<{ stdout: string; stderr: string }> { + const sinks = { stdout: '', stderr: '' }; + const capture = (stream: 'stdout' | 'stderr') => + vi.spyOn(process[stream], 'write').mockImplementation((( + chunk: string | Uint8Array, + ) => { + sinks[stream] += + typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + return true; + }) as never); + const outSpy = capture('stdout'); + const errSpy = capture('stderr'); + try { + await fn(); + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + } + return sinks; +} + +// The no-tmux refusal fires exactly where the real-tmux block below is +// skipped, so it gets its own suite that runs EVERYWHERE, driving the probe +// seam instead of the real binary: a refactor that inverts the probe must +// fail here, not surface as a raw ENOENT on some tmux-less host. +// The manifest a PREVIOUS run of this command actually wrote — the only +// thing the clear phase now accepts as its own. A bare `{"evidence":"png"}` +// no longer qualifies, and must not: a user's own JSON carrying that field +// authorized deleting the files beside it (probe-reproduced). +function staleManifest(outBase: string, evidence = 'png'): string { + return JSON.stringify({ + command: 'printf hi', + cwd: '/tmp', + cols: 80, + rows: 24, + ansPath: `${outBase}.ans`, + // An ans-only run (the degraded freeze rung) records a NULL pngPath, + // exactly as the command writes it — so it cannot authorize deleting + // anything at the png name. + pngPath: evidence === 'png' ? `${outBase}.png` : null, + evidence, + settledBy: 'fixed-delay', + }); +} + +// `.holder-ready` is no longer the sentinel — it lives per-pid under +// the system temp dir — so asserting that path is absent proves nothing. +// This is the assertion that still has teeth: nothing of ours outlives the +// run where the sentinel really is. +/** Absolute path to capture-tui.ts, for the child drivers. Was copy-pasted + * at six sites, five of which failed SILENTLY when neither candidate + * existed: the driver then imported nothing and the test read as a passing + * run of a command it never invoked. One place, one loud failure. */ +function captureTuiSource(): string { + const candidates = [ + join(process.cwd(), 'src/commands/review/capture-tui.ts'), + join(process.cwd(), 'packages/cli/src/commands/review/capture-tui.ts'), + ]; + const found = candidates.find((p) => existsSync(p)); + if (!found) { + throw new Error( + `capture-tui.ts not found for the child driver; tried:\n ${candidates.join('\n ')}`, + ); + } + return found; +} + +function leakedSentinels(pid: number = process.pid): string[] { + return readdirSync(tmpdir()).filter((f) => + f.startsWith(`qwen-capture-ready-${pid}-`), + ); +} + +describe('hostStateFor', () => { + // Five of these six arms were asserted nowhere: reaching them through + // the real syscalls needs a fault injector, so deleting any one shipped + // green while the refusal blamed the caller's --out for the host. + it.each([ + ['EMFILE', 'out of file descriptors'], + ['ENFILE', 'system file table is full'], + ['ENOSPC', 'filesystem is full'], + ['EDQUOT', 'disk quota is exhausted'], + ['EROFS', 'filesystem is read-only'], + ['EIO', 'I/O errors'], + ['ESTALE', 'network filesystem handle is stale'], + ])('names the host state for %s', (code, expected) => { + expect(hostStateFor(code)).toContain(expected); + }); + + it('says nothing for a code that IS about the argument', () => { + // ENOENT/EACCES/EISDIR are answers about --out itself, and must keep + // the '--out is not writable' wording rather than blaming the host. + for (const code of ['ENOENT', 'EACCES', 'EISDIR', undefined]) { + expect(hostStateFor(code)).toBeNull(); + } + }); +}); + +describe('capture-tui without tmux (probe seam)', () => { + const realTmux = probes.tmux; + beforeEach(() => { + process.exitCode = undefined; + }); + afterEach(() => { + probes.tmux = realTmux; + process.exitCode = undefined; + }); + + it('refuses with the contract — exit 3, no artifacts, the RIGHT reason', async () => { + probes.tmux = () => ({ status: 'absent' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-notmux-')); + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + // The reason pins the PATH taken: an inverted probe would fall through + // to the mid-capture catch and say "tmux failed mid-capture" instead. + expect(stderr).toContain('tmux is not installed'); + // The refusal JSON rides on stdout too: an agent consumer must not + // have to scrape stderr to tell WHY the ladder stopped at none. + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('tmux is not installed'), + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'refuses an unusable TMPDIR up front, naming it', + async () => { + // The sentinel lives under the system temp dir now, and nothing probed + // that directory: an unusable TMPDIR burned the whole --timeout-ms + // waiting for a holder that could never signal ready, then blamed the + // capture. The dir is created BEFORE TMPDIR is overridden — mkdtemp + // reads the same variable. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-badtmp-')); + // Seeded, and call-logged: the elapsed pin below catches only the + // gate-DELETED mutant (the old ready-wait burn). A gate MOVED below + // plan.start refuses just as fast, having started a real server and + // run the user's command first — and leaves the previous run's + // evidence:"png" manifest beside the refusal if it also moved above + // the clear. Both are what the sibling gate families pin. + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLog = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$*" >> "${callLog}"\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + const realTmp = process.env['TMPDIR']; + process.env['TMPDIR'] = join(dir, 'no-such-temp-dir'); + const started = performance.now(); + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + // Generous on purpose: the point is that it refuses in + // milliseconds instead of sitting out the ready deadline. + timeoutMs: 60_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('temporary directory is not usable'); + expect(stderr).toContain('TMPDIR'); + expect(performance.now() - started).toBeLessThan(10_000); + // Nothing started... + const calls = existsSync(callLog) ? readFileSync(callLog, 'utf8') : ''; + expect(calls).not.toContain('new-session'); + // ...and the clear ran first, like every sibling gate. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmp === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = realTmp; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'refuses a file-shaped TMPDIR at the gate, not at the sentinel removal', + async () => { + // The existing-but-unusable shapes the nonexistent-dir test cannot + // reach: `force` suppresses ENOENT only, so the clear-phase sentinel + // removal threw ENOTDIR here and the --out-attributing catch claimed + // it ('--out is not writable: ENOTDIR'), shadowing the dedicated gate + // — and the gate itself checked only W_OK|X_OK, which a mode-0777 + // regular file PASSES (probe-verified), so without the directoryness + // check the run burns the full holder-init window and then blames + // the pane ('capture never started'), naming TMPDIR nowhere. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-filetmp-')); + // Created BEFORE TMPDIR is overridden — mkdtemp reads the same + // variable (same discipline as the nonexistent-dir sibling). + const fileTmp = join(dir, 'tmp-as-file'); + writeFileSync(fileTmp, 'not a directory'); + chmodSync(fileTmp, 0o777); + const realTmp = process.env['TMPDIR']; + process.env['TMPDIR'] = fileTmp; + const started = performance.now(); + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 60_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('temporary directory is not usable'); + expect(stderr).toContain('TMPDIR'); + expect(stderr).not.toContain('--out is not writable'); + // Refused in milliseconds, not after the holder-init window: the + // sail-through mutant pays the full 10s before blaming the pane. + expect(performance.now() - started).toBeLessThan(5_000); + } finally { + if (realTmp === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = realTmp; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'refuses an over-long tmux socket path up front, naming it', + async () => { + // A unix socket path is capped by sockaddr_un (104 bytes on macOS, + // 108 on Linux). Over it, the server START succeeds and the first + // control call fails with "error connecting to … (File name too + // long)" — a mid-capture refusal that blames tmux for a path this + // command chose, after paying for the start. Found by making this + // suite's own TMUX_TMPDIR test assert success: it had been passing + // over exactly this failure. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-longsock-')); + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + // CREATED, not just named: tmux only uses a base it can use, and an + // unusable one falls back to /tmp — where the path is short and the + // capture would have been fine. + // Sized AGAINST THE CONSTANT, not just "very long": the previous + // fixture overflowed by ~100 bytes, so any mutant bound in roughly + // [104, 197] refused it too and the gate's defining 103 was + // undiscriminated. This lands exactly one byte over the gate's 103 + // bound (a 104-byte path), built from the same pieces production + // measures — the `> 104` off-by-one admits it and this refusal goes + // missing. + const socketTail = `/tmux-${process.getuid?.() ?? 0}/${captureServerName( + process.pid, + 'deadbeef', + )}`; + const pad = Math.max(1, 103 - Buffer.byteLength(dir) - socketTail.length); + const longBase = join(dir, 'x'.repeat(pad)); + mkdirSync(longBase, { recursive: true, mode: 0o700 }); + process.env['TMUX_TMPDIR'] = longBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('too long for a unix socket'); + expect(stderr).toContain('TMUX_TMPDIR'); + expect(stderr).not.toContain('mid-capture'); + } finally { + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'measures the CANONICAL socket base — a lengthening symlink cannot slip past the gate', + async () => { + // tmux resolves a symlinked base before it binds, and the sockaddr + // bound applies to the CANONICAL path: a lexical measure admitted + // this run and the start then failed mid-capture with ENAMETOOLONG, + // blaming tmux for a path this command chose (probe-verified on 3.4; + // macOS meets the default shape, /tmp -> /private/tmp). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-gatel-')); + const deep = join(dir, 'y'.repeat(50), 'y'.repeat(50)); + mkdirSync(deep, { recursive: true, mode: 0o700 }); + const link = join(dir, 'link'); + symlinkSync(deep, link); + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['TMUX_TMPDIR'] = link; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('too long for a unix socket'); + expect(stderr).not.toContain('mid-capture'); + } finally { + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'admits a run whose CANONICAL socket path fits a lexical path that does not', + async () => { + // The converse arm of the gate above: a long lexical path through a + // deep link whose TARGET is short fits the sockaddr bound once + // canonicalized — the lexical measure refused a capture that was + // about to succeed (probe-verified on 3.4). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-gates-')); + const real = join(dir, 'real'); + mkdirSync(real, { mode: 0o700 }); + const deep = join(dir, 'z'.repeat(60), 'z'.repeat(60)); + mkdirSync(deep, { recursive: true }); + const link = join(deep, 'link'); + symlinkSync(real, link); + writeFakeTmux(dir, ' :'); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = link; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('refuses arguments past the manifest cap measured in BYTES, not code units', async () => { + // The reader caps a manifest at MAX_MANIFEST_BYTES of UTF-8, so the + // writer gate must measure the same unit: multibyte arguments between + // half the cap in CHARACTERS and the cap in BYTES used to pass it and + // wrote a manifest the next run could not verify — artifacts no longer + // clearable, every re-run refused on the collision (probe-reproduced + // with CJK --keys tokens). 180k CJK chars are one UTF-16 code unit + // each (well under half the cap) and three UTF-8 bytes each (over it). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-bigargs-')); + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: ['\u4e00'.repeat(180_000)], + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('would not fit a readable manifest'); + // The refusal JSON rides on stdout too, like every sibling refusal. + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('would not fit a readable manifest'), + }); + // The gate fires before anything starts. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses small tokens that pass DENSE but overflow the cap pretty-printed', async () => { + // The writer emits JSON.stringify(manifest, null, 2); the gate used to + // measure the DENSE serialization. Pretty-printing an array of many + // small elements expands ~2.25x: 130k one-char --keys tokens measure + // ~520kB dense (under the gate) and ~1.17MB pretty — past the reader + // cap — so the run passed, wrote a manifest its own next run could not + // verify, and every re-run against the same --out refused on the + // collision instead (probe-reproduced). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-prettyargs-')); + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + // Never matches: on pre-gate code the run proceeds, and withheld + // keys keep that run fast instead of typing 130k send-keys calls. + ready: 'NEVER-MATCHES', + keys: Array.from({ length: 130_000 }, () => 'k'), + out: join(dir, 'cap'), + timeoutMs: 500, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('would not fit a readable manifest'); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('would not fit a readable manifest'), + }); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses the --no-keys negation with the reason that names it', async () => { + // yargs's default boolean-negation parses `--no-keys` on the + // array-typed option to [false] (probed on this repo's yargs): the + // caller supplied no key tokens at all, and the refusal must not say + // their tokens have the wrong type — an agent consumer would go + // inspect tokens it never passed and retry unchanged. Every sibling + // flag's negation gets the accurate "given exactly once" message. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-nokeys-')); + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: [false], + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('--keys must be given exactly once'); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: '--keys must be given exactly once, as strings.', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // The png rung's mid-window occupancy, driven with NO real tmux: a PATH + // shim answers every control call — planting at .png where the + // pane's command would — and the freeze seam does the render. The + // ladder's occupancy decision runs identically, so these pins also work + // on tmux-less hosts where the real-tmux planter fixtures skip. + function writeFakeTmux(dir: string, plant: string): void { + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then +${plant} + s=$(printf '%s\\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then exit 0; fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + } + + // win32: `resolveOnPath` splits PATH on ':' and requires an absolute POSIX + // element, which no Windows PATH satisfies — the whole command refuses on + // the tmux probe long before that matters in production, but this test + // drives the resolver directly and would fail red on the Windows lane. + it.skipIf(process.platform === 'win32')( + 'resolves sleep to an absolute executable path', + () => { + // The plan embeds whatever this answers, so a walker that returns a bare + // name or a directory would put one back in the pane's hands — silently, + // because the holder only fails under a PATH that lacks sleep. + const resolved = probes.sleepBin(); + expect(resolved).toBeDefined(); + expect(resolved?.startsWith('/')).toBe(true); + expect(statSync(resolved as string).isFile()).toBe(true); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'skips PATH elements that are not absolute — the answer cannot depend on cwd', + () => { + // POSIX reads an EMPTY element as the current directory and resolves + // relative ones against it; execvp honours both. The capture's cwd is + // the REVIEWED WORKTREE, so either rule lets the PR under review + // supply the binary the holder runs. A relative answer is also + // re-resolved against the PANE's own `--cwd`, which puts the lookup + // back exactly where resolving it here exists to take it from. + const root = mkdtempSync(join(tmpdir(), 'capture-tui-pathrel-')); + const before = process.cwd(); + const realPath = process.env['PATH']; + try { + mkdirSync(join(root, 'rel'), { recursive: true }); + writeFileSync(join(root, 'rel', 'sleep'), '#!/bin/sh\nexit 0\n', { + mode: 0o755, + }); + process.chdir(root); + // Both non-absolute shapes, and a `sleep` execvp would find through + // either of them. + process.env['PATH'] = ':rel'; + expect(probes.sleepBin()).toBeUndefined(); + // The SAME directory named absolutely is taken — so this pins the + // element's shape, not the planted file. + process.env['PATH'] = join(root, 'rel'); + expect(probes.sleepBin()).toBe(join(root, 'rel', 'sleep')); + } finally { + process.chdir(before); + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'never probes a tmux the reviewed tree put in the way', + () => { + // The probe used to spawn a BARE name, and execvp honours the + // empty-element rule the resolver refuses: an executable named `tmux` + // committed to the PR would answer this probe and then every control + // call after it — the attacker would BE the tmux, authoring the .ans + // bytes and the manifest that the verdict machinery reads as + // rendering evidence, with every `-L` scoping defence downstream of a + // binary this command no longer chose. + const root = mkdtempSync(join(tmpdir(), 'capture-tui-pathplant-')); + const before = process.cwd(); + const realPath = process.env['PATH']; + try { + writeFileSync( + join(root, 'tmux'), + '#!/bin/sh\necho "tmux 9.9-PLANTED"\nexit 0\n', + { mode: 0o755 }, + ); + mkdirSync(join(root, 'empty'), { recursive: true }); + process.chdir(root); + // A legal PATH carrying an empty element, and nothing on it that + // holds tmux. + process.env['PATH'] = `:${join(root, 'empty')}`; + expect(probes.tmux()).toEqual({ status: 'absent' }); + // Control: the same plant reached through an ABSOLUTE element still + // answers — which is what every fake-tmux fixture in this file is — + // so the refusal above is about the element, not the file. + process.env['PATH'] = root; + expect(probes.tmux()).toEqual({ + status: 'ok', + out: 'tmux 9.9-PLANTED', + }); + } finally { + process.chdir(before); + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it('identifies the stamped socket by more than its inode', () => { + // An inode is not a durable name for a file: ext-family allocators hand + // the freed number straight back on an immediate same-directory + // recreate (measured 5/5 on a review host), so `rm` + recreate at the + // socket path read as "still ours" and a verdict about the REPLACEMENT + // was credited — the reap crediting a goal state about a socket this + // run never owned. Pinned here rather than through a capture, because a + // test cannot ask a filesystem to reuse an inode on demand: every one + // a fixture can rely on hands out a fresh one, so the widened + // comparison has no behavioural arm to reach it. + const stamp = { ino: 7214321, mode: 0o140700, mtimeMs: 1_700_000_000_000 }; + expect(isSameSocket(stamp, { ...stamp })).toBe(true); + // The inode-reuse shape: same number, different file. + expect(isSameSocket(stamp, { ...stamp, mtimeMs: stamp.mtimeMs + 1 })).toBe( + false, + ); + // A regular file recreated where a socket stood keeps neither. + expect(isSameSocket(stamp, { ...stamp, mode: 0o100644 })).toBe(false); + // And the inode alone still counts. + expect(isSameSocket(stamp, { ...stamp, ino: stamp.ino + 1 })).toBe(false); + }); + + it('opens artifact writes non-blocking — a FIFO must refuse, not wedge', () => { + // The one flag here that a behavioural test cannot reach: O_NONBLOCK + // only decides the outcome when a FIFO lands in the microseconds + // between changed()'s lstat and the open, and a FIFO at any other + // moment is caught by changed() as the occupant it is. Without it, + // open(O_WRONLY) on a FIFO waits for a reader forever — on the main + // thread, inside a synchronous syscall — so the machine-read refusal + // contract breaks entirely and only an external SIGKILL ends the run. + // Pinned at the flag set, which is the thing an edit would drop. + expect(ARTIFACT_OPEN_FLAGS & fsConstants.O_WRONLY).toBeTruthy(); + expect(ARTIFACT_OPEN_FLAGS & fsConstants.O_CREAT).toBeTruthy(); + expect(ARTIFACT_OPEN_FLAGS & fsConstants.O_TRUNC).toBeTruthy(); + // Gated the way PRODUCTION degrades, not by skipping the whole test: + // Windows exposes neither constant (node documents the eight it has), + // and the flag set is built with `?? 0` for exactly that — so asserting + // them unconditionally reddens the Windows lane over two flags that + // legitimately contribute nothing there, while the three above stay + // meaningful on every platform. + for (const flag of [fsConstants.O_NOFOLLOW, fsConstants.O_NONBLOCK]) { + if (typeof flag !== 'number') continue; + expect(ARTIFACT_OPEN_FLAGS & flag).toBeTruthy(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'visits a base once even when two candidate strings name it', + async () => { + // The candidates are the env base and `/tmp`, and they were + // de-duplicated as STRINGS — so `/tmp/`, `/tmp/.` or a TMUX_TMPDIR + // symlinked to /tmp produced two visits to one base. That was + // harmless while identity was a pre-loop snapshot; it is not now that + // the verdict re-reads it, because the first visit credits and + // UNLINKS, and the second would read the socket this reap had just + // removed as a swap and warn about an orphan it reaped itself. Same + // rule cleanup.ts's sweep already states for its own scan. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-alias-')); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const killLog = join(dir, 'kills'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + mkdir -p "\${TMUX_TMPDIR}/tmux-$(id -u)" + : > "\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\n' "$TMUX_TMPDIR" >> '${killLog}' + exit 0 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + // A trailing slash: a different string, the same directory. The socket + // this creates under the real tmux dir carries this run's own unique + // name and the credited kill unlinks it again. + process.env['TMUX_TMPDIR'] = '/tmp/'; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + const kills = existsSync(killLog) + ? readFileSync(killLog, 'utf8').trim().split('\n') + : []; + expect(kills).toHaveLength(1); + // And the run stays quiet: one visit, credited, nothing left to doubt. + expect(stderr).not.toContain('WARNING'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not let a kill that fell back to /tmp vouch for the start base', + async () => { + // `-L` PINS the socket base in the client's environment; it does not + // bind tmux to it. An unusable base sends the client to /tmp — which + // is why the failure path checks verdictExaminedBase before believing + // a wording — and the success path had no equivalent: an exit 0 from a + // kill aimed at a destroyed base was credited to that base. The + // captured command destroys the base mid-window and binds a + // sacrificial server at this run's unique name under /tmp; the + // fallback kill exits 0, and the run's own server — alive behind its + // removed socket, unreachable by `-L` and invisible to the readdir + // sweep — was orphaned at exit 0 with nothing on stderr. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-fellback-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + // Binds under the START base so the stamp is taken there, destroys + // that base while the capture runs, then answers every kill with + // exit 0 the way a fallback kill against /tmp would. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + mkdir -p "\${TMUX_TMPDIR}/tmux-$(id -u)" + : > "\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then exit 0; fi +done +rm -rf '${envBase}' +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('may still be running'); + // And it says WHICH doubt: an operator told "kill-server failed + // twice" would go looking for a wedged server. + expect(stderr).toContain('could not reach the base this run started'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'reads the stamped identity at VERDICT time, not once before the loop', + async () => { + // The identity arm used to read a snapshot taken before the candidate + // loop — which spans both bases' attempts, a retry and a 15s belt. A + // daemonized survivor of the captured command (this file's documented + // same-uid class; it knows the path from `$TMUX`) waits for the reap, + // renames the LIVE socket away and leaves a creditable occupant at + // the path: the stale snapshot still said "alive", the replacement's + // goal-state verdict was credited, and the entry this capture never + // wrote was unlinked — exit 0, no WARNING, the real server holding + // its pane holder for up to three hours and invisible to the sweep. + // + // The sibling pin ('a socket that is not the stamped one') cannot + // catch this: its own start base is credited either way, and it goes + // red only through the fallback base. Here BOTH bases answer a + // creditable wording, so the verdict-time read is the only thing + // between the swap and a silent orphan. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-verdictid-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + mkdir -p "\${TMUX_TMPDIR}/tmux-$(id -u)" + : > "\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + p="\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" + # The swap lands DURING the kill — after any pre-loop snapshot, before + # the verdict this answer produces. + # By RENAME, not rm-then-create: an inode is not a durable file name — + # ext-family allocators hand the freed number straight back on an + # immediate same-directory recreate, so rm+create can land the swap on + # the SAME inode and the fixture would then be pinning nothing on those + # filesystems while passing on tmpfs/APFS. + if [ "$TMUX_TMPDIR" = "${envBase}" ]; then q="$p.swap"; : > "$q"; rm -f "$p"; mv -f "$q" "$p"; fi + echo "no server running on $p" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('may still be running'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'a start that THREW still warns — the ENOENT wording buys doubt, not death', + async () => { + // The ENOENT class says the socket path was gone when the client + // looked, and nothing else. Two proxies for "so the server never + // existed" were tried here and both conflated: an absent stamp is + // also absent when the stamp failed AFTER a successful start, and + // `startThrew` is also set for a belt-cut start that threw with the + // server already forked and its socket bound — the shape this file + // documents at the start call. On a real tmux, `rm` of a live + // server's socket answers this exact wording while `kill -0` shows it + // alive, and the captured command is the thing that removes it. So + // the class credits nothing on any base: a refusal that cannot prove + // the server is gone says so, at the cost of a warning in the shape + // where it truly never came up. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-startthrew-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + // start FAILS and binds nothing the stamp could see; every kill then + // answers the ENOENT class. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + echo "start refused" >&2 + exit 1 + fi + if [ "$a" = "kill-server" ]; then + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('may still be running'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'never connects the kill on another base when a stamp proves the bind', + async () => { + // A stamp proves the bind was on the start base — the same fact + // `confirmedDead` leans on. So a socket at this run's unique name on + // ANY OTHER candidate base cannot be ours, and the pinned kill must + // not connect to it. Without this the identity arm was gated on the + // start base, and a plain foreign socket renamed onto the name under + // /tmp passed the symlink/nlink tests and took the kill — the user's + // own server destroyed, exit 0, no WARNING. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const uid = String(process.getuid?.()); + const dir = mkdtempSync(join('/tmp', 'capture-tui-crossbind-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const killLog = join(dir, 'kills'); + const srvFile = join(dir, 'srv'); + // Binds under the START base (stamp), records the server name, and + // ALSO drops a foreign entry at this run's name under /tmp — the + // renamed-foreign-socket shape. Every kill-server records the base it + // was invoked under. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + mkdir -p "\${TMUX_TMPDIR}/tmux-${uid}" + : > "\${TMUX_TMPDIR}/tmux-${uid}/$SRV" + printf '%s' "$SRV" > '${srvFile}' + mkdir -p /tmp/tmux-${uid} + : > /tmp/tmux-${uid}/"$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\n' "$TMUX_TMPDIR" >> '${killLog}' + exit 0 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + const killedBases = existsSync(killLog) + ? readFileSync(killLog, 'utf8').trim().split('\n') + : []; + // The start base is killed; the OTHER base (/tmp), where a socket + // stands at this run's name with a stamp proving we did not bind + // there, is NEVER connected to. + // The start base is killed; the OTHER base (/tmp), where a socket + // stands at this run's name with a stamp proving we did not bind + // there, is NEVER connected to — pre-fix it was, destroying it. + expect(killedBases).toContain(envBase); + expect(killedBases).not.toContain('/tmp'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + // Remove the foreign entry this test planted under the real /tmp. + try { + const srv = readFileSync(srvFile, 'utf8').trim(); + if (srv) rmSync(join('/tmp', `tmux-${uid}`, srv), { force: true }); + } catch { + // Nothing planted, or already gone. + } + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not let a kill on another base vouch for the start base', + async () => { + // A successful kill used to establish death GLOBALLY on the strength + // of the server name being unique to this run. Unique is not + // exclusive here: the captured command reads that name from `$TMUX` + // and can bind a sacrificial server under the OTHER candidate base, + // whose exit-0 kill then vouched for a server it never touched and + // silenced the orphan WARNING for the real one. A present stamp + // proves the bind happened on the start base, so a success anywhere + // else cannot be this run's. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-crossbase-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + // Binds under the START base (so the stamp is taken there), then + // answers the start base's kill with the ENOENT class — the shape of + // a live server whose socket was removed — while the OTHER base's + // kill exits 0, as a sacrificial server's would. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + mkdir -p "\${TMUX_TMPDIR}/tmux-$(id -u)" + : > "\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + if [ "$TMUX_TMPDIR" = "${envBase}" ]; then + echo "error connecting to $TMUX_TMPDIR/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi + exit 0 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + // The capture succeeds; what must not happen is the silence. + expect(process.exitCode).toBeUndefined(); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('may still be running'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('refuses a capture whose holder would have no sleep to run', async () => { + // The holder's watchdog (`sleep 10800`) and bounded hold loop + // (`sleep 60` x 180) are what keep the pane open. Resolved bare, they + // went through the PANE's inherited PATH, and under a PATH that finds + // tmux but not sleep the watchdog exited 127 in milliseconds and fell + // straight into `kill -9 -$$` — the whole pane process group SIGKILLed, + // the capture window collapsed to ~0ms, and the evidence read as "the + // command rendered nothing" with nothing naming the cause. Refuse up + // front instead, before any server exists. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realSleepProbe = probes.sleepBin; + probes.sleepBin = () => undefined; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-nosleep-')); + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('sleep is not on PATH'); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('sleep is not on PATH'), + }); + // "Nothing was started" is part of the refusal's claim. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + } finally { + probes.sleepBin = realSleepProbe; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'does not credit an ENOENT verdict when the STAMP merely failed', + async () => { + // An absent stamp is TWO states and only one of them is "start never + // bound a socket": it is also absent when the stamp failed after a + // SUCCESSFUL start (this file's own `startThrew` declaration says so, + // and the captured command can produce it by unlinking the socket it + // reaches through `$TMUX`). Crediting the ENOENT wording there read a + // live server as reaped — exit 0, no WARNING, server and holder + // orphaned for the holder's whole window and invisible to the + // readdir-based sweep, which discovers orphans by the socket that + // this state has already lost. `startThrew` separates the two. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-stampfail-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + // Starts fine and binds NOTHING the stamp can see, then answers every + // kill with the ENOENT class — the shape of a server whose socket is + // gone while the server itself stands. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + // The capture itself still succeeds — this is about what the reap + // is entitled to claim afterwards, not about failing the run. + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'refuses to connect to an entry it has no recorded identity for', + async () => { + // The identity half of the entry guard compares against the stamp, so + // an ABSENT stamp silently disabled it: a plain foreign socket bound + // at this run's own unique name passed "not a symlink" and "one link" + // and took the pinned kill. Absent-stamp is reachable without any + // race — a start that bound under the other base, or a stamp lstat + // that failed — so the check fails closed: an entry standing on the + // start base that this run cannot show is its own is not connected + // to, and the WARNING carries the manual reap command. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-nostamp-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const killLog = join(dir, 'kills'); + // Binds nothing at start (so the stamp finds nothing), then an entry + // APPEARS at the start base while the capture runs. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +SRV=""; prev="" +for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done +for a in "$@"; do + if [ "$a" = "new-session" ]; then + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\n' "$TMUX_TMPDIR" >> '${killLog}' + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +mkdir -p "\${TMUX_TMPDIR}/tmux-$(id -u)" +: > "\${TMUX_TMPDIR}/tmux-$(id -u)/$SRV" +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + const killedBases = existsSync(killLog) + ? readFileSync(killLog, 'utf8').trim().split('\n') + : []; + // The start base carries an entry with no recorded identity: never + // connected to... + expect(killedBases).not.toContain(envBase); + // ...while the base that carries no entry at all is still killed, + // which is what separates a refusal from a reap that never ran. + expect(killedBases).toContain('/tmp'); + expect(stderr).toContain('the reap refused to connect'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'never connects a kill through an entry this capture did not bind', + async () => { + // The reap addresses the socket by NAME and tmux connects to whatever + // that name resolves to. The captured command runs under this uid — + // untrusted code is what a review captures — and knows the path from + // `$TMUX`; a HARD LINK planted there aims the pinned kill-server at + // another server, which dies with exit 0 while `confirmedDead` credits + // the success globally and nothing warns. That is this command's + // headline premise failing, so the entry is inspected first, exactly + // as the sibling sweep in cleanup.ts inspects its own. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const uid = process.getuid?.(); + // Short base by necessity: a unix socket path is capped near 104 bytes, + // and the default mkdtemp parent plus a capture server name overruns it. + const base = mkdtempSync('/tmp/ctui-r111-'); + const sockDir = join(base, `tmux-${String(uid)}`); + mkdirSync(sockDir, { recursive: true, mode: 0o700 }); + // A FOREIGN server's socket — what the planted link would aim at. A + // real one, because the guard's question is the entry's type and link + // count, and a regular file would answer a weaker one. + const foreign = join(sockDir, 'foreign'); + const foreignServer = createServer(); + await new Promise((resolveListen) => { + foreignServer.listen(foreign, () => resolveListen()); + }); + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-r111-')); + const killLog = join(dir, 'kills'); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + // The shim plants at new-session — the earliest moment the server name + // exists — and records the base of every kill it is asked to make. + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + srv=$(printf '%s\\n' "$@" | grep -o 'qwen-review-capture-[0-9]*-[0-9a-f]*' | head -1) + [ -n "$srv" ] && ln '${foreign}' '${sockDir}'/"$srv" + s=$(printf '%s\\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\\n' "$TMUX_TMPDIR" >> '${killLog}' + exit 0 + fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = base; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 5000, + } as never), + ); + const killedBases = existsSync(killLog) + ? readFileSync(killLog, 'utf8').trim().split('\n') + : []; + // The planted base is never connected to... + expect(killedBases).not.toContain(base); + // ...while the untouched fallback base still gets its kill, which is + // what separates "the guard refused" from "the reap never ran". + expect(killedBases).toContain('/tmp'); + // And the entry is left standing: it may BE the foreign socket, + // reached through the link, so unlinking it is not this run's to do. + const planted = readdirSync(sockDir).filter((n) => + n.startsWith('qwen-review-capture-'), + ); + expect(planted).toHaveLength(1); + expect(lstatSync(join(sockDir, planted[0])).nlink).toBe(2); + // The foreign server is untouched — the whole point. + expect(lstatSync(foreign).isSocket()).toBe(true); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + foreignServer.close(); + rmSync(base, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not render THROUGH a symlink planted at .png mid-window', + async () => { + // The png stamp is taken before the window, and the ladder used to + // consult nothing but it: an occupant arriving at .png during + // the window was written through by freeze (following the link out + // of the --out base), the exact escape writeArtifact's changed() + // closes for the .ans and the manifest. The ladder must decide + // occupancy AGAIN at render time. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-pngsym-')); + const outside = join(dir, 'victim.txt'); + writeFileSync(outside, 'VICTIM-CONTENT'); + writeFakeTmux(dir, ` ln -s '${outside}' '${join(dir, 'cap.png')}'`); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync( + freezeBin, + '#!/bin/sh\nprintf \'PNG-BYTES\' > "$5"\nexit 0\n', + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + // The link's target never received the render... + expect(readFileSync(outside, 'utf8')).toBe('VICTIM-CONTENT'); + // ...and the link itself is not ours to remove. + expect(lstatSync(join(dir, 'cap.png')).isSymbolicLink()).toBe(true); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain( + 'holds a file this capture did not write', + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not DELETE a file planted at .png mid-window when the render fails', + async () => { + // The sibling harm on the failed-render cleanup: it removes the png + // only when `changed()` credits it to this run, but with no occupant + // stamped before the window, a file the captured command planted + // mid-window counted as ours and was silently deleted on a run that + // reported success. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-pngplant-')); + writeFakeTmux( + dir, + ` printf 'PLANTED-BY-COMMAND' > '${join(dir, 'cap.png')}'`, + ); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync(freezeBin, '#!/bin/sh\nexit 9\n', { mode: 0o755 }); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe( + 'PLANTED-BY-COMMAND', + ); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain( + 'holds a file this capture did not write', + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'leaves a TORN png when the render fails — deletion cannot attribute it', + async () => { + // Sibling of the plant test above for the occupant THIS run's + // freeze wrote: a torn png at the path the manifest is about to + // deny is indistinguishable from a foreign file claimed during the + // probe/render window — an empty pre-window stamp makes changed() + // reduce to occupied() — and deleting on presence alone destroyed + // the foreign shape (probe-reproduced). The sibling manifest-write + // cleanup already spares the png whenever the render produced + // nothing; the failed-render arm keeps its hands off too and names + // the leftover. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-pngtorn-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync(freezeBin, '#!/bin/sh\nprintf torn > "$5"\nexit 9\n', { + mode: 0o755, + }); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('torn'); + expect(manifest.degradedBecause).toContain('left in place'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'spares a spared png the owner rewrote when the MANIFEST write fails', + async () => { + // The clear phase left the user's png (no manifest proved ownership) + // and the ladder degraded on the stamp — png null, never touched. + // The owner rewriting their own file inside the window then + // answered changed() true, and the manifest-write cleanup deleted a + // file the ladder had classified as not ours: changed() answers + // "the occupant changed", not "this run put it there" + // (probe-reproduced; no adversarial race — the window legally runs + // up to an hour). The plant stands in for the owner's rewrite AND + // for the manifest write failing, so the shape needs no real tmux. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-pngspare-')); + writeFileSync(join(dir, 'cap.png'), 'USER-ORIGINAL'); + writeFakeTmux( + dir, + ` printf 'USER-REWRITTEN-BY-OWNER' > '${join(dir, 'cap.png')}'\n mkdir '${join(dir, 'cap.json')}'`, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('cannot write capture manifest'); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe( + 'USER-REWRITTEN-BY-OWNER', + ); + // The cleanup DID run — the .ans this run wrote is gone with it, + // and the collision occupant is never ours to remove. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(statSync(join(dir, 'cap.json')).isDirectory()).toBe(true); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not land a render THROUGH a symlink planted during the render window', + async () => { + // The pngsym sibling plants its link during the CAPTURE; this one + // plants it inside the render itself — the check-then-write window + // the pre-staging ladder left open (probe-verified on freeze v0.2.2: + // freeze opens the OUTPUT name it is given and follows the link). + // The render writes a nonce'd sibling and lands by rename, and a + // claimant at the landing path degrades the ladder instead of being + // replaced. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-pngland-')); + const outside = join(dir, 'victim.txt'); + writeFileSync(outside, 'VICTIM-CONTENT'); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync( + freezeBin, + `#!/bin/sh +ln -s '${outside}' '${join(dir, 'cap.png')}' +printf 'PNG-BYTES' > "$5" +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(outside, 'utf8')).toBe('VICTIM-CONTENT'); + expect(lstatSync(join(dir, 'cap.png')).isSymbolicLink()).toBe(true); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain( + 'holds a file this capture did not write', + ); + // The nonce'd stage never outlives the run. + expect(readdirSync(dir).filter((f) => f.includes('.render-'))).toEqual( + [], + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'stages the render INPUT under a nonce — freeze never reads the .ans by name', + async () => { + // A swap of the .ans during the probe window fed foreign bytes to a + // by-name render input (probe-verified): the staged hard link pins + // the bytes this run wrote under a name only this run knows. The + // fake refuses to render from the literal .ans name — the + // pre-staging argv fails it. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-ansstage-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync( + freezeBin, + `#!/bin/sh +[ "$3" = '${join(dir, 'cap.ans')}' ] && { echo "render read the .ans by name" >&2; exit 9; } +[ -s "$3" ] || { echo "render input missing" >&2; exit 9; } +printf 'x' > "$5" +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('png'); + expect(readdirSync(dir).filter((f) => f.includes('.render-'))).toEqual( + [], + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'degrades rather than falsely claiming a swap when staging itself fails', + async () => { + // R20-2 set `ansLost` unconditionally in the staging catch, conflating + // a real swap with `linkSync` ITSELF failing — a host with no link() + // (exFAT/FAT/WSL DrvFs), or ENOSPC/EACCES on the stage's directory + // entry. There the .ans this run wrote is intact, so refusing with + // "replaced during the render window" is factually false and wedges + // the --out for every later capture. Identity decides: an intact .ans + // is a stage failure to degrade past, not a swap to refuse over. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-stagefail-')); + writeFakeTmux(dir, ' :'); + const realPath = process.env['PATH']; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + // Make the --out directory unwritable DURING the render window, after + // the .ans is already on disk: the stage linkSync then fails with + // EACCES while .ans stays exactly the bytes this run wrote. (A + // read-only dir also fails the manifest write, so the run still + // refuses — but for the honest reason, never the fabricated swap.) + probes.freeze = () => { + chmodSync(dir, 0o500); + return { status: 'ok', out: '' } as const; + }; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + // The staging failed on an intact .ans — never the false swap claim + // that stranded it and wedged the --out. + expect(stderr).not.toContain('replaced while the render'); + expect(stderr).not.toContain('replaced during the render window'); + } finally { + chmodSync(dir, 0o700); + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'refuses a render input rewritten IN PLACE — the inode never changed', + async () => { + // The sibling below swaps the file; this one rewrites it. The staging + // check pinned its input by inode alone, and an in-place rewrite keeps + // the inode by definition — no allocator reuse needed, nothing to + // race. So an actor that truncates and rewrites .ans inside the + // stamp→link window (which spans the whole freeze availability probe) + // had its bytes staged, rendered and credited at the publishable png + // rung, with a manifest naming both artifacts: a complete evidence + // forgery reported as success. Identity here is now the comparison + // `changed()` and isSameSocket already make — ino, size and mtime — + // and size and mtime are exactly what an in-place rewrite moves. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-inplace-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + const rendered = join(dir, 'freeze-ran'); + writeFileSync( + freezeBin, + `#!/bin/sh\ncat "$3" > "$5"\n: > '${rendered}'\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + let inodeHeld = false; + probes.freeze = () => { + const ans = join(dir, 'cap.ans'); + const before = lstatSync(ans).ino; + // Same file, new bytes — the shape an inode comparison cannot see. + writeFileSync(ans, 'FORGED-EVIDENCE-BYTES-FORGED-EVIDENCE-BYTES'); + inodeHeld = lstatSync(ans).ino === before; + return { status: 'ok', out: '' } as const; + }; + try { + const { stdout } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + // The premise: the inode really did survive the rewrite, so an + // inode-only check would have passed these bytes through. + expect(inodeHeld).toBe(true); + // ino+size+mtime catches it, and a .ans that is no longer this run's + // is no honest evidence: the run REFUSES rather than credit the + // forged bytes or mint the signature that would delete them. + expect(process.exitCode).toBe(3); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('replaced during the render window'), + }); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + // freeze never saw the staged input, and the forged file is left in + // place (not ours to delete), just never credited. + expect(existsSync(rendered)).toBe(false); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe( + 'FORGED-EVIDENCE-BYTES-FORGED-EVIDENCE-BYTES', + ); + expect(readdirSync(dir).filter((f) => f.includes('.render-'))).toEqual( + [], + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + "refuses a symlink swapped in for the .ans before staging — neither platform's link() refuses it", + async () => { + // The staging comment claimed link() refuses a symlinked ansPath + // with ELOOP; neither platform does. Measured on Linux it CLONES the + // link, so a survivor swapping ansPath for a symlink during the probe + // window (a fresh freeze availability probe — seconds, not + // microseconds) staged the link intact and freeze rendered the + // victim's bytes, credited as "evidence": "png" (probe-verified end + // to end). Measured on darwin it FOLLOWS the link instead, staging a + // hard link to the victim's own inode — a regular file, so a + // type-only guard passes it and the same bytes are credited. Identity + // is what refuses both: the stage must name the inode this run's own + // .ans write produced. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-anssym-')); + const victim = join(dir, 'victim.txt'); + writeFileSync(victim, 'FOREIGN-VICTIM-BYTES'); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + const rendered = join(dir, 'freeze-ran'); + writeFileSync( + freezeBin, + // Reads the staged input by name — following a symlink exactly + // like the real freeze (measured on v0.2.2) — and records that it + // ran at all. + `#!/bin/sh\ncat "$3" > "$5"\n: > '${rendered}'\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + // The swap rides the freeze AVAILABILITY probe: it runs after the + // .ans is on disk and before the staging linkSync — the seconds-long + // window the survivor class plants in. + probes.freeze = () => { + rmSync(join(dir, 'cap.ans')); + symlinkSync(victim, join(dir, 'cap.ans')); + return { status: 'ok', out: '' } as const; + }; + try { + const { stdout } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + // A .ans no longer this run's is no honest evidence: the run REFUSES + // rather than credit or delete the foreign file (a manifest crediting + // it would also mint the clear signature that deletes it next run). + expect(process.exitCode).toBe(3); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('replaced during the render window'), + }); + // No manifest is written on a refusal — that is what keeps 'none' out + // of any manifest and off the clear-phase signature. + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + // freeze never saw the staged input, and the victim is untouched. + expect(existsSync(rendered)).toBe(false); + expect(readFileSync(victim, 'utf8')).toBe('FOREIGN-VICTIM-BYTES'); + // The link this run's linkSync staged is this run's to remove. + expect(readdirSync(dir).filter((f) => f.includes('.render-'))).toEqual( + [], + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'a stage replaced by a directory during the render does not mask the capture result', + async () => { + // The stage-removal rmSync pair in the render finally was the only + // unguarded fs operation in runCaptureTui: an actor with write access + // to the --out directory (the class the clear phase and collision + // gate exist for) replacing a stage with a directory mid-render made + // the finally throw EISDIR out of the function — exit 1, a stack + // trace, no contract JSON, and drainSignalsThenRelease never ran. + // Litter is cosmetic; the capture's result is not. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-stagedir-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync( + freezeBin, + `#!/bin/sh +rm -f "$3" && mkdir "$3" +printf 'x' > "$5" +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + const { stdout } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.evidence).toBe('png'); + expect(JSON.parse(stdout)).toMatchObject({ captured: true }); + // The planted directory is another actor's — left in place, never + // recursively deleted, and the run still completed its contract. + const litter = readdirSync(dir).filter((f) => f.includes('.render-')); + expect(litter).toHaveLength(1); + expect(lstatSync(join(dir, litter[0])).isDirectory()).toBe(true); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'records the -T caveat — erased cells still capture as trailing spaces', + async () => { + // -T trims only positions that never held a character; cells written + // and later erased still capture as trailing spaces on the very + // versions that take the flag, and the joined marker view carries + // them mid-line (measured on 3.4) — so the manifest carries the + // caveat the way the pad case does. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-tcaveat-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync(freezeBin, '#!/bin/sh\nprintf x > "$5"\nexit 0\n', { + mode: 0o755, + }); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse( + readFileSync(join(dir, 'cap.json'), 'utf8'), + ); + expect(manifest.degradedBecause).toContain( + 'trims only never-written trailing positions', + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'reaps where the server ACTUALLY lives when the socket base moves mid-window', + async () => { + // tmux resolves the socket base from the CLIENT's environment at kill + // time, but the server starts under the first USABLE base: a stale + // TMUX_TMPDIR pointing at an unusable path puts the socket under /tmp, + // and when the env base becomes usable before the reap, a bare kill + // answers tmux's "nothing to kill" wordings ABOUT THE ENV BASE — the + // goal state, with the server alive under /tmp — and the reap that + // trusted the verdict unlinked the live server's real socket: no + // WARNING, and invisible to the orphan sweep that discovers orphans by + // readdir of the very socket dirs the unlink emptied (probe-reproduced + // on real tmux 3.4). The shim models tmux's own rule: it records which + // base a start would use, creates the env base inside the window, and + // answers kill-server goal-state only about the base its own env + // resolves — so an env-resolved kill reads as a nothing-to-kill here + // exactly as it does against real tmux. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-reapbase-')); + const envBase = join(dir, 'env-base'); // nonexistent at start + const stateDir = join(dir, 'state'); + mkdirSync(stateDir); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + if [ -n "$TMUX_TMPDIR" ] && [ -d "$TMUX_TMPDIR" ]; then + printf '%s' "$TMUX_TMPDIR" > "${stateDir}/alive-base" + else + printf '%s' /tmp > "${stateDir}/alive-base" + fi + [ -n "$TMUX_TMPDIR" ] && mkdir -p "$TMUX_TMPDIR" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\n' "\${TMUX_TMPDIR:-}" >> "${stateDir}/kill-calls" + if [ "\${TMUX_TMPDIR:-/tmp}" = "$(cat "${stateDir}/alive-base")" ]; then + printf 'killed\n' >> "${stateDir}/kills" + exit 0 + fi + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-1000/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + // The kill was pinned to BOTH candidate bases — the env-resolved + // call alone answers goal-state about the wrong base. + const killCalls = readFileSync(join(stateDir, 'kill-calls'), 'utf8') + .trim() + .split('\n'); + expect(killCalls).toContain('/tmp'); + // ...and a kill actually REACHED the base the server lives under — + // the env-resolved verdict never killed anything. + expect(existsSync(join(stateDir, 'kills'))).toBe(true); + expect(readFileSync(join(stateDir, 'kills'), 'utf8')).toContain( + 'killed', + ); + // The server is confirmed dead, so the run stays silent: a false + // WARNING here would send an operator hunting a reaped server. + expect(stderr).not.toContain('WARNING'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not credit a goal-state verdict the client answered about the FALLBACK base', + async () => { + // The server starts under the env base (usable at start) and the base + // is deleted mid-window: at reap the client falls back to /tmp and + // answers the goal-state wording ABOUT /tmp while the kill was pinned + // to the vanished base (probe-verified on 3.4). Crediting that + // verdict to the pinned base read the live server as reaped — no + // WARNING, and invisible to the sweep once the socket dir went with + // the base. The shim models tmux's rule: a kill pinned at a vanished + // base answers with /tmp's path, exactly as the real client did. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + // SHORT base, never under tmpdir(): the socket-length gate refuses + // a socket path over 103 bytes, and a macOS-shaped tmpdir + // (/var/folders/<2>/<32>/T/, ~50 bytes) pushed this fixture's usable + // env base past the bound — the run refused before any window opened + // and the kill-verdict pins never executed while short-tmpdir hosts + // showed green (probe-verified with a long-TMPDIR arm). + const dir = mkdtempSync(join('/tmp', 'capture-tui-reapfb-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const stateDir = join(dir, 'state'); + mkdirSync(stateDir); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + printf '%s' "$TMUX_TMPDIR" > "${stateDir}/alive-base" + rm -rf "$TMUX_TMPDIR" + s=$(printf '%s\\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\\n' "\${TMUX_TMPDIR:-}" >> "${stateDir}/kill-calls" + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + if [ -n "$TMUX_TMPDIR" ] && [ ! -d "$TMUX_TMPDIR" ]; then + echo "error connecting to /tmp/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + // Both candidate bases were tried... + const killCalls = readFileSync(join(stateDir, 'kill-calls'), 'utf8') + .trim() + .split('\n'); + expect(killCalls).toContain(envBase); + expect(killCalls).toContain('/tmp'); + // ...and the env-base verdict — the goal-state wording naming /tmp + // — must NOT have been credited: the server's fate under the + // vanished base is unconfirmed, and a presumed-alive server is + // never a silent outcome. + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not credit a create-directory verdict on a base that HELD the server at start', + async () => { + // The sibling arm: the base is replaced by a regular file mid-window. + // The client answers `couldn't create directory` naming the pinned + // base — but it examined nothing behind it, and the server that + // started there may still be alive. The verdict is credited only + // where the server could never have started (the next test). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + // SHORT base — same gate reason as the reapfb sibling above. + const dir = mkdtempSync(join('/tmp', 'capture-tui-reapcd-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const stateDir = join(dir, 'state'); + mkdirSync(stateDir); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + printf '%s' "$TMUX_TMPDIR" > "${stateDir}/alive-base" + rm -rf "$TMUX_TMPDIR" && : > "$TMUX_TMPDIR" + s=$(printf '%s\\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\\n' "\${TMUX_TMPDIR:-}" >> "${stateDir}/kill-calls" + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + if [ -f "$TMUX_TMPDIR" ]; then + echo "couldn't create directory $TMUX_TMPDIR/tmux-$(id -u) (Not a directory)" >&2 + exit 1 + fi + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + const killCalls = readFileSync(join(stateDir, 'kill-calls'), 'utf8') + .trim() + .split('\n'); + expect(killCalls).toContain(envBase); + expect(killCalls).toContain('/tmp'); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'credits the create-directory verdict when start itself failed before binding a socket', + async () => { + // The third arm of the create-directory exclusion: the base passes + // the start-time W_OK|X_OK gate, but tmux's mkdir of tmux- + // persistently fails there (ENOSPC on that filesystem, EROFS under + // root, NFS root-squash). Start throws, no server ever existed, and + // both kills answer the same persistent wording — yet the exclusion, + // unconditional on the stamp (which never ran either), vetoed credit + // and the reap printed a false orphan WARNING next to the refusal. + // The discriminating signal is whether the start call threw. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-reapns-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const stateDir = join(dir, 'state'); + mkdirSync(stateDir); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + echo "couldn't create directory $TMUX_TMPDIR/tmux-$(id -u) (No space left on device)" >&2 + exit 1 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\\n' "\${TMUX_TMPDIR:-}" >> "${stateDir}/kill-calls" + if [ "$TMUX_TMPDIR" = '${envBase}' ]; then + echo "couldn't create directory $TMUX_TMPDIR/tmux-$(id -u) (No space left on device)" >&2 + exit 1 + fi + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + echo "no server running on \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV" >&2 + exit 1 + fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stdout, stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('refused'); + expect(stderr).toContain("couldn't create directory"); + // Both candidate bases were tried... + const killCalls = readFileSync(join(stateDir, 'kill-calls'), 'utf8') + .trim() + .split('\n'); + expect(killCalls).toContain(envBase); + expect(killCalls).toContain('/tmp'); + // ...but a start that threw never bound a socket, so the server + // never existed: the persistent create failure on the start base + // IS the goal state there, and no orphan WARNING may print. + expect(stderr).not.toContain('WARNING'); + expect(JSON.parse(stdout)).toMatchObject({ captured: false }); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'still credits a create-directory verdict on a base that never held the server', + async () => { + // The balance point of the arm above: an env base ALREADY unusable at + // start (a regular file) never held the server — tmux started it + // under /tmp — so the same `couldn't create directory` wording IS + // honest there, and a false WARNING would send an operator hunting a + // server that was confirmed dead by the /tmp kill (the measured harm + // that folded the wording into the goal state in the first place). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-reapnc-')); + const envBase = join(dir, 'file-base'); + writeFileSync(envBase, 'not a directory'); + const stateDir = join(dir, 'state'); + mkdirSync(stateDir); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + printf '%s' /tmp > "${stateDir}/alive-base" + s=$(printf '%s\\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + printf '%s\\n' "\${TMUX_TMPDIR:-}" >> "${stateDir}/kill-calls" + if [ -f "$TMUX_TMPDIR" ]; then + echo "couldn't create directory $TMUX_TMPDIR/tmux-$(id -u) (Permission denied)" >&2 + exit 1 + fi + exit 0 + fi +done +printf 'MARK\\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + const killCalls = readFileSync(join(stateDir, 'kill-calls'), 'utf8') + .trim() + .split('\n'); + expect(killCalls).toContain(envBase); + expect(killCalls).toContain('/tmp'); + expect(stderr).not.toContain('WARNING'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not credit an ENOENT verdict once start STAMPED a socket — the file can vanish under a live server', + async () => { + // The shim's start binds a socket under the env base and the kill + // removes it, answering the ENOENT wording naming the pinned base. + // The wording proves only that the path was gone at look time — a + // live server behind a removed socket file answers exactly this + // (probed live on tmux) — so once start stamped a socket the verdict + // is unconfirmed and the presumed-alive server surfaces as the + // WARNING. Crediting it read the live server as reaped. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-reapabs-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + mkdir -p "$TMUX_TMPDIR/tmux-$(id -u)" + : > "$TMUX_TMPDIR/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + rm -f "$TMUX_TMPDIR/tmux-$(id -u)/$SRV" + echo "error connecting to $TMUX_TMPDIR/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not credit a start-base verdict about a socket that is not the stamped one', + async () => { + // The base's socket is replaced mid-window and the kill answers `no + // server running` about the REPLACEMENT — the shape a base destroyed + // and recreated mid-window produces (probe-verified on 3.4). The + // stamped inode separates a verdict about THIS run's server from one + // about a socket this run never owned; crediting the latter read the + // live server behind the destroyed socket as dead, with no WARNING. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join('/tmp', 'capture-tui-reapid-')); + const envBase = join(dir, 'scratch'); + mkdirSync(envBase); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh +[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; } +for a in "$@"; do + if [ "$a" = "new-session" ]; then + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + mkdir -p "$TMUX_TMPDIR/tmux-$(id -u)" + : > "$TMUX_TMPDIR/tmux-$(id -u)/$SRV" + s=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1) + [ -n "$s" ] && : > "$s" + exit 0 + fi + if [ "$a" = "kill-server" ]; then + SRV=""; prev="" + for x in "$@"; do [ "$prev" = "-L" ] && SRV="$x"; prev="$x"; done + if [ "$TMUX_TMPDIR" = "${envBase}" ]; then + p="$TMUX_TMPDIR/tmux-$(id -u)/$SRV" + # Same reason as the sibling fixture: swap by rename so the + # replacement cannot inherit the freed inode. + q="$p.swap" + : > "$q" + rm -f "$p" + mv -f "$q" "$p" + echo "no server running on $p" >&2 + exit 1 + fi + echo "error connecting to \${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/$SRV (No such file or directory)" >&2 + exit 1 + fi +done +printf 'MARK\n' +exit 0 +`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + process.env['TMUX_TMPDIR'] = envBase; + try { + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'a wall of freeze stderr cannot push the manifest past the reader cap', + async () => { + // The writer gate caps the EMBEDDED arguments, but degradedBecause + // is added later: the errTail carried the last two lines of up to + // FREEZE_MAX_BUFFER of freeze output verbatim, and one newline-free + // megabyte-line of it pushed a successful run's manifest past + // MAX_MANIFEST_BYTES — which its own next run could not verify, + // refusing on the collision forever after (probe-reproduced). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const realFreezeProbe = probes.freeze; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-errtail-')); + writeFakeTmux(dir, ' :'); + const freezeBin = join(dir, 'fakebin', 'freeze'); + writeFileSync( + freezeBin, + "#!/bin/sh\nhead -c 3145728 /dev/zero | tr '\\0' 'x' >&2\nexit 9\n", + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + const realBin = freezeRender.bin; + process.env['PATH'] = `${join(dir, 'fakebin')}:${realPath ?? ''}`; + freezeRender.bin = freezeBin; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'MARK', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + } as never), + ); + expect(process.exitCode).toBeUndefined(); + const manifestPath = join(dir, 'cap.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('freeze failed (exit 9'); + // The tail stops at its cap, and the manifest a SUCCESSFUL run + // writes stays small enough for the next run to verify. + expect(manifest.degradedBecause.length).toBeLessThan(8192); + expect(statSync(manifestPath).size).toBeLessThan(64 * 1024); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + freezeRender.bin = realBin; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('refuses a tmux too old for capture-pane -N, naming the version', async () => { + // -N landed in tmux 3.1; an older host passes -V and would otherwise + // die MID-capture on the unknown flag — blaming tmux for a version + // problem, after paying for a server start. + probes.tmux = () => ({ status: 'ok', out: 'tmux 2.8' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-oldtmux-')); + try { + // The call log, not just the message: exit 3, the wording and the + // absent .ans are all location-invariant, and a mutant that moved + // this gate BELOW plan.start stayed green on a tmux-equipped lane — + // a real new-session ran the user's command, then the identical + // refusal, with the start/reap cycle and its orphan window on every + // refusal on a genuinely old host. That start is the cost the gate + // exists to avoid, so the pin has to be able to see it. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLog = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$*" >> "${callLog}"\n[ "$1" = "-V" ] && { echo "tmux 2.8"; exit 0; }\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr: string; + try { + ({ stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBe(3); + expect(stderr).toContain('tmux 2.8 is too old'); + expect(stderr).toContain('capture-pane -N'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + // Nothing was started. (The version came from the probe seam, so the + // log may be empty — that is the strongest form of the same claim.) + const calls = existsSync(callLog) ? readFileSync(callLog, 'utf8') : ''; + expect(calls).not.toContain('new-session'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('leaves NO stale artifacts when a re-run refuses', async () => { + // The previous run's artifacts cannot survive a refused re-run: a stale + // manifest claiming a png rung whose .ans no longer exists is exactly + // the wrong-evidence failure this command exists to prevent. On POSIX + // the fake tmux passes the version probe and fails every real command + // (a MID-capture refusal); on win32 the shim is unreachable, the probe + // answers {status:'absent'}, and the refusal is the no-tmux one — BOTH + // land after the up-front clear, so the assertions pin the clear on + // every platform. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-stale-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + writeFileSync(join(dir, 'cap.holder-ready'), ''); + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + '#!/bin/sh\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "no server running on /tmp/x" >&2; exit 1; }; done\necho "fake tmux: refusing" >&2\nexit 1\n', + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + // A user file at this name is not ours: the sentinel lives under + // the system temp dir now. It used to be unlinked unconditionally, + // before any refusal the run was already headed for. + expect(existsSync(join(dir, 'cap.holder-ready'))).toBe(true); + // The writability probe uses a unique sibling and removes it — it + // must not outlive the run either. + expect(readdirSync(dir).filter((f) => f.includes('write-probe'))).toEqual( + [], + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('clears stale artifacts even when the refusal is PRE-capture', async () => { + // The clear must precede every gate, not just the mid-capture ones: a + // refactor moving it below the validation chain leaves the previous + // run's png-claiming manifest next to a typo'd-flag refusal. + // A REAL-looking probe so the run reaches the --until compile gate its + // title claims (with the probe undefined, the no-tmux refusal fired + // first and every later gate stayed unpinned for the clear). + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-staleearly-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + writeFileSync(join(dir, 'cap.holder-ready'), ''); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: '[', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + // The REASON, so the gate this pins is the marker compile and not + // whichever gate happens to refuse first: without it, deleting or + // hoisting that gate leaves the run refusing elsewhere — still exit + // 3, still cleared, still green. + expect(stderr).toContain('not a valid regex'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + // A user file at this name is not ours: the sentinel lives under + // the system temp dir now. It used to be unlinked unconditionally, + // before any refusal the run was already headed for. + expect(existsSync(join(dir, 'cap.holder-ready'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'refuses through the REAL probe when tmux is absent from PATH', + async () => { + // Every other seam test overrides probes.tmux; this one leaves the + // real probe in place and empties PATH — a probeOutput regression + // that stops distinguishing status!=0 would otherwise ship green and + // misdiagnose an absent tmux as "tmux failed mid-capture". + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-realprobe-')); + const emptyBin = join(dir, 'emptybin'); + mkdirSync(emptyBin, { recursive: true }); + const realPath = process.env['PATH']; + process.env['PATH'] = emptyBin; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + rmSync(dir, { recursive: true, force: true }); + } + expect(process.exitCode).toBe(3); + expect(stderr).toContain('tmux is not installed'); + expect(stderr).not.toContain('mid-capture'); + }, + ); + + it('leaves UNRELATED files at the artifact paths alone on refusal', async () => { + // The artifact names are not reserved: a colliding --out must not + // force-delete unrelated files on a run that refuses (measured shape: + // --out package → the --cols 0 refusal deleted package.json). The + // clear keys on the manifest's evidence rung — a JSON that is not a + // capture manifest leaves its sibling files untouched too. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-unrelated-')); + try { + writeFileSync( + join(dir, 'cap.json'), + '{"name":"not-a-manifest","version":"1.0.0"}', + ); + writeFileSync(join(dir, 'cap.ans'), 'user file'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 0, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + // The refusal is now the COLLISION itself, taken before anything + // starts: a file the clear phase verified is not a capture artifact + // is not ours to replace, and a successful run used to rewrite it. + expect(stderr).toContain('collides with a file this capture did not'); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe( + '{"name":"not-a-manifest","version":"1.0.0"}', + ); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe('user file'); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // Probe-reproduced harm: a foreign `.json` carrying nothing but + // `"evidence":"png"` authorized the clear phase to DELETE the user's + // .json, .ans and .png — before the collision gate this run was already + // headed for could refuse. `evidence` is a field any report-shaped JSON + // can plausibly hold; ownership has to be proved by the full signature a + // previous run of THIS command wrote, every rung of it. + for (const [label, manifest] of [ + ['only the evidence rung', '{"evidence":"png"}'], + [ + 'a manifest naming a DIFFERENT capture', + JSON.stringify({ + evidence: 'png', + ansPath: '/somewhere/else/other.ans', + settledBy: 'timeout', + }), + ], + [ + // Everything valid EXCEPT the evidence rung: without this fixture no + // case isolated it — the others are caught by ansPath first, so the + // rung could be deleted and the suite stayed green. + 'a manifest whose evidence is not a rung this tool writes', + JSON.stringify({ + evidence: 'text', + ansPath: 'PLACEHOLDER', + settledBy: 'timeout', + }), + ], + [ + 'a manifest with no settledBy', + JSON.stringify({ + evidence: 'png', + ansPath: 'PLACEHOLDER', + }), + ], + ] as const) { + it(`refuses instead of clearing a foreign manifest — ${label}`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-foreignjson-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'user file'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + const json = manifest.replace('PLACEHOLDER', join(dir, 'cap.ans')); + writeFileSync(join(dir, 'cap.json'), json); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe(json); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe('user file'); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + } + + it('a manifest whose pngPath names ANOTHER file cannot authorize clearing .png', async () => { + // The internally inconsistent png-rung shape: the signature passes + // (evidence rung, exact ansPath, closed-set settledBy) but the + // recorded pngPath points elsewhere — a manifest this writer never + // produces, since every png rung it writes records THIS .png. + // The evidence rung alone licensed deleting the user's cap.png + // (probe-reproduced); unverified is not permission to delete. + probes.tmux = () => ({ status: 'absent' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-foreignpng-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'user file'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + const json = JSON.stringify({ + evidence: 'png', + ansPath: join(dir, 'cap.ans'), + pngPath: '/elsewhere/foreign.png', + settledBy: 'timeout', + }); + writeFileSync(join(dir, 'cap.json'), json); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + // The refusal is the probe's (the ans and the manifest verified as + // ours and were cleared, so no collision remains to name) — the + // teeth are in the file assertions below. + expect(stderr).toContain('tmux is not installed'); + // The png survives: the manifest's own pngPath never named it. + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + // The signature-passing halves were verified ours and cleared. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('never touches a user DIRECTORY at .holder-ready, refusal or not', async () => { + // Was: the sentinel sat at this path and its unlink was the one clear + // that could THROW (EISDIR, which `force` does not suppress), so it had + // to be ordered last or it stranded the previous run's evidence:"png" + // manifest beside the refusal. The sentinel moved under the system temp + // dir, so the throw is gone at the source and nothing here is ours: the + // stale artifacts still clear, and the directory — content and all — + // outlives a run that refuses. A plain file at the same name is covered + // by the SHAPE-guard test above; a DIRECTORY is what used to throw. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-sentinelorder-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + mkdirSync(join(dir, 'cap.holder-ready')); + writeFileSync(join(dir, 'cap.holder-ready', 'user-file'), 'not ours'); + await withStdio(() => + runCaptureTui({ + // Refuses at the shape guard — deterministic, and no tmux of any + // version is spawned, so this pins the clear phase alone. + command: ['a', 'b'], + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(statSync(join(dir, 'cap.holder-ready')).isDirectory()).toBe(true); + expect( + readFileSync(join(dir, 'cap.holder-ready', 'user-file'), 'utf8'), + ).toBe('not ours'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('clears stale artifacts even when a SHAPE guard refuses', async () => { + // The measured R4-1 regression: an array-shaped --command refused at + // the shape guard BEFORE the clear, leaving a stale evidence:"png" + // manifest next to the refusal. The clear must precede the shape + // guards too — only an unnameable --out refuses without clearing. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-staleshape-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + writeFileSync(join(dir, 'cap.holder-ready'), ''); + await withStdio(() => + runCaptureTui({ + command: ['a', 'b'], + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + // A user file at this name is not ours: the sentinel lives under + // the system temp dir now. It used to be unlinked unconditionally, + // before any refusal the run was already headed for. + expect(existsSync(join(dir, 'cap.holder-ready'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'cuts a HANGING availability probe with the belt — wedged, not absent', + async () => { + // A tmux -V that hangs would otherwise block before the refusal + // contract or any signal handler exists; through the seam the belt is + // provable — the hardcoded-timeout mutant hangs past the wall bound. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-hangprobe-')); + const binDir = join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + // /bin/sleep by absolute path: PATH is binDir alone below, so a bare + // `sleep` would ENOENT and the shim would EXIT instantly instead of + // hanging — the test then never exercised the belt at all. + // TERM-immune, like the measured wedge: without killSignal SIGKILL + // the belt only SENDS a TERM this shim ignores, and the spawn blocks + // past any deadline — the SIGKILL half of the belt is what this pins. + writeFileSync( + join(binDir, 'tmux'), + "#!/bin/sh\ntrap '' TERM\n/bin/sleep 30\n", + { + mode: 0o755, + }, + ); + const realPath = process.env['PATH']; + const realBudget = probeBudget.timeoutMs; + process.env['PATH'] = binDir; + probeBudget.timeoutMs = 500; + const started = performance.now(); + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + )); + } finally { + probeBudget.timeoutMs = realBudget; + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + rmSync(dir, { recursive: true, force: true }); + } + expect(process.exitCode).toBe(3); + // A belt-killed probe is WEDGED, not absent — the refusal must not + // send an operator to reinstall a binary that exists. + expect(stderr).toContain('present but wedged'); + expect(stderr).not.toContain('not installed'); + const elapsed = performance.now() - started; + // Floor proves the shim actually hung to the belt; the tight ceiling + // kills a hardcoded 10s mutant. + expect(elapsed).toBeGreaterThanOrEqual(450); + expect(elapsed).toBeLessThan(2_500); + }, + ); + + it('clears stale artifacts when the ENVIRONMENT refuses — absent, hung, too old', async () => { + // The clear-first contract has ordering pins for the sentinel, the + // shape guards, the marker gate and the directory-shaped --out — but + // every one of them runs with an OK probe, so a regression that let the + // probe-refusal family bypass the clear shipped green while a stale + // manifest claiming "evidence":"png" sat beside the refusal JSON. + for (const [name, probe] of [ + ['absent', () => ({ status: 'absent' }) as const], + ['hung', () => ({ status: 'hung' }) as const], + ['too old', () => ({ status: 'ok', out: 'tmux 3.0a' }) as const], + ] as const) { + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-probestale-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + probes.tmux = probe; + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + // The probe name rides IN the compared value, so a miss prints + // WHICH of the three refusals regressed instead of `false`. + expect({ + probe: name, + exitCode: process.exitCode, + ans: existsSync(join(dir, 'cap.ans')), + png: existsSync(join(dir, 'cap.png')), + manifest: existsSync(join(dir, 'cap.json')), + }).toEqual({ + probe: name, + exitCode: 3, + ans: false, + png: false, + manifest: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = undefined; + } + } + }); + + it('leaves an occupant BYTE-FOR-BYTE intact when it refuses', async () => { + // What pins this now is the collision gate, not a per-path write probe: + // that probe is gone (an append-mode open passes on a `chattr +a` file + // the truncating final write then fails on), and the gate refuses on + // any occupant before anything opens it. Content, not existence — a + // regression that truncates on the way to refusing stays green + // otherwise. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-notrunc-')); + try { + writeFileSync(join(dir, 'cap.json'), '{"name":"not-a-manifest"}'); + writeFileSync(join(dir, 'cap.ans'), 'user bytes'); + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 0, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe( + '{"name":"not-a-manifest"}', + ); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe('user bytes'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'refuses an unwritable MANIFEST path too, not just the .ans', + async () => { + // The probe loop covers both paths; dropping manifestPath from it + // passed the whole file. The brief template's `--out package` against + // a stage that left package.json mode-0444 is exactly this shape: + // every gate passes, the capture runs, and the manifest write fails + // at the very end with the pane text already thrown away. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-romanifest-')); + try { + writeFileSync(join(dir, 'cap.json'), '{"name":"not-a-manifest"}', { + mode: 0o444, + }); + const started = performance.now(); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 5_000, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 30_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + expect(stderr).toContain('cap.json'); + expect(performance.now() - started).toBeLessThan(3_000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('a RELATIVE ansPath does not pass the ownership signature', async () => { + // Probe-reproduced: while ownership compared `resolve(m.ansPath)`, a + // foreign JSON carrying a relative ansPath that happened to resolve to + // this run's .ans passed the signature and took all three of the user's + // files with it. This tool always records the already-resolved absolute + // path, so the relative form can only come from somewhere else. + // The relative path is built to resolve CORRECTLY from the test's cwd — + // process.chdir is unavailable in a vitest worker thread, and would + // prove less: this is the exact string the old comparison accepted. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-relansp-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'user file'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + const json = JSON.stringify({ + evidence: 'png', + ansPath: relative(process.cwd(), join(dir, 'cap.ans')), + settledBy: 'timeout', + }); + writeFileSync(join(dir, 'cap.json'), json); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe(json); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe('user file'); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // Both of these need NO tmux: the size cap and the bounded read run in + // the clear phase, and the collision gate refuses before probes.tmux() + // is ever called. Sitting in the real-tmux describe, they were skipped on + // every tmux-less lane — which is every Windows lane by definition — so + // the heap guard was unpinned exactly where an unattended runner would + // meet it. + for (const [label, makeJson] of [ + ['a bare oversize file', (): string => 'x'.repeat(1024 * 1024 + 10)], + [ + // VALID JSON carrying the FULL ownership signature (evidence rung, + // exact ansPath, closed-set settledBy), so the CAP is what decides: + // without it the file parses, `shaped` is true, and the clear phase + // deletes the sibling artifacts. A garbage payload would not + // discriminate — JSON.parse rejects it either way — and neither + // would a partial signature, which fails `shaped` with or without + // the cap. + 'valid manifest-shaped JSON past the cap', + (base: string): string => + JSON.stringify({ + evidence: 'png', + ansPath: `${base}.ans`, + pngPath: `${base}.png`, + settledBy: 'timeout', + pad: 'x'.repeat(2 * 1024 * 1024), + }), + ], + ] as const) { + it(`REFUSES an oversized .json rather than read it into the heap — ${label}`, async () => { + // Measured at ~479MB: the clear phase used to readFileSync + + // JSON.parse whatever regular file sat there, and the process died on + // the heap limit before any refusal could print. Past the cap the + // file is simply not ours, which the collision gate refuses by name. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-hugejson-')); + try { + const json = makeJson(join(dir, 'cap')); + writeFileSync(join(dir, 'cap.json'), json); + writeFileSync(join(dir, 'cap.ans'), 'previous run text'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + // Nothing touched: a manifest this run could not verify is not + // authority to delete anything beside it. + expect(statSync(join(dir, 'cap.json')).size).toBe(json.length); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe( + 'previous run text', + ); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + } + + it('clears stale artifacts for the SHAPE-BOUNDS gate family too', async () => { + // Seven refusal gates have seeded-artifact ordering pins; the family + // between the probe gates and the marker gate — geometry bounds, an + // empty --command, a non-enterable --cwd, the settle/timeout bounds — + // had none, so hoisting any of them above the clear block shipped green + // while a stale manifest claiming "evidence":"png" survived beside the + // refusal. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + // The REASON, not just exit 3: without it, deleting a gate outright + // ships green — the run sails on and refuses for some other reason + // (a tmux-less lane refuses 'not installed'), which is still exit 3 + // with the artifacts cleared. The pin has to name the gate it pins. + for (const [name, over, reason] of [ + ['geometry', { rows: 9999 }, '--rows must'], + ['empty command', { command: ' ' }, '--command must not be empty'], + [ + 'cwd', + { cwd: join(tmpdir(), 'capture-tui-nope-does-not-exist') }, + '--cwd', + ], + ['settle bound', { settleMs: -1 }, '--settle-ms'], + ['timeout bound', { timeoutMs: -1 }, '--timeout-ms'], + ] as ReadonlyArray, string]>) { + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-boundstale-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + rows: 24, + ...(over as Record), + } as never), + ); + // The gate name rides IN the compared value, so a miss prints WHICH + // gate stopped naming itself instead of a bare `false`. + expect({ gate: name, named: stderr.includes(reason) }).toEqual({ + gate: name, + named: true, + }); + expect({ + gate: name, + exitCode: process.exitCode, + ans: existsSync(join(dir, 'cap.ans')), + png: existsSync(join(dir, 'cap.png')), + manifest: existsSync(join(dir, 'cap.json')), + }).toEqual({ + gate: name, + exitCode: 3, + ans: false, + png: false, + manifest: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = undefined; + } + } + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a FIFO at an artifact path instead of blocking on it', + async () => { + // Reading a FIFO blocks until a writer appears — a HANG, not a throw, + // so no refusal is printed and no reap handler is installed yet. Run + // from a CHILD with a kill deadline: the block is a synchronous read + // on the main thread, so an in-process timeout cannot interrupt it + // (measured — a regression wedged the whole vitest run past its own + // 10s test timeout), and only an external killer turns it red. + const captureTuiTs = captureTuiSource(); + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-fifo-')); + try { + const mkfifo = spawnSync('mkfifo', [join(dir, 'cap.json')]); + // A bare `return` here reported PASSED on a lane without mkfifo — + // spawnSync does not throw for an absent binary, it hands back an + // ENOENT error object — so the only pin against the blocking + // manifest read went green while testing nothing. Fail loudly + // instead: this suite's lanes all have it, and a lane that does not + // should say so rather than quietly drop the coverage. + expect( + mkfifo.error ?? null, + 'mkfifo is unavailable — this pin cannot run here', + ).toBeNull(); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + const driver = join(dir, 'driver-fifo.mts'); + writeFileSync( + driver, + [ + `const mod = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `mod.probes.tmux = () => ({ status: 'absent' });`, + `await mod.runCaptureTui({ command: 'printf hi', cwd: ${JSON.stringify(dir)}, cols: 80, rows: 24, settleMs: 0, until: undefined, keys: undefined, out: ${JSON.stringify(join(dir, 'cap'))}, timeoutMs: 1000 } as never);`, + ].join('\n'), + ); + const { spawn } = await import('node:child_process'); + const child = spawn(process.execPath, ['--import', 'tsx', driver], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout.on('data', (b: Buffer) => (out += b.toString())); + child.stderr.on('data', (b: Buffer) => (out += b.toString())); + const killer = setTimeout(() => child.kill('SIGKILL'), 20_000); + const code = await new Promise((resolve) => + child.once('exit', (c) => resolve(c)), + ); + clearTimeout(killer); + // Exit 3 with the collision named — not a SIGKILL'd hang. + expect(code).toBe(3); + expect(out).toContain('collides with a file this capture did not'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 60_000, + ); + + it('refuses an EMPTY --cwd, --until or --ready — a template that expanded to nothing', async () => { + // `resolve('')` is the launcher's own cwd, so the enterability gate + // always passed it: an empty --cwd captured somewhere the caller never + // named and the manifest recorded that directory as if asked for + // (probe-reproduced: exit 0, success, wrong cwd). An empty --until is a + // pattern matching everything, settling on the first frame. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + for (const flag of ['cwd', 'until', 'ready', 'out'] as const) { + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-emptyarg-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + // --out's own empty form was pinned only by an exit-3 + // assertion that holds with the gate deleted (resolve('') is + // the cwd, and the artifacts then collide there). + [flag]: ' ', + } as never), + ); + expect(`${flag}:${process.exitCode}`).toBe(`${flag}:3`); + expect(stderr).toContain(`--${flag} must not be empty`); + if (flag === 'out') { + // The ONE gate that refuses without clearing, by design: an --out + // this run cannot name gives it nowhere to clear. So the pin is + // the opposite one — the seeded files are still there. + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + } else { + // ...and the clear ran first, like every sibling gate. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('refuses a BARE --keys — no tokens is a template that drove nothing', async () => { + // yargs `array: true` turns `--keys` (bare), `--keys=` and an unquoted + // `--keys $EMPTY` into [], which was accepted silently: nothing typed, + // success reported, while the QUOTED form of the same template failure + // was refused. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-barekeys-')); + try { + // Seeded, so the assertions below are not vacuous: against an empty + // dir `existsSync(cap.json) === false` passes with or without the + // clear, and every other refusal-gate family in this suite pins the + // ordering with real artifacts in place. + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: [], + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('no tokens'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses an EMPTY --keys token — a keypress that never happens', async () => { + // `send-keys ''` types nothing, so the run reported success with the + // token in manifest.keys and keysSent true — a keypress a verdict can + // cite that never happened. A brief template expanding an empty + // variable produces exactly this token. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-emptykey-')); + try { + // Seeded, so the assertions below are not vacuous: against an empty + // dir `existsSync(cap.json) === false` passes with or without the + // clear, and every other refusal-gate family in this suite pins the + // ordering with real artifacts in place. + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: [''], + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('empty token'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a READ-ONLY file at an artifact path before the capture window', + async () => { + // The sibling write probe proves the DIRECTORY writable, not the + // artifact paths. A mode-0444 (or foreign-owned, the shape a shared + // CI stage leaves) .ans passed every gate and refused only at the + // final write — after the whole settle/timeout window and a render, + // with the pane text produced and thrown away. Not skipped as root: the gate + // is occupancy, not permission — a pure lstat — so the mode bits + // never enter it and a root lane must exercise it like any other. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-roartifact-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'not writable by us', { + mode: 0o444, + }); + const started = performance.now(); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 5_000, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 30_000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + // BEFORE the window: the 5s settle never ran. + expect(performance.now() - started).toBeLessThan(3_000); + // And the file is still the user's, untouched. + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe( + 'not writable by us', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('SKIPS a directory squatting at an artifact path and clears the rest', async () => { + // A capture writes files, so a DIRECTORY at an artifact path is someone + // else's — the recursive EISDIR fallback deleted it and its contents on + // every re-run against the same --out (a stale shaped manifest is the + // normal state from the second run on). The directory survives; the + // unlink's throw must not abort the clear of the OTHER paths either. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-eisdir-')); + try { + mkdirSync(join(dir, 'cap.ans')); + writeFileSync(join(dir, 'cap.ans', 'user-file'), 'not ours'); + writeFileSync(join(dir, 'cap.png'), 'stale png'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + // The undeletable path is also occupied, so the collision gate + // refuses UP FRONT naming it — not after a full capture window at + // the final write. + expect(stderr).toContain('collides with a file this capture did not'); + expect(stderr).toContain('cap.ans'); + expect(statSync(join(dir, 'cap.ans')).isDirectory()).toBe(true); + expect(existsSync(join(dir, 'cap.ans', 'user-file'))).toBe(true); + // The throw on the directory does not strand the other stale evidence. + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // skipIf(win32) like every shim-dependent sibling: an extensionless + // shebang script is not spawnable via CreateProcess and PATH joins with + // ';' there, so the real probe answers 'absent', the run refuses + // 'tmux is not installed' before either gate, and the call-log + // assertions pass VACUOUSLY — pinning nothing on the windows lane. + it.skipIf(process.platform === 'win32')( + 'starts NO process before the marker gates refuse — pinned by call log', + async () => { + // Location-invariant assertions could not see a mutant that moved the + // --until compile below plan.start: the refusal looked identical while + // a real private server ran the user's command. The call log can. + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-order-')); + try { + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLog = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$*" >> "${callLog}"\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realTmuxProbe = probes.tmux; + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + try { + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: '[', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 1000, + } as never), + ); + } finally { + probes.tmux = realTmuxProbe; + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBe(3); + const calls = existsSync(callLog) ? readFileSync(callLog, 'utf8') : ''; + expect(calls).not.toContain('new-session'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('refuses non-string argv shapes before anything else', async () => { + // yargs parses duplicated options into arrays and --no-X into booleans; + // both must refuse, not throw uncaught or silently corrupt the capture. + // Undefined required options are the exported-function vector of the + // same class: demandOption covers the CLI path only. + probes.tmux = () => ({ status: 'absent' }) as const; // never reached — shapes refuse first + // A test-owned out, not '/tmp/never-written': the hardcoded path + // routed most iterations through the mkdir+probe block before the + // guards under test, and on Windows resolve() lands it at the drive + // root (a stray :\tmp on admin lanes, EPERM on the others). + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-shapes-')); + try { + const base = { + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'never-written'), + timeoutMs: 1000, + }; + for (const [over, flag] of [ + [{ command: ['a', 'b'] }, '--command'], // --command A --command B + [{ command: false }, '--command'], // --no-command + [{ command: undefined }, '--command'], + [{ command: 'x', until: ['A', 'B'] }, '--until'], // --until A --until B + [{ command: 'x', keys: [false] }, '--keys'], // --keys false + [{ command: 'x', keys: false }, '--keys'], // --no-keys (boolean) + [{ command: 'x', keys: 'Enter' }, '--keys'], // bare string + [{ command: 'x', out: ['x', 'y'] }, '--out'], + [{ command: 'x', out: undefined }, '--out'], + [{ command: 'x', ready: ['A', 'B'] }, '--ready'], // --ready A --ready B + [{ command: 'x', cwd: ['a', 'b'] }, '--cwd'], + ] as const) { + process.exitCode = undefined; + const { stderr } = await withStdio(() => + runCaptureTui({ ...base, ...over } as never), + ); + expect(process.exitCode).toBe(3); + // The FLAG NAME, not just the shared word 'must': a label↔value + // swap in production's guard loop misnames the offending flag in + // the machine-parsed refusal JSON, sending an agent consumer to + // fix a flag it never duplicated (measured: with --until/--ready + // labels swapped, a duplicated --until blamed --ready). + expect(stderr).toContain(`${flag} must`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'keeps unverifiable artifacts when the WRITE PROBE itself fails', + async () => { + // Fd exhaustion means the manifest CANNOT be read, so the capture + // signature cannot be verified — and unverified is not permission to + // delete: the artifact names are not reserved, so what sits there may + // be the user's. This run refuses; the files stay. (The sentinel is + // this tool's alone and clears unconditionally.) + // + // Driven from a CHILD, and that is not incidental: vitest runs test + // files in worker THREADS that share one process fd table, so + // exhausting it in-process starves whatever else happens to be + // running — measured, this test passed alone 3/3 while the full + // review suite failed here and timed out an unrelated hadolint test + // in the same run. A child contains the blast radius, and real + // exhaustion is still what drives the EMFILE (vi.spyOn on node:fs + // does not reach this module's named imports). + const captureTuiTs = captureTuiSource(); + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-staleprobe-')); + try { + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'old run'); + writeFileSync(join(dir, 'cap.json'), staleManifest(join(dir, 'cap'))); + writeFileSync(join(dir, 'cap.holder-ready'), ''); + const driver = join(dir, 'driver-emfile.mts'); + writeFileSync( + driver, + [ + `const { openSync, writeFileSync } = await import('node:fs');`, + `const mod = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `mod.probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' });`, + `const fdSource = ${JSON.stringify(join(dir, 'fd-source'))};`, + `writeFileSync(fdSource, 'x');`, + `for (;;) { try { openSync(fdSource, 'r'); } catch { break; } }`, + `await mod.runCaptureTui({ command: 'printf hi', cwd: undefined, cols: 80, rows: 24, settleMs: 0, until: undefined, keys: undefined, out: ${JSON.stringify(join(dir, 'cap'))}, timeoutMs: 1000 } as never);`, + ].join('\n'), + ); + const { spawn } = await import('node:child_process'); + const child = spawn(process.execPath, ['--import', 'tsx', driver], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout.on('data', (b: Buffer) => (out += b.toString())); + child.stderr.on('data', (b: Buffer) => (out += b.toString())); + // An external killer, like the FIFO sibling: an in-process timeout + // cannot interrupt a child that never exits, so without this a + // regression hangs the run instead of reddening it. + const killer = setTimeout(() => child.kill('SIGKILL'), 20_000); + const code = await new Promise((resolve) => + child.once('exit', (c) => resolve(c)), + ); + clearTimeout(killer); + expect(code).toBe(3); + // The reason names the HOST, not the argument: this refusal used to + // read '--out is not writable' under fd exhaustion, sending an + // agent consumer to fix a --out that was fine. It is machine-read, + // so the misattribution propagated into whatever acted on it. + expect(out).toContain('out of file descriptors'); + expect(out).toContain('not a problem with the argument'); + expect(out).not.toContain('--out is not writable'); + // Unverifiable is not permission to delete. + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + expect(existsSync(join(dir, 'cap.png'))).toBe(true); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + // A user file at this name is not ours: the sentinel lives under + // the system temp dir now. It used to be unlinked unconditionally, + // before any refusal the run was already headed for. + expect(existsSync(join(dir, 'cap.holder-ready'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 60_000, + ); + // POSIX only, like every other child-spawning test here: on Windows a + // broken pipe surfaces through libuv as UV_EOF/UV_EAGAIN + // (ERROR_BROKEN_PIPE / ERROR_NO_DATA), never as EPIPE — and + // guardBrokenPipes rethrows every non-EPIPE code while artifactsComplete + // is false, so the refusal path this pins does not exist there. + it.skipIf(process.platform === 'win32')( + 'holds the exit-3 contract when the stdout reader is GONE — EPIPE-proof', + async () => { + // withStdio mocks the streams, so no in-process test can raise a real + // EPIPE; a child whose stdout pipe closes early can. Without the guard + // the refusal crashed on the async 'error' event and exited 1. + const captureTuiTs = captureTuiSource(); + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-epipe-')); + try { + const driver = join(dir, 'driver-epipe.mts'); + writeFileSync( + driver, + [ + `const mod = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `mod.probes.tmux = () => ({ status: 'absent' }) as never;`, + `// Give the pipe a beat to be closed by the parent first.`, + `await new Promise((r) => setTimeout(r, 300));`, + `await mod.runCaptureTui({ command: 'printf hi', cwd: undefined, cols: 80, rows: 24, settleMs: 0, until: undefined, keys: undefined, out: ${JSON.stringify(join(dir, 'cap'))}, timeoutMs: 1000 } as never);`, + ].join('\n'), + ); + const { spawn } = await import('node:child_process'); + const child = spawn(process.execPath, ['--import', 'tsx', driver], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + // Kill the reader immediately: the child's refusal write hits EPIPE. + child.stdout.destroy(); + // Same external killer as the FIFO sibling — see there for why an + // in-process timeout cannot stand in for it. + const killer = setTimeout(() => child.kill('SIGKILL'), 20_000); + const code = await new Promise((resolve) => + child.once('exit', (c) => resolve(c)), + ); + clearTimeout(killer); + expect(code).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it('clears stale artifacts before the directory-shaped --out refusal too', async () => { + // The last nameable gate without an ordering pin: a mutant hoisting the + // isDirectory check above the clears left the previous run's manifest + // beside the refusal. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + const dir = mkdtempSync(join(tmpdir(), 'capture-tui-dirout-')); + try { + writeFileSync(join(dir, 'adir.ans'), 'old'); + writeFileSync(join(dir, 'adir.png'), 'old'); + writeFileSync(join(dir, 'adir.json'), staleManifest(join(dir, 'adir'))); + mkdirSync(join(dir, 'adir')); + const { stderr } = await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'adir'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('existing directory'); + expect(existsSync(join(dir, 'adir.ans'))).toBe(false); + expect(existsSync(join(dir, 'adir.json'))).toBe(false); + expect(existsSync(join(dir, 'adir'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('registers the full REAP set — a pure pin, gated on nothing', () => { + // The set check needs neither tmux nor pgrep; buried in a skipIf test + // it vanished on slim hosts — where dropping SIGHUP/SIGQUIT (a + // regression that shipped green once before) would ship green again. + expect([...REAP_SIGNALS].sort()).toEqual([ + 'SIGHUP', + 'SIGINT', + 'SIGQUIT', + 'SIGTERM', + ]); + }); +}); + +// The command boundary drives REAL tmux — a private-server capture the mocks +// cannot vouch for (the isolation property IS the exec shape). Skipped where +// tmux is absent; the pure plan shapes stay pinned in tui-capture.test.ts +// everywhere. +describe.skipIf(!hasTmux)('capture-tui (real tmux)', () => { + let dir: string; + // The probe seams are restored HERE, not per test: a test that fakes the + // version and forgets to put it back leaves every later capture believing + // it, and the plan then sends flags the real tmux may not have. That is + // exactly what happened — a leaked 'tmux 3.9' made 20 captures send -T on + // a runner whose tmux is 3.2a, and it was invisible on a dev machine + // whose tmux accepts -T. A hook cannot be forgotten. + const realTmuxProbe = probes.tmux; + const realFreezeProbe = probes.freeze; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'capture-tui-')); + process.exitCode = undefined; + }); + afterEach(() => { + probes.tmux = realTmuxProbe; + probes.freeze = realFreezeProbe; + rmSync(dir, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + function run(over: Record = {}): Promise { + return runCaptureTui({ + command: 'printf "HELLO-\\033[31mRED\\033[0m-WORLD\\n"; sleep 30', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + // Settle on CONTENT, not a fixed delay: under CI load a fixed delay + // races the shell's startup, captures a blank pane, and the ladder + // assertions turn flaky (measured once: empty .ans → freeze bounds + // error → 'png' expectation failed). + until: 'WORLD', + keys: undefined, + out: join(dir, 'cap'), + timeoutMs: 10_000, + ...over, + } as never); + } + + it('refuses a FIFO at .json instead of hanging on it', async () => { + // The manifest checks and the read used to resolve the path twice, so a + // racer could swap a verified regular file for a FIFO and hang the + // synchronous read forever — no refusal printed, no timeout able to + // interrupt it. One descriptor decides both now, opened non-blocking. + // A FIFO standing there from the start is the same shape without the + // race, and it must not stall the run. + // No wall-clock assertion here: it would only run once the call had + // already returned, and under the regression it names — a blocking + // synchronous FIFO read — the event loop never gets there. The bound + // that actually bites is this test's own budget below, which fails the + // test by name instead of letting a hang masquerade as a slow run. + const { stderr } = await withStdio(() => + run({ + command: `mkfifo cap.json 2>/dev/null || mknod cap.json p; printf 'MARK\\n'; sleep 20`, + until: 'MARK', + }), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('claimed during the capture window'); + // Not ours, so still there. + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + }, 45_000); + + it('refuses when the CAPTURED COMMAND claims an artifact path mid-window', async () => { + // The collision gate runs before the window; the window then lasts up + // to --timeout-ms. Probe-reproduced: a command writing its own + // .json had that file silently replaced and the run reported + // success — the same ownership harm the pre-window gate exists to + // prevent, arriving from inside the capture instead of before it. The + // refusal must ALSO leave the occupant alone: this run's cleanup path + // would otherwise delete the very file it refused to replace. + const { stderr } = await withStdio(() => + run({ + command: `printf 'USER-FILE-CONTENT' > cap.json; printf 'MARK\\n'; sleep 20`, + until: 'MARK', + }), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('claimed during the capture window'); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe( + 'USER-FILE-CONTENT', + ); + }); + + it('does not follow a SYMLINK planted at .ans mid-window', async () => { + // The write followed links, and the occupancy check that guards it ran + // before the window: a symlink planted at .ans during the capture + // redirected this run's bytes OUT of the --out base — exactly what the + // lstat-based gate refuses at check time. The target must stay empty + // and the run must refuse rather than write through the link. + const outside = join(dir, 'outside-the-base'); + writeFileSync(outside, 'untouched'); + const { stderr } = await withStdio(() => + run({ + command: `ln -s '${outside}' cap.ans; printf 'MARK\\n'; sleep 20`, + until: 'MARK', + }), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('claimed during the capture window'); + expect(readFileSync(outside, 'utf8')).toBe('untouched'); + // The link itself is not ours to remove either. + expect(lstatSync(join(dir, 'cap.ans')).isSymbolicLink()).toBe(true); + }); + + it.skipIf(tmuxPadsWithCaptureN(tmuxVersionProbe.stdout ?? '') !== true)( + 'names the padding tmux ONCE in the degradation, not twice', + async () => { + // tmuxVersion is already the `tmux -V` line, so the prefix produced + // "tmux tmux 3.2a" in the manifest of every capture on a padding + // host. Nothing pinned the string — the only `pads` reference in + // this file was a skipIf guard. + await run(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.degradedBecause).toContain('pads capture-pane -N'); + expect(manifest.degradedBecause).not.toContain('tmux tmux'); + }, + ); + + it('an ans-only manifest does not authorize clearing .png', async () => { + // Mutation-probed: dropping the `manifestHadPng` condition shipped the + // whole suite green while this exact shape deleted a user's file. A + // previous run that degraded to ans-only names no png in its manifest, + // so whatever sits at .png is someone else's — and the re-run + // against the same --out (the documented reuse shape) must degrade its + // own png rung rather than clear the way for one. Real tmux, real + // version: faking 3.9 here would send flags an older runner's tmux + // rejects. + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + writeFileSync( + join(dir, 'cap.json'), + staleManifest(join(dir, 'cap'), 'ans-only'), + ); + const { stderr } = await withStdio(() => run()); + expect(process.exitCode).toBeUndefined(); + // Untouched, byte for byte — the whole point. + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + // ...and the run says so instead of quietly claiming a png rung. + expect(stderr).toContain('holds a file this capture did not write'); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + // Its own .ans was cleared and rewritten by this run. + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('WORLD'); + }); + + it('an ans-only manifest with a string pngPath still spares .png', async () => { + // The internally inconsistent shape: a signature-passing manifest whose + // evidence says ans-only but whose pngPath is a string. The old + // disjunct fired on it and cleared the user's file (probe-reproduced), + // contradicting the guard's own invariant — an ans-only run wrote no + // png, so nothing at that name is the next run's to remove. + writeFileSync(join(dir, 'cap.ans'), 'old run'); + writeFileSync(join(dir, 'cap.png'), 'user file'); + writeFileSync( + join(dir, 'cap.json'), + JSON.stringify({ + evidence: 'ans-only', + ansPath: join(dir, 'cap.ans'), + pngPath: join(dir, 'cap.png'), + settledBy: 'fixed-delay', + }), + ); + const { stderr } = await withStdio(() => run()); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('user file'); + expect(stderr).toContain('holds a file this capture did not write'); + }); + + it('captures the real rendering into .ans and records the ladder honestly', async () => { + await run(); + expect(process.exitCode).toBeUndefined(); + const ans = readFileSync(join(dir, 'cap.ans'), 'utf8'); + expect(ans).toContain('HELLO-'); + expect(ans).toContain('WORLD'); + // The escapes survived (-e): the red text carries its SGR bytes. + expect(ans).toContain('[31m'); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(['png', 'ans-only']).toContain(manifest.evidence); + // Field-omission contract in the HAPPY shape: no keys were given, so + // keysSent must be absent (a keysSent=false initialization mutant would + // report "keys withheld" on a keys-less run); until was given, so + // settleMs must be absent. + expect(manifest.keysSent).toBeUndefined(); + expect(manifest.settleMs).toBeUndefined(); + if (hasFreeze) { + // A present-but-broken freeze (--help exits 0, render dies) degrades + // to ans-only BY CONTRACT — that is a designed rung, not a failure. + expect(['png', 'ans-only']).toContain(manifest.evidence); + if (manifest.evidence === 'png') { + expect(manifest.pngPath).toBe(join(dir, 'cap.png')); + expect(existsSync(join(dir, 'cap.png'))).toBe(true); + } else { + expect(manifest.degradedBecause).toContain('freeze'); + } + } else { + expect(manifest.degradedBecause).toContain('freeze'); + } + }); + + it('captures a command that renders and EXITS — the one-shot fixture case', async () => { + // Without the pane holder, tmux destroys the session the moment the + // command exits (remain-on-exit off) and the obtainable frame is lost + // with a misleading "no server running" refusal (measured: 0/10). + await run({ command: 'printf "FAST-DONE\\n"', until: 'FAST-DONE' }); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('FAST-DONE'); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + }); + + it('matches --until across an SGR attribute change', async () => { + // On the physical (-e) frame the escape bytes sit inside the marker and + // it can never match; the logical matching view has no escapes. + await run({ + command: 'printf "AA\\033[31mBB\\033[0m-DONE\\n"; sleep 30', + until: 'AABB-DONE', + }); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + // The saved frame is still the physical one, escapes and all. + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('[31m'); + }); + + it('matches --until across a wrap boundary', async () => { + // A 60-char marker in a 40-column pane wraps; only the joined (-J) + // matching view can see it whole. The .ans stays physical: two lines. + await run({ + // The repeated marker is built in NODE, not `$(seq …)`: seq is GNU + // coreutils, and a pane shell without it (stock macOS userland ships + // jot) expanded the fixture to `MEND` — the until marker never + // matched and the test failed red while Linux CI showed green + // (probe-verified with an exit-127 PATH shim). + command: `printf "%sEND\\n" "${'M'.repeat(60)}"; sleep 30`, + cols: 40, + until: 'M{60}END', + }); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + const ans = readFileSync(join(dir, 'cap.ans'), 'utf8'); + // Physical evidence: the marker is split across lines, as rendered. + expect(ans).not.toMatch(/M{60}END/); + expect(ans).toContain('END'); + }); + + it('survives a catastrophic-backtracking --until pattern', async () => { + // The deadline is only checked between test() calls; the vm budget + // interrupts a superlinear match so the poll keeps expiring on time. + // Monotonic clock for every wall bound in this suite: Date.now() can be + // stepped by NTP mid-test and read a wrong elapsed value either way. + const started = performance.now(); + await run({ + // Node-built repetition — the `$(seq …)` fixture was GNU-only; see + // the wrap-boundary sibling for the probe. + command: `printf '%s\\n' "${'a'.repeat(79)}"; sleep 30`, + until: '(a+)+b', + timeoutMs: 1500, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('timeout'); + // The budget cutoff is RECORDED, not swallowed: a backtracking-prone + // marker may be present, and "never matched" alone would hide that the + // match was cut off rather than the marker absent. + expect(manifest.degradedBecause).toContain('exceeded its'); + // Bounded TIGHT: vitest's own testTimeout kills anything over 15s, so a + // 30s bound would have zero bite. Healthy runs measure ~2s; the budget + // VALUE itself is declaration-pinned in the defaults test. + expect(performance.now() - started).toBeLessThan(8_000); + }); + + it('leaves no tmux server behind — the isolation is also the cleanup', async () => { + // TMUX_TMPDIR under the test dir, restored after: standard CI lanes + // set no TMUX_TMPDIR, so without this the production TMUX_TMPDIR + // branch of the socket-dir resolution never runs there — a + // /tmp-hardcoding mutant ships green on those lanes, and on hosts that + // DO set the variable it unlinks in /tmp while tmux created the socket + // under $TMUX_TMPDIR/tmux-/ (measured: tmux honors the variable). + // SHORT on purpose, and not under the mkdtemp base: a unix socket path + // is capped at ~104 bytes, and the deep /var/folders path this suite's + // dirs live under blew past it — the capture then refused mid-run and + // the leftover-socket probe below found an empty directory and passed + // over the branch it exists to watch (which is why the success + // assertions were added). + const tmuxTmp = join('/tmp', `qtt-${process.pid}`); + rmSync(tmuxTmp, { recursive: true, force: true }); + mkdirSync(tmuxTmp, { mode: 0o700, recursive: true }); + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['TMUX_TMPDIR'] = tmuxTmp; + try { + await run(); + } finally { + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + } + // The capture SUCCEEDED — otherwise this pins nothing: a run that + // refuses under a custom TMUX_TMPDIR creates no server at all, the + // probe below finds an empty directory, and both assertions pass over + // the broken branch they exist to watch. + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('WORLD'); + // Any server this run created is named qwen-review-capture--…; + // asking it for sessions must fail because the server is gone. Probe + // the SAME dir production resolved — the TMUX_TMPDIR this test set — + // so a regression in that branch cannot pass this probe vacuously. + const base = tmuxTmp; + // Quote the dir and grep the names: interpolating ${base} unquoted into + // a glob makes this assertion pass VACUOUSLY whenever TMUX_TMPDIR + // carries whitespace (measured with a planted orphan in such a dir). + const probe = spawnSync('bash', [ + '-c', + `ls "${base}/tmux-$(id -u)" 2>/dev/null | grep "^qwen-review-capture-${process.pid}-" || true`, + ]); + // Binary absence must be loud: with no bash, stdout is undefined and the + // empty-string assertion below would pass while checking nothing. + expect(probe.error).toBeUndefined(); + expect((probe.stdout ?? Buffer.from('')).toString().trim()).toBe(''); + rmSync(tmuxTmp, { recursive: true, force: true }); + }); + + it.skipIf(!hasPgrep)( + 'kills the tmux SERVER itself — pid probed while it was alive', + async () => { + // The socket probe above cannot distinguish "server reaped" from "we + // unlinked a live server's socket" (the cleanup unlinks it either way). + // This pins server DEATH: grab the server's pid mid-capture, then + // assert the process is gone after the run. + const inFlight = run({ until: 'NEVER-MATCHES', timeoutMs: 3000 }); + let serverPid = 0; + for (let i = 0; i < 100 && !serverPid; i++) { + const r = spawnSync( + 'pgrep', + ['-f', `qwen-review-capture-${process.pid}-`], + { encoding: 'utf8' }, + ); + const pid = Number((r.stdout ?? '').trim().split('\n')[0]); + if (Number.isInteger(pid) && pid > 1) serverPid = pid; + else await sleep(50); + } + await inFlight; + expect(serverPid).toBeGreaterThan(1); + let alive = true; + for (let i = 0; i < 40 && alive; i++) { + try { + process.kill(serverPid, 0); + await sleep(50); + } catch { + alive = false; + } + } + expect(alive).toBe(false); + }, + ); + + it('kills the processes the capture started — not just the socket file', async () => { + const pidFile = join(dir, 'shell.pid'); + await run({ + command: `echo $$ > "${pidFile}"; printf "PIDDED\\n"; sleep 30`, + until: 'PIDDED', + }); + const pid = Number(readFileSync(pidFile, 'utf8').trim()); + expect(Number.isInteger(pid) && pid > 1).toBe(true); + // kill-server delivers the reap asynchronously; give it a beat. + let alive = true; + for (let i = 0; i < 40 && alive; i++) { + try { + process.kill(pid, 0); + await sleep(50); + } catch { + alive = false; + } + } + expect(alive).toBe(false); + }); + + it('refuses mid-capture tmux failure with the contract, not a stack trace', async () => { + // A fake tmux that answers -V but fails every real command models the + // "probe passes, session fails" host (ancient tmux, unwritable socket + // dir). The catch must land on the refusal contract. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + '#!/bin/sh\n[ "$1" = "-V" ] && exit 0\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "no server running on /tmp/x" >&2; exit 1; }; done\necho "fake tmux: refusing" >&2\nexit 1\n', + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => run())); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBe(3); + expect(stderr).toContain('tmux failed mid-capture'); + // The DIAGNOSTIC rides the reason (stderr tail first): with the || + // operands swapped the reason degrades to the failed argv line and the + // real cause is lost to the consumer. + expect(stderr).toContain('fake tmux: refusing'); + // The start that threw created no server: reap() still attempts the + // kill (the flag precedes the call), and the goal-state answer keeps it + // silent — a warning here would send an operator hunting a socket that + // was never created. + expect(stderr).not.toContain('may still be running'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + }); + + it('reaps a server whose START died on the belt — the flag precedes the call', async () => { + // plan.start forks the server in the SAME client call that creates the + // session: a start cut by the control belt throws with the server + // ALREADY UP. serverStarted must precede the call, or reap() skips + // exactly that window and orphans the server (measured shape on loaded + // runners). The fake's new-session hangs past the seam and its + // kill-server answers the goal state: the call log proves the kill was + // ATTEMPTED — with the flag after the call, reap() returns early and + // no kill-server ever reaches the log. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLog = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$*" >> "${callLog}"\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "new-session" ] && sleep 5; done\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "no server running on /tmp/x" >&2; exit 1; }; done\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realBelt = tmuxControl.timeoutMs; + tmuxControl.timeoutMs = 500; + const started = performance.now(); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + run({ until: undefined, settleMs: 0 }), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + tmuxControl.timeoutMs = realBelt; + } + const elapsed = performance.now() - started; + // The belt cut the hung start — the 5s sleep never ran out. + expect(elapsed).toBeGreaterThanOrEqual(450); + expect(elapsed).toBeLessThan(4_000); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('tmux failed mid-capture'); + // The reap ATTEMPTED the kill despite the start never returning. + expect(readFileSync(callLog, 'utf8')).toContain('kill-server'); + expect(stderr).not.toContain('may still be running'); + }, 15_000); + + it.skipIf(process.getuid?.() === 0 || process.platform === 'win32')( + 'refuses a FAILED .ans write after capture — contract, not stack trace', + async () => { + // The capture window legally runs up to an hour; the disk can fill + // (or the target turn hostile) inside it. Reaching the WRITE failure + // now takes a blocker the occupancy gate cannot see: a directory at + // the .ans path is intercepted as a mid-window collision before + // openSync is ever attempted (which is correct, and pinned + // elsewhere), so this drops the write permission on the PARENT — + // unstamped, unwatched, and exactly the "target turns hostile" shape. + const { stdout, stderr } = await withStdio(() => + run({ + command: 'chmod a-w .; printf "RO-DIR\\n"; sleep 30', + until: 'RO-DIR', + }), + ); + // Restore first: the suite's own cleanup cannot empty a read-only dir. + chmodSync(dir, 0o700); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('cannot write capture output'); + expect(stderr).toContain('EACCES'); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('cannot write capture output'), + }); + // Nothing of OURS remains — and nothing that was not ours was touched. + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + }, + ); + + it('removes what it already wrote when the MANIFEST path is claimed', async () => { + // Was aimed at the manifest write failing EISDIR on a directory the + // command creates. That now refuses one step earlier — the mid-window + // occupancy gate sees the directory before openSync is attempted — so + // what this pins is the half it still reaches, and the half that + // matters: the .ans is already on disk when the refusal happens, and + // the run must not leave it there undescribed ("THIS run's artifacts + // or nothing"). The write-failure branch proper is pinned by the .ans + // sibling above, through a read-only parent. + const { stdout, stderr } = await withStdio(() => + run({ + command: 'mkdir cap.json; printf "DIR-BLOCK2\\n"; sleep 30', + until: 'DIR-BLOCK2', + }), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('cannot write capture manifest'); + expect(stderr).toContain('claimed during the capture window'); + expect(JSON.parse(stdout.trim())).toEqual({ + captured: false, + evidence: 'none', + reason: expect.stringContaining('cannot write capture manifest'), + }); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + expect(statSync(join(dir, 'cap.json')).isDirectory()).toBe(true); + expect(leakedSentinels()).toEqual([]); + }); + + it('a user DIRECTORY at .holder-ready no longer breaks the run', async () => { + // Was: the sentinel sat at this path, so a user's directory there made + // the run refuse (`--out is not writable`) — a capture destroyed by a + // name collision in the user's own namespace. The sentinel moved under + // the system temp dir, so the directory is now simply none of our + // business: it survives untouched and the capture still succeeds. + mkdirSync(join(dir, 'cap.holder-ready')); + writeFileSync(join(dir, 'cap.holder-ready', 'user-file'), 'content'); + await withStdio(() => run()); + expect(process.exitCode).toBeUndefined(); + expect(statSync(join(dir, 'cap.holder-ready')).isDirectory()).toBe(true); + expect( + readFileSync(join(dir, 'cap.holder-ready', 'user-file'), 'utf8'), + ).toBe('content'); + expect(leakedSentinels()).toEqual([]); + }); + + it('a pre-existing DIRECTORY at the png path survives the ans-only run', async () => { + // No manifest → shaped=false → the clear phase protects the directory; + // the freeze torn-png cleanup is a plain rmSync, so the directory's + // EISDIR is swallowed and the otherwise-successful run never deletes + // what it did not write (recursive removal destroyed the tree and the + // run still reported success — measured). + mkdirSync(join(dir, 'cap.png')); + writeFileSync(join(dir, 'cap.png', 'user-file'), 'content'); + await run(); + expect(process.exitCode).toBeUndefined(); + expect(statSync(join(dir, 'cap.png')).isDirectory()).toBe(true); + expect(readFileSync(join(dir, 'cap.png', 'user-file'), 'utf8')).toBe( + 'content', + ); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + }); + + it('keeps the exit contract when the reap WARNING cannot be written', async () => { + // reap() runs from the finally and from onSignal; a throwing stderr + // write there turned an exit-3 refusal into an exit-1 stack trace (and + // an uncaughtException out of the signal handler, killing the re-raise). + // The write is incidental — its reader going away must not decide the + // command's disposition. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "wedged" >&2; exit 1; }; done\ns=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1); [ -n "$s" ] && : > "$s"\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + // NOT withStdio: it installs its own stderr spy, which would replace a + // throwing one — the sinks are wired by hand so the WARNING write is + // the one that fails. + const sinks = { stdout: '', stderr: '' }; + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((( + chunk: string | Uint8Array, + ) => { + sinks.stdout += String(chunk); + return true; + }) as never); + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( + chunk: string | Uint8Array, + ) => { + const text = String(chunk); + if (text.includes('WARNING')) { + throw Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + } + sinks.stderr += text; + return true; + }) as never); + try { + await run({ until: undefined, settleMs: 0 }); + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + const stdout = sinks.stdout; + // The capture still completed: no stack trace, no exit-1 disposition. + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + expect(JSON.parse(stdout.trim().split('\n').at(-1) ?? '')).toMatchObject({ + captured: true, + }); + }); + + it('WARNS when kill-server fails twice — never an unqualified success', async () => { + // A fake tmux that succeeds at everything except kill-server models the + // wedged-server shape: the reap retries once, then must say so — a + // presumed-alive private server holding a pane for up to three hours + // is not a silent outcome. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLogPath = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$@" >> "${callLogPath}"\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { sleep 5; echo "wedged" >&2; exit 1; }; done\ns=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1); [ -n "$s" ] && : > "$s"\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + // TMUX_TMPDIR controlled like the sibling socket-dir tests: the reap + // iterates one candidate base per distinct value, and a host exporting + // one logged 4 kill calls against this test's 2-call cap — a false red + // unrelated to the retry count (CI lanes export none, so the + // fragility shipped invisibly). + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + delete process.env['TMUX_TMPDIR']; + // The control-call belt through its SEAM: the fake kill HANGS (sleep 5) + // and the shortened belt must cut it — a hardcoded-timeout mutant waits + // out both 5s hangs and blows the wall bound. + const realBelt = tmuxControl.timeoutMs; + tmuxControl.timeoutMs = 500; + const started = performance.now(); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stdout = ''; + let stderr = ''; + try { + ({ stdout, stderr } = await withStdio(() => + run({ until: undefined, settleMs: 0 }), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + tmuxControl.timeoutMs = realBelt; + } + expect(performance.now() - started).toBeLessThan(8_000); + expect(stderr).toContain('WARNING'); + expect(stderr).toContain('kill-server failed twice'); + // The cap is ONE retry, pinned from ABOVE too: the assertions here hold + // for any cap up to ~13 under the shortened belt, and the "failed + // twice" wording blesses whatever the cap happens to be. Against a + // genuinely wedged server every extra attempt pays the full 15s belt + // while the capture is already done. + const killCalls = readFileSync(callLogPath, 'utf8') + .split('\n') + .filter((l) => l.includes('kill-server')); + expect(killCalls).toHaveLength(2); + // The other half of "never an unqualified success": a wedged reap is a + // WARNING next to a COMPLETE capture, not a failure — exit code clean, + // artifacts written, success JSON emitted (a mutant setting exitCode + // in the reap's !serverDead branch reports exit 3 next to a finished + // capture, and shipped green before these assertions). + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + expect(JSON.parse(stdout.trim().split('\n').at(-1) ?? '')).toMatchObject({ + captured: true, + }); + // The sentinel is plumbing — removed on every exit path, including this + // degraded one whose fixture is the only one that creates it. + expect(leakedSentinels()).toEqual([]); + }); + + it('refuses when the holder never initializes — and sends NO key into the void', async () => { + // A fake tmux that succeeds every command but whose new-session writes + // no sentinel models a pane that died at startup: the 10s holder + // deadline must refuse (not hang, not fire keys at an unknown screen). + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const callLog = join(dir, 'tmux-calls'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\necho "$*" >> "${callLog}"\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realHolder = holderInit.timeoutMs; + holderInit.timeoutMs = 600; + const started = performance.now(); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + run({ keys: ['C-c'], until: undefined, settleMs: 0 }), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + holderInit.timeoutMs = realHolder; + } + expect(process.exitCode).toBe(3); + // The deadline actually elapsed (floor) and is seam-driven (ceiling — + // the hardcoded-10s mutant blows it). + const waited = performance.now() - started; + expect(waited).toBeGreaterThanOrEqual(550); + expect(waited).toBeLessThan(5_000); + expect(stderr).toContain('never initialized'); + // No key was fired into the uninitialized pane. + expect(readFileSync(callLog, 'utf8')).not.toContain('send-keys'); + }, 20_000); + + it('treats a kill answering "no server running" as the goal state — no WARNING', async () => { + // A server dying between the last capture and the reap is success, not + // a wedge: the always-false regex mutant printed a false WARNING that + // sends an operator hunting a server that does not exist. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "no server running on /tmp/x" >&2; exit 1; }; done\ns=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1); [ -n "$s" ] && : > "$s"\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + run({ until: undefined, settleMs: 0 }), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBeUndefined(); + expect(stderr).not.toContain('WARNING'); + expect(existsSync(join(dir, 'cap.json'))).toBe(true); + }); + + it('records the LAUNCHER cwd when --cwd is omitted', async () => { + // Every other success capture passes an explicit cwd; the default + // branch feeds both new-session -c and the manifest — a mutant default + // would make the capture's only record name a directory the command + // never ran in. + await runCaptureTui({ + command: 'printf "CWDLESS\\n"; sleep 30', + cwd: undefined, + cols: 80, + rows: 24, + settleMs: 0, + until: 'CWDLESS', + keys: undefined, + out: join(dir, 'nocwd'), + timeoutMs: 10_000, + } as never); + const manifest = JSON.parse(readFileSync(join(dir, 'nocwd.json'), 'utf8')); + expect(manifest.cwd).toBe(process.cwd()); + }); + + it('survives a C-\\ sent through --keys — QUIT is trapped at layer 0', async () => { + // The wrapped holder trapped one layer deep: INT survived by shell + // wait semantics, QUIT killed the untrapped session leader (measured: + // exit 3, "no server running", zero artifacts). The unwrapped script + // traps both at the pane's own shell. + await runCaptureTui({ + command: 'cat', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 800, + until: undefined, + keys: ['C-\\'], + out: join(dir, 'cq'), + timeoutMs: 10_000, + } as never); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cq.json'))).toBe(true); + }); + + it('renders WIDTH x HEIGHT, not height x width — the frame has rows lines', async () => { + // validGeometry accepts a transposed pair, so only a behavioral pin + // catches a cols/rows swap: a 30x10 pane captures as 10 physical lines. + await run({ + command: 'printf "GEOM\\n"; sleep 30', + until: 'GEOM', + cols: 30, + rows: 10, + out: join(dir, 'geom'), + }); + const ans = readFileSync(join(dir, 'geom.ans'), 'utf8'); + const lines = ans.replace(/\n$/, '').split('\n').length; + expect(lines).toBe(10); + }); + + it('reaps on the SECOND kill attempt without a WARNING — the retry is real', async () => { + // Every prior fixture failed both attempts or neither; the fail-once + // shape is what the retry exists for, and a retry-less mutant WARNs. + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const marker = join(dir, 'kill-attempted'); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { if [ ! -e "${marker}" ]; then : > "${marker}"; echo "transient" >&2; exit 1; fi; exit 0; }; done\ns=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1); [ -n "$s" ] && : > "$s"\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + let stderr = ''; + try { + ({ stderr } = await withStdio(() => + run({ until: undefined, settleMs: 0 }), + )); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + expect(process.exitCode).toBeUndefined(); + expect(stderr).not.toContain('WARNING'); + expect(existsSync(marker)).toBe(true); + }); + + it('unlinks the socket from /tmp when TMUX_TMPDIR points somewhere unusable', async () => { + // tmux takes the first USABLE base, so the socket really lives under + // /tmp; the env-base-only unlink mutant left dead sockets littering it. + const realEnv = process.env['TMUX_TMPDIR']; + process.env['TMUX_TMPDIR'] = join(dir, 'no-such-base'); + try { + await run({ until: undefined, settleMs: 0, out: join(dir, 'tt') }); + } finally { + if (realEnv === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realEnv; + } + expect(process.exitCode).toBeUndefined(); + const probe = spawnSync('bash', [ + '-c', + `ls "/tmp/tmux-$(id -u)" 2>/dev/null | grep "^qwen-review-capture-${process.pid}-" || true`, + ]); + expect(probe.error).toBeUndefined(); + expect((probe.stdout ?? Buffer.from('')).toString().trim()).toBe(''); + }); + + it('settles by regex when --until matches, and says so', async () => { + await run({ until: 'WORLD', settleMs: 0 }); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + }); + + it('captures anyway on --until timeout and records the degraded settle', async () => { + const started = performance.now(); + await run({ until: 'NEVER-APPEARS', timeoutMs: 1500, settleMs: 0 }); + const elapsed = performance.now() - started; + expect(process.exitCode).toBeUndefined(); + // The poll SPENT its budget. Everything else here is satisfied by a + // mutant that bails on the first miss and records `settledBy: + // 'timeout'` anyway — the marker would then be declared absent after + // one look, and a UI that renders it 200ms later is reported as never + // having rendered it. A floor at 80% of the deadline tolerates timer + // coarseness without tolerating a curtailed poll. + expect(elapsed).toBeGreaterThan(1500 * 0.8); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('timeout'); + // timeoutMs recorded on an UNTIL-ONLY run too: every other timeoutMs + // assertion in this suite rides a --ready run, so a spread mutated to + // `args.ready !== undefined` alone shipped green while every until-only + // capture silently lost the record of its governing budget. + expect(manifest.timeoutMs).toBe(1500); + // The field whose contract is "why the ladder stopped" carries the late + // frame too, not just the freeze rung. + expect(manifest.degradedBecause).toContain('--until never matched'); + const ans = readFileSync(join(dir, 'cap.ans'), 'utf8'); + expect(ans).toContain('HELLO-'); + }); + + it('refuses NaN durations instead of hanging on them', async () => { + // A NaN deadline never expires — `--settle-ms abc` must refuse. + await run({ until: undefined, settleMs: Number.NaN }); + expect(process.exitCode).toBe(3); + process.exitCode = undefined; + await run({ timeoutMs: Number.NaN }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('refuses negative and over-bound durations', async () => { + // The bounds are the guard's other half: without them a typo'd + // --timeout-ms of a day is accepted and the poll loop runs for a day. + await run({ until: undefined, settleMs: -1 }); + expect(process.exitCode).toBe(3); + process.exitCode = undefined; + // settle-ms's 600_000 ceiling was unpinned: only its negative side was + // tested, and a raised-max mutant accepted a 1-hour fixed delay. + await run({ until: undefined, settleMs: 600_001 }); + expect(process.exitCode).toBe(3); + process.exitCode = undefined; + await run({ timeoutMs: 3_600_001 }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('refuses an unwritable --out on the contract, not a stack trace', async () => { + writeFileSync(join(dir, 'blocker'), 'x'); + await run({ out: join(dir, 'blocker', 'cap') }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'blocker', 'cap.ans'))).toBe(false); + }); + + it('sends --keys tokens verbatim, one per token', async () => { + await runCaptureTui({ + command: 'cat', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 800, + until: 'typed-input', + keys: ['typed-input', 'Enter'], + out: join(dir, 'keys'), + timeoutMs: 10_000, + } as never); + const ans = readFileSync(join(dir, 'keys.ans'), 'utf8'); + expect(ans).toContain('typed-input'); + // Per-token dispatch, not one joined call: joined, tmux types the + // literal string "typed-input Enter" (Enter is only a key NAME as its + // own token) and this line is what turns red. + expect(ans).not.toContain('typed-input Enter'); + // The manifest records the keys: a capture driven by keys shows a + // different screen than the bare command, and a reproducer must know. + const manifest = JSON.parse(readFileSync(join(dir, 'keys.json'), 'utf8')); + expect(manifest.keys).toEqual(['typed-input', 'Enter']); + expect(manifest.until).toBe('typed-input'); + expect(manifest.cwd).toBe(dir); + }); + + it('sends --keys only after --ready matches — early keys get eaten', async () => { + // The fixture DRAINS its input before printing READY, the way a + // slow-mounting TUI eats keystrokes fired at start (measured on this + // repo's own onboarding dialog: a Down consumed, the Enter behind it + // lost). The drain does NOT hide early keys from the pane — the kernel + // echoes them before the fixture's read -s begins (measured) — so the + // pin below is the ORDER (gated keys after READY): the one signal that + // actually discriminates gated from ungated. + await runCaptureTui({ + command: `bash -c 'sleep 0.7; IFS= read -rs -t 0.3 -n 10000 junk || true; printf "READY\\n"; cat'`, + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + ready: 'READY', + until: 'gated-input', + keys: ['gated-input', 'Enter'], + out: join(dir, 'ready'), + timeoutMs: 10_000, + } as never); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'ready.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + expect(manifest.keysSent).toBe(true); + expect(manifest.ready).toBe('READY'); + const ans = readFileSync(join(dir, 'ready.ans'), 'utf8'); + expect(ans).toContain('gated-input'); + // Order is the discriminating signal: ungated, the keys still echo + // into the pane, `until` matches on the echo, and every assertion + // above stays green against the exact regression this test was written + // to catch (measured: the no-gate mutant passed in 35ms). + expect(ans.indexOf('gated-input')).toBeGreaterThan(ans.indexOf('READY')); + }); + + it('withholds --keys when --ready never matches, and says so', async () => { + // Typing into a screen that never reached the expected state would + // drive an unknown UI; the keys are withheld and the manifest is honest + // about both the miss and the withholding. + await run({ + ready: 'NEVER-READY', + keys: ['DANGER', 'Enter'], + until: undefined, + settleMs: 0, + timeoutMs: 1500, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.keysSent).toBe(false); + expect(manifest.degradedBecause).toContain('--ready never matched'); + expect(manifest.degradedBecause).toContain('NOT sent'); + // The manifest tells the truth about HOW the run ended: it waited out + // --timeout-ms (a timeout settle, not a fixed delay), and the active + // duration recorded is the one that governed it. + expect(manifest.settledBy).toBe('timeout'); + expect(manifest.timeoutMs).toBe(1500); + expect(manifest.settleMs).toBeUndefined(); + const ans = readFileSync(join(dir, 'cap.ans'), 'utf8'); + // The pty would echo even unread keystrokes — absence proves withheld. + expect(ans).not.toContain('DANGER'); + // And the late frame is a real frame: dropping the readyFailed-branch + // capture shipped a 0-byte .ans whose degradation claimed "late frame + // captured" while a second entry said "pane captured empty". + expect(ans).toContain('HELLO-'); + }); + + it('gates --ready on the LOGICAL view — an SGR-split marker still opens it', async () => { + // Both prior ready tests used plain markers; on the physical (-e) view + // an escape lands inside the marker and the gate never opens — keys + // withheld on a healthy UI. + await runCaptureTui({ + command: `bash -c 'sleep 0.5; printf "GA\\033[31mTE\\033[0m\\n"; cat'`, + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + ready: 'GATE', + until: 'sgr-gated', + keys: ['sgr-gated', 'Enter'], + out: join(dir, 'sgr-ready'), + timeoutMs: 10_000, + } as never); + const manifest = JSON.parse( + readFileSync(join(dir, 'sgr-ready.json'), 'utf8'), + ); + expect(manifest.settledBy).toBe('until-match'); + expect(manifest.keysSent).toBe(true); + }); + + it('refuses an empty or invalid --ready like it refuses --until', async () => { + await run({ ready: ' ' }); + expect(process.exitCode).toBe(3); + process.exitCode = undefined; + await run({ ready: '[' }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('sends --keys in fixed-delay mode too — keys are not an --until feature', async () => { + await runCaptureTui({ + command: 'cat', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 800, + until: undefined, + keys: ['typed-input', 'Enter'], + out: join(dir, 'keys-fixed'), + timeoutMs: 10_000, + } as never); + expect(readFileSync(join(dir, 'keys-fixed.ans'), 'utf8')).toContain( + 'typed-input', + ); + }); + + it('dispatches EVERY key token — a marker only a second token can produce', async () => { + // All prior keys fixtures settle on the FIRST token's echo, so a + // first-token-only mutant shipped green. This fixture's marker appears + // only after Enter completes the read. + await runCaptureTui({ + command: `bash -c 'IFS= read -r line; printf "GOT:%s\\n" "$line"; cat'`, + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'GOT:hello', + keys: ['hello', 'Enter'], + out: join(dir, 'twotok'), + timeoutMs: 10_000, + } as never); + const manifest = JSON.parse(readFileSync(join(dir, 'twotok.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + }); + + it('dispatches keys IN ORDER — reversal drives a different key sequence', async () => { + await runCaptureTui({ + command: 'cat', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 0, + until: 'LINE2', + keys: ['LINE1', 'Enter', 'LINE2'], + out: join(dir, 'order'), + timeoutMs: 10_000, + } as never); + const ans = readFileSync(join(dir, 'order.ans'), 'utf8'); + expect(ans.indexOf('LINE1')).toBeGreaterThan(-1); + expect(ans.indexOf('LINE2')).toBeGreaterThan(ans.indexOf('LINE1')); + }); + + it('refuses degenerate geometry with the refusal contract', async () => { + await run({ cols: 3 }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('refuses an empty command', async () => { + await run({ command: ' ' }); + expect(process.exitCode).toBe(3); + }); + + it('refuses an invalid --until regex BEFORE anything starts', async () => { + const { stderr } = await withStdio(() => run({ until: '[' })); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + expect(existsSync(join(dir, 'cap.json'))).toBe(false); + expect(leakedSentinels()).toEqual([]); + // The reason pins the path: validated up front, this reads "not a valid + // regex"; thrown after tmux started, it would read "tmux failed + // mid-capture: Invalid regular expression…" — a caller mistake blamed + // on tmux, from a server that was started for nothing. + expect(stderr).toContain('not a valid regex'); + expect(stderr).not.toContain('mid-capture'); + }); + + it('records a fixed-delay settle honestly when no --until is given', async () => { + // The wait itself is pinned by wall clock: sleep(0) captures before the + // TUI renders, sleep(timeoutMs) waits up to 20x longer than requested — + // both shipped green when only the manifest field was asserted. + const started = performance.now(); + await run({ until: undefined, settleMs: 600 }); + const elapsed = performance.now() - started; + // The 50ms of slack absorbs libuv starting the settle timer off a cached + // loop tick under load; the bound still catches the sleep(0) and + // sleep(timeoutMs) mutants it exists for. + expect(elapsed).toBeGreaterThanOrEqual(550); + expect(elapsed).toBeLessThan(5_000); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('fixed-delay'); + expect(manifest.settleMs).toBe(600); + // The capture happens AFTER the wait: a sleep↔capture swap published a + // pre-render frame as the settled rung. + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('HELLO-'); + }); + + it('settles by FIXED DELAY after a matched --ready without --until', async () => { + // Every ready-matched test also passed --until, leaving this branch — + // fixed delay AFTER the gate opens, and its dual-duration manifest — + // unpinned: two mutants shipped green (skipping the settle sleep; + // dropping settleMs from this shape's manifest). + const started = performance.now(); + await runCaptureTui({ + command: `bash -c 'printf "READY-NO-UNTIL\\n"; sleep 0.3; printf "SETTLED-LATE\\n"; cat'`, + cwd: dir, + cols: 80, + rows: 24, + settleMs: 600, + until: undefined, + ready: 'READY-NO-UNTIL', + keys: undefined, + out: join(dir, 'readyfixed'), + timeoutMs: 10_000, + } as never); + const elapsed = performance.now() - started; + expect(process.exitCode).toBeUndefined(); + // Wall bound on the settle; the content check below is the real + // discriminator, and this bound catches a sleep(timeoutMs) mutant. + expect(elapsed).toBeGreaterThanOrEqual(550); + expect(elapsed).toBeLessThan(8_000); + const manifest = JSON.parse( + readFileSync(join(dir, 'readyfixed.json'), 'utf8'), + ); + expect(manifest.settledBy).toBe('fixed-delay'); + expect(manifest.settleMs).toBe(600); + // BOTH durations are active in this shape: ready spent the timeout + // budget AND settle governed the wait — omitting either misdescribes + // the run (the ACTIVE-durations contract). + expect(manifest.timeoutMs).toBe(10_000); + // The settle really waited: the frame carries the line that renders + // 300ms AFTER the ready marker matched — a skipped sleep captures + // before it exists. + const ans = readFileSync(join(dir, 'readyfixed.ans'), 'utf8'); + expect(ans).toContain('READY-NO-UNTIL'); + expect(ans).toContain('SETTLED-LATE'); + }); + + it('records an empty-pane capture honestly and never hands it to freeze', async () => { + // A pane that rendered nothing (sleep, settle 0) is the blank-capture + // branch: freeze on empty input fails with a misleading bounds error, + // so the ladder must stop at ans-only with the blank named as the why. + await run({ command: 'sleep 30', until: undefined, settleMs: 0 }); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('pane captured empty'); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + }); + + /** Point the freeze render at a fake binary by ABSOLUTE path (a PATH shim + * is skipped by execvp when non-executable) with the probe seam forced + * open, so the real spawn runs and the degradation composition is pinned + * by real exec, not by reading. */ + async function withFakeFreeze( + script: string, + fn: () => Promise, + ): Promise { + const binDir = join(dir, 'fakebin'); + mkdirSync(binDir, { recursive: true }); + const bin = join(binDir, 'freeze'); + writeFileSync(bin, script, { + mode: script.startsWith('#!') ? 0o755 : 0o644, + }); + const realFreeze = probes.freeze; + const realBin = freezeRender.bin; + probes.freeze = () => ({ status: 'ok', out: '' }) as const; + freezeRender.bin = bin; + try { + await fn(); + } finally { + probes.freeze = realFreeze; + freezeRender.bin = realBin; + } + } + + it('renders only AFTER the .ans is on disk — text evidence survives a hang', async () => { + // The fake refuses to render unless the .ans already exists and is + // non-empty: a write-after-render mutant fails it. + await withFakeFreeze( + '#!/bin/sh\n[ -s "$3" ] || { echo "ans missing at render time" >&2; exit 9; }\nprintf x > "$5"\nexit 0\n', + () => run(), + ); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('png'); + }); + + it('degrades when the rendered png cannot be statted, never crashes', async () => { + // Honest about what this covers: the hazard the guard closes is a + // TOCTOU — the png vanishing BETWEEN the existsSync and the statSync, + // reachable only with a concurrent deleter or fs fault injection, and + // this test does not reproduce it (a dangling symlink short-circuits + // at existsSync, so the pre-guard code degrades here too). What it + // does pin is the branch the guard creates: a path freeze left that + // cannot be statted produces a clean ans-only contract rather than an + // uncaught ENOENT — exit 1, no contract JSON, a stack trace, and both + // artifacts orphaned with no manifest (fault-injected upstream). + await withFakeFreeze( + '#!/bin/sh\nln -s /no-such-target-for-stat "$5"\nexit 0\n', + () => run(), + ); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('exited 0 but wrote no image'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + }); + + it('does not blame the render belt for a maxBuffer overrun', async () => { + // Both shapes kill with SIGKILL and set r.error, so presence alone + // could not tell them apart and the overrun was recorded as 'signal + // SIGKILL after the 30000ms render belt' — a hang that never happened. + // The fake spews past the cap instead of hanging: same disposition, + // different cause. + await withFakeFreeze('#!/bin/sh\nexec yes "spew" \n', () => run()); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('signal SIGKILL'); + expect(manifest.degradedBecause).not.toContain('render belt'); + expect(manifest.degradedBecause).toContain('bytes this spawn captures'); + }, 60_000); + + it('records a freeze CRASH with its diagnostics, not just its absence', async () => { + await withFakeFreeze( + '#!/bin/sh\necho "boom: render exploded" >&2\nexit 9\n', + () => run(), + ); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('freeze failed (exit 9'); + expect(manifest.degradedBecause).toContain('boom: render exploded'); + // A freeze failure never costs the text evidence: .ans was written first. + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + }); + + it('leaves a TORN png in place when the render fails — deletion cannot attribute it', async () => { + // A freeze that writes bytes to the png path and THEN fails leaves an + // occupant an empty pre-window stamp cannot attribute: the identical + // shape is a foreign file claimed during the probe/render window (the + // captured command is the named planter), and deleting on presence + // alone destroyed such a file on a run that reported success + // (probe-reproduced). The sibling manifest-write cleanup already + // spares the png whenever the render produced nothing; the + // failed-render arm keeps its hands off too. The leftover is loud, + // not lost: the manifest denies the png rung and names it, and the + // next run's ladder degrades on the occupant. + await withFakeFreeze('#!/bin/sh\nprintf torn > "$5"\nexit 9\n', () => + run(), + ); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('torn'); + expect(manifest.degradedBecause).toContain('left in place'); + }); + + it('cuts a HANGING freeze with the timeout belt and keeps the .ans', async () => { + const realBelt = freezeRender.timeoutMs; + freezeRender.timeoutMs = 1000; + try { + await withFakeFreeze('#!/bin/sh\nsleep 40\n', () => run()); + } finally { + freezeRender.timeoutMs = realBelt; + } + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('signal'); + expect(existsSync(join(dir, 'cap.ans'))).toBe(true); + }); + + it('stays exit 0 when STDIO fails after the evidence is on disk', async () => { + // The success tail's writes were the only contract writes with no stdio + // protection, and the broken-pipe guard rethrows every non-EPIPE + // 'error' — so a full disk on a file-backed stdout flipped a COMPLETED + // capture to exit 1, .ans and manifest both written. + // + // The error is queued on the reap WARNING, which is written during the + // synchronous stretch BEFORE the tail: it therefore dispatches inside + // the drain that follows, which is exactly where the completion flag + // has to be armed already. Arming it after the drain — as it was — + // leaves the guard rethrowing at that moment, so this test measures the + // arming POINT and not merely the guard's existence. + const captureTuiTs = captureTuiSource(); + const binDir = join(dir, 'fakebin-stdio'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'tmux'), + `#!/bin/sh\n[ "$1" = "-V" ] && { echo "tmux 3.9"; exit 0; }\nfor a in "$@"; do [ "$a" = "kill-server" ] && { echo "wedged" >&2; exit 1; }; done\ns=$(printf '%s\n' "$@" | grep -o "/[^']*qwen-capture-ready-[0-9a-f-]*" | head -1); [ -n "$s" ] && : > "$s"\necho ""\nexit 0\n`, + { mode: 0o755 }, + ); + const patch = join(dir, 'stdio-enospc.cjs'); + writeFileSync( + patch, + [ + 'const real = process.stderr.write.bind(process.stderr);', + 'let armed = false;', + 'process.stderr.write = function (chunk, ...rest) {', + " if (!armed && String(chunk).includes('WARNING')) {", + ' armed = true;', + ' process.nextTick(() =>', + " process.stderr.emit('error', Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' })),", + ' );', + ' return true;', + ' }', + ' return real(chunk, ...rest);', + '};', + ].join('\n'), + ); + const driver = join(dir, 'driver-stdio.mts'); + writeFileSync( + driver, + [ + `const mod = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `mod.probes.freeze = () => ({ status: 'absent' });`, + `await mod.runCaptureTui({ command: 'printf hi', cwd: ${JSON.stringify(dir)}, cols: 80, rows: 24, settleMs: 0, until: undefined, keys: undefined, out: ${JSON.stringify(join(dir, 'stdio'))}, timeoutMs: 5_000 } as never);`, + ].join('\n'), + ); + const { spawn } = await import('node:child_process'); + const child = spawn( + process.execPath, + ['--require', patch, '--import', 'tsx', driver], + { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PATH: `${binDir}:${process.env['PATH'] ?? ''}` }, + }, + ); + // Drained, not just piped: nobody read these, so a spewing regression + // fills the ~64KB pipe buffer and blocks the child forever — the same + // hang the killer below exists for, reached a different way. + child.stdout?.on('data', () => {}); + child.stderr?.on('data', () => {}); + const killer = setTimeout(() => child.kill('SIGKILL'), 20_000); + const code = await new Promise((resolve) => + child.once('exit', (c) => resolve(c)), + ); + clearTimeout(killer); + // A completed capture is a success, whatever happened to stdio after. + expect(code).toBe(0); + expect(existsSync(join(dir, 'stdio.ans'))).toBe(true); + expect(existsSync(join(dir, 'stdio.json'))).toBe(true); + }, 60_000); + + it('does not inherit a previous run REFUSAL exit code', async () => { + // runCaptureTui is exported and driven repeatedly in-process, so a + // refusal that left exitCode 3 standing made the NEXT successful + // capture report failure with its artifacts on disk (probe-observed). + // The disposition is per run, like the completion flag beside it. + probes.tmux = () => ({ status: 'ok', out: 'tmux 3.9' }) as const; + await withStdio(() => + runCaptureTui({ + command: 'printf hi', + cwd: undefined, + cols: 0, + rows: 24, + settleMs: 0, + until: undefined, + keys: undefined, + out: join(dir, 'inherit-refusal'), + timeoutMs: 1000, + } as never), + ); + expect(process.exitCode).toBe(3); + probes.tmux = realTmuxProbe; + probes.freeze = () => ({ status: 'absent' }) as const; + await withStdio(() => run({ settleMs: 0 })); + expect(process.exitCode).toBeUndefined(); + }); + + it('names the until marker as NEVER SEARCHED when --ready times out', async () => { + // The manifest records `until` and settledBy 'timeout', which reads as + // "searched and not found" — but the poll never ran. Measured with the + // marker present in the pane for the whole run: a reader deciding the + // marker never appears would decide from a search that never happened. + probes.freeze = () => ({ status: 'absent' }) as const; + await run({ + command: 'printf "PRESENT\n"; sleep 30', + ready: 'NEVER-MATCHES-THIS', + until: 'PRESENT', + settleMs: 0, + timeoutMs: 1200, + }); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.degradedBecause).toContain('--ready never matched'); + expect(manifest.degradedBecause).toContain('never searched for'); + }); + + it('refuses a DANGLING SYMLINK at a mandatory path — writes must not escape', async () => { + // existsSync and statSync follow links, so a dangling one read as + // "nothing here": the collision gate never fired and writeFileSync's + // O_CREAT then created the pane text and the manifest at the links' + // TARGETS, outside the --out base, while the run reported success and + // the manifest named .json (probe-verified end to end). Occupancy + // is an lstat question — the link itself is the occupant. + const elsewhere = join(dir, 'elsewhere'); + mkdirSync(elsewhere, { recursive: true }); + symlinkSync(join(elsewhere, 'ans-victim.txt'), join(dir, 'cap.ans')); + const { stderr } = await withStdio(() => run({ settleMs: 0 })); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + // Nothing escaped the base, and the link is still the user's. + expect(readdirSync(elsewhere)).toEqual([]); + expect(lstatSync(join(dir, 'cap.ans')).isSymbolicLink()).toBe(true); + }); + + it('REFUSES rather than rewrite a foreign file at a mandatory path', async () => { + // The collision the clear phase's own comment names: `--out package` in + // a Node project. package.json parses but carries no evidence rung, so + // the clear spares it — and a fully SUCCESSFUL run then rewrote it as a + // capture manifest at exit 0 with nothing recorded. Both files must + // come back byte-for-byte. + const pkg = '{"name":"my-pkg","version":"1.0.0"}'; + const notes = 'hand-written notes'; + writeFileSync(join(dir, 'cap.json'), pkg); + writeFileSync(join(dir, 'cap.ans'), notes); + const { stderr } = await withStdio(() => run({ settleMs: 0 })); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('collides with a file this capture did not'); + expect(readFileSync(join(dir, 'cap.json'), 'utf8')).toBe(pkg); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toBe(notes); + }); + + it('degrades rather than rewrite a foreign file at the PNG path', async () => { + // The png is a rung, not a requirement: an occupied png path stops the + // ladder at the text rung and says why, instead of failing a capture + // that can still produce evidence — or replacing the file. + const foreign = 'a foreign image'; + writeFileSync(join(dir, 'cap.png'), foreign); + await withFakeFreeze('#!/bin/sh\nprintf x > "$5"\n', () => run()); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('did not write'); + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe(foreign); + }); + + // Both of these were written against the post-render arms — a freeze + // exiting 0 without writing (credit), and one exiting 9 (torn-png + // cleanup) — but with an occupant at .png the ladder stops BEFORE + // freeze is spawned at all, so neither fixture ever ran. The outcomes + // they assert are right and worth keeping; what they pin is that the + // protection happens earlier than they claimed, which is stronger. The + // marker makes that explicit instead of leaving a fixture that looks + // load-bearing and is not. + for (const [label, script] of [ + ['exits 0 without writing', 'exit 0'], + ['exits 9 after a torn write', 'printf torn > "$5"; exit 9'], + ] as const) { + it(`never spawns freeze at all when .png is occupied — ${label}`, async () => { + writeFileSync(join(dir, 'cap.png'), 'the user file'); + const ran = join(dir, 'freeze-ran'); + await withFakeFreeze(`#!/bin/sh\n: > "${ran}"\n${script}\n`, () => run()); + expect(existsSync(ran)).toBe(false); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('did not write'); + // Neither credited nor deleted: the two harms those arms guard + // against, prevented one step sooner. + expect(readFileSync(join(dir, 'cap.png'), 'utf8')).toBe('the user file'); + }); + } + + it('never manifests a png rung on exit code alone — the file must exist', async () => { + // A freeze that exits 0 without writing anything would otherwise ship + // "evidence": "png" pointing at nothing. + await withFakeFreeze('#!/bin/sh\nexit 0\n', () => run()); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('wrote no image'); + }); + + it('never manifests a png rung on a 0-BYTE image either — size is checked', async () => { + // A freeze that exits 0 but leaves an empty/truncated png (ENOSPC + // mid-write — the shape the .ans write guard's comment names) would + // otherwise sail past an existence-only guard and publish zero pixels + // as "evidence": "png" (probe-verified end-to-end). + await withFakeFreeze('#!/bin/sh\n: > "$5"\nexit 0\n', () => run()); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + // The empty shell stays for the same reason a torn png does: with an + // empty pre-window stamp, presence alone cannot attribute it. + expect(existsSync(join(dir, 'cap.png'))).toBe(true); + expect(manifest.degradedBecause).toContain('left in place'); + }); + + it('names a freeze that could not SPAWN, not "exit null"', async () => { + // A non-executable freeze produces neither status nor signal; the + // reason lives in r.error and the manifest must carry it. + await withFakeFreeze('not executable', () => run()); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('spawn failed'); + expect(manifest.degradedBecause).not.toContain('exit null'); + }); + + it('probes freeze with --help — the flag freeze <=0.1.6 actually has', async () => { + // Both real-freeze tests override the seam, so the FLAG the real probe + // sends was unpinned: a --version mutant stays green wherever freeze + // >=0.2.2 or no freeze at all is installed, and only fails on a 2024 + // freeze — where it misdiagnoses it as absent. This fake accepts ONLY + // --help, so the mutant fails everywhere. + const binDir = join(dir, 'probebin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, 'freeze'), + '#!/bin/sh\n[ "$1" = "--help" ] && exit 0\nexit 1\n', + { mode: 0o755 }, + ); + const realPath = process.env['PATH']; + process.env['PATH'] = `${binDir}:${realPath ?? ''}`; + try { + expect(probes.freeze().status).toBe('ok'); + } finally { + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + } + }); + + it('degrades to ans-only when freeze is WEDGED — and says wedged, not absent', async () => { + // The hung branch's message had no pin: a collapse to "not installed" + // sends an operator to reinstall a binary that exists but hangs. + const realFreeze = probes.freeze; + probes.freeze = () => ({ status: 'hung' }) as const; + try { + await run(); + } finally { + probes.freeze = realFreeze; + } + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.degradedBecause).toContain('wedged'); + expect(manifest.degradedBecause).not.toContain('not installed'); + }); + + it('degrades to ans-only when freeze is unavailable, and says why', async () => { + // Through the probe seam, so the freeze-less rung is pinned even on + // hosts that have freeze installed. + const realFreeze = probes.freeze; + probes.freeze = () => ({ status: 'absent' }) as const; + try { + await run(); + } finally { + probes.freeze = realFreeze; + } + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('ans-only'); + expect(manifest.pngPath).toBeNull(); + expect(manifest.degradedBecause).toContain('freeze is not installed'); + expect(existsSync(join(dir, 'cap.png'))).toBe(false); + }); + + it('survives a command with a trailing semicolon or comment — the holder is tail-proof', async () => { + // Appended with `;`, the hold would become `;;` (syntax error, pane + // dies instantly) after a trailing semicolon, and a trailing `#` + // comment would swallow it entirely — both re-creating the one-shot + // failure on commands that are themselves valid shell. + await run({ command: 'printf "TAIL-SEMI\\n";', until: 'TAIL-SEMI' }); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('TAIL-SEMI'); + await run({ + command: 'printf "TAIL-HASH\\n" # keep-alive note', + until: 'TAIL-HASH', + out: join(dir, 'hash'), + }); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'hash.ans'), 'utf8')).toContain('TAIL-HASH'); + }); + + it('captures a command that ENDS ITSELF with exit 0 — the inner shell absorbs it', async () => { + // Single-shell holder measured: `printf ...; exit 0` took pane, session + // and server down before capture — "no server running" on a valid + // command. The nested holder absorbs the exit. + await run({ command: 'printf "EXITY\\n"; exit 0', until: 'EXITY' }); + expect(process.exitCode).toBeUndefined(); + expect(readFileSync(join(dir, 'cap.ans'), 'utf8')).toContain('EXITY'); + }); + + it('survives a C-c sent through --keys — the holder traps SIGINT', async () => { + // Non-interactive shells stay in the pane's foreground process group, + // so a C-c delivered by this feature's own --keys path reaches the + // holder shell too; untrapped, it dies and takes pane → session → + // server down before the capture (measured: exit 3, zero artifacts, + // misattributed "no server running"). The holder's `trap : INT` is + // what this pins — and a trapped (not ignored) signal resets to + // default in the children, so ^C still lands in the pane. + await runCaptureTui({ + command: 'cat', + cwd: dir, + cols: 80, + rows: 24, + settleMs: 800, + until: undefined, + keys: ['C-c'], + out: join(dir, 'cc'), + timeoutMs: 10_000, + } as never); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'cc.ans'))).toBe(true); + const manifest = JSON.parse(readFileSync(join(dir, 'cc.json'), 'utf8')); + expect(manifest.keysSent).toBe(true); + // The ^C landed in the pane: delivered, not swallowed. + expect(readFileSync(join(dir, 'cc.ans'), 'utf8')).toContain('^C'); + }); + + it('survives a C-c AFTER a one-shot command exits — the hold is a loop', async () => { + // The trap protects only while the command is in the foreground: once + // a render-and-exit command is done, a --keys C-c landing in the hold + // killed a single sleep and ended the script — pane, session, server + // gone (measured 5/5 with the single-sleep hold). The loop re-enters + // sleep and the pane survives; a single-sleep mutant dies here. + await run({ + command: 'printf "CCLOOP\\n"; exit 0', + until: undefined, + settleMs: 800, + keys: ['C-c'], + out: join(dir, 'ccloop'), + }); + expect(process.exitCode).toBeUndefined(); + expect(existsSync(join(dir, 'ccloop.ans'))).toBe(true); + const manifest = JSON.parse(readFileSync(join(dir, 'ccloop.json'), 'utf8')); + expect(manifest.keysSent).toBe(true); + }); + + it('refuses --until/--ready patterns that would MATCH a blank pane', async () => { + // The blank pane's logical capture is rows of newlines, not the empty + // string — `.?`, `x*`, `\s` and `\n` all pass an empty-string-only + // oracle yet settle (or fire keys) before the UI rendered anything. + for (const until of ['.?', '(MARKER)?', 'x*', '\\s', '\\n']) { + process.exitCode = undefined; + const { stderr } = await withStdio(() => run({ until })); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('matches a blank pane'); + } + process.exitCode = undefined; + const { stderr } = await withStdio(() => + run({ until: 'REAL', ready: '\\s', keys: ['x'] }), + ); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('--ready'); + expect(stderr).toContain('matches a blank pane'); + }); + + it('reports the success JSON on stdout — captured, evidence, manifest path', async () => { + // The consumer is an agent: the success line is machine-read, and only + // the refusal side was pinned before. + const { stdout } = await withStdio(() => run()); + const line = stdout.trim().split('\n').at(-1) ?? ''; + // hasFreeze only proves --help answers; a broken render degrades to + // ans-only by contract, so the evidence field is shape-checked. + expect(JSON.parse(line)).toEqual({ + captured: true, + evidence: hasFreeze + ? expect.stringMatching(/^(png|ans-only)$/) + : 'ans-only', + manifest: join(dir, 'cap.json'), + }); + }); + + it('shares ONE deadline between the ready gate and the until poll', async () => { + // Two separate clocks would let a ready+until capture run to + // 2× --timeout-ms: ready matches late (~1.5s), until never matches, and + // the whole run must still end near the single 2s deadline, not 3.5s. + // The freeze render is NOT what this test measures: leaving it in the + // timed window spends up to a second of the bound on the render, and + // hosts with freeze would test a different window than hosts without. + const realFreeze = probes.freeze; + probes.freeze = () => ({ status: 'absent' }) as const; + const started = performance.now(); + try { + await run({ + command: 'sleep 1.5; printf "GATE-OPEN\\n"; sleep 30', + ready: 'GATE-OPEN', + until: 'NEVER-MATCHES', + timeoutMs: 2500, + }); + } finally { + probes.freeze = realFreeze; + } + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('timeout'); + // What this pins is the SINGLE deadline, not that the gate ran: with + // --ready matching and no keys, no observable here separates a gate + // that polled from one that skipped (a keys-gated skip mutant passes — + // the sibling test below pins the gate's own residue through the + // overrun accounting instead). The degradation names the until miss and + // timeoutMs was the active knob. + expect(manifest.degradedBecause).toContain('--until never matched'); + expect(manifest.timeoutMs).toBe(2500); + // Pristine ends near the single 2.5s deadline; the two-clock mutant + // needs ready(~1.6s) + until(2.5s) ≈ 4.1s and lands past the bound. + expect(performance.now() - started).toBeLessThan(3600); + }); + + it('accounts budget overruns in the READY loop too', async () => { + // Deleting matchOverruns++ from the ready loop shipped green — only the + // until loop's accounting was pinned. + await run({ + // Node-built repetition — the `$(seq …)` fixture was GNU-only; see + // the wrap-boundary sibling for the probe. + command: `printf '%s\\n' "${'a'.repeat(79)}"; sleep 30`, + ready: '(a+)+b', + until: undefined, + settleMs: 0, + timeoutMs: 1500, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.degradedBecause).toContain('budget'); + }); + + it('polls --ready even with no keys and no --until — and says how it settled', async () => { + // Production deliberately spends the timeout budget on a ready-only + // run; a mutant gating the poll on keys-present settles instantly on a + // pre-render frame with settledBy 'fixed-delay' and no degradation — + // the false-settle shape --ready exists to prevent. + await run({ + ready: 'NEVER-READY', + until: undefined, + keys: undefined, + settleMs: 0, + timeoutMs: 1500, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.settledBy).toBe('timeout'); + expect(manifest.degradedBecause).toContain('--ready never matched'); + expect(manifest.timeoutMs).toBe(1500); + expect(manifest.settleMs).toBeUndefined(); + }); + + it('refuses an empty --until instead of settling on a blank frame', async () => { + // new RegExp('') matches ANY pane text: the first poll would settle + // "until-match" before anything rendered — a false settle claim. + await run({ until: ' ' }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('refuses an empty --out instead of writing .ans', async () => { + await run({ out: '' }); + expect(process.exitCode).toBe(3); + }); + + it('refuses a --out naming an existing directory — artifacts land NEXT TO it', async () => { + // resolve('.') and resolve('./') are the cwd itself — the same shape + // the empty guard refuses — and any existing directory sails through + // an empty-string-only guard; artifacts would land as .ans next + // to it, silently clobbering whatever holds those names (measured: + // out '.' overwrote a pre-seeded .ans with the pane text). + const adir = join(dir, 'adir'); + mkdirSync(adir); + const { stderr } = await withStdio(() => run({ out: adir })); + expect(process.exitCode).toBe(3); + expect(stderr).toContain('must not name an existing directory'); + process.exitCode = undefined; + await run({ out: '.' }); + expect(process.exitCode).toBe(3); + }); + + it.skipIf(process.getuid?.() === 0)( + 'refuses an unwritable existing --out dir BEFORE the capture runs', + async () => { + // mkdirSync({recursive}) does no permission check on an existing dir: + // without the write probe the capture would run to completion and + // lose the pane text at the very last write. + const ro = join(dir, 'ro'); + mkdirSync(ro, { mode: 0o555 }); + const { stderr } = await withStdio(() => run({ out: join(ro, 'cap') })); + expect(process.exitCode).toBe(3); + expect(existsSync(join(ro, 'cap.ans'))).toBe(false); + // The reason pins WHERE the refusal landed: without the up-front + // probe, the capture runs to completion (a 1h --timeout-ms burns the + // whole window first) and refuses at the final write instead. + expect(stderr).toContain('not writable'); + expect(stderr).not.toContain('cannot write capture output'); + }, + ); + + it.skipIf(process.getuid?.() === 0)( + 'refuses a --cwd the process cannot ENTER, not just a missing one', + async () => { + // statSync alone passes a mode-644 directory; entering it needs +x — + // tmux would exit 0 and silently run the pane in the launcher's cwd + // while the manifest records the requested one. + const blocked = join(dir, 'blocked'); + mkdirSync(blocked, { mode: 0o644 }); + await run({ cwd: blocked }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }, + ); + + it('refuses a --cwd that is not a directory', async () => { + // tmux new-session -c with a nonexistent dir exits 0 and silently runs + // the pane somewhere else — evidence from the wrong directory. + await run({ cwd: join(dir, 'no-such-dir') }); + expect(process.exitCode).toBe(3); + expect(existsSync(join(dir, 'cap.ans'))).toBe(false); + }); + + it('accepts the exact documented duration maxima', async () => { + // The refusal message promises inclusive [0, max]: a `>=` off-by-one + // would refuse a legal exactly-one-hour timeout with a + // self-contradictory message. `until` settles these in ~1s. + await run({ timeoutMs: 3_600_000 }); + expect(process.exitCode).toBeUndefined(); + await run({ settleMs: 600_000, out: join(dir, 'max2') }); + expect(process.exitCode).toBeUndefined(); + }); + + // Skipped where production deliberately omits -N: on tmux 3.1-3.2.x its + // -N FABRICATES trailing spaces (it pads to the grid allocation) with no + // -T to undo it, so the ladder trims there by design and this assertion + // would red on every run — Ubuntu 22.04 ships 3.2a, and the local tmux is + // what decides. + it.skipIf(tmuxPadsWithCaptureN(tmuxVersionProbe.stdout ?? '') === true)( + 'preserves trailing spaces in the physical frame (-N)', + async () => { + // "A clipped right edge is trailing-space significant": without -N, + // capture-pane trims the trailing run and a padding/clipping claim + // reads trimmed output as evidence. + await run({ + command: 'printf "AB \\n"; sleep 30', + until: 'AB', + }); + const ans = readFileSync(join(dir, 'cap.ans'), 'utf8'); + // At least the three printed spaces survive (tmux may pad further); + // without -N the whole trailing run is trimmed to "AB\n". + expect(ans).toMatch(/AB {3,}(\r?\n|$)/); + }, + ); + + it('maps the yargs surface — hyphenated keys reach the right fields', async () => { + // Every other test hand-builds the args object; this drives the real + // handler mapping. A wrong key (e.g. argv['settleMs']) leaves the field + // undefined, the duration guard refuses, and this test turns red — the + // option-contract bug class test-plan.test.ts documents. + await (captureTuiCommand.handler as (argv: unknown) => Promise)({ + command: `bash -c 'sleep 0.3; printf "MAPPED\\n"; cat'`, + cwd: dir, + // NON-default geometry, deliberately: both handler call sites used to + // pass 80x24 — byte-equal to the yargs defaults — so a handler + // hardcoding DEFAULT_COLS/DEFAULT_ROWS shipped green while + // `--cols 120` silently captured at 80 and the manifest recorded 80. + cols: 120, + rows: 30, + 'settle-ms': 0, + ready: 'MAPPED', + until: 'typed-by-map', + keys: ['typed-by-map', 'Enter'], + out: join(dir, 'mapped'), + 'timeout-ms': 10_000, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse(readFileSync(join(dir, 'mapped.json'), 'utf8')); + expect(manifest.settledBy).toBe('until-match'); + // Every mapped field observable in the manifest is pinned BY VALUE — a + // settle-ms/timeout-ms swap or a wrong ready/keys argv key ships green + // otherwise (keys/ready only shape-check when !== undefined). + expect(manifest.keysSent).toBe(true); + expect(manifest.keys).toEqual(['typed-by-map', 'Enter']); + expect(manifest.ready).toBe('MAPPED'); + expect(manifest.until).toBe('typed-by-map'); + expect(manifest.timeoutMs).toBe(10_000); + expect(manifest.cwd).toBe(dir); + // Identity fields too: a command/ansPath/cols/rows mutant self- + // consistently records the lie (measured: a transposed cols/rows pair + // passes validGeometry and every prior assertion). + expect(manifest.command).toBe( + `bash -c 'sleep 0.3; printf "MAPPED\\n"; cat'`, + ); + expect(manifest.ansPath).toBe(join(dir, 'mapped.ans')); + expect(manifest.cols).toBe(120); + expect(manifest.rows).toBe(30); + }); + + it('maps settle-ms where it is OBSERVABLE — the fixed-delay shape', async () => { + // With --until set, settleMs is structurally unobservable (omitted from + // the manifest); a settle-ms→timeout-ms swap mutant shipped green until + // this invocation, where the mapping is the active duration. + await (captureTuiCommand.handler as (argv: unknown) => Promise)({ + command: 'printf "FIXED\\n"; sleep 30', + cwd: dir, + cols: 80, + rows: 24, + 'settle-ms': 123, + until: undefined, + keys: undefined, + out: join(dir, 'mapped-fixed'), + 'timeout-ms': 10_000, + }); + expect(process.exitCode).toBeUndefined(); + const manifest = JSON.parse( + readFileSync(join(dir, 'mapped-fixed.json'), 'utf8'), + ); + expect(manifest.settledBy).toBe('fixed-delay'); + expect(manifest.settleMs).toBe(123); + // Symmetric omission pin: no marker was given, so the marker budget + // must be absent (an always-spread mutant recorded timeoutMs:10000 in a + // fixed-delay manifest). + expect(manifest.timeoutMs).toBeUndefined(); + }); + + it('declares the yargs surface — array keys, required command/out, defaults', () => { + // The mapping test drives the handler; this pins the BUILDER: dropping + // array:true from keys refuses the documented `--keys "/review" Enter` + // usage while every handler-level test stays green. + const options: Record> = {}; + const fake = { + option(name: string, cfg: Record) { + options[name] = cfg; + return this; + }, + }; + (captureTuiCommand.builder as (y: unknown) => unknown)(fake); + expect(options['keys']?.['array']).toBe(true); + // type:'string' is load-bearing for keys: yargs coerces UNTYPED array + // values to numbers (measured: `--keys 3 Enter` → [3, 'Enter']), and a + // numeric token is a legitimate send-keys shape — untyped, it hits the + // shape guard's misleading "--keys must be strings." refusal. + expect(options['keys']?.['type']).toBe('string'); + expect(options['command']?.['type']).toBe('string'); + expect(options['out']?.['type']).toBe('string'); + expect(options['command']?.['demandOption']).toBe(true); + expect(options['out']?.['demandOption']).toBe(true); + expect(options['cols']?.['default']).toBe(80); + expect(options['rows']?.['default']).toBe(24); + expect(options['settle-ms']?.['default']).toBe(3000); + expect(options['timeout-ms']?.['default']).toBe(60_000); + expect(options['ready']?.['type']).toBe('string'); + // until/cwd must be DECLARED at all: reviewCommand registers under + // .strict(), so a dropped .option('until') rejects the flagship + // documented usage with "Unknown argument" while every handler-level + // test stays green. And the numeric options must be type:'number' — + // as strings, '--settle-ms 600' parses to '600' and the duration + // guard refuses a legal value. + expect(options['until']?.['type']).toBe('string'); + expect(options['cwd']?.['type']).toBe('string'); + for (const numeric of ['cols', 'rows', 'settle-ms', 'timeout-ms']) { + expect(options[numeric]?.['type']).toBe('number'); + } + }); + + it('pins the production freeze render defaults — the belt is 30s, the bin is freeze', () => { + // The belt test overrides-and-restores; without this pin a mutant + // shipping timeoutMs: 5_000 (or a renamed bin) is invisible. + expect(freezeRender.timeoutMs).toBe(30_000); + expect(freezeRender.bin).toBe('freeze'); + // Same declaration-pin for the match budget: the wall-clock gate alone + // tolerates any value up to ~7s, silently inflating every poll + // iteration past the shared deadline. + expect(MATCH_BUDGET_MS).toBe(500); + expect(tmuxControl.timeoutMs).toBe(15_000); + expect(probeBudget.timeoutMs).toBe(10_000); + expect(holderInit.timeoutMs).toBe(10_000); + }); + + it.skipIf(!hasPgrep)( + 'reaps an ATTACHED child but not a DAEMONIZED one — the documented boundary', + async () => { + // The reap is kill-server, not a process-tree reaper, and the header, + // the plan's kill comment and the agent brief now say so. This pins + // the line they draw, in both directions — a claim about a limit is + // worth no more than a claim about a guarantee if nothing measures + // it. The attached arm is the guarantee: a child in the pane's + // session dies with the server. The detached arm is the limit: it + // setsids into its own session, its parent is init before the reap + // even runs, and nothing portable reaches it. + // The tag has to ride on the LONG-LIVED process itself. Two earlier + // shapes did not: a bare `sleep 41` matched any concurrent run of + // this suite on a shared host (and its pkill reached into them), and + // `sh -c 'sleep N; : '` tagged only the WRAPPER — sh forks an + // untagged sleep and waits, so pgrep found the wrapper, pkill killed + // it, and the sleep survived reparented to init: an orphan per run, + // from the test that exists to pin orphans. A per-run symlink to the + // real sleep puts the tag in argv[0] of the process that actually + // lives. + const tag = (arm: string): string => + `capture-tui-orphan-${process.pid}-${arm}`; + const sleeper = (arm: string): string => { + const link = join(dir, tag(arm)); + symlinkSync('/bin/sleep', link); + return link; + }; + const arms: Array<[string, string, boolean]> = [ + // A child in the pane's session: dies with the server. The guarantee. + [ + 'attached', + `${sleeper('attached')} 41 & printf 'MARK\\n'; sleep 20`, + false, + ], + // setsid'd into its own session, parent already init before the reap + // runs: nothing portable reaches it. The documented limit. + [ + 'detached', + `node -e "require('child_process').spawn('${sleeper('detached')}',['42'],{detached:true,stdio:'ignore'}).unref()"; printf 'MARK\\n'; sleep 20`, + true, + ], + ]; + for (const [arm, command, expectedAlive] of arms) { + const alive = (): boolean => + ( + spawnSync('pgrep', ['-f', tag(arm)], { + encoding: 'utf8', + }).stdout ?? '' + ).trim() !== ''; + try { + await withStdio(() => + run({ command, until: 'MARK', timeoutMs: 20_000 }), + ); + // The reap is asynchronous at the OS level: kill-server returns + // before the pane's descendants have finished dying. BOTH arms + // get the same window — the attached one leaves it early, the + // detached one sits through all of it. + for (let i = 0; i < 40 && alive(); i++) await sleep(50); + expect(`${arm}:${alive()}`).toBe(`${arm}:${expectedAlive}`); + } finally { + spawnSync('pkill', ['-f', tag(arm)]); + } + } + }, + 90_000, + ); + + it.skipIf(!hasPgrep)( + 'reaps the private server when the capture is signalled mid-poll — SIGTERM and SIGINT', + async () => { + // The no-orphan guarantee cannot rest on finally alone — a signal + // skips it. Spawn the capture as a child, kill it mid --until poll, + // and assert nothing named for the CHILD's pid survives. BOTH + // signals: deleting only the SIGINT registration shipped green while + // an operator's Ctrl+C left server, socket and holder alive. + // vitest's transform does not guarantee a usable file: import.meta.url; + // resolve from the working directory (package root or repo root). + const captureTuiTs = captureTuiSource(); + expect(existsSync(captureTuiTs)).toBe(true); + const { spawn } = await import('node:child_process'); + // The FULL registration set, behaviorally: a registration mutant + // dropping SIGHUP/SIGQUIT shipped green while the membership pin + // still saw all four members. + for (const signal of REAP_SIGNALS) { + const outBase = join(dir, `sig-${signal}`); + const driver = join(dir, `driver-${signal}.mts`); + writeFileSync( + driver, + [ + `const { runCaptureTui } = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `await runCaptureTui({ command: 'sleep 300', cwd: ${JSON.stringify(dir)}, cols: 80, rows: 24, settleMs: 0, until: 'NEVER-MATCHES', keys: undefined, out: ${JSON.stringify(outBase)}, timeoutMs: 60_000 } as never);`, + ].join('\n'), + ); + const child = spawn(process.execPath, ['--import', 'tsx', driver], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + const childPid = child.pid as number; + // Attached BEFORE the discovery loop, not after the kill: if the + // child dies on its own during discovery — precisely the crash + // regression this test polices — the sole `exit` event fires + // unobserved, the disposition promise never settles, and the test + // fails as a bare 15s timeout naming neither exit code nor signal + // (probe-verified 10/10 in the attach-after-kill shape: the + // surviving server still satisfies the loop and child.kill() + // returns false silently). + const disposition = new Promise<[number | null, NodeJS.Signals | null]>( + (resolve) => child.once('exit', (c, sig) => resolve([c, sig])), + ); + let seen = false; + for (let i = 0; i < 200 && !seen; i++) { + const r = spawnSync( + 'pgrep', + ['-f', `qwen-review-capture-${childPid}-`], + { encoding: 'utf8' }, + ); + if ((r.stdout ?? '').trim() !== '') seen = true; + else await sleep(50); + } + // Kill on ANY exit from here, including a thrown assertion: the + // regression this test polices is exactly "the child did not do + // what it should", and leaving it inside a 60s capture orphaned a + // node process (and its tmux server) on every such failure. + // BELOW this test's own budget, and held across every wait that + // follows: cleared before `await disposition`, the guard covered + // none of the window it exists for. Against the dropped-re-raise + // mutant this test polices, `child.kill(signal)` does not end the + // child, the disposition never settles, and vitest fails the test — + // with the guard already cleared, the child then ran out its + // 60s capture with its private tmux server alive on every red run. + // SIGTERM, not SIGKILL: the child's own handler is what reaps its + // private server, and a SIGKILL'd child leaves it standing + // (measured) — a guard meant to prevent an orphan would create one. + // And the rescue is RECORDED: SIGTERM is also this loop's expected + // cause of death, so a child the guard had to kill would otherwise + // produce the expected disposition and pass green with nothing + // saying the guard fired. + let guardFired = false; + const orphanGuard = setTimeout(() => { + guardFired = true; + child.kill('SIGTERM'); + }, 20_000); + try { + expect(seen).toBe(true); + child.kill(signal); + // Capture the disposition: the re-raise half of the contract — the + // handler reaps FIRST and then re-raises, so the child must die OF + // the signal (the conventional exit disposition). A dropped + // re-raise reads normal completion to a harness killing a wedged + // capture (probe-verified: the exact mutant passed the + // exit-event-only version of this wait). + const [code, exitSignal] = await disposition; + expect(exitSignal).toBe(signal); + expect(code).toBeNull(); + // The reap ran before the re-raise: no server named for the child. + let gone = false; + for (let i = 0; i < 40 && !gone; i++) { + const r = spawnSync( + 'pgrep', + ['-f', `qwen-review-capture-${childPid}-`], + { encoding: 'utf8' }, + ); + if ((r.stdout ?? '').trim() === '') gone = true; + else await sleep(50); + } + expect(gone).toBe(true); + expect(guardFired).toBe(false); + // The sentinel cleanup on the signal path runs in the CHILD, so + // the child's pid is the only one that can prove it ran — + // leakedSentinels() defaulted to this worker's pid, which no + // child sentinel ever carries, so the sole cleanup for the + // signal-death path (the finally never runs: the re-raise + // terminates without unwinding) was pinned by nothing. + expect(leakedSentinels(childPid)).toEqual([]); + } catch (e) { + // SIGTERM for the same reason as the guard above: the child's own + // handler is the only thing that reaps its private server. + child.kill('SIGTERM'); + throw e; + } finally { + clearTimeout(orphanGuard); + } + } + }, + // Four sequential cold `node --import tsx` lifecycles, each starting a + // real tmux server: measured at 18.4s under load, so the default 15s + // fails this test against healthy production code on a busy runner. + // Every other child-spawning test in this file already carries a budget. + 60_000, + ); + + it(// No pgrep needed: sentinel + child disposition only. + 'dies OF the signal even when it lands during the render window', async () => { + // The tail after reap is synchronous (freeze render up to its belt); + // a queued signal must drain to the handler before the listeners go + // away — without the drain the process exited 0 with the success JSON + // as if the harness's kill never landed. + const captureTuiTs = captureTuiSource(); + const slowFreeze = join(dir, 'slow-freeze'); + // The sentinel line is what the wait below keys on — one write, so the + // fixture a maintainer edits is the one the child runs. + const renderStarted = join(dir, 'render-started'); + writeFileSync( + slowFreeze, + // `sleep` from PATH, and FAIL LOUD if it is not there: hardcoding + // /bin/sleep collapsed the 4s render window to ~0ms on hosts that do + // not have it (NixOS store paths, minimal rootfs) — sh has no set -e, + // so the shim went on to write the png and the test silently stopped + // testing the render window. + `#!/bin/sh\n: > "${renderStarted}"\nsleep 4 || exit 97\nprintf x > "$5"\n`, + { mode: 0o755 }, + ); + const driver = join(dir, 'driver-render.mts'); + writeFileSync( + driver, + [ + `const mod = await import(${JSON.stringify(pathToFileURL(captureTuiTs).href)});`, + `mod.probes.freeze = () => ({ status: 'ok', out: '' });`, + `mod.freezeRender.bin = ${JSON.stringify(slowFreeze)};`, + `await mod.runCaptureTui({ command: 'printf "RSIG\\n"; sleep 30', cwd: ${JSON.stringify(dir)}, cols: 80, rows: 24, settleMs: 0, until: 'RSIG', keys: undefined, out: ${JSON.stringify(join(dir, 'rsig'))}, timeoutMs: 30_000 } as never);`, + ].join('\n'), + ); + const { spawn } = await import('node:child_process'); + const child = spawn(process.execPath, ['--import', 'tsx', driver], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + // Attached BEFORE the wait, like its mid-poll sibling: if the child + // dies during the render window the sole exit event fires unobserved + // and the await below never settles. + const disposition = new Promise<{ + code: number | null; + signal: string | null; + }>((resolve) => + child.once('exit', (code, signal) => resolve({ code, signal })), + ); + // ...and the child is killed on ANY exit from here, including a thrown + // assertion. Without this, a sentinel that never appears (a cold tsx + // start on a loaded runner, a fixture regression) threw before the + // kill below and left the node process AND its private tmux server + // alive together — measured through a 16s window. SIGTERM, never + // SIGKILL: the child's own handler is what reaps the server, and a + // SIGKILL'd child leaves it standing (measured). + // Recorded, not just sent: SIGTERM is also the disposition this test + // asserts, so a child the guard had to kill produces exactly the + // expected death and would pass green with nothing saying so. + let guardFired = false; + const orphanGuard = setTimeout(() => { + guardFired = true; + child.kill('SIGTERM'); + }, 20_000); + try { + let waited = 0; + while (!existsSync(renderStarted) && waited < 200) { + await sleep(50); + waited++; + } + expect(existsSync(renderStarted)).toBe(true); + child.kill('SIGTERM'); + const { code, signal } = await disposition; + // Death BY the signal — not a swallowed exit 0 with success JSON. + expect(signal ?? `code:${code}`).toBe('SIGTERM'); + expect(guardFired).toBe(false); + // Same as the mid-poll sibling: the child's own pid is what proves + // the signal path cleaned up after itself. + expect(leakedSentinels(child.pid as number)).toEqual([]); + } catch (e) { + child.kill('SIGTERM'); + throw e; + } finally { + clearTimeout(orphanGuard); + } + }, 60_000); + + it('renders through a stdin-IGNORING spawn — a pipe stdin breaks freeze', async () => { + // The production spawn sets stdio ignore because freeze treats a + // non-/dev/null stdin as "the input is stdin" (measured: EOF'd pipe → + // "ERROR No input", exit 1). spawnSync's pipe stdin EOFs at spawn — it + // never blocks — so this fake discriminates by SHAPE: fd 0 must be the + // /dev/null character device, or it fails the way real freeze does. + await withFakeFreeze( + // The property is stdin being /dev/null SPECIFICALLY, not "some + // character device": measured against freeze v0.2.2, a pty is a + // character device and hangs it indefinitely, while a regular file + // sends it into file mode — both would have satisfied a `-c` test + // while breaking the render. Compare the device itself. + '#!/bin/sh\nif [ ! -c /dev/stdin ] || [ -t 0 ]; then echo "ERROR No input" >&2; exit 1; fi\nprintf x > "$5"\nexit 0\n', + () => run(), + ); + const manifest = JSON.parse(readFileSync(join(dir, 'cap.json'), 'utf8')); + expect(manifest.evidence).toBe('png'); + }); +}); diff --git a/packages/cli/src/commands/review/capture-tui.ts b/packages/cli/src/commands/review/capture-tui.ts new file mode 100644 index 00000000000..b3c0de7a988 --- /dev/null +++ b/packages/cli/src/commands/review/capture-tui.ts @@ -0,0 +1,2576 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// `qwen review capture-tui`: run a command in a throwaway terminal and hand +// back what it actually rendered — the evidence producer for rendering claims. +// +// A verifier ruling on "the panel clips at 80 columns" without this command +// reads the layout code and imagines a terminal; measured on this repo, the +// imagining is where rendering verdicts go wrong. This command makes the +// terminal real and the evidence a file: +// +// tmux (PRIVATE server, -L) → .ans (pane text with escapes, always) +// → .png (freeze-rendered, when available) +// +// The safety property is isolation, and it is structural: every tmux call is +// scoped to a per-run private server socket, so the capture cannot see — +// let alone resize or kill — the user's own tmux sessions. The measured +// failure mode of desktop-automation verification was exactly "drives the +// user's own windows"; a private server makes that impossible rather than +// discouraged. `kill-server` at the end reaps the server, the session, the +// holder and everything still attached to the pane — which is everything a +// normal command leaves behind. It is NOT a process-tree reaper: a command +// that DAEMONIZES (setsid, `spawn(..., {detached: true}).unref()`, a TUI +// that forks a browser or updater helper) puts its descendant in a new +// session, and no portable kill reaches it — measured, with a control arm: +// the detached grandchild outlived the reap, the attached one did not. +// Nothing links that process back to the capture afterwards either (its +// parent is already init), so guessing at it would kill the wrong pid. +// Capture such commands only when you are prepared to reap their daemons +// yourself. +// +// WHAT THIS COMMAND GUARANTEES ABOUT FILES, and what it does not. +// +// It guarantees, against ordinary conditions — a re-used --out, a stale +// artifact from a previous run, an unrelated file that happens to hold one +// of the names, a concurrent capture on a different --out, a host that runs +// out of descriptors or disk mid-run: +// +// · it never writes over, or deletes, a file it cannot show a previous +// run of THIS command wrote (manifest signature: the evidence rung, the +// absolute ansPath it recorded, and a settledBy from the closed set); +// · it never credits bytes it did not produce as this run's evidence; +// · it refuses rather than hangs, crashes, or half-writes — every refusal +// is exit 3, a reason on stderr, and machine-readable JSON on stdout; +// · it leaves no tmux server, session, or holder behind, for everything +// that stays in the capture's own session (see the reap comment for the +// one documented exception: commands that daemonize a descendant). +// +// It does NOT guarantee any of that against an ACTIVE adversary sharing the +// uid — a process deliberately racing this one to swap files, symlinks, or +// directories between a check and the syscall that follows it. Node exposes +// no *at() syscalls (openat/unlinkat against a directory descriptor), so +// every path here resolves BY NAME, and a name can be redirected in the +// interval no userspace check can close. Hardening against that model is +// real work with its own guarantees and its own tests; it is deliberately +// NOT this file's claim, and a finding of that shape is a feature request +// against a stated non-goal rather than a defect against this contract. +// +// The practical boundary: an actor able to win those races already shares +// the uid, and can read the .ans, edit the source tree under review, or +// replace the reviewing binary outright. This command is not the weak link +// in that scenario, and pretending otherwise would buy a false guarantee. +// +// Degradation is explicit, not silent: the manifest names which evidence rung +// was reached (`png` or `ans-only`) and why, because a verifier must say +// which rung its verdict stands on — a PNG is publishable rendering evidence, +// an .ans proves bytes but not pixels, and prose is neither. A REFUSED +// capture writes no manifest at all — the bottom rung (`none`) is reported by +// the refusal JSON on stdout instead, so a missing manifest reads as a +// refusal, not corruption. + +import type { CommandModule } from 'yargs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + accessSync, + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + linkSync, + mkdirSync, + openSync, + readSync, + realpathSync, + renameSync, + rmSync, + lstatSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { createContext, runInContext, type Context } from 'node:vm'; +import { + writeStdoutLine, + writeStdoutLineSafe, + writeStderrLine, + writeStderrLineSafe, +} from '../../utils/stdioHelpers.js'; +import { + resolveOnPath, + DEFAULT_COLS, + DEFAULT_ROWS, + captureServerName, + freezePlan, + tmuxPlan, + tmuxSupportsCaptureN, + tmuxSupportsCaptureT, + tmuxPadsWithCaptureN, + isNothingToKill, + isSocketDirNeverCreated, + isSocketDirUnusable, + verdictExaminedBase, + validGeometry, + type CaptureManifest, +} from './lib/tui-capture.js'; + +interface CaptureTuiArgs { + command: string; + cwd: string | undefined; + cols: number; + rows: number; + settleMs: number; + until: string | undefined; + ready: string | undefined; + keys: string[] | undefined; + out: string; + timeoutMs: number; +} + +/** The availability-probe deadline, a seam like its two belt siblings: a + * hanging `tmux -V`/`freeze --help` would otherwise block runCaptureTui + * before the refusal contract or any signal handler exists — and without + * the seam the belt itself is untestable. */ +export const probeBudget = { timeoutMs: 10_000 }; + +/** The holder-init sentinel deadline, a seam like the other belts — a pane + * that dies at startup must refuse within it, and without the seam neither + * the floor nor the ceiling is provable. */ +export const holderInit = { timeoutMs: 10_000 }; + +type ProbeResult = + | { status: 'ok'; out: string } + | { status: 'absent' } + // Belt-killed: present but not answering. Reported distinctly — an + // operator told "not installed" for a wedged binary goes to fix an + // installation that exists (the freeze render path names its belt kill + // for the same reason). + // `code` is set when the probe answered neither cleanly nor with + // absence. `spawned` distinguishes the two ways that happens: false when + // the spawn itself failed (EMFILE/ENFILE/EACCES — the binary may be + // perfectly installed and this host could not start it), true when the + // binary RAN and failed (a non-zero exit, a signal). The messages say + // which, because "could not spawn it" is a false claim about the second. + | { status: 'hung'; code?: string; spawned?: boolean }; + +/** Probe the binary itself (`tmux -V` / `freeze --help`), not `which`: a + * host without `which` would otherwise misdiagnose an installed binary as + * missing, and the binary answering is the only fact that matters. Answers + * with a ProbeResult — `ok` carries the trimmed stdout, and a belt-killed + * binary is `hung`, NEVER `absent`: an operator told "not installed" for a + * wedged binary goes to fix an installation that exists. */ +function probeOutput(bin: string, flag: string): ProbeResult { + // Resolved, never bare: execvp honours the empty-PATH-element → cwd rule + // that `resolveOnPath` refuses, and the cwd here is the reviewed worktree. + // A `tmux` planted there would answer this probe AND every control call + // that follows it — the attacker would BE the tmux, and every `-L` scoping + // defence in this file is downstream of a binary it no longer chose. + // Unresolvable reads as absent, which is the same answer execve's ENOENT + // produces below and the same refusal wording: the binary is not reachable + // at an absolute PATH element, which is the only place this command looks. + const resolved = resolveOnPath(bin); + if (resolved === undefined) return { status: 'absent' }; + const r = spawnSync(resolved, [flag], { + encoding: 'utf8', + timeout: probeBudget.timeoutMs, + // SIGKILL, not the default SIGTERM: a TERM-immune child (trap '' TERM) + // blocks the sync spawn past any belt — measured unkillable except by + // external SIGKILL. + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (r.status === 0) return { status: 'ok', out: (r.stdout ?? '').trim() }; + const code = r.error && (r.error as NodeJS.ErrnoException).code; + if (code === 'ETIMEDOUT') return { status: 'hung' }; + // ENOBUFS is spawnSync's maxBuffer overrun — the binary RAN and spewed + // past the capture limit, so it answers like a non-zero exit, not like a + // spawn failure; the render degradation branch already discriminates it. + if (code === 'ENOBUFS') return { status: 'hung', code, spawned: true }; + // A spawn that could not even be attempted is NOT an absent binary: under + // fd exhaustion (the transient condition this file names three times) + // both probes reported 'not installed', and that false environment claim + // then persisted into the manifest's degradedBecause. ENOENT is the only + // answer that CAN mean absent — with a caveat the refusal wording has to + // carry: execve also answers ENOENT when the file exists and is + // executable but its interpreter does not (a broken shebang, a dangling + // `#!/usr/bin/env` chain — measured: a mode-0755 tmux with + // `#!/nonexistent/interp` is indistinguishable here from no tmux at all). + // Nothing in the spawn result separates them, so the refusal says both + // rather than asserting the one it cannot know. + if (code && code !== 'ENOENT') { + return { status: 'hung', code, spawned: false }; + } + // Nor is a binary that RAN and failed: an installed freeze whose --help + // exits non-zero, or dies on a signal, spawned fine — reporting it as not + // installed sends an operator to install something already present. + if (r.status !== null && r.status !== 0) { + return { status: 'hung', code: `exit ${String(r.status)}`, spawned: true }; + } + if (r.signal) { + return { status: 'hung', code: `signal ${r.signal}`, spawned: true }; + } + return { status: 'absent' }; +} + +/** How much stdout+stderr the freeze render spawn will hold. Explicit, not + * Node's silent 1 MiB default: an overrun does not truncate, it KILLS the + * child (SIGKILL + ENOBUFS), so the value is part of the render contract + * and the degradation wording names it. */ +const FREEZE_MAX_BUFFER = 8 * 1024 * 1024; + +/** The freeze render invocation, exported as a seam: the 30s belt against a + * wedged freeze (measured hangs on this repo's own workflows) is otherwise + * untestable — a test cannot wait out the real value to prove the belt + * exists — and `bin` lets a test point the render at a fake binary by + * absolute path (a PATH shim is skipped by execvp when non-executable). + * Tests override and restore; production never does. */ +export const freezeRender = { bin: 'freeze', timeoutMs: 30_000 }; + +/** The availability probes, exported as a seam: the no-tmux refusal fires + * exactly where `describe.skipIf(!hasTmux)` skips the real-tmux tests, so + * without this seam that path is untestable in the one environment where it + * matters. Tests override a probe and restore it; production never does. */ +export const probes = { + // The VERSION LINE, not a boolean: capture-pane -N needs tmux 3.1, and a + // host with an older tmux must be told so up front — the real cause named, + // no server started — instead of dying mid-capture on the unknown flag. + tmux: (): ProbeResult => probeOutput('tmux', '-V'), + // `--help`, not `--version`: freeze ≤0.1.6 (the whole 2024 release line) + // has no --version flag and would be misdiagnosed as absent; --help exits + // 0 on both release lines (measured on v0.1.6 and v0.2.2). + freeze: (): ProbeResult => probeOutput('freeze', '--help'), + // The holder's own `sleep`. A probe like the two above so a test can + // answer "PATH has no sleep" without editing the process's PATH, which is + // how the degraded-PATH refusal below gets covered at all. + sleepBin: (): string | undefined => resolveOnPath('sleep'), +}; + +/** Whether tmux would actually USE this socket base. + * + * The two tests the start gate applies, shared so the reap cannot drift from + * it. `-L` PINS a base in the client's environment but does not bind tmux to + * it: an unusable base sends the client to /tmp, which is exactly why the + * reap's failure path checks `verdictExaminedBase` before believing a + * wording. The success path needs the same question asked a different way — + * an exit-0 kill proves something died where the client LOOKED, not where it + * was aimed. + */ +function baseIsUsable(base: string): boolean { + try { + // Directoryness FIRST, like the --cwd gate: a regular file (or a symlink + // to one) passes W_OK|X_OK on some hosts. + if (!statSync(base).isDirectory()) return false; + accessSync(base, fsConstants.W_OK | fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** What a capture's own socket looked like at start, by identity. */ +export interface SocketStamp { + ino: number; + mode: number; + mtimeMs: number; +} + +/** Whether an lstat reading is still the FILE a stamp named. + * + * Exported so the comparison can be pinned directly: it cannot be reached + * behaviourally on a filesystem that hands out a fresh inode per create, + * which is every filesystem a test can rely on. An inode is not a durable + * name for a file — ext-family allocators return the freed number on an + * immediate same-directory recreate (measured 5/5 on a review host) — so an + * inode-only comparison read `rm` + recreate at the socket path as "still + * ours" and credited a verdict about the replacement. Mode and mtime are + * the same two the rest of this PR already compares: the sweep's post-kill + * re-check takes mode, `changed()` takes size and mtime, and this was the + * narrowest of the three. + */ +export function isSameSocket(stamp: SocketStamp, st: SocketStamp): boolean { + return ( + st.ino === stamp.ino && + st.mode === stamp.mode && + st.mtimeMs === stamp.mtimeMs + ); +} + +/** The flags every artifact write opens with. + * + * Named and exported because the one that matters most cannot be reached by + * a behavioural test: `O_NONBLOCK` only changes the outcome when a FIFO + * lands in the microseconds between `changed()`'s lstat and this open. A + * FIFO present at any other moment is caught by `changed()` as the occupant + * it is, so a test can only win that race by spraying — and a race a test + * loses proves nothing. Without the flag, `open(O_WRONLY)` on a FIFO waits + * for a reader that never comes, on the MAIN THREAD, inside a synchronous + * syscall: the refusal contract breaks outright (no reason on stdout, no + * exit 3) and only an external SIGKILL ends it. The manifest READ open + * carries the same flag against the same class, and that side IS covered. + * + * `O_NOFOLLOW` so a symlink at the path is never written THROUGH; the + * atomic create-or-fail form, and the identity guarantees that go with it, + * are the hardening PR's business. A regular file is unaffected by either. + */ +export const ARTIFACT_OPEN_FLAGS = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW ?? 0) | + (fsConstants.O_NONBLOCK ?? 0); + +/** A capture manifest is a few hundred bytes; anything past this is not + * one, and reading it would cost more than refusing does. */ +const MAX_MANIFEST_BYTES = 1024 * 1024; + +/** The signals Node lets JavaScript observe whose default action would + * otherwise terminate the process past the finally-based reap. NOT every + * such signal exists here — SIGUSR2, SIGALRM, SIGXCPU and friends still + * kill the process with the server standing, and SIGKILL cannot be caught + * at all; the holder's own watchdog is what bounds those: a closed terminal window HUPs the foreground process + * group (measured: exit 129 with server, socket and holder all surviving), + * and the capture window legally runs up to an hour. Exported so the signal + * tests iterate the REAL list. */ +export const REAP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const; + +/** The tmux control-call deadline, exported as a seam like its freezeRender + * sibling: belt-less, a wedged server blocks the first execFileSync forever + * — and in reap() the process could not even die on the re-raised signal. + * Tests shorten it; production never does. */ +export const tmuxControl = { timeoutMs: 15_000 }; + +function tmux(argv: string[], env?: NodeJS.ProcessEnv): string { + // Same reason as the probe above, and this is the site that matters most: + // every control call — start, capture, send-keys, kill — flows through + // here, so a cwd-planted `tmux` would author the `.ans` bytes and the + // manifest that the verdict machinery consumes as rendering evidence. + // Resolved per call rather than cached: PATH is a test seam here, and a + // cache would make the first capture in a process decide the rest. + // Unresolvable throws rather than falling back to the bare name — the + // fallback IS the hole — and it is nearly unreachable in practice, since + // the availability probe refuses the run before any control call when + // tmux cannot be resolved. + const bin = resolveOnPath('tmux'); + if (bin === undefined) { + throw new Error( + 'tmux is not reachable at any absolute PATH element — refusing to ' + + 'resolve it through the current directory', + ); + } + return execFileSync(bin, argv, { + // The reap pins each kill to a candidate socket base (see there): tmux + // resolves the base from the CLIENT's environment, and the server lives + // where the first USABLE base was at start time — not necessarily where + // this process's env points now. + ...(env !== undefined ? { env } : {}), + encoding: 'utf8', + // A pane of text is small; a runaway TUI writing a scrollback is not our + // problem — capture-pane returns the visible pane only. + maxBuffer: 8 * 1024 * 1024, + // Every tmux command here is a quick control call; a server wedged hard + // enough to sit on one this long should turn into a refusal, not hang + // the whole review agent behind it. + timeout: tmuxControl.timeoutMs, + killSignal: 'SIGKILL', + // EXPLICIT, like every sibling spawn here: with no stdio option Node's + // execFileSync sets `inheritStderr = !options.stdio` and tees the + // child's captured stderr into process.stderr with a raw, UNGUARDED + // write — outside the broken-pipe guard that keeps the exit + // disposition honest, and interleaved with the contract output a + // machine reads. Errors reach the caller through the thrown error's + // own stderr, which is where this command already reads them. + stdio: ['ignore', 'pipe', 'pipe'], + }) as string; +} + +/** Async on purpose: the waits dominate the capture's wall time, and an + * idle event loop is what lets the SIGINT/SIGTERM reap below actually run — + * a fully synchronous capture would queue the signal until after the work + * it was meant to interrupt. */ +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** One marker match's time budget: a backtracking-prone --until/--ready + * pattern can spin a single test() call past any deadline (the deadline is + * only checked BETWEEN calls). vm interrupts the match; an overrun is + * reported as such — it is "the match was cut off", NOT "no match". + * Exported so the declaration test can pin the VALUE — the wall-clock test + * alone tolerates anything up to ~7s, silently inflating every poll + * iteration past the shared deadline. */ +export const MATCH_BUDGET_MS = 500; + +// ONE context for every match of the run: the interrupt property lives in +// the timeout option, which runInContext keeps — runInNewContext built a +// fresh V8 context per poll (~14k of them across a 1h capture). +// createContext wires the sandbox object in place, so writing re/text before +// each match feeds the context without re-creating it. +const matchSandbox: { re: RegExp; text: string } = { re: /(?!)/, text: '' }; + +const matchContext: Context = createContext(matchSandbox); + +function testWithBudget( + re: RegExp, + text: string, +): 'match' | 'miss' | 'overrun' { + matchSandbox.re = re; + matchSandbox.text = text; + try { + return runInContext('re.test(text)', matchContext, { + timeout: MATCH_BUDGET_MS, + }) + ? 'match' + : 'miss'; + } catch { + return 'overrun'; + } +} + +let brokenPipeGuarded = false; +/** Set once this run's artifacts are on disk AND described by a manifest. + * From that moment a stdio failure cannot change what happened: the + * evidence exists, so an exit 1 with a stack trace would report a + * successful capture as a failed command. */ +let artifactsComplete = false; + +/** The host conditions that must not be reported as a bad `--out`. This + * block wraps the mkdir, the write probe and the clear-phase removals, and + * its refusal reason is machine-read: attributing a full disk or an + * exhausted fd table to the caller's argument sends an agent to fix + * something that is fine. Exported so each arm is pinned directly — every + * one of them but EMFILE needs a fault injector to reach through the real + * syscalls, which left three of the four asserted nowhere. */ +export function hostStateFor(code: string | undefined): string | null { + switch (code) { + case 'EMFILE': + return 'this process is out of file descriptors'; + case 'ENFILE': + return 'the system file table is full'; + case 'ENOSPC': + return 'the filesystem is full'; + case 'EDQUOT': + return "this user's disk quota is exhausted"; + case 'EROFS': + return 'the filesystem is read-only'; + case 'EIO': + return 'the underlying storage is reporting I/O errors'; + case 'ESTALE': + return 'the network filesystem handle is stale'; + default: + return null; + } +} + +/** Thrown when an artifact path was claimed by something else DURING the + * capture window. Distinct from a write failure because the cleanup must + * behave differently: the occupant is not ours to remove. */ +class ArtifactCollision extends Error {} + +/** Thrown when the pane never signalled ready. Distinct from a tmux + * failure: the start succeeded and every tmux call returned — what did not + * happen is the holder writing its sentinel, and the refusal reason is + * machine-read, so it must not name a component that did its job. */ +class PaneInitFailed extends Error {} + +/** The contract writes (refusal/success JSON, stderr summary, the reap + * WARNING) must never flip the exit disposition: a gone reader raises an + * ASYNC EPIPE 'error' event that crashes the process at exit 1 with a stack + * trace where the machine-read contract promised exit 3 (or 0). Swallow + * EPIPE only; anything else stays loud. */ +function guardBrokenPipes(): void { + if (brokenPipeGuarded) return; + brokenPipeGuarded = true; + const swallow = (err: NodeJS.ErrnoException): void => { + // EPIPE always: a reader that left (`qwen … | head`) is not this + // command's failure. Anything else only once the evidence is complete — + // measured with an ENOSPC shim and stdout redirected to a file, the + // async 'error' event arrived AFTER a fully successful capture and + // rethrowing it exited 1 with the .ans and manifest both on disk. Before + // that point a stdio fault still propagates: it can mean the refusal + // itself never reached anyone. + if (err.code !== 'EPIPE' && !artifactsComplete) throw err; + }; + process.stdout.on('error', swallow); + process.stderr.on('error', swallow); +} + +export async function runCaptureTui(args: CaptureTuiArgs): Promise { + // Per RUN, not per process: the guard is installed once, but a second + // capture in the same process must not inherit the first one's completion + // — nor its DISPOSITION. `runCaptureTui` is exported and the tests drive + // it repeatedly, so a refusal left exitCode 3 standing and the next + // SUCCESSFUL run reported failure with its artifacts on disk + // (probe-observed: refuse → 3, then a clean capture → still 3). + artifactsComplete = false; + process.exitCode = undefined; + guardBrokenPipes(); + const refuse = (reason: string): void => { + // Exit code FIRST: it is the disposition a harness reads, and the + // writes below can throw synchronously on a closed fd. + process.exitCode = 3; + // TWO try blocks, not one: a synchronous throw from the stderr write — + // a file-backed stderr under ENOSPC throws sync in Node — used to skip + // the stdout JSON entirely, so a HEALTHY stdout got nothing (measured: + // exit 3 with empty stdout). The consumer is an agent that must tell an + // environment refusal from a caller mistake without scraping stderr, so + // the machine-readable half must not depend on the human-readable one. + try { + writeStderrLine(`capture-tui: refused — ${reason}`); + } catch { + // A gone reader cannot un-refuse: the exit code already carries it. + } + try { + // `evidence: 'none'` names the ladder's bottom rung. + writeStdoutLine( + JSON.stringify({ captured: false, evidence: 'none', reason }), + ); + } catch { + // Same. + } + }; + + // --out's shape FIRST, then the stale-artifact clear, then every other + // gate: any refusal that fires with a valid --out and stale artifacts + // still in place hands a consumer the PREVIOUS run's manifest next to + // this run's refusal JSON (measured: a duplicated --command flag against + // a reused --out left all three prior artifacts, manifest still claiming + // "evidence":"png"). Only an --out we cannot even name is allowed to + // refuse without clearing — there is nowhere to clear. + if (typeof args.out !== 'string') { + refuse('--out must be given exactly once, as a string.'); + return; + } + if (args.out.trim() === '') { + // resolve('') is the cwd: artifacts would land as .ans/.png/.json + // NEXT TO the working directory, silently clobbering whatever holds + // those names (the brief's template with an empty variable hits this). + refuse('--out must not be empty.'); + return; + } + const outBase = resolve(args.out); + const ansPath = `${outBase}.ans`; + const pngPath = `${outBase}.png`; + const manifestPath = `${outBase}.json`; + // NOT beside --out: the sentinel is this tool's plumbing, and putting it + // in the user's chosen namespace meant unlinking a path we cannot verify + // is ours — a `report.holder-ready` holding user data was removed before + // a refusal that run was already headed for (probe-reproduced). A + // per-run name under the system temp dir cannot collide with anything of + // the user's, and cannot be stale either: the pid+nonce is this run's + // alone, which is what the unconditional clear used to be for. + // resolve(), because os.tmpdir() hands back a RELATIVE TMPDIR verbatim + // (measured on Node 22): unresolved, this path resolves against the + // LAUNCHER's cwd for our probe and our polling, but against the PANE's + // cwd (--cwd) inside the holder script — so the holder wrote its sentinel + // somewhere we never looked, and the precise early refusal this probe + // exists for silently became a dead ready-gate wait. + const holderReadyPath = join( + resolve(tmpdir()), + `qwen-capture-ready-${process.pid}-${randomBytes(6).toString('hex')}`, + ); + // What sat at each artifact path after the clear phase, by identity. + // `changed` answers the one question the evidence rung and every cleanup + // both ask: did THIS run put it there? + interface Stamp { + existed: boolean; + size: number; + mtimeMs: number; + ino: number; + } + // lstat, never stat: a SYMLINK is an occupant in its own right. Following + // it made a dangling link read as "nothing here" — the collision gate + // never fired and the .ans and manifest writes then followed the link + // OUT of the --out base (probe-verified end to end: a run reported + // success with the manifest naming .json while the bytes landed at + // the link's target). A live-target link was refused correctly; only the + // dangling shape slipped through. + const stampOf = (path: string): Stamp => { + try { + const st = lstatSync(path); + return { existed: true, size: st.size, mtimeMs: st.mtimeMs, ino: st.ino }; + } catch { + return { existed: false, size: 0, mtimeMs: 0, ino: 0 }; + } + }; + const changed = (path: string, stamp: Stamp): boolean => { + // Never taken: nothing here is ours to credit or remove. + if (stamp.size === UNSTAMPED && stamp.ino === UNSTAMPED) return false; + if (!stamp.existed) return occupied(path); + try { + const st = lstatSync(path); + return ( + st.ino !== stamp.ino || + st.size !== stamp.size || + st.mtimeMs !== stamp.mtimeMs + ); + } catch { + // Gone, or unstattable: nothing of ours to credit or remove. + return false; + } + }; + // The collision gate before the capture window is not enough on its own: + // the captured command runs for up to --timeout-ms (70 minutes at the + // cap) and can claim these paths while it runs. Two shapes, both + // probe-reproduced: a command doing `printf … > cap.json` had its file + // silently replaced and the run reported success; and a SYMLINK planted + // at .ans redirected this run's bytes clean out of the --out base — + // the escape the lstat-based gate closes at check time, re-opened through + // the window. So occupancy is decided AGAIN at write time, and the open + // itself refuses to follow a link (O_NOFOLLOW closes the residual race + // between that check and the write). + const writeArtifact = (path: string, stamp: Stamp, data: string): void => { + if (changed(path, stamp)) { + throw new ArtifactCollision( + `${path} was claimed during the capture window by something this ` + + `capture did not write — refusing to replace it`, + ); + } + let fd: number; + try { + fd = openSync(path, ARTIFACT_OPEN_FLAGS, 0o666); + } catch (e) { + // EEXIST never fires without O_EXCL (the atomic create-or-fail form + // is the hardening PR's business): the raced shape this guard was + // written for — a symlink planted between changed() and the open — + // fails the O_NOFOLLOW open with ELOOP, and rethrowing that as a + // generic write failure let the write-failure catch delete the very + // occupant the collision path one syscall earlier explicitly spared + // (probe-verified: same occupant, one microsecond apart, one outcome + // deleted it). Both errnos are the collision. + const code = (e as NodeJS.ErrnoException).code; + // ENXIO joins them: a FIFO that failed the non-blocking open is a + // claimant this run did not write, which is exactly what the other + // two mean. + if (code === 'EEXIST' || code === 'ELOOP' || code === 'ENXIO') { + throw new ArtifactCollision( + `${path} was claimed during the capture window by something this ` + + `capture did not write — refusing to replace it`, + ); + } + throw e; + } + try { + writeFileSync(fd, data, 'utf8'); + } finally { + closeSync(fd); + } + }; + // Fail-safe by CONSTRUCTION, not by comment: `changed()` compares + // identity when `existed` is true, and an impossible ino made every real + // file look changed — the exact opposite of the promise, and it would + // have authorized deletes and credited an evidence rung. A sentinel that + // is byte-equal to nothing must instead answer "unchanged" for anything, + // which is what a NaN-free impossible SIZE plus a matching guard does: + // `changed()` returns false whenever the stamp is this sentinel. + const UNSTAMPED = -1; + const untouched: Stamp = { + existed: true, + size: UNSTAMPED, + mtimeMs: UNSTAMPED, + ino: UNSTAMPED, + }; + // Occupancy is a LINK-level question everywhere it is asked: existsSync + // follows symlinks, so a dangling one answered false at every gate below. + const occupied = (path: string): boolean => { + try { + lstatSync(path); + return true; + } catch { + return false; + } + }; + // What this run's own `.ans` write left on disk, by identity — the render + // stage pins its staged input against it. + let ansWritten: Stamp = untouched; + let ansStamp: Stamp = untouched; + let pngStamp: Stamp = untouched; + let manifestStamp: Stamp = untouched; + try { + // Clears FIRST — before even mkdir and before the directory-shaped + // --out refusal below: under fd exhaustion (EMFILE, measured on macOS) + // mkdirSync itself can throw, and EVERY nameable refusal must leave no + // stale capture evidence ("only an --out we cannot even name refuses + // without clearing"). But clear ONLY what looks like a previous + // capture: the artifact names are not reserved, and a colliding --out + // must not force-delete an unrelated file on a run that later refuses + // (measured: --out package deleted package.json at the --cols 0 + // refusal — the brief's template puts --out inside the plan dir). A + // manifest that parses with an evidence rung is the capture's own + // signature; .ans/.png/.json clear alongside it. The sentinel clears + // unconditionally: this tool alone writes it, and a stale one from a + // SIGKILL'd run would pass the ready gate before the new holder + // installs its trap. + const clearArtifact = (path: string): void => { + // Plain unlink only — no descriptor needed, so it survives EMFILE — + // and a throw is SWALLOWED rather than escalated to a recursive rm. + // A capture writes files; a DIRECTORY at an artifact path was made by + // someone else, and the recursive fallback destroyed it and its + // contents on every re-run against the documented same-`--out` usage + // (a stale shaped manifest is the normal state from the second run + // on — no fd exhaustion needed). Skipping it keeps the clear going + // for the other paths; a directory still standing where this run must + // write turns into the write-failure refusal downstream, which is the + // fail-closed outcome. + try { + rmSync(path, { force: true }); + } catch { + // Not ours to delete (EISDIR), or unlinkable for a reason the + // write probe below will name. + } + }; + let shaped = false; + let manifestHadPng = false; + let manifestFd: number | undefined; + try { + // A FIFO here would block readFileSync FOREVER — a hang, not a throw, + // so no refusal is printed, no reap handler is installed yet, and NO + // timeout inside the process can interrupt it: the read is + // synchronous on the main thread (measured — a vitest run wedged past + // its own 10s test timeout until the runner killed it). Only a + // regular file can be a capture manifest anyway; anything else is + // unverifiable, which the catch below already treats as "not ours". + // ONE descriptor for both the checks and the read: lstat verified a + // path, readFileSync then re-resolved it, and a racer swapping + // .json between the two re-opened both failure classes these + // checks close — a FIFO that blocks the synchronous read forever, and + // an arbitrarily large file that dies on the heap limit. O_NOFOLLOW + // keeps the lstat semantics (a symlink is not a manifest of ours), + // and fstat asks about the file this fd is already holding open. + manifestFd = openSync( + manifestPath, + fsConstants.O_RDONLY | + (fsConstants.O_NOFOLLOW ?? 0) | + // Never BLOCK on the open either: a FIFO opened read-only waits + // for a writer, which is the same hang one step earlier. + (fsConstants.O_NONBLOCK ?? 0), + ); + const st = fstatSync(manifestFd); + if (!st.isFile()) throw new Error('not a file'); + // A capture manifest is a few hundred bytes. Reading an arbitrarily + // large regular file here killed the process before any refusal could + // print — measured, a 479MB dense JSON at .json hit + // `FATAL ERROR: Reached heap limit` — and lstat already has the size, + // so the cap costs nothing. Too big to be ours: treat as unverified. + if (st.size > MAX_MANIFEST_BYTES) throw new Error('too large'); + // Read a BOUNDED number of bytes, not "to EOF": fstat measured the + // file once, and readFileSync then reads to the LIVE end of the + // pinned inode — an appender writing through its own descriptor + // defeats the cap and reproduces the heap-limit death the cap was + // measured against. The cap+1 read also makes "it grew past the cap + // between the fstat and here" observable rather than silent. + const buf = Buffer.allocUnsafe(MAX_MANIFEST_BYTES + 1); + const read = readSync(manifestFd, buf, 0, buf.length, 0); + if (read > MAX_MANIFEST_BYTES) throw new Error('too large'); + const m = JSON.parse(buf.subarray(0, read).toString('utf8')) as { + evidence?: unknown; + pngPath?: unknown; + ansPath?: unknown; + settledBy?: unknown; + }; + // Ownership is the manifest naming THESE artifacts, not a field value + // that happens to read like ours. An evidence rung alone is a weak + // signature: a user's own `report.json` carrying `evidence: "png"` + // authorized deleting `report.ans` and `report.png` beside it + // (probe-reproduced — all three gone before the refusal that run was + // headed for). Every manifest this tool writes records the absolute + // `ansPath` it wrote and how the capture settled; requiring both, and + // requiring the path to be the one we are about to write, is what + // makes "a previous capture's own artifacts" mean that and nothing + // else. + shaped = + m !== null && + typeof m === 'object' && + (m.evidence === 'png' || m.evidence === 'ans-only') && + typeof m.ansPath === 'string' && + // The STRING, not what it resolves to: every manifest this tool + // writes records `resolve(--out) + '.ans'` already, so a RELATIVE + // ansPath is a shape it never produces. Resolving first accepted + // one — `{"ansPath":"cap.ans"}` in a foreign JSON, run with the cwd + // at that directory, passed the signature and deleted all three of + // the user's files (probe-reproduced). Strict comparison keeps + // every manifest this tool actually wrote and no other. + m.ansPath === ansPath && + (m.settledBy === 'until-match' || + m.settledBy === 'timeout' || + m.settledBy === 'fixed-delay'); + // The png clear needs the manifest to have CLAIMED a png: the + // evidence rung AND a recorded pngPath naming THIS .png. An + // ans-only manifest says this tool never wrote one, so whatever sits + // at .png belongs to someone else — and clearing it on the next + // run against the same --out (the documented reuse shape) destroyed + // a foreign file the previous run had deliberately spared. + // The evidence rung ALONE is one signature rung short: every png-rung + // manifest this writer produces records that exact path, so a + // signature-passing manifest whose pngPath names ANOTHER file is + // internally inconsistent, and it deleted a foreign .png its + // pngPath never named (probe-reproduced) — the same harm the + // ans-only half of this guard closes. + manifestHadPng = shaped && m.evidence === 'png' && m.pngPath === pngPath; + } catch { + // ANY read failure means the capture signature could not be verified — + // including fd exhaustion (EMFILE/ENFILE), which is transient under + // the concurrent captures the briefs encourage. Unverified is NOT + // permission to delete: clearing here deleted unrelated user files at + // the artifact names on a run that then refused (measured), against + // this block's own invariant. Stale evidence left beside a refusal is + // the lesser harm — the refusal names itself, a deleted file does not. + shaped = false; + } finally { + if (manifestFd !== undefined) { + try { + closeSync(manifestFd); + } catch { + // Nothing downstream depends on this close succeeding. + } + } + } + if (shaped) { + clearArtifact(ansPath); + if (manifestHadPng) clearArtifact(pngPath); + clearArtifact(manifestPath); + } + // Belt only, and no longer load-bearing: the sentinel is minted per run + // under the system temp dir with 48 random bits, so nothing of a + // previous run — nor anything of the USER'S — can be sitting at this + // path. It used to be derived from --out, where all three hazards were + // live: a stale sentinel from a SIGKILL'd run passed the ready gate + // before the new holder installed its trap; a user's DIRECTORY at the + // name threw EISDIR (which `force` does not suppress) and refused the + // run; and recursive removal destroyed that directory on every run, + // successful ones included (all measured). Ordering still holds — after + // the clears, never before — so a removal that throws cannot strand the + // previous run's evidence:"png" manifest beside a refusal. The throw + // itself belongs to the dedicated TMPDIR gate below: `force` suppresses + // ENOENT only, so an existing-but-unusable TMPDIR threw HERE and reached + // the --out-attributing catch, shadowing the gate's naming refusal + // (probe-reproduced: a mode-0600 directory answered EACCES and a regular + // file ENOTDIR, both read as '--out is not writable', which no --out + // value can fix). + try { + rmSync(holderReadyPath, { force: true }); + } catch { + // Belt only — the TMPDIR gate below owns the refusal wording. + } + // Anything still sitting at an artifact path is something this run did + // not write and did not clear (an unverifiable manifest, an unrelated + // file the signature check spared). Stamp all three BY IDENTITY: the + // write probe proves only that a path was writable BEFORE the capture + // window, and a write can still fail at open() — under the fd + // exhaustion this file names three times as measured — having + // truncated nothing, leaving the previous file whole. Deleting then + // destroys a file this run never touched; crediting one as this run's + // rendering evidence is worse still. + ansStamp = stampOf(ansPath); + pngStamp = stampOf(pngPath); + manifestStamp = stampOf(manifestPath); + mkdirSync(dirname(outBase), { recursive: true }); + // Probe the actual write target BEFORE any process starts: mkdirSync + // with `recursive` does no permission check on a directory that already + // exists, so an unwritable --out would otherwise run the full capture — + // up to the 1h ceiling — and lose the pane text at the very last write. + // The probe uses a UNIQUE sibling, never the .ans itself: truncating the + // real one would delete the previous run's text while its manifest/png + // survive to misdescribe it. + const probePath = `${outBase}.write-probe-${randomBytes(4).toString('hex')}`; + const fd = openSync(probePath, 'w'); + closeSync(fd); + rmSync(probePath, { force: true }); + // A file the clear phase SPARED still occupies a path this run must + // write. Refuse before anything starts rather than truncate it: the + // clear verified it is not a capture manifest's artifact, so it belongs + // to someone else, and a successful run silently replaced it (measured + // with the collision the clear's own comment names — `--out package` in + // a Node project rewrote package.json as a capture manifest at exit 0, + // no degradation recorded). A previous capture's OWN artifacts never + // reach here: the clear removed them. + // Only the MANDATORY writes refuse: the .ans and the manifest are + // written on every successful run, so a survivor there can only be + // replaced. The png is a RUNG, not a requirement — an occupied png path + // degrades the ladder instead (see the render below), which keeps a + // capture that can still produce text evidence from failing outright. + for (const path of [ansPath, manifestPath]) { + if (!occupied(path)) continue; + refuse( + `--out collides with a file this capture did not write: ${path}. ` + + "A previous capture's own artifacts are cleared automatically; " + + 'this one is not ours to replace. Pick another --out.', + ); + return; + } + // No per-path writability probe beyond this: the collision gate above + // refuses on ANY survivor at the two mandatory paths, so a directory, a + // read-only file, an append-only one (chattr +a, which an append-mode + // probe would have passed and the truncating final write would then + // have failed) — every shape that could fail the last write — is + // already refused before the capture window opens. + } catch (e) { + // WHOSE fault it is, not just what failed: this block wraps the mkdir, + // the write probe and the clear-phase removals, and it attributed fd + // exhaustion and a full disk to `--out` — telling an agent consumer to + // fix an argument that is fine while the host is the thing that is not. + // The refusal reason is machine-read, so the misattribution propagates. + const hostState = hostStateFor((e as NodeJS.ErrnoException).code); + refuse( + hostState + ? `--out could not be prepared — ${hostState}, not a problem with ` + + `the argument: ${e instanceof Error ? e.message : String(e)}` + : `--out is not writable: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + // resolve('.') and resolve('./') are the cwd itself — the same shape the + // empty guard above refuses — and any existing directory sails through: + // artifacts would land as .ans/.png/.json NEXT TO the directory, + // silently clobbering whatever holds those names. AFTER the clears, like + // every other nameable refusal. + let outIsDirectory = false; + try { + outIsDirectory = statSync(outBase).isDirectory(); + } catch { + // A nonexistent out base is the normal shape. + } + if (outIsDirectory) { + refuse(`--out must not name an existing directory: ${outBase}`); + return; + } + // Remaining shape guards: yargs parses a DUPLICATED string option into an + // array (`--command A --command B` → ['A','B']) and its default --no-X + // negation into a boolean (`--no-command` → false) — both sail through + // the `as` casts and either throw uncaught TypeErrors past the refusal + // contract or silently corrupt the capture (`--until A --until B` + // compiles /A,B/; `--no-keys` types the literal word "false" into the + // pane). --command also refuses undefined: demandOption covers the CLI + // path, but runCaptureTui is exported. + if (typeof args.command !== 'string') { + refuse('--command must be given exactly once, as a string.'); + return; + } + for (const [name, v] of [ + ['--cwd', args.cwd], + ['--until', args.until], + ['--ready', args.ready], + ] as const) { + // The EMPTY form too, and --cwd is why: `resolve('')` is the launcher's + // own cwd, which always passes the enterability gate — so an empty + // --cwd (a brief template expanding a missing variable, the same shape + // the --keys gates refuse) silently captured somewhere the caller never + // named, and the manifest recorded that directory as if it had been + // asked for. The marker flags get it for free: an empty --until is a + // pattern that matches everything, settling on the first frame. + if (typeof v === 'string' && v.trim() === '') { + refuse(`${name} must not be empty.`); + return; + } + if (v !== undefined && typeof v !== 'string') { + refuse(`${name} must be given exactly once, as a string.`); + return; + } + } + if (args.keys !== undefined) { + if (!Array.isArray(args.keys)) { + refuse('--keys must be given exactly once, as strings.'); + return; + } + if (args.keys.some((k) => typeof k !== 'string')) { + // yargs's default --no-X negation parses an array option to [false] + // (probed on this repo's yargs): the caller supplied no key tokens + // at all, so the refusal says THAT — the sibling flags' negation + // message — not "must be strings", which sends an agent consumer + // inspecting tokens it never passed. + refuse( + args.keys.every((k) => typeof k === 'boolean') + ? '--keys must be given exactly once, as strings.' + : '--keys must be strings.', + ); + return; + } + // An EXACTLY empty token types nothing (`send-keys ''` is a no-op), and + // the run then reports success with the token recorded in the manifest + // and keysSent true — a keypress a verdict can cite that never happened, + // the same evidence-corruption class as an unescaped trailing `;`. A + // brief template expanding an empty variable produces exactly this. A + // token of one SPACE is real input and stays legal (measured: it types). + if (args.keys.some((k) => k === '')) { + refuse('--keys must not contain an empty token.'); + return; + } + // A BARE `--keys` (or `--keys=`, or an unquoted `--keys $EMPTY`) parses + // to [] under yargs `array: true` and used to be accepted silently: the + // run typed nothing and reported success, while the quoted form of the + // very same template failure (['']) refused. Both are a brief that + // meant to drive the TUI and did not — the manifest must not carry a + // keys field the run never acted on. + if (args.keys.length === 0) { + refuse('--keys was given with no tokens.'); + return; + } + } + const tmuxProbe = probes.tmux(); + if (tmuxProbe.status === 'hung') { + refuse( + tmuxProbe.code + ? `tmux could not be probed (${tmuxProbe.code}) — ` + + (tmuxProbe.spawned + ? 'the binary ran and failed, so it is installed but not ' + + 'usable here. Fix the installation; rendering claims stay ' + + 'argued from the code until it answers.' + : 'the binary may be installed; this host could not spawn ' + + 'it. Retry once the condition clears; rendering claims ' + + 'stay argued from the code until it answers.') + : `tmux did not answer -V within ${probeBudget.timeoutMs}ms — present ` + + 'but wedged, not absent. Fix or restart the tmux binary; rendering ' + + 'claims stay argued from the code until it answers.', + ); + return; + } + if (tmuxProbe.status === 'absent') { + refuse( + 'tmux is not installed, or is installed but cannot be executed (a ' + + 'broken interpreter line answers the same ENOENT). Rendering claims ' + + 'stay argued from the code on this host; say so in the finding ' + + 'rather than describing an imagined terminal as evidence.', + ); + return; + } + // The reader caps a manifest at MAX_MANIFEST_BYTES, and the WRITER had + // no bound at all: --command, every --keys token, --ready and --until go + // in verbatim, and a few large tokens sum past 1 MiB while still fitting + // in ARG_MAX. Such a run wrote a manifest its own next run cannot verify + // — the artifacts stop being clearable and every re-run refuses on the + // collision instead. Measured from the arguments, before the window + // opens, with room left for the fields this run adds later. + // In BYTES, not code units: the bound and the reader enforcing it count + // UTF-8 bytes, and `.length` counts UTF-16 code units — multibyte + // arguments between half the cap in characters and the cap in bytes + // passed this gate and were rejected by the reader on the next run + // (probe-reproduced with CJK --keys tokens). + // In the WRITER'S shape — JSON.stringify(manifest, null, 2) — not the + // dense one: pretty-printing an array of many small elements expands + // ~2.25x, and the dense measure passed manifests the reader cap then + // rejected on the next run (probe-reproduced with many one-char --keys + // tokens: the dense bytes passed the gate while the pretty bytes the + // writer emitted overflowed the cap and wedged the same--out reuse). + const embedded = Buffer.byteLength( + JSON.stringify( + { + command: args.command, + keys: args.keys, + ready: args.ready, + until: args.until, + cwd: args.cwd, + ansPath, + pngPath, + }, + null, + 2, + ), + 'utf8', + ); + if (embedded > MAX_MANIFEST_BYTES / 2) { + refuse( + `the arguments would not fit a readable manifest: they serialize to ` + + `${embedded} bytes, and a capture manifest this command can verify ` + + `on a later run is capped at ${MAX_MANIFEST_BYTES}. Shorten ` + + `--command/--keys, or point them at a script.`, + ); + return; + } + + // The base the server will ACTUALLY start under, by tmux's own rule: + // the first USABLE one. An unusable TMUX_TMPDIR (nonexistent, + // unwritable) is not where the socket lands — measuring the socket path + // against it anyway refused runs that were about to succeed under /tmp, + // which is how the length gate below was caught being wrong the first + // time. Remembered for the reap: whether a base COULD hold the server + // decides what a failed kill there establishes, and the reap cannot + // re-derive start-time state after a window in which the base itself + // can be destroyed. + let startBase = '/tmp'; + { + const envBase = process.env['TMUX_TMPDIR']; + if (envBase) { + // Unusable means tmux falls back to /tmp, and so does this + // measurement. RESOLVED: a relative TMUX_TMPDIR measured verbatim + // under-counts the real socket path by the whole cwd — the same + // split-resolution hazard this file already met one gate earlier with + // TMPDIR, where the probe and the holder disagreed about what the + // path meant. + if (baseIsUsable(envBase)) startBase = resolve(envBase); + } + } + + // A unix socket path is bounded by sockaddr_un — 104 bytes on macOS, 108 + // on Linux — and tmux builds this run's from the socket base, the uid + // directory and the private server name. Over the limit, the start + // SUCCEEDS and the first control call fails with `error connecting to … + // (File name too long)`: a mid-capture refusal, after paying for a + // server start, blaming tmux for a path this command chose. Measured + // with a TMUX_TMPDIR under a mkdtemp base — not an exotic shape at all, + // since that is where a CI job's scratch directory lives. + if (process.getuid) { + // Measure the CANONICAL base: tmux resolves a symlinked base before it + // binds, and the sockaddr_un bound applies to the canonical path — a + // lexical measure admitted runs whose real path was over the bound + // (then refused mid-capture, blaming tmux for a path this command + // chose) and refused runs whose real path fit. macOS meets this by + // default (/tmp -> /private/tmp). Lexical fallback when the base does + // not resolve — the same shape cleanup.ts's base dedup uses. + let measuredBase = startBase; + try { + measuredBase = realpathSync(startBase); + } catch { + // Unresolvable: the lexical measure is all there is. + } + const socketPath = join( + measuredBase, + `tmux-${process.getuid()}`, + // The nonce is 8 hex chars for every run — its VALUE cannot change + // the length, so measuring a representative one measures them all. + captureServerName(process.pid, 'deadbeef'), + ); + // The conservative bound of the two, less a byte for the NUL. + if (Buffer.byteLength(socketPath) > 103) { + refuse( + `the tmux socket path this capture would use is too long for a ` + + `unix socket (${Buffer.byteLength(socketPath)} bytes): ` + + `${socketPath}. Point TMUX_TMPDIR at a shorter directory.`, + ); + return; + } + } + + // The sentinel's HOME is the one path this run writes that no gate looks + // at. It moved into the system temp dir precisely so nothing a user can + // name collides with it — which also took it out from behind --out's + // write probe. An unusable TMPDIR (nonexistent, or not writable) showed + // up as the holder simply never becoming ready: the whole --timeout-ms + // burned, then a refusal blaming the capture for an environment problem + // named nowhere (probe-verified driving the real command). Probed here, + // before any server starts, so it refuses in milliseconds and says why. + try { + // Directoryness FIRST, mirroring the TMUX_TMPDIR gate above: a regular + // file (or a symlink to one) passes W_OK|X_OK on some hosts, and a + // file-shaped TMPDIR then sailed through the very gate added to catch + // it — burning the full holder-init window before refusing with a + // wording that blamed the pane and named TMPDIR nowhere (probe- + // reproduced on a 0777 regular file). + const sentinelDir = dirname(holderReadyPath); + if (!statSync(sentinelDir).isDirectory()) { + throw new Error('not a directory'); + } + accessSync(sentinelDir, fsConstants.W_OK | fsConstants.X_OK); + } catch (e) { + refuse( + `the temporary directory is not usable for this capture's ready ` + + `sentinel: ${dirname(holderReadyPath)} (${ + e instanceof Error ? e.message : String(e) + }). Point TMPDIR at a writable directory.`, + ); + return; + } + const tmuxVersion = tmuxProbe.out; + if (tmuxSupportsCaptureN(tmuxVersion) === false) { + refuse( + `${tmuxVersion} is too old: capture-pane -N needs tmux 3.1 or newer.`, + ); + return; + } + const geometry = validGeometry(args.cols, args.rows); + if (!geometry.ok) { + refuse(geometry.reason); + return; + } + if (args.command.trim() === '') { + refuse('--command must not be empty.'); + return; + } + // No trailing-backslash gate: the two-shell holder single-quotes the + // command at every layer (esc balances every quote), so no shell ever + // parses the command text adjacent to the hold line — probe-verified with + // four odd-run shapes on the production plan, all executed correctly with + // the holder alive. The old gate protected the earlier single-shell + // holder and over-refused valid commands. + if (args.cwd !== undefined) { + // tmux new-session -c with an unusable directory exits 0 and silently + // runs the pane in the launching process's cwd — evidence from the + // wrong directory with nothing recording the swap. stat alone is not + // enough: a directory without +x stats fine but cannot be entered + // (measured: mode-644 --cwd passed the gate and the manifest lied). + let usable = false; + try { + const abs = resolve(args.cwd); + usable = statSync(abs).isDirectory(); + if (usable) accessSync(abs, fsConstants.X_OK); + } catch { + usable = false; + } + if (!usable) { + refuse(`--cwd is not an enterable directory: ${args.cwd}`); + return; + } + } + // yargs coerces a non-numeric `--settle-ms abc` to NaN, and a NaN + // deadline makes the --until poll loop unexpirable (`now >= NaN` is + // always false). Refuse, don't hang — and refuse the out-of-bounds + // values too, so a day-long timeout cannot be requested by typo. + for (const [name, v, max] of [ + ['--settle-ms', args.settleMs, 600_000], + ['--timeout-ms', args.timeoutMs, 3_600_000], + ] as const) { + if (!Number.isFinite(v) || v < 0 || v > max) { + refuse(`${name} must be a number in [0, ${max}], got ${String(v)}`); + return; + } + } + // Validate the regex BEFORE any process starts: an invalid pattern is a + // caller mistake and gets the refusal contract, not a stack trace thrown + // from inside a running capture. + // --ready: measured on this repo's own onboarding TUI, keys fired at start + // straddle the UI's mount — a Down was consumed and the Enter behind it + // lost — so key-driven captures of anything that takes a moment to render + // are unreliable without a gate. The gate is a marker, like --until. + // A marker that matches a BLANK pane settles before the TUI rendered + // anything — a false settle claim (or keys fired into a still-mounting UI, + // the exact failure --ready exists to prevent). Empty/whitespace patterns + // are the obvious case; `.?`, `x*`, `^`, `\s` and `\n` are the sneaky + // ones: the blank pane's logical capture is rows of newlines, not the + // empty string, so both oracles are probed (through the match budget — a + // backtracking pattern must not hang the gate itself). + const compileMarker = ( + name: '--ready' | '--until', + pattern: string, + ): RegExp | { error: string } => { + if (pattern.trim() === '') return { error: `${name} must not be empty.` }; + let re: RegExp; + try { + re = new RegExp(pattern); + } catch (e) { + return { + error: `${name} is not a valid regex: ${e instanceof Error ? e.message : String(e)}`, + }; + } + const blankPane = '\n'.repeat(args.rows); + if ( + testWithBudget(re, '') === 'match' || + testWithBudget(re, blankPane) === 'match' + ) { + return { + error: + `${name} ${JSON.stringify(pattern)} matches a blank pane — it ` + + `would settle before the UI rendered anything; pick a marker the ` + + `claim actually draws`, + }; + } + return re; + }; + let readyRe: RegExp | undefined; + if (args.ready !== undefined) { + const r = compileMarker('--ready', args.ready); + if ('error' in r) { + refuse(r.error); + return; + } + readyRe = r; + } + let untilRe: RegExp | undefined; + if (args.until !== undefined) { + const r = compileMarker('--until', args.until); + if ('error' in r) { + refuse(r.error); + return; + } + untilRe = r; + } + + const server = captureServerName(process.pid, randomBytes(4).toString('hex')); + const session = 'cap'; + const resolvedCwd = args.cwd ? resolve(args.cwd) : process.cwd(); + // Measured on 3.2a (what Ubuntu 22.04 ships): `capture-pane -p -N` padded + // a three-character line out to the grid's allocated width with 17 + // phantom spaces, and its tmux has no -T. Trailing spaces are dropped + // there rather than fabricated, and the manifest says so. + const capturePads = tmuxPadsWithCaptureN(tmuxVersion) === true; + const captureTrims = tmuxSupportsCaptureT(tmuxVersion) === true; + // BEFORE the start, not after a collapsed capture: the holder cannot hold + // a pane open without `sleep`, and a bare name in the held script would + // resolve through the pane's PATH — the watchdog then exits 127 and runs + // `kill -9 -$$` on the pane group, which reads downstream as "the command + // rendered nothing". Refusing here names the real cause and starts no + // server. + const sleepBin = probes.sleepBin(); + if (sleepBin === undefined) { + refuse( + 'sleep is not on PATH — the pane holder runs it to keep the capture ' + + 'window open, and it is resolved here rather than inside the pane. ' + + 'Nothing was started.', + ); + return; + } + + const plan = tmuxPlan({ + server, + session, + cols: args.cols, + rows: args.rows, + command: args.command, + cwd: resolvedCwd, + // Only 3.4+ has the flag; older versions have nothing to trim. + captureTrim: captureTrims, + // ...and 3.1-3.2.x invent trailing spaces with -N and cannot undo it. + captureTrailing: !capturePads, + readyFile: holderReadyPath, + sleepBin, + }); + + // The no-orphan guarantee cannot rest on `finally` alone: a SIGINT/SIGTERM + // (an operator's Ctrl+C on an un-settling capture, a harness reaping a + // stuck one) skips finally and would leave the server, its socket, and the + // captured TUI alive. The handler reaps first and then re-raises so the + // exit code stays the conventional one for the signal — removing only + // itself before the re-raise, so any OTHER handler the host process + // installed sees the signal a second time; accepted for a leaf + // subcommand, which this is. + let serverStarted = false; + // The inode of the socket start bound under the start base, when start + // produced one: the reap trusts goal-state kill verdicts about that base + // only while this identity survives (see the reap below). + // What start's socket looked like, by identity — see isSameSocket for why + // the comparison is wider than an inode. + let socketStamp: SocketStamp | undefined; + const isStampedSocket = (st: SocketStamp): boolean => + socketStamp !== undefined && isSameSocket(socketStamp, st); + // Whether the start call itself threw: the reap admits the + // socket-directory-never-created wording on the start base only under + // this flag (see the reap below) — the stamp cannot carry the + // distinction, since it is also absent when stamping fails after a + // successful start. + let startThrew = false; + let reaped = false; + const reap = (): void => { + // serverStarted is set BEFORE the start call: the call forks the + // server before it returns, so a start that threw or was belt-cut can + // still have a live server behind it — skipping the reap on that path + // orphaned server, session and holder (measured on loaded runners). + // kill-server against a server that never came up answers "no server + // running" — the goal state below — so the attempt stays warning-free. + if (reaped || !serverStarted) return; + reaped = true; + // Unlink the socket ONLY when the server is known dead — and kill + // WHERE THE SERVER ACTUALLY LIVES: tmux resolves the socket base from + // the CLIENT's environment at kill time, while the server started under + // whichever base was USABLE at start, and the two can diverge inside + // one window (a stale TMUX_TMPDIR pointing at an unusable path puts the + // socket under /tmp; the env base becoming usable before the reap puts + // the CLIENT elsewhere — captures legally run up to an hour). A bare + // kill then answers tmux's "nothing to kill" wordings ABOUT THE WRONG + // BASE — the goal state, with the server alive under the other base — + // and the unlink that trusted the verdict orphaned it: socket gone, no + // WARNING, the holder alive up to three hours, invisible to the orphan + // sweep that discovers orphans by readdir of the very socket dirs the + // unlink just emptied (probe-reproduced on 3.4). Kill can ALSO throw + // with the server alive (the tmux CLIENT failing to spawn — EMFILE, a + // wedged server outlasting the 15s timeout), and unlinking then makes + // the live server unreachable forever — nothing addressable by -L can + // ever kill it again, while it holds the pane holder (the bounded hold + // loop runs up to three hours). So: kill each candidate base with that + // base pinned in the environment — the shape cleanup.ts's sweep uses, + // "kill it where it was FOUND" — and unlink a base only when THAT + // base's own kill answered the goal state. + // One retry per base before giving up: a transient client-spawn failure + // is the named shape, and a second attempt reaps it (measured). + let unconfirmed = false; + let confirmedDead = false; + let killSpawnFailed = false; + let killDirUnusable = false; + let plantedEntry = false; + let killBaseUnusable = false; + const uid = process.getuid?.(); + /** Whether the socket start bound is still the one at its path — read + * WHERE THE VERDICT IS, never once up front. A goal-state verdict about + * the start base is about THIS run's server only while the stamped + * socket survives unchanged, and the loop below spans both bases' + * attempts, a retry and a 15s belt: a snapshot taken before all of that + * is stale by the time anything is credited, so a survivor that renamed + * the live socket away mid-reap and left a creditable occupant in its + * place passed the check and had its replacement's verdict credited — + * and then unlinked. The old justification for hoisting it ("a kill + * this loop credits unlinks the socket, and that removal is this reap's + * own") does not need the hoist: each base's verdict is evaluated + * BEFORE that base's unlink, so a read here can never misread this + * reap's own removal. What stays open is the rename between this read + * and the unlink that follows it — the same interval the entry guard + * documents, and the same reason: Node exposes no `*at()` syscalls. */ + const stampedSocketAlive = (): boolean => { + if (socketStamp === undefined || uid === undefined) return false; + try { + return isStampedSocket( + lstatSync(join(startBase, `tmux-${uid}`, server)), + ); + } catch { + return false; + } + }; + // Untrimmed, matching tmux (a padded value is used verbatim). Same + // candidate set as before: tmux takes the first USABLE base. + const envBase = process.env['TMUX_TMPDIR']; + // De-duplicated by the directory a kill would actually REACH, not by the + // raw string — the rule cleanup.ts's sweep already states for its own + // scan. `/tmp/`, `/tmp/.` and a TMUX_TMPDIR symlinked to /tmp are + // different strings naming one base, and visiting it twice matters now + // that identity is read at verdict time: the first visit credits and + // unlinks, and the second would read the socket it just removed as a + // swap and warn about an orphan it had itself reaped. + const candidateBases: string[] = []; + const seenBases = new Set(); + for (const base of [envBase || '/tmp', '/tmp']) { + let key = base; + try { + key = realpathSync(base); + } catch { + // Unresolvable: the raw path is a fine key, and a base that cannot + // be resolved is not one another candidate silently aliases. + } + if (seenBases.has(key)) continue; + seenBases.add(key); + candidateBases.push(base); + } + for (const base of candidateBases) { + // The kill below addresses the socket by NAME, and tmux connects to + // whatever that name resolves to at connect() time. The captured + // command is untrusted code running under this uid — that is what a + // review captures — and it knows this path from `$TMUX`; a symlink or + // a HARD LINK planted there points the pinned kill at another server, + // and `kill-server` destroys THAT one with exit 0 while + // `confirmedDead` credits the success globally and nothing warns. + // Measured on 3.4: a two-link socket entry killed the server on the + // other end of the link. That is the PR's headline premise — a capture + // cannot kill the user's own sessions — failing, so the entry is + // inspected before anything connects to it, the same three shapes the + // sibling sweep in cleanup.ts rejects. The residual rename race + // between this lstat and tmux's connect() has no portable close (Node + // exposes no `*at()` syscalls); what closes here is the case of no + // check at all. + let planted = false; + if (uid !== undefined) { + try { + const entry = lstatSync(join(base, `tmux-${uid}`, server)); + planted = + // The two shapes that REDIRECT a connect: tmux follows a + // symlink to its target, and connect(2) is inode-addressed, so + // a hard link reaches the foreign server race-free. A + // non-socket entry is not among them — connect fails ENOTSOCK + // and kills nothing — which is why this rule is narrower than + // the sweep's: that one also UNLINKS entries it did not + // create, and has to know a tmux socket from a stranger's + // file. Here the path carries this run's own unique name. + entry.isSymbolicLink() || + // A tmux-created socket has exactly one link, so more is never + // this run's own. + entry.nlink > 1 || + // Identity only on the base the stamp was taken under: the + // server binds ONE socket, and only there does a differing + // inode mean the entry was swapped rather than that this base + // never held it. + // + // FAIL-CLOSED on a missing stamp, because the stamp is absent in + // two states and one of them is the attack: a start that bound + // under the other base leaves no entry here at all (the lstat + // above throws and nothing is refused), while a stamp that + // FAILED after a successful start leaves the check with nothing + // to compare — and a plain foreign socket bound at this run's + // own name then passed all three tests and took the pinned + // kill. An entry standing here that this run cannot show is its + // own is not connected to; the WARNING carries the manual + // command, which is the disclosed cost of that choice. + (resolve(base) === startBase && + (socketStamp === undefined || !isStampedSocket(entry))) || + // A stamp PROVES the bind happened on the start base — the same + // fact `confirmedDead` already leans on. So on any OTHER base a + // socket at this run's unique name cannot be ours, and the kill + // must not connect to it: without this the identity arm above + // was gated on the start base and a plain foreign socket + // renamed onto the name under /tmp passed the symlink/nlink + // tests and took the pinned kill (measured: the user's own + // server destroyed, exit 0, no WARNING). Uses only the run's own + // stamp, no forgeable identity signal. + (socketStamp !== undefined && resolve(base) !== startBase); + } catch { + // Absent or unstattable: there is nothing planted to connect + // through, and the kill's own goal-state wordings already answer + // for this base. + } + } + if (planted) { + // Never killed, and never unlinked either — the entry may BE the + // user's own socket, reached through a link. Doubt stays visible: + // this run's server may still be standing behind the real path. + plantedEntry = true; + unconfirmed = true; + continue; + } + let baseDead = false; + for (let attempt = 0; attempt < 2 && !baseDead; attempt++) { + // Back-to-back, the second attempt was a copy of the first: under fd + // exhaustion — the condition the comment above names, and the one + // that makes the CLIENT fail rather than the server — both threw + // EMFILE in the same microsecond and the retry bought nothing + // (probe-verified). A pause cannot conjure a descriptor on its own, + // but it is the only thing that lets one this process is releasing + // elsewhere land before the last attempt. Synchronous by necessity: + // reap() runs from `finally` and from a signal handler, neither of + // which can await. + if (attempt > 0) { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } catch { + // Blocking waits are disallowed on some hosts; the retry still + // happens, just without the pause. + } + } + try { + tmux(plan.kill, { ...process.env, TMUX_TMPDIR: base }); + // WHERE THE CLIENT LOOKED, not where it was aimed. `-L` pins the + // base in the environment; it does not bind tmux to it, and an + // unusable base sends the client to /tmp — the same divergence + // the catch branch below defends against with + // verdictExaminedBase, which this success path had no equivalent + // of. A base destroyed mid-window therefore sends this kill to + // /tmp, where a sacrificial server bound at this run's unique + // name answers exit 0; crediting that silenced the WARNING over + // this run's own still-live server. An exit 0 from a base the + // client could not have examined establishes nothing HERE, so it + // does not end this base's attempts either — it leaves the doubt + // that the WARNING is for. Only with a stamp: without one the + // bind site is unknown, a fallback kill that succeeded killed + // whatever was actually there, and there is nothing better to + // weigh it against. + if ( + socketStamp !== undefined && + resolve(base) === startBase && + !baseIsUsable(base) + ) { + killBaseUnusable = true; + break; + } + baseDead = true; + // Death established GLOBALLY only where this kill CAN have reached + // this run's server. The name is unique to this run, but uniqueness + // is not exclusivity under this file's threat model: the captured + // command reads the name from `$TMUX` and can bind a sacrificial + // server at that name under the OTHER candidate base, whose exit-0 + // kill then vouched for a server it never touched and silenced the + // orphan WARNING for the real one. A present stamp PROVES the bind + // happened on the start base, so a success anywhere else cannot be + // ours; with no stamp the bind site is unknown and any base's + // success is the best evidence there is — which is the fallback + // shape this inference was written for. + if (resolve(base) === startBase || socketStamp === undefined) { + confirmedDead = true; + } + } catch (e) { + // A spawn that never ran says nothing about the server: the + // WARNING has to separate "tmux told us it failed" from "we could + // not run tmux at all", or an operator reads a wedged server where + // the real problem is this process's fd table. + const code = (e as NodeJS.ErrnoException).code; + if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN') { + killSpawnFailed = true; + } + // A kill failing because there was nothing to kill is the goal + // state — in every wording tmux uses for it, including the + // socket-directory-never-created one a start that failed before the + // socket existed produces (measured with a mode-0555 TMUX_TMPDIR: + // both attempts answered `couldn't create directory …` and the + // one-wording test printed a false orphan WARNING). + const stderrText = String((e as { stderr?: unknown }).stderr ?? ''); + // ...but a goal-state wording establishes death only about the + // base the client EXAMINED. Two shapes examine nothing here: a + // base destroyed mid-window sends the client to /tmp, whose path + // the wording then names while the kill was pinned elsewhere + // (probe-verified on 3.4 — the live server under the destroyed + // base read as reaped: no WARNING, and invisible to the sweep + // once the socket dir went with the base); and a base that HELD + // the server at start answers `couldn't create directory` once + // destroyed mid-window — the client never looked, and the + // server may still be alive. The same wording stays honest + // where the server started under the OTHER base: that base + // never held it — nor did one whose start THREW before binding + // a socket: there the directory never existed, so the wording + // is admitted on the start base under that flag (without it a + // false orphan WARNING printed for a server that never existed). + // + // The ENOENT class establishes death only where start never bound + // a socket: once stamped, it means the stamped socket was removed + // mid-window — possibly with a live server behind it (probed: rm + // the file under a running server and kill answers exactly this), + // so it is never credited once stamped, on any base. And on the + // START base, every goal-state wording is only as trustworthy as + // the stamped socket's identity: a base destroyed and recreated + // mid-window can answer about a socket this run never owned, and + // the live server behind the destroyed one would read as dead. + const onStartBase = resolve(base) === startBase; + // The ENOENT class is NOT credited, on any base, under any flag. + // It says the socket path was gone at look time and nothing more, + // and every proxy for "so the server never existed" has turned out + // to be a different question: an absent stamp is also absent when + // the stamp failed after a successful start, and `startThrew` is + // also set for a belt-cut start that threw with the server already + // forked and its socket bound (the shape this file documents at + // the start call). Both were tried here and both conflated. On a + // real tmux, `rm` of a live server's socket answers this exact + // wording while `kill -0` shows the server alive — and the + // captured command, untrusted same-uid code, is the thing that + // removes it. So the wording buys doubt, not death; the run says + // so out loud rather than exiting 0 over an orphan that neither + // `-L` nor the readdir sweep can reach any more. + baseDead = + isNothingToKill(stderrText) && + verdictExaminedBase(stderrText, base) && + !( + isSocketDirNeverCreated(stderrText) && + onStartBase && + !startThrew + ) && + !( + onStartBase && + socketStamp !== undefined && + !stampedSocketAlive() + ); + // ...and NOT for a refusal the client made before it looked. Those + // two wordings were briefly folded into isNothingToKill, which made + // a LIVE server read as reaped: no WARNING, exit 0, and its socket + // unlinked under both bases — unreachable forever (probe-verified + // by making the socket dir non-0700 after the start). + if (isSocketDirUnusable(stderrText)) killDirUnusable = true; + } + } + if (!baseDead) { + // A kill that threw establishes nothing about this base — the server + // may be alive under it — so nothing of its is unlinked. + unconfirmed = true; + continue; + } + if (uid !== undefined) { + try { + // tmux does not always unlink the socket of a killed server; a + // review that captures often would litter the socket dir with dead + // ones. tmux resolves that dir from TMUX_TMPDIR, falling back to + // /tmp — it does NOT consult TMPDIR, so neither do we. ONLY the + // base this kill answered about: the goal-state verdict that + // authorizes the removal is base-scoped, and removing a socket + // under a base the kill never established death on is exactly what + // orphaned a live server under the other one. + rmSync(join(base, `tmux-${uid}`, server), { force: true }); + } catch { + // Litter is cosmetic; never let cleanup mask the capture's result. + } + } + } + if (unconfirmed && !confirmedDead) { + // A presumed-alive private server is never a silent outcome: the + // holder keeps it up for up to three hours, and the briefs encourage many + // captures per review — orphans would accumulate invisibly. Doubt + // stays quiet only when a kill SUCCEEDED: the server name is unique + // to this run, so that death is global and outranks a verdict that + // could not be placed. + // SAFE, like refuse()'s writes: process.stderr.write throws + // synchronously on a closed fd, and reap() runs BOTH from the finally + // (where a throw turns the exit-3 refusal into an exit-1 stack trace + // and skips the sentinel cleanup below it) and from onSignal (where it + // becomes an uncaughtException — exit 1 instead of 128+sig, the + // re-raise never reached, and this very warning lost). + writeStderrLineSafe( + // The lead clause has to match what happened: a refused connect is + // not a kill that failed, and an operator told "failed twice" would + // go looking for a wedged server instead of a planted entry. + `capture-tui: WARNING — ${ + killBaseUnusable + ? 'the reap could not reach the base this run started under' + : plantedEntry + ? 'the reap refused to connect' + : 'kill-server failed twice' + }${ + killBaseUnusable + ? ' (the socket base this run started under is no longer usable, ' + + 'so the pinned kill fell back to /tmp and can say nothing ' + + 'about the base this run bound under)' + : plantedEntry + ? " (the socket entry was not this capture's own plain socket " + + '— a symlink, a hard link or a non-socket stood at its path, ' + + 'so the kill was NOT attempted: connecting would have reached ' + + 'whatever that entry resolves to)' + : killSpawnFailed + ? ' (this process could not spawn tmux at all — fd ' + + 'exhaustion, not a wedged server)' + : killDirUnusable + ? ' (tmux refused before reaching the socket directory — ' + + 'its permissions or type, not the server)' + : '' + }; the private tmux server ${server} may still be running ` + + `(tmux -L ${server} kill-server to reap it by hand).`, + ); + } + }; + // (REAP_SIGNALS is module-level and exported so the signal tests iterate + // the REAL list — a dropped entry must fail a test, not ship silently.) + const releaseSignals = (): void => { + for (const s of REAP_SIGNALS) process.removeListener(s, onSignal); + }; + // The success tail after the reap is synchronous (freeze render up to its + // belt, two writes): a signal landing there is QUEUED in libuv's self-pipe, + // and removing the listeners before that pipe is read swallows it — the + // process then exits 0 as if nothing happened (measured on the bundled + // runtime: SIGTERM during a fake render → exit 0, handler never ran). + // TWO turns, and neither may be a 0ms timer. libuv delivers signals by + // writing to a pipe the POLL phase reads; the JS handler runs from there. + // A timer resolves in the timers phase, which precedes poll — it can + // release with the byte unread. setImmediate resolves in the check phase, + // which follows poll WITHIN THE SAME ITERATION: scheduled from a + // poll-phase continuation (where this tail resumes after the render's + // blocking spawnSync) its poll has ALREADY run, so one turn releases with + // the byte still queued and the signal is dropped when uv_signal_stop + // takes the watcher away. A second turn cannot land before the next + // iteration's poll, so a full poll phase — the one that dispatches the + // handler, which reaps and re-raises — always runs first. Measured on + // Linux/Node 22 through the real command: one turn swallowed the SIGTERM + // every time (exit 0, success JSON, handler never entered, kernel showing + // the signal delivered and no longer pending); two turns died 143. + const drainSignalsThenRelease = async (): Promise => { + for (let turn = 0; turn < 2; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + releaseSignals(); + }; + function onSignal(sig: NodeJS.Signals): void { + reap(); + // The re-raise below terminates the process WITHOUT unwinding, so the + // finally holding the only other sentinel cleanup never runs — every + // signal death left a qwen-capture-ready-* file behind in the system + // temp dir, and a harness reaping stuck captures produces one per run. + // Cheap, and the last chance to do it. + try { + rmSync(holderReadyPath, { force: true }); + } catch { + // Litter is cosmetic; the reap above is what matters here. + } + releaseSignals(); + process.kill(process.pid, sig); + } + for (const s of REAP_SIGNALS) process.on(s, onSignal); + + let ansText = ''; + let settledBy: CaptureManifest['settledBy'] = 'fixed-delay'; + let readyFailed = false; + let keysSent: boolean | undefined; + let matchOverruns = 0; + // How long the --until poll actually had — see where it is set. + let untilPolledMs = 0; + let captureFailed = false; + try { + // BEFORE the call: plan.start forks the server in the same client + // invocation that creates the session, so a start cut by the control + // belt throws with the server ALREADY UP — exactly the window an + // after-the-call flag left unreaped (measured orphan shape on loaded + // runners). kill-server against a server that never came up answers + // the goal state, so the early flag adds no false warnings. + serverStarted = true; + try { + tmux(plan.start); + } catch (e) { + startThrew = true; + throw e; + } + { + const uidAtStart = process.getuid?.(); + if (uidAtStart !== undefined) { + try { + const st = lstatSync(join(startBase, `tmux-${uidAtStart}`, server)); + socketStamp = { ino: st.ino, mode: st.mode, mtimeMs: st.mtimeMs }; + } catch { + // Start can bind under the OTHER base when the env base turns + // unusable between the gate and the start; the reap's per-base + // kills cover that shape, and its doubt is the fail-closed answer. + } + } + } + // Before ANY key can be sent, wait for the holder's ready sentinel: the + // pty's INTR fires the instant tmux writes 0x03 — a C-c racing the + // holder's own `trap : INT` line killed pane, session and server + // (measured; no in-script ordering can win, so the wait sits out here). + { + const holderDeadline = Date.now() + holderInit.timeoutMs; + while (!existsSync(holderReadyPath)) { + if (Date.now() >= holderDeadline) { + throw new PaneInitFailed( + 'the pane never initialized — its holder wrote no ready marker', + ); + } + await sleep(25); + } + } + // One deadline covers the ready gate AND the until poll: two separate + // clocks would let a capture run to 2× --timeout-ms. + const deadline = Date.now() + args.timeoutMs; + if (readyRe) { + readyFailed = true; + for (;;) { + const logical = tmux(plan.captureText); + const m = testWithBudget(readyRe, logical); + if (m === 'match') { + readyFailed = false; + break; + } + if (m === 'overrun') matchOverruns++; + if (Date.now() >= deadline) break; + await sleep(250); + } + } + if (args.keys !== undefined && args.keys.length > 0) { + if (readyFailed) { + // The UI never reached the state the keys were meant for: typing + // them anyway would drive an unknown screen. Withhold, and say so. + keysSent = false; + } else { + for (const key of args.keys) { + tmux(plan.sendKeys(key)); + } + keysSent = true; + } + } + if (readyFailed) { + // The deadline is spent; a late frame is all there is. This is a + // timeout settle even without --until — the run waited out + // --timeout-ms, and calling it 'fixed-delay' would misdescribe it. + settledBy = 'timeout'; + ansText = tmux(plan.capture); + } else if (untilRe) { + // What the marker search ACTUALLY got, for the degradation to report: + // the ready gate above shares this one deadline, so with --ready given + // the poll starts with only the remainder. + untilPolledMs = Math.max(0, deadline - Date.now()); + // Poll for the settle marker on the LOGICAL view (wraps joined, + // escapes absent): on the physical frame, a marker spanning a wrap + // boundary or an SGR attribute change can never match (measured: + // both miss forever). On timeout, capture anyway and SAY SO — a late + // frame is degraded evidence, not no evidence. The physical frame is + // captured in the same poll iteration as its matching logical view, + // so the `.ans` is the frame the match ruled on, give or take the + // milliseconds between two capture-pane calls. + settledBy = 'timeout'; + for (;;) { + const logical = tmux(plan.captureText); + const m = testWithBudget(untilRe, logical); + if (m === 'match') { + settledBy = 'until-match'; + ansText = tmux(plan.capture); + break; + } + if (m === 'overrun') matchOverruns++; + if (Date.now() >= deadline) { + ansText = tmux(plan.capture); + break; + } + await sleep(250); + } + } else { + await sleep(args.settleMs); + ansText = tmux(plan.capture); + } + } catch (e) { + // tmux failing mid-run (ancient tmux without a flag we use, a command + // tmux itself refuses, a server that died under us) is an environment + // that could not produce evidence — the refusal contract, not a stack + // trace. The finally below still reaps whatever did start. + const err = e as Error & { stderr?: string }; + const detail = + (err.stderr ?? '').trim().split('\n').slice(-1)[0] || + (err.message ?? String(e)).split('\n')[0]; + captureFailed = true; + // WHO failed: the ready gate is a pure existsSync poll after a tmux + // start that SUCCEEDED, so routing it through this catch blamed tmux + // for something no tmux invocation did — in a reason a machine reads. + refuse( + e instanceof PaneInitFailed + ? `capture never started: ${detail}` + : `tmux failed mid-capture: ${detail}`, + ); + return; + } finally { + // Always, even when the capture threw mid-run: the private server holds + // every process this capture launched that stayed in its session, and an + // orphaned TUI outliving the review is the mess this command exists to + // make impossible. The boundary is the session, not the process tree — + // see the header on daemonizing commands. A start + // that threw has no server to reap, and reap() stays silent about it. + // The reap runs FIRST — releasing the listeners before it left a + // measured 25ms-to-death window with no listener. Honest limit, also + // measured: Node cannot dispatch a JS handler while reap()'s synchronous + // execFileSync blocks (up to 2×15s against a wedged server), so a signal + // landing in that window only dispatches once the block ends — a kill + // there reads normal completion, and the no-orphan guarantee is the + // reap's, not the handler's. The listeners cover the await windows, + // where dispatch works. On the success path they stay installed through + // the freeze render below, which can block up to its belt. + reap(); + // The sentinel is plumbing, not evidence — removed on EVERY exit path + // (a mid-capture refusal after the holder wrote it used to leave it + // stranded next to where evidence should be, measured). + try { + rmSync(holderReadyPath, { force: true }); + } catch { + // Litter is cosmetic. + } + // Same drain as the success tail: a signal queued while reap() blocked + // in execFileSync must dispatch to the handler (re-raise) before the + // listeners go away — bare release read as "it refused on its own" + // where the harness's kill actually landed. + if (captureFailed) await drainSignalsThenRelease(); + } + + try { + writeArtifact(ansPath, ansStamp, ansText); + // Identity of the bytes THIS run just wrote, for the render stage to + // pin against. Taken here rather than derived at stage time: by then + // ansPath may already be the swap. + ansWritten = stampOf(ansPath); + } catch (e) { + // The disk can fill (or the target turn hostile) during a long capture + // window; the same principle as the mkdir guard — refusal contract, not + // a stack trace. "THIS run's artifacts or nothing": a partial or 0-byte + // .ans from an interrupted write (measured with a real ENOSPC) must not + // persist undescribed. + try { + // Plain, never recursive: this run wrote a FILE (or nothing); a + // directory at the path is not ours to delete. And only if the file + // actually CHANGED: a write that failed at open() truncated nothing, + // and the previous file — which the clear phase may have deliberately + // spared as unrelated — must survive the refusal. A partial write + // does change it, and that truncated .ans left undescribed is the + // harm this catch exists for. + // ...and NEVER on a collision: the thing at the path is precisely + // what this run refused to replace, so removing it would be the + // data loss the refusal exists to prevent. + if (!(e instanceof ArtifactCollision) && changed(ansPath, ansStamp)) + rmSync(ansPath, { force: true }); + } catch { + // The refusal reason below is the primary signal either way. + } + await drainSignalsThenRelease(); + refuse( + `cannot write capture output: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + + // .ans FIRST, then render: freeze has hung mid-render on this repo's own + // workflows, and the text evidence must already be on disk when it does. + let png: string | null = null; + // Identity of the png THIS run landed, for the manifest-failure cleanup to + // pin against — the same role ansWritten plays for the .ans. + let pngWritten: Stamp = untouched; + // Set when staging finds .ans is no longer the bytes this run wrote — + // swapped or gone during the render window. The on-disk .ans is then not + // ours, so it must not be credited as this run's evidence (nor mint the + // clear-phase signature that would authorize a later run to delete it). + let ansLost = false; + // Collect every way this capture fell short of "settled png" — the field's + // contract is that a manifest reader learns WHY the ladder stopped where it + // did, and a late frame and a failed render can both be true at once. + const degradations: string[] = []; + // Probed ONCE and reused: each probe is a fresh 10s-belted spawn, and a + // wedged freeze paid the belt twice — worse, a stateful binary could + // answer differently between condition and message. + let freezeProbe: ReturnType; + if (readyFailed) { + degradations.push( + `--ready never matched within ${args.timeoutMs}ms — ${ + keysSent === false ? 'keys were NOT sent, ' : '' + }late frame captured`, + ); + // ...and --until never ran at all. The manifest records `until` and + // `settledBy: 'timeout'`, which reads as "searched and not found" — + // measured with the marker present in the pane for the whole run. A + // reader deciding the marker never appears would be deciding from a + // search that never happened. + if (args.until !== undefined) { + degradations.push( + `--until was never searched for: the ready gate consumed the budget first`, + ); + } + } else if (settledBy === 'timeout') { + // The window ACTUALLY spent on the marker, not the whole budget: ONE + // deadline covers the ready gate and the until poll, so with --ready + // also given the gate consumes part of it and the poll runs for the + // remainder — reporting the full --timeout-ms overstated the search by + // up to the entire budget, and a reader deciding "the marker never + // appears" from that number is deciding from a window that never ran. + degradations.push( + `--until never matched within ${untilPolledMs}ms of the ` + + `${args.timeoutMs}ms budget — late frame captured`, + ); + } + if (capturePads) { + degradations.push( + // No `tmux ` prefix: tmuxVersion IS the `tmux -V` line ("tmux 3.2a"), + // so prefixing produced "tmux tmux 3.2a" in the manifest of every + // capture on a padding host — 3.1-3.2.x, which is what Ubuntu 22.04 + // ships. + `${tmuxVersion} pads capture-pane -N to the grid allocation and ` + + `has no -T — trailing spaces were TRIMMED rather than fabricated; ` + + `a trailing-space or right-edge claim needs tmux 3.3+`, + ); + } + if (captureTrims) { + degradations.push( + // The -T remedy is incomplete the way the pad case is: it trims only + // positions that NEVER held a character — cells written and later + // erased (CR + EL, the canonical TUI redraw) still capture as + // trailing spaces on the very versions that take the flag, and the + // joined marker-matching view carries them mid-line with and without + // -T (measured on 3.4). No capture-pane flag separates the two, so + // the caveat is the honest fix. + `${tmuxVersion} trims only never-written trailing positions under ` + + `-T — cells written and later erased still capture as trailing ` + + `spaces, and the joined marker view carries them mid-line; ` + + `trailing-space, right-edge and marker claims carry that caveat`, + ); + } + if (matchOverruns > 0) { + // A budget cutoff is not the same as an absent marker: the match may + // have been interrupted mid-backtrack, and the field's contract is that + // a reader learns WHY the settle landed where it did. + degradations.push( + `marker matching exceeded its ${MATCH_BUDGET_MS}ms budget ${matchOverruns} time(s) — the marker may be present, its match cut off`, + ); + } + if (ansText.trim() === '') { + // A blank capture — zero bytes or nothing but whitespace — has no pixels + // worth rendering: freeze fails empty input with a misleading bounds + // error, and a blank image would be evidence-shaped noise anyway. + degradations.push( + 'pane captured empty — nothing to render, no image produced', + ); + } else if (pngStamp.existed || occupied(pngPath)) { + // Something this run did not write occupies the png path — sitting + // there since the pre-window stamp, or claimed DURING the window, the + // same mid-window escape writeArtifact's changed() closes for the .ans + // and the manifest. Rendering would replace it — the same silent + // destruction the .ans/.json collision refuses over — so the ladder + // stops at the text rung and says why. + degradations.push( + `${pngPath} holds a file this capture did not write — no image ` + + 'rendered; clear it or pick another --out for a png rung', + ); + } else if ((freezeProbe = probes.freeze()).status !== 'ok') { + degradations.push( + freezeProbe.status === 'hung' + ? freezeProbe.code + ? // The SPAWN failed, which says nothing about installation: a + // false 'not installed' would persist in the manifest as an + // environment claim this run never established. + `freeze could not be probed (${freezeProbe.code}) — ${ + freezeProbe.spawned + ? 'it ran and failed, so it is installed but not usable here' + : 'it may be installed; this host could not spawn it' + }. .ans text captured, no image rendered` + : `freeze did not answer --help within ${probeBudget.timeoutMs}ms — present but wedged; .ans text captured, no image rendered` + : // Same caveat the tmux refusal carries: execve answers ENOENT for + // a present-but-unexecable binary too, and nothing in the spawn + // result separates them. Asserting the one it cannot know is what + // the tmux side was corrected for. + 'freeze is not installed, or is installed but cannot be executed ' + + '— .ans text captured, no image rendered', + ); + } else { + // stdin MUST be /dev/null: freeze treats a pipe stdin — Node's spawnSync + // default — as "the input is stdin" and ignores the positional file. A + // pipe that EOFs promptly produces `ERROR No input` (exit 1); a pipe that + // stays open hangs freeze indefinitely. Both modes were measured on this + // machine in one evening — the historical "freeze hangs" incidents on + // this repo's workflows are this exact shape. The timeout stays as the + // second belt. + // BOTH render paths ride per-run nonces: freeze opens whatever names it + // is given and follows symlinks on INPUT and OUTPUT alike (measured on + // freeze v0.2.2), and the captured command — the survivor class the + // kill-plan comment documents — runs while these names are decided. + // link() stages the INPUT under a name only this run knows, and the + // isFile() check on the stage pins the bytes this run wrote: link() + // clones a symlinked ansPath instead of refusing it with ELOOP + // (measured on Linux), so a symlink swapped in during the probe window + // would otherwise be staged intact and feed the victim's bytes to the + // render. rename() lands the OUTPUT: it replaces a symlink planted at + // pngPath instead of following it out of the --out base. + // Resolved on the same rule as the probe and the control calls: freeze + // follows the names it is given and AUTHORS the image this run publishes + // as evidence, so a cwd-planted `freeze` would author it. An absolute + // `bin` — the test seam above, and any operator override — is taken as + // given; only the bare default is looked up. Unresolvable at this point + // means it went missing between the availability probe and here, which + // degrades the ladder like any other failed render rather than refusing + // a capture whose text rung already succeeded. + const freezeBin = isAbsolute(freezeRender.bin) + ? freezeRender.bin + : resolveOnPath(freezeRender.bin); + const renderNonce = randomBytes(6).toString('hex'); + const ansStage = `${ansPath}.render-${renderNonce}`; + const pngStage = `${pngPath}.render-${renderNonce}`; + let renderInputStaged = false; + try { + linkSync(ansPath, ansStage); + // lstat never follows: a cloned symlink is not a regular file. But + // "regular file" is the whole guard only where link() clones — + // darwin FOLLOWS a symlinked source (measured: the staged entry is a + // hard link to the victim's inode, isFile() true), so the type test + // alone let the same swap through on macOS and freeze rendered the + // victim's bytes as this capture's png. Identity is the portable + // question: the stage must be another name for the very inode this + // run's own .ans write produced. + // ino+size+mtime, the comparison `changed()` and isSameSocket both + // make and for the same reason: an inode is not a durable name for a + // FILE, and the stamp→link window here spans the whole freeze + // availability probe — a 10s-belted spawn plus a PATH walk. An actor + // that rm's and recreates .ans inside it gets the freed inode + // back from an ext-family allocator, and an inode-only check then fed + // foreign bytes to the render and credited them at the png rung. + const staged = lstatSync(ansStage); + if ( + !staged.isFile() || + staged.ino !== ansWritten.ino || + staged.size !== ansWritten.size || + staged.mtimeMs !== ansWritten.mtimeMs + ) { + throw new Error('staged render input is not the .ans this run wrote'); + } + renderInputStaged = true; + } catch (stageErr) { + // TWO causes reach here and must NOT be conflated: + // · linkSync SUCCEEDED but the staged inode is not the .ans this run + // wrote — a real swap. The on-disk .ans is foreign, so it is + // neither rendered nor credited: `ansLost` drops the ans rung and + // the run refuses below, which also keeps the clear-phase signature + // off a foreign file. + // · linkSync itself THREW — a host that cannot hard-link the stage + // (exFAT/FAT/WSL DrvFs have no link(); ENOSPC/EDQUOT/EACCES can hit + // the directory-entry create mid-window). This run's own .ans is + // untouched and still identity-checkable, so ONLY the png rung is + // lost. Setting `ansLost` there refused with a factually false + // "replaced during the render window", stranded this run's intact + // .ans with no manifest, and wedged EVERY later capture at that + // --out (the collision gate then refuses the unsignatured file + // forever) — a link-less mount broke the ladder exactly where it + // should degrade png → ans-only. + // .ans's identity decides which: still ours ⇒ a stage failure to + // degrade past; changed or gone ⇒ a swap to refuse over. + let swapped = true; + try { + const cur = lstatSync(ansPath); + swapped = + cur.ino !== ansWritten.ino || + cur.size !== ansWritten.size || + cur.mtimeMs !== ansWritten.mtimeMs; + } catch { + // Gone — this run's .ans is not on disk to credit; treat as lost. + } + if (swapped) { + ansLost = true; + degradations.push( + `${ansPath} was replaced while the render was being prepared — ` + + 'this run can no longer show the .ans it wrote, so no evidence ' + + 'rung is claimed', + ); + } else { + // The .ans stands and is still ours; only the render could not be + // staged. Name the errno and fall through to an ans-only manifest. + degradations.push( + `could not stage ${ansPath} for rendering (${ + (stageErr as NodeJS.ErrnoException).code ?? String(stageErr) + }) — .ans text captured, no image rendered`, + ); + } + try { + rmSync(ansStage, { force: true }); + } catch { + // Litter is cosmetic; never let cleanup mask the capture's result. + } + } + if (renderInputStaged && freezeBin === undefined) { + degradations.push( + `${freezeRender.bin} is not reachable at any absolute PATH element ` + + '— .ans text captured, no image rendered', + ); + try { + rmSync(ansStage, { force: true }); + } catch { + // Litter is cosmetic; never let cleanup mask the capture's result. + } + } + if (renderInputStaged && freezeBin !== undefined) { + try { + const r = spawnSync(freezeBin, freezePlan(ansStage, pngStage), { + encoding: 'utf8', + timeout: freezeRender.timeoutMs, + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: FREEZE_MAX_BUFFER, + }); + // Read the stage DEFENSIVELY: a concurrent actor on the same --out + // is the very shape the clear phase and collision gate exist to + // handle, and an uncaught ENOENT here meant exit 1, no contract + // JSON on stdout, a stack trace on stderr, and both artifacts + // orphaned with no manifest (fault-injected). lstat, and isFile: + // anything but a regular file at the stage is not a rung. + let pngSize = 0; + try { + const stageStat = lstatSync(pngStage); + if (stageStat.isFile()) pngSize = stageStat.size; + } catch { + // Gone or unstattable mid-check — the degradation branch below + // says so. + } + if (r.status === 0 && pngSize > 0) { + // Exit code alone is not evidence: a freeze that exits 0 without + // writing the file — or leaving a 0-byte/truncated one (ENOSPC + // mid-write, the shape the .ans write guard's comment names) — + // would otherwise manifest a png rung with no pixels, and a + // verifier would publish it. + if (occupied(pngPath)) { + // Claimed between the ladder's check and the landing: the same + // mid-window escape, degrade instead of replacing the claimant. + degradations.push( + `${pngPath} holds a file this capture did not write — the ` + + 'rendered image was not landed; clear it or pick another ' + + '--out for a png rung', + ); + } else { + try { + renameSync(pngStage, pngPath); + // lstat identity, never stat: a swap that lands a symlink at + // pngPath after the rename is not a rung either. + const landed = lstatSync(pngPath); + if (landed.isFile() && changed(pngPath, pngStamp)) { + png = pngPath; + pngWritten = stampOf(pngPath); + } else { + degradations.push( + `${pngPath} changed while the rendered image was being ` + + 'landed — .ans text captured, no image rendered', + ); + } + } catch { + degradations.push( + `the rendered image could not be landed at ${pngPath} — ` + + '.ans text captured, no image rendered', + ); + } + } + } else { + // The stderr tail rides along: a bare exit code is undiagnosable from + // a manifest, and the whole point of recording degradation is that a + // reader can tell WHY the ladder stopped. A spawn that never ran + // (EMFILE, a binary vanishing between probe and render) has neither + // status nor signal — its reason lives in r.error. + // Bounded like everything else that rides into the manifest: a + // newline-free wall of freeze stderr (up to FREEZE_MAX_BUFFER of it) + // otherwise flowed into degradedBecause verbatim and pushed the + // manifest past the reader cap a successful run must stay under — + // probe-reproduced: the next run then refused to verify it. + const errTail = `${r.stderr ?? ''} ${r.stdout ?? ''}` + .trim() + .split('\n') + .slice(-2) + .join(' ') + .slice(0, 2048); + const why = + r.status === 0 + ? 'exited 0 but wrote no image' + : r.signal + ? // A belt kill carries BOTH signal and error (ETIMEDOUT); name + // the belt, or it reads as an unexplained external kill. The + // error CODE decides, not its mere presence: a maxBuffer + // overrun kills with the same SIGKILL and also sets an error + // (ENOBUFS — probe-measured for this exact spawn), and + // blaming the render belt for it is a false causal claim + // that sends a reader hunting a hang that never happened. + `signal ${r.signal}${ + (r.error as NodeJS.ErrnoException | undefined)?.code === + 'ETIMEDOUT' + ? ` after the ${freezeRender.timeoutMs}ms render belt` + : (r.error as NodeJS.ErrnoException | undefined)?.code === + 'ENOBUFS' + ? ` — freeze wrote more than the ${FREEZE_MAX_BUFFER} bytes this spawn captures` + : '' + }` + : r.status !== null + ? `exit ${String(r.status)}` + : `spawn failed: ${r.error ? r.error.message : 'unknown error'}`; + degradations.push( + `freeze failed (${why}${errTail ? `: ${errTail}` : ''}) — .ans text captured, no image rendered`, + ); + // A failed render's torn bytes sit at the STAGE and are this + // run's unambiguously (the nonce name): land them when the path + // is still free — a leftover is loud, not lost: this manifest + // denies the png rung, and the next run's ladder degrades on the + // occupant. An occupant that claimed the path meanwhile is + // spared, and the torn bytes go with the stage. + if (occupied(pngPath)) { + degradations.push( + `${pngPath} holds a file that appeared while the render ` + + "failed — left in place: this run's torn output was not " + + 'landed beside it', + ); + } else { + // Land the stage even at 0 bytes (the ENOSPC-truncated shape): + // the nonce makes it this run's unambiguously, and leaving it + // named keeps the next run's ladder loud about the occupant. + let stageIsFile = false; + try { + stageIsFile = lstatSync(pngStage).isFile(); + } catch { + // Gone — nothing to land. + } + if (stageIsFile) { + try { + renameSync(pngStage, pngPath); + degradations.push( + `${pngPath} holds this run's torn render output — left in ` + + 'place: the manifest denies the png rung', + ); + } catch { + // Unlandable — the stage cleanup below removes it. + } + } + } + } + } finally { + try { + rmSync(ansStage, { force: true }); + } catch { + // Litter is cosmetic; never let cleanup mask the capture's result. + } + // Gone already when the rename landed it. + try { + rmSync(pngStage, { force: true }); + } catch { + // Same. + } + } + } + } + + if (ansLost) { + // The .ans this run wrote was replaced or removed during the render + // window (staging detected it by identity). There is no honest evidence + // rung left — the bytes on disk are not ours — so this refuses like the + // write-failure paths rather than write a manifest crediting them, which + // would both misattribute the foreign bytes and mint the clear-phase + // signature that authorizes a later run to delete them. The foreign file + // is left untouched; only this run's own stage was removed above. The + // server was already reaped before the .ans write, so nothing leaks. + await drainSignalsThenRelease(); + refuse( + `${ansPath} was replaced during the render window; this run can no ` + + 'longer show the .ans it wrote and will not credit or remove the ' + + 'file now at that path', + ); + return; + } + + const degradedBecause = degradations.length + ? degradations.join('; ') + : undefined; + const manifest: CaptureManifest = { + command: args.command, + cwd: resolvedCwd, + cols: args.cols, + rows: args.rows, + ...(args.keys !== undefined ? { keys: args.keys } : {}), + ...(keysSent !== undefined ? { keysSent } : {}), + ...(args.ready !== undefined ? { ready: args.ready } : {}), + ...(args.until !== undefined ? { until: args.until } : {}), + // The ACTIVE durations: settleMs governed the run only when a fixed + // delay actually happened; timeoutMs governed it when either marker + // (--until OR --ready) was in play — a --ready-only run spends the + // timeout budget too, and recording settleMs alone would misdescribe it. + ...(args.until === undefined && !readyFailed + ? { settleMs: args.settleMs } + : {}), + ...(args.until !== undefined || args.ready !== undefined + ? { timeoutMs: args.timeoutMs } + : {}), + ansPath, + pngPath: png, + evidence: png ? 'png' : 'ans-only', + ...(degradedBecause ? { degradedBecause } : {}), + settledBy, + }; + try { + writeArtifact( + manifestPath, + manifestStamp, + `${JSON.stringify(manifest, null, 2)}\n`, + ); + } catch (e) { + // "THIS run's artifacts or nothing": a refusal must not leave an .ans + // (and possibly a png) with no manifest to describe them — best-effort + // remove what this run already wrote before refusing. + try { + // Plain, never recursive — same rationale as the .ans catch above — + // and each path only if this run put it there. Reaching here, the + // Each path only if THIS run changed it: the .ans write succeeded so + // it is ours, the png is ours only when the render actually produced + // one, and a manifest write that failed at open() left the previous + // file whole. A PARTIAL manifest is worse than none — it parses or + // half-parses as an evidence description — and it, having changed, + // is removed. + // PER PATH, like the clear phase's own clearArtifact: one try around + // all three let a throw on the .ans or .png removal skip the manifest + // one — leaving exactly the partial manifest this block calls worse + // than none (probe-reproduced with a directory at the .png path). + // BY IDENTITY, against what this run actually put there — not against + // the pre-window stamp. For the .ans and the .png that stamp had + // `existed: false`, so `changed()` collapses to `occupied()` ("is + // something there"), and a file SWAPPED into .ans during the + // render window then answered it and was destroyed on an ordinary + // manifest-write failure (collision/ENOSPC/EMFILE). `ansWritten` and + // `pngWritten` record the exact bytes this run wrote and landed, so a + // path is removed only while it is still that file; a swap that + // replaced it is foreign and left alone. + const removeIfStillOurs = (path: string, written: Stamp) => { + if (written.size === UNSTAMPED && written.ino === UNSTAMPED) return; + try { + const st = lstatSync(path); + if ( + st.ino === written.ino && + st.size === written.size && + st.mtimeMs === written.mtimeMs + ) { + rmSync(path, { force: true }); + } + } catch { + // Gone, or unstattable: nothing of ours to remove. + } + }; + // The .ans write succeeded, so ansWritten is set; remove it only while + // it is still those bytes. + removeIfStillOurs(ansPath, ansWritten); + // The png is ours only when the render actually landed one (pngWritten + // stays UNSTAMPED otherwise, so removeIfStillOurs is a no-op). + removeIfStillOurs(pngPath, pngWritten); + // The manifest is the file this write just failed on: a partial one is + // worse than none and is ours (O_TRUNC truncated it), so it goes — but + // NOT a collision occupant this run refused to replace. + if (!(e instanceof ArtifactCollision)) { + try { + if (changed(manifestPath, manifestStamp)) + rmSync(manifestPath, { force: true }); + } catch { + // The refusal reason below is the primary signal either way. + } + } + } catch { + // Unreachable in practice; the per-path catches above own the risk. + } + await drainSignalsThenRelease(); + refuse( + `cannot write capture manifest: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + // BEFORE the drain, not after: the drain is the first event-loop turn + // following a long synchronous stretch, so an async stdio 'error' queued + // during that stretch dispatches INSIDE it — with the flag still false, + // the guard rethrew and a completed capture exited 1 with its .ans and + // manifest both written. The evidence is on disk and described by the + // time we get here; nothing stdio does after that changes what happened. + artifactsComplete = true; + await drainSignalsThenRelease(); + writeStderrLineSafe( + `capture-tui: ${manifest.evidence} at ${args.cols}x${args.rows} ` + + `(settled by ${settledBy})${degradedBecause ? ` — ${degradedBecause}` : ''}`, + ); + writeStdoutLineSafe( + JSON.stringify({ + captured: true, + evidence: manifest.evidence, + manifest: manifestPath, + }), + ); +} + +export const captureTuiCommand: CommandModule = { + command: 'capture-tui', + describe: + 'Run a command in a throwaway PRIVATE tmux server and capture what it rendered — .ans always, .png when freeze is available — as evidence for rendering claims', + builder: (yargs) => + yargs + .option('command', { + type: 'string', + demandOption: true, + describe: 'The command to run inside the capture terminal', + }) + .option('cwd', { + type: 'string', + describe: 'Working directory for the command (default: current)', + }) + .option('cols', { + type: 'number', + default: DEFAULT_COLS, + describe: 'Terminal width — layout claims are claims about a width', + }) + .option('rows', { + type: 'number', + default: DEFAULT_ROWS, + describe: 'Terminal height', + }) + .option('settle-ms', { + type: 'number', + default: 3000, + describe: 'Fixed delay before capturing (ignored when --until is set)', + }) + .option('until', { + type: 'string', + describe: + 'Capture as soon as the pane text matches this regex; on timeout, capture anyway and record that the marker never appeared', + }) + .option('ready', { + type: 'string', + describe: + 'Send --keys only after the pane matches this regex — keys fired at start straddle a slow-mounting UI and get partially eaten (measured); on timeout the keys are withheld and the manifest says so', + }) + .option('keys', { + type: 'string', + array: true, + describe: + 'tmux send-keys tokens sent after start (or after --ready matches), one per token (e.g. --keys "/review" Enter); a token starting with "-" must use the --keys= form or yargs parses it as an unknown flag', + }) + .option('out', { + type: 'string', + demandOption: true, + describe: + 'Output basename: .ans, .png (when rendered) and .json (the manifest) are written', + }) + .option('timeout-ms', { + type: 'number', + default: 60_000, + describe: + 'One shared deadline for the --ready gate and --until polling. NOT the whole capture: --settle-ms runs after it, so wall time is bounded by timeout-ms + settle-ms', + }), + handler: (argv) => + runCaptureTui({ + command: argv['command'] as string, + cwd: argv['cwd'] as string | undefined, + cols: argv['cols'] as number, + rows: argv['rows'] as number, + settleMs: argv['settle-ms'] as number, + until: argv['until'] as string | undefined, + ready: argv['ready'] as string | undefined, + keys: argv['keys'] as string[] | undefined, + out: argv['out'] as string, + timeoutMs: argv['timeout-ms'] as number, + }), +}; diff --git a/packages/cli/src/commands/review/cleanup.test.ts b/packages/cli/src/commands/review/cleanup.test.ts index 0579c351cda..43922ba4f5e 100644 --- a/packages/cli/src/commands/review/cleanup.test.ts +++ b/packages/cli/src/commands/review/cleanup.test.ts @@ -1,16 +1,59 @@ // Copyright 2026 Qwen Team // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; +/** Everything cleanup reads from an lstat result, across both of its readers: + * the capture sweep's entry guard and post-kill identity re-check, and the + * worktree-family symlink guard with the ancestor walk beside it. + * `isDirectory` is optional because no cleanup path reads it — the family + * fixtures set it to say what the entry beside the link is. + * + * The MOCK below returns `Partial<>` of this, and that is load-bearing. A + * `beforeEach` that only has to say "nothing here is a symlink" is boilerplate + * every `runCleanup` describe carries and main keeps adding more of; requiring + * the sweep's four fields there breaks each new one at `tsc` with nothing + * about the sweep at fault. Measured: CI went red on this branch the round a + * fourth such describe landed (#9633), on a line no capture code touches. A + * fixture that DOES speak for the sweep annotates the full type and keeps the + * strict check. */ +type SweepEntryStat = { + isSymbolicLink: () => boolean; + isSocket: () => boolean; + isDirectory?: () => boolean; + nlink: number; + ino: number; + mode: number; +}; + const mocks = vi.hoisted(() => ({ execFileSync: vi.fn(), + // The sweep resolves `tmux` on PATH before it will spawn it, and that walk + // reads node:fs — so it has to come through this file's mock like every + // other read, or the tests would depend on where the host installed tmux. + // The default refuses every candidate: a describe that needs a resolvable + // binary says so. + accessSync: vi.fn((_path: string): void => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }), existsSync: vi.fn((_path: string): boolean => false), + // The path is taken because several tests dispatch on it. The default + // answer serves BOTH lstat consumers at once: the redirect guard reads + // isSymbolicLink/isDirectory, and the capture-server reap's entry guard + // and post-kill identity re-check read the SweepEntryStat fields + // (isSocket/nlink/ino/mode) — "a plain entry that is a socket" lets the + // name-matched fixtures reach the pid probe and kill while no ancestor + // looks redirected. lstatSync: vi.fn( - (): { isSymbolicLink: () => boolean; isDirectory: () => boolean } => ({ + (_path: string): Partial => ({ isSymbolicLink: () => false, isDirectory: () => true, + isSocket: () => true, + nlink: 1, + ino: 1, + mode: 0o140700, }), ), // The return type is declared so `mockReturnValue` can take string arrays — @@ -77,6 +120,7 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, default: { ...actual, + accessSync: mocks.accessSync, existsSync: mocks.existsSync, lstatSync: mocks.lstatSync, readdirSync: mocks.readdirSync, @@ -84,6 +128,7 @@ vi.mock('node:fs', async (importOriginal) => { statSync: mocks.statSync, rmSync: mocks.rmSync, }, + accessSync: mocks.accessSync, existsSync: mocks.existsSync, lstatSync: mocks.lstatSync, readdirSync: mocks.readdirSync, @@ -159,6 +204,7 @@ import { type RawIssueComment, type RawReview, } from './cleanup.js'; +import { captureServerName } from './lib/tui-capture.js'; describe('runCleanup', () => { beforeEach(() => { @@ -167,10 +213,6 @@ describe('runCleanup', () => { // set in one test would otherwise decide what the next one's directory // sweep sees. mocks.readdirSync.mockReturnValue([]); - mocks.lstatSync.mockReturnValue({ - isSymbolicLink: () => false, - isDirectory: () => true, - }); mocks.existsSync.mockReturnValue(false); // Implementations survive clearAllMocks — restore the fail-open throw // so one retention test's mtimes cannot leak into the next test. The @@ -181,6 +223,9 @@ describe('runCleanup', () => { mocks.statSync.mockImplementation(() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); + mocks.accessSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); mocks.readFileSync.mockImplementation(() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); @@ -188,6 +233,15 @@ describe('runCleanup', () => { // path-dependent implementations, and a later test reading the declared // `[]` default would otherwise inherit them. mocks.readdirSync.mockImplementation((_path: string): string[] => []); + mocks.lstatSync.mockImplementation( + (_path: string): SweepEntryStat => ({ + isSymbolicLink: () => false, + isSocket: () => true, + nlink: 1, + ino: 1, + mode: 0o140700, + }), + ); mocks.refExists.mockReturnValue(true); mocks.releaseWorktree.mockReturnValue({ existed: false, @@ -460,6 +514,1012 @@ describe('runCleanup', () => { ]); }); + describe.skipIf(process.platform === 'win32')( + 'orphaned capture-tui servers', + () => { + // win32: the implementation early-returns when process.getuid is + // undefined, so both halves under test are unreachable there and the + // fixtures (POSIX socket-dir layout) would fail for the wrong reason. + // A SIGKILL'd harness leaves the private tmux server alive; cleanup is + // the sweep that reclaims it, keyed on the launcher pid in the socket + // name. The pid liveness probe and the tmux kill are the two halves. + const uid = process.getuid?.(); + const dir = `/fake-tmp/tmux-${String(uid)}`; + // The sweep resolves `tmux` on PATH before it will spawn anything, and + // that walk goes through this file's own node:fs mock — so the walk is + // given a deterministic answer rather than the host's real tmux, which + // would make these assertions depend on where the machine installed it. + const SWEEP_PATH_DIR = '/fake-bin'; + const SWEEP_TMUX = `${SWEEP_PATH_DIR}/tmux`; + let realPath: string | undefined; + // A pid that WAS alive and is not: spawn a process and let it exit. + const deadPid = String(spawnSync(process.execPath, ['-e', '']).pid ?? 0); + const deadPid2 = String(spawnSync(process.execPath, ['-e', '']).pid ?? 0); + // Built with the PRODUCER, not hand-spelled: the sweep's matcher + // (`^${CAPTURE_SERVER_PREFIX}(\\d+)-`) only works while the pid sits + // immediately after the prefix, and a captureServerName edit that + // inserted a segment before it would leave every hand-written fixture + // matching while the real sweep stopped recognising real sockets. + const orphan = captureServerName(Number(deadPid), 'aaaa'); + // Listed AFTER the wedged orphan: an unreapable entry must not stop the + // sweep (a continue→break mutant leaves this one alive for the + // holder's full bounded window — up to three hours — with no stderr trail). + const orphan2 = captureServerName(Number(deadPid2), 'cccc'); + const live = captureServerName(process.pid, 'bbbb'); + + beforeEach(() => { + process.env['TMUX_TMPDIR'] = '/fake-tmp'; + realPath = process.env['PATH']; + // One absolute element, holding one binary: the resolution the sweep + // performs is then a fact of the fixture rather than of the host. + process.env['PATH'] = SWEEP_PATH_DIR; + mocks.accessSync.mockImplementation((p: string) => { + if (p !== SWEEP_TMUX) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + }); + mocks.statSync.mockImplementation((p: string) => { + if (p === SWEEP_TMUX) return { isFile: () => true } as never; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + // The foreign socket comes FIRST: a continue→break mutant stops the + // sweep at the first non-matching name (typically the user's own + // socket), leaving every orphan after it alive. + // Live socket BEFORE the orphans: an `if (alive) continue` → + // `break` mutant would stop at the first live socket and leave + // every orphan after it holding its bounded pane hold. + p === dir ? ['some-other-socket', live, orphan, orphan2] : [], + ); + mocks.execFileSync.mockReturnValue(Buffer.from('')); + }); + + afterEach(() => { + delete process.env['TMUX_TMPDIR']; + if (realPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = realPath; + }); + + it('reaps sockets whose launcher pid is dead and leaves live ones alone', () => { + runCleanup('local'); + + expect(mocks.execFileSync).toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', orphan, 'kill-server'], + expect.objectContaining({ + stdio: 'pipe', + timeout: 15_000, + killSignal: 'SIGKILL', + // The kill runs in the base the socket was found under; the + // dedicated env tests pin the value. + env: expect.objectContaining({ TMUX_TMPDIR: expect.any(String) }), + }), + ); + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', live, 'kill-server'], + expect.objectContaining({ + stdio: 'pipe', + timeout: 15_000, + killSignal: 'SIGKILL', + // The kill runs in the base the socket was found under; the + // dedicated env tests pin the value. + env: expect.objectContaining({ TMUX_TMPDIR: expect.any(String) }), + }), + ); + expect(mocks.rmSync).toHaveBeenCalledWith(`${dir}/${orphan}`, { + force: true, + }); + // And the LIVE socket is never announced as reaped: every stdout + // negative in this suite named the orphan, so a mutant printing the + // success line for a skipped live server escaped all of them. + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${live}`, + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + // BOTH orphans: a break-after-first-reap mutant left every later + // orphan alive on multi-review hosts and shipped green. + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan2}`, + ); + // The foreign socket stands in for the USER's own tmux server: the + // regex gate keeps the sweep off it entirely — a deleted `continue` + // on non-match kill-server'd the user's default server in probe (the + // blast radius private -L isolation exists to prevent). + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', 'some-other-socket', 'kill-server'], + expect.anything(), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/some-other-socket`, + expect.anything(), + ); + // The orphans were host-wide reaps, not target-scoped removals: + // the target-scoped answer stands on target-scoped facts, and + // nothing of THIS target's was there to clean. + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + 'Nothing to clean for target "local".', + ); + }); + + it('sweeps orphans even when another session holds the lease', () => { + // The harness crash that leaves an orphan leaves the lease too, + // held by the dead session — and the lease check is session-id + // only. A sweep gated behind the lease skipped on exactly the + // cleanup calls meant to reclaim the orphan (probe-reproduced); + // the sweep touches only servers whose launcher pid is dead, + // never the leased worktree, so the skip and the sweep coexist. + const lease = { + sessionId: 'session-a', + promptId: 'prompt-a', + target: 'pr-123', + repositoryRoot: '/repo', + worktreePath: '/repo/.qwen/tmp/review-pr-123', + branch: 'qwen-review/pr-123', + }; + mocks.readReviewWorktreeLease.mockReturnValueOnce(lease); + mocks.reviewLeaseHeldByAnotherSession.mockImplementationOnce( + (l: unknown) => l === lease, + ); + runCleanup('pr-123'); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('skipped cleanup for "pr-123"'), + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan2}`, + ); + // The skip still protects the holder's worktree. + expect(mocks.releaseWorktree).not.toHaveBeenCalled(); + }); + + it('notes a server it cannot kill and does not unlink a live server socket', () => { + // Throw for the FIRST orphan only (both retry attempts), so the sweep + // must note it and CONTINUE to the second one. + mocks.execFileSync.mockImplementation((bin: string, argv: string[]) => { + if (bin === SWEEP_TMUX && argv?.[1] === orphan) { + throw Object.assign(new Error('wedged'), { + stderr: 'tmux: server is wedged', + }); + } + return Buffer.from(''); + }); + + runCleanup('local'); + + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + `could not reap orphaned capture server ${orphan}`, + ), + ); + // And the hand-reap command it suggests carries the base override + // the sweep itself needed: without it `-L` resolves elsewhere and + // answers 'no server running', reading as "already gone". + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining(`TMUX_TMPDIR='/fake-tmp'`), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${orphan}`, + expect.anything(), + ); + // ...and stdout must not claim it WAS reaped. Hoisting the success + // line above the failure branch escaped every other assertion here. + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + // The sweep REACHED the orphan listed after the wedged one — a + // continue→break mutant left it alive for the holder's bounded window, unnoted. + expect(mocks.execFileSync).toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', orphan2, 'kill-server'], + expect.objectContaining({ + stdio: 'pipe', + timeout: 15_000, + killSignal: 'SIGKILL', + // The kill runs in the base the socket was found under; the + // dedicated env tests pin the value. + env: expect.objectContaining({ TMUX_TMPDIR: expect.any(String) }), + }), + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan2}`, + ); + // The title's second clause, pinned directly: the LIVE server's + // socket is never unlinked (unlinking it would make the live server + // unreachable forever). + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${live}`, + expect.anything(), + ); + // An unreapable orphan is a FAILURE, not a nothing: stdout must not + // contradict the stderr note with a "Nothing to clean" claim. + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Nothing to clean'), + ); + }); + + it('shell-quotes the manual-reap base — $ and backticks survive the paste', () => { + // JSON.stringify does not escape $ or backticks: a base carrying + // one expanded when the operator pasted the suggested command, + // resolving the wrong base and answering 'already gone' while the + // orphan ran out its window (probe-verified for a $-carrying + // base). + const base = '/fake-$tmp'; + const dollarDir = `${base}/tmux-${String(uid)}`; + process.env['TMUX_TMPDIR'] = base; + mocks.existsSync.mockImplementation((p: string) => p === dollarDir); + mocks.readdirSync.mockImplementation((p: string) => + p === dollarDir ? [orphan] : [], + ); + mocks.execFileSync.mockImplementation(() => { + throw Object.assign(new Error('wedged'), { stderr: 'wedged' }); + }); + runCleanup('local'); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining(`TMUX_TMPDIR='/fake-$tmp'`), + ); + }); + + it('treats "no server running" as reaped — socket unlinked, success printed', () => { + // The kill throwing because the server is ALREADY dead is the goal + // state, not a failure: the socket is litter and must still go. A + // `serverDead = false` mutant ships this branch green otherwise. + mocks.execFileSync.mockImplementation((bin: string) => { + if (bin === SWEEP_TMUX) { + throw Object.assign(new Error('exited 1'), { + stderr: Buffer.from(`no server running on ${dir}/${orphan}`), + }); + } + return Buffer.from(''); + }); + + runCleanup('local'); + + expect(mocks.rmSync).toHaveBeenCalledWith(`${dir}/${orphan}`, { + force: true, + }); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + expect(mocks.writeStderrLine).not.toHaveBeenCalledWith( + expect.stringContaining('could not reap'), + ); + }); + + it('never spawns a bare tmux name — the cwd is the reviewed tree', () => { + // execvp honours the empty-PATH-element → cwd rule, and `cleanup` + // runs with the reviewed worktree as its cwd: on a host whose PATH + // carries an empty element, a `tmux` committed to the PR under + // review is what the pinned kill-server executes, with the + // reviewer's environment. The sweep resolves on absolute elements + // only, so an unresolvable tmux is a disclosed skip rather than a + // spawn of whatever the tree supplied. + mocks.accessSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + runCleanup('local'); + + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + 'tmux', + expect.anything(), + expect.anything(), + ); + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + expect.anything(), + expect.anything(), + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('not reachable at any absolute PATH element'), + ); + // A skip is a failure to reap, so it must not read as a clean run. + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Nothing to clean'), + ); + }); + + it('ignores a capture-PREFIXED name that the producer cannot mint', () => { + // The matcher is anchored to the producer's whole shape, not its + // prefix. With an open suffix a same-uid planter chose the rest of + // the name — and the sweep put that name into a command built to be + // PASTED, plus its stdout and stderr lines, so `$(…)` in a socket + // name reached an operator's shell. Nothing here is escaped after + // the fact; the name simply never becomes this sweep's business. + const forged = `${captureServerName(Number(deadPid), 'aaaa')}$(touch pwned)`; + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [forged] : [], + ); + + runCleanup('local'); + + // Never inspected, never killed, never named on either stream. + expect(mocks.lstatSync).not.toHaveBeenCalledWith(`${dir}/${forged}`); + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', forged, 'kill-server'], + expect.anything(), + ); + for (const spy of [mocks.writeStdoutLine, mocks.writeStderrLine]) { + for (const call of spy.mock.calls) { + expect(String(call[0])).not.toContain('touch pwned'); + } + } + }); + + it('never follows a symlink planted under a capture-shaped name', () => { + // The sweep matches by NAME and used to inspect nothing else: a + // symlink planted under a capture-shaped name redirected the + // pinned kill-server to whatever socket it points at — the user's + // own server among them — and the exit-0 branch printed "Reaped" + // while the victim died and the link was unlinked behind it + // (probe-verified end to end; no race needed). The guard inspects + // the entry TYPE before the pid probe. + const planted = captureServerName(Number(deadPid), 'eeee'); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [planted] : [], + ); + mocks.lstatSync.mockImplementation( + (p: string): SweepEntryStat => ({ + isSymbolicLink: () => p === `${dir}/${planted}`, + isSocket: () => p !== `${dir}/${planted}`, + nlink: 1, + ino: 1, + mode: 0o140700, + }), + ); + + runCleanup('local'); + + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', planted, 'kill-server'], + expect.anything(), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${planted}`, + expect.anything(), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${planted}`, + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining(`not reaping ${planted}`), + ); + }); + + it('never reaps through a HARD LINK to a foreign socket', () => { + // link() succeeds on a unix socket (measured on Linux: same inode, + // nlink 2), and connect(2) is inode-addressed — so a hard link to + // the user's own server sails through a symlink-only guard, the + // dead-pid probe and the pinned kill-server, and destroys the + // victim race-free with exit 0 while the sweep prints "Reaped" + // (probe-verified end to end). A tmux-created socket has exactly + // one link, so nlink > 1 is never an orphan. + const planted = captureServerName(Number(deadPid), 'ffff'); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [planted] : [], + ); + mocks.lstatSync.mockImplementation( + (_p: string): SweepEntryStat => ({ + isSymbolicLink: () => false, + isSocket: () => true, + nlink: 2, + ino: 1, + mode: 0o140700, + }), + ); + + runCleanup('local'); + + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', planted, 'kill-server'], + expect.anything(), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${planted}`, + expect.anything(), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${planted}`, + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining(`not reaping ${planted}`), + ); + }); + + it('never reaps a non-socket entry under a capture-shaped name', () => { + // isSocket() completes the guard: a planted regular file or FIFO + // is not a server, and a kill-server pointed at it probes at best + // an error and at worst something unrelated. + const planted = captureServerName(Number(deadPid), '0d0d'); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [planted] : [], + ); + mocks.lstatSync.mockImplementation( + (_p: string): SweepEntryStat => ({ + isSymbolicLink: () => false, + isSocket: () => false, + nlink: 1, + ino: 1, + mode: 0o100644, + }), + ); + + runCleanup('local'); + + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', planted, 'kill-server'], + expect.anything(), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${planted}`, + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining(`not reaping ${planted}`), + ); + }); + + it('warns instead of claiming "Reaped" when the entry changed under the kill', () => { + // tmux re-resolves the entry at connect(), after the fork+exec, so + // a racer can swap it between the guard's lstat and the kill and + // land the pinned kill-server on an unrelated server (probe- + // verified: the race won on the first attempt). No portable close + // exists on the connect itself — but the success line must not + // assert a certainty the sweep does not have, so the post-kill + // identity re-check names the swap instead. + const planted = captureServerName(Number(deadPid), '1e1e'); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [planted] : [], + ); + let lstatCalls = 0; + mocks.lstatSync.mockImplementation( + (_p: string): SweepEntryStat => ({ + isSymbolicLink: () => false, + isSocket: () => true, + nlink: 1, + // The guard's lstat and the post-kill re-check disagree on the + // entry's identity: the racer won the window. + ino: lstatCalls++ === 0 ? 111 : 222, + mode: 0o140700, + }), + ); + + runCleanup('local'); + + // The entry is NOT unlinked: a racer renamed something onto the name + // in the connect→re-check window, and it may be a live server whose + // socket, once unlinked, is unreachable forever — the harm the + // function's own "unlink ONLY when known dead" rule forbids. Leaving + // it is self-healing; the next sweep re-examines it. + expect(mocks.rmSync).not.toHaveBeenCalledWith(`${dir}/${planted}`, { + force: true, + }); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${planted}`, + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + `${planted} changed between the type guard and the kill`, + ), + ); + // A possibly-wrong kill is not a clean nothing. + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Nothing to clean'), + ); + }); + + it('does not credit an ENOENT answer as death — the file can vanish under a live server', () => { + // The sweep found the entry by readdir, so an ENOENT answer from + // the kill means the file vanished between the scan and the kill — + // possibly off a LIVE server (probed: rm the socket under a + // running server and kill answers exactly this). Not death, not an + // unlink, and loud like the other never-death wordings. + mocks.execFileSync.mockImplementation((bin: string, argv: string[]) => { + if (bin === SWEEP_TMUX && argv?.[1] === orphan) { + throw Object.assign(new Error('exited 1'), { + stderr: Buffer.from( + `error connecting to ${dir}/${orphan} ` + + '(No such file or directory)', + ), + }); + } + return Buffer.from(''); + }); + + runCleanup('local'); + + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${orphan}`, + expect.anything(), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + `could not reap orphaned capture server ${orphan}`, + ), + ); + }); + + it('accumulates orphans across BOTH bases, not just the last one', () => { + // No fixture returned entries from both socket bases at once, so + // the cross-base `entries.concat(...)` was unpinned and an + // overwrite mutant shipped green — losing every orphan under the + // env base whenever /tmp also had one (and vice versa). + const tmpDir = `/tmp/tmux-${String(uid)}`; + mocks.existsSync.mockImplementation( + (p: string) => p === dir || p === tmpDir, + ); + mocks.readdirSync.mockImplementation((p: string) => { + if (p === dir) return [orphan]; + if (p === tmpDir) return [orphan2]; + return []; + }); + runCleanup('local'); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan2}`, + ); + }); + + /** The pid probe, stubbed: the literal pids below are assumed dead, + * and on a busy host one of them can be alive — the sweep would then + * skip the socket and the test would fail for a reason that has + * nothing to do with what it pins. ESRCH is "dead", which is the + * precondition these tests want. */ + function withDeadPids(fn: () => void): void { + const realKill = process.kill; + process.kill = ((): never => { + throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); + }) as typeof process.kill; + try { + fn(); + } finally { + process.kill = realKill; + } + } + + it('unlinks EVERY reaped socket, not just the first', () => { + // Positive rmSync assertions covered only the first matching + // socket; the second orphan was pinned by its kill argv and its + // "Reaped" line, so a mutant unlinking one socket per sweep left + // dead sockets littering the base and shipped this suite green. + const o1 = captureServerName(717171, 'aaa'); + const o2 = captureServerName(727272, 'bbb'); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [o1, o2] : [], + ); + withDeadPids(() => runCleanup('local')); + for (const name of [o1, o2]) { + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${name}`, + ); + expect(mocks.rmSync).toHaveBeenCalledWith(`${dir}/${name}`, { + force: true, + }); + } + }); + + it('never unlinks on a CLIENT-side refusal — and says which it was', () => { + // `directory … has unsafe permissions` and `… is not a directory` + // are refusals tmux makes before looking at the server, so a live + // orphan can be sitting behind that socket. Neither wording + // appeared in any fixture, so the whole isSocketDirUnusable wiring + // — the note's parenthetical included — was unexercised. + for (const wording of [ + 'directory /tmp/tmux-501 has unsafe permissions', + '/tmp/tmux-501 is not a directory', + ]) { + vi.clearAllMocks(); + const orphan = captureServerName(838383, 'ccc'); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [orphan] : [], + ); + mocks.execFileSync.mockImplementation(() => { + throw Object.assign(new Error('kill failed'), { stderr: wording }); + }); + withDeadPids(() => runCleanup('local')); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Reaped orphaned capture server'), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + `${dir}/${orphan}`, + expect.anything(), + ); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('before reaching the socket directory'), + ); + } + mocks.execFileSync.mockReset(); + }); + + it('treats a NON-EPERM probe error as alive too — only ESRCH reaps', () => { + // The invariant is "reap only a pid positively known dead". Pinning + // EPERM alone left `alive = code === 'EPERM'` shipping green, and a + // host answering EINVAL would then have its live server reaped. + const orphan = captureServerName(515151, 'def'); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [orphan] : [], + ); + const realKill = process.kill; + process.kill = ((): never => { + throw Object.assign(new Error('EINVAL'), { code: 'EINVAL' }); + }) as typeof process.kill; + try { + runCleanup('local'); + } finally { + process.kill = realKill; + } + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Reaped orphaned capture server'), + ); + }); + + it('keeps the run alive when the post-kill UNLINK fails', () => { + // The unlink guard's `catch {}` has no test: mocks.rmSync is a bare + // vi.fn() that never throws, so removing the catch — turning + // cosmetic litter into a crash that aborts the sweep mid-way — + // ships green. + const orphan = captureServerName(626262, 'aaa'); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [orphan] : [], + ); + mocks.rmSync.mockImplementation(() => { + throw Object.assign(new Error('EBUSY'), { code: 'EBUSY' }); + }); + try { + expect(() => runCleanup('local')).not.toThrow(); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + } finally { + // RESET, not clear: the suite's beforeEach uses + // vi.clearAllMocks(), which drops call history and keeps + // implementations — so without this the throwing rmSync leaks + // into every later test in this file, and the six orphan-reap + // tests after it would silently exercise this catch instead of + // the success path they present themselves as pinning. + mocks.rmSync.mockReset(); + } + }); + + it('reports nothing swept where there are no uids — the win32 arm', () => { + // process.getuid is undefined on win32, which is the only platform + // that reaches this arm — and the describe holding these tests is + // skipIf(win32), so its contract ({reaped:false, failed:false}, + // and no scan) was asserted on no lane at all. + const realGetuid = process.getuid; + // Modelling the win32 shape on a POSIX lane. `delete` on an + // optional property needs no suppression — an @ts-expect-error + // here is itself an error under `tsc --build`, which is what CI + // runs (and what `npm run typecheck` did not catch). + delete (process as { getuid?: unknown }).getuid; + try { + runCleanup('local'); + } finally { + process.getuid = realGetuid; + } + expect(mocks.readdirSync).not.toHaveBeenCalled(); + expect(mocks.writeStderrLine).not.toHaveBeenCalledWith( + expect.stringContaining('could not scan'), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Reaped orphaned capture server'), + ); + }); + + it('leaves a socket alone when the pid probe answers EPERM', () => { + // EPERM means the pid is alive under another user, so the socket + // named for it in THIS uid's 0700 directory is pid reuse, not our + // launcher — reaping on that assumption would kill a server this + // sweep cannot prove is ours. The simplifying mutant (`alive = + // false` on any throw) shipped green through the whole suite + // because process.kill was never stubbed here. + const orphan = captureServerName(424242, 'abc'); + mocks.existsSync.mockImplementation((p: string) => p === dir); + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [orphan] : [], + ); + const realKill = process.kill; + process.kill = ((): never => { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); + }) as typeof process.kill; + try { + runCleanup('local'); + } finally { + process.kill = realKill; + } + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Reaped orphaned capture server'), + ); + expect(mocks.rmSync).not.toHaveBeenCalledWith( + expect.stringContaining(orphan), + expect.anything(), + ); + }); + + it('surfaces an UNTRAVERSABLE ancestor instead of skipping the base', () => { + // existsSync swallows EACCES and answers false, so an ancestor + // without +x made the whole base look absent: skipped in silence, + // past the catch that exists to be loud, with any orphan under it + // invisible. The scan asks readdir directly now — ENOENT is the + // only answer quiet enough to ignore. + mocks.existsSync.mockReturnValue(false); + mocks.readdirSync.mockImplementation(() => { + throw Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + }); + runCleanup('local'); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('could not scan'), + ); + }); + + it('scans the OTHER base after one of them cannot be read', () => { + // The unreadable-dir fixture makes both bases throw, so a + // catch-then-break mutant shipped green: an env-base tmux- + // that exists but is mode-000 would then hide every orphan under + // /tmp behind one stderr note. + const tmpDir = `/tmp/tmux-${String(uid)}`; + mocks.existsSync.mockImplementation( + (p: string) => p === dir || p === tmpDir, + ); + mocks.readdirSync.mockImplementation((p: string) => { + if (p === dir) { + throw Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + } + if (p === tmpDir) return [orphan]; + return []; + }); + runCleanup('local'); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('could not scan'), + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + }); + + it('sweeps under a pr- target too — the sweep is host-wide', () => { + // Every other fixture drives 'local'. The sweep is deliberately not + // target-scoped (an orphan belongs to the host, not to one review), + // so an edit that moves it into a local-only path would leave nine + // orphan tests green while PR runs — where captures actually + // happen — stopped reaping. + runCleanup('pr-8388'); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + }); + + it('falls back to /tmp when TMUX_TMPDIR is unset — the common host', () => { + // All other fixtures set TMUX_TMPDIR; the fallback branch governs + // standard CI lanes and dev machines, and a wrong-literal mutant + // scanned the wrong directory and returned clean forever. + delete process.env['TMUX_TMPDIR']; + const tmpDir = `/tmp/tmux-${String(uid)}`; + mocks.existsSync.mockImplementation((p: string) => p === tmpDir); + mocks.readdirSync.mockImplementation((p: string) => + p === tmpDir ? [orphan] : [], + ); + runCleanup('local'); + expect(mocks.readdirSync).toHaveBeenCalledWith(tmpDir); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + }); + + it('scans /tmp even when TMUX_TMPDIR points elsewhere — tmux fell back', () => { + // tmux takes the first USABLE base: a stale profile-exported + // TMUX_TMPDIR pointing at an unusable path puts the socket under + // /tmp while a single-base sweep `[envBase || '/tmp']` scans only + // the env base and reports clean with the orphan still live + // (measured end-to-end: 'Nothing to clean' beside a live orphan). + const tmpDir = `/tmp/tmux-${String(uid)}`; + mocks.existsSync.mockImplementation((p: string) => p === tmpDir); + mocks.readdirSync.mockImplementation((p: string) => + p === tmpDir ? [orphan] : [], + ); + runCleanup('local'); + expect(mocks.readdirSync).toHaveBeenCalledWith(tmpDir); + // And the KILL goes to the base the socket was FOUND under, not to + // this process's env: `-L` re-resolves the socket dir from the + // environment and tmux does NOT fall back when the env base exists + // (it creates it) — measured on 3.3a, the kill answered + // `error connecting to /tmux-/` and the orphan + // survived, while the same call under the found base reaped it. + // The mocked execFileSync cannot show that; the env it is called + // with can. + expect(mocks.execFileSync).toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', orphan, 'kill-server'], + expect.objectContaining({ + env: expect.objectContaining({ + TMUX_TMPDIR: '/tmp', + // The parent environment rides along: `env: { TMUX_TMPDIR }` + // alone leaves tmux without a PATH, and every kill then fails + // for a reason that has nothing to do with the socket. + PATH: process.env['PATH'], + }), + }), + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + }); + + it('reads TMUX_TMPDIR UNTRIMMED — tmux uses a padded value verbatim', () => { + // Measured against real tmux 3.4: with a trailing space in + // TMUX_TMPDIR the socket landed under the PADDED path, while a + // trimming sweep scanned a directory tmux never used and reported + // clean — re-adding .trim() must turn this red. + process.env['TMUX_TMPDIR'] = '/fake-tmp '; + const paddedDir = `/fake-tmp /tmux-${String(uid)}`; + mocks.existsSync.mockImplementation((p: string) => p === paddedDir); + mocks.readdirSync.mockImplementation((p: string) => + p === paddedDir ? [orphan] : [], + ); + runCleanup('local'); + expect(mocks.readdirSync).toHaveBeenCalledWith(paddedDir); + // The kill carries the padded base too — trimming EITHER side + // sends tmux to a directory it never used. + expect(mocks.execFileSync).toHaveBeenCalledWith( + SWEEP_TMUX, + ['-L', orphan, 'kill-server'], + expect.objectContaining({ + env: expect.objectContaining({ TMUX_TMPDIR: '/fake-tmp ' }), + }), + ); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + }); + + it('surfaces an unreadable socket dir — a scan failure is not a silent nothing', () => { + // A mode-000 tmux- or a filesystem hiccup makes readdirSync + // throw; the sweep must note the unreadable dir on stderr, must + // not claim 'Nothing to clean' while an orphan may be hiding, and + // must still clear the target-scoped lease. The swallowing mutant + // `catch {}` hid orphans for the holder's whole bounded window and + // shipped green. + mocks.existsSync.mockImplementation((p: string) => + p.endsWith(`/tmux-${String(uid)}`), + ); + mocks.readdirSync.mockImplementation((p: string) => { + if (p.endsWith(`/tmux-${String(uid)}`)) { + throw Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + } + return []; + }); + runCleanup('local'); + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('could not scan'), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Nothing to clean'), + ); + expect(mocks.clearReviewWorktreeLease).toHaveBeenCalledWith( + process.cwd(), + 'local', + ); + }); + + it('reaps on the SECOND kill attempt — the sweep retry is real', () => { + let calls = 0; + mocks.execFileSync.mockImplementation((bin: string) => { + if (bin === SWEEP_TMUX) { + calls++; + if (calls === 1) { + throw Object.assign(new Error('transient'), { + stderr: 'transient client failure', + }); + } + } + return Buffer.from(''); + }); + runCleanup('local'); + expect(mocks.writeStdoutLine).toHaveBeenCalledWith( + `Reaped orphaned capture server: ${orphan}`, + ); + expect(mocks.writeStderrLine).not.toHaveBeenCalledWith( + expect.stringContaining('could not reap'), + ); + // The cap is ONE retry, pinned from ABOVE as well: every earlier + // assertion here holds for any cap >= 2, so a "robustness" edit + // could widen it silently — and against a genuinely wedged server + // each attempt pays the full 15s belt before the cleanup moves on. + const killCalls = mocks.execFileSync.mock.calls.filter( + (c: unknown[]) => + c[0] === SWEEP_TMUX && + Array.isArray(c[1]) && + (c[1] as string[]).includes('kill-server') && + (c[1] as string[]).includes(orphan), + ); + expect(killCalls).toHaveLength(2); + }); + + it('gives up after ONE retry — the cap, pinned from above', () => { + // The fixture above stops throwing after the first call, so the + // loop exits via serverDead on attempt 2 for ANY cap >= 2. Here + // every attempt throws, so the count IS the cap: a widened cap pays + // the full 15s belt per attempt against a genuinely wedged server + // while the cleanup waits. + mocks.execFileSync.mockImplementation((bin: string) => { + if (bin === SWEEP_TMUX) { + throw Object.assign(new Error('wedged'), { + stderr: 'tmux: server is wedged', + }); + } + return Buffer.from(''); + }); + runCleanup('local'); + const killCalls = mocks.execFileSync.mock.calls.filter( + (c: unknown[]) => + c[0] === SWEEP_TMUX && + Array.isArray(c[1]) && + (c[1] as string[]).includes('kill-server') && + (c[1] as string[]).includes(orphan), + ); + expect(killCalls).toHaveLength(2); + }); + + it('reports an ONLY-unreapable-orphan sweep without "Nothing to clean" — and without holding the lease', () => { + // With a second reapable orphan in the fixture, removedAny masks + // the sweep.failed propagation — deleting it shipped green. Here + // the sole capture socket is unreapable: stdout must not claim + // nothing needed cleaning while stderr says the reap failed. + mocks.readdirSync.mockImplementation((p: string) => + p === dir ? [orphan] : [], + ); + mocks.execFileSync.mockImplementation((bin: string) => { + if (bin === SWEEP_TMUX) { + throw Object.assign(new Error('wedged'), { + stderr: 'tmux: server is wedged', + }); + } + return Buffer.from(''); + }); + + runCleanup('local'); + + expect(mocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('could not reap'), + ); + expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith( + expect.stringContaining('Nothing to clean'), + ); + // The sweep is host-wide, the lease is target-scoped: an + // unreapable orphan from ANY capture must not wedge THIS target's + // worktree lease (measured complaint: an unrelated review's orphan + // blocked the lease release with nothing connecting the two). + expect(mocks.clearReviewWorktreeLease).toHaveBeenCalledWith( + process.cwd(), + 'local', + ); + }); + }, + ); it('sweeps every verifier scratch tree, which it can only find by prefix', () => { // One per verifier shard, named for the shard's record key — so unlike the // probe and base siblings, the sweeper cannot reconstruct the names and @@ -496,14 +1556,11 @@ describe('runCleanup', () => { 'review-pr-123-scratch-verify--round-1--aaa', ] as unknown as []); mocks.existsSync.mockReturnValue(false); - mocks.lstatSync.mockImplementation(((p: string) => ({ + mocks.lstatSync.mockImplementation((p: string) => ({ // Only the family entry is a link; its parent directory is a directory. isSymbolicLink: () => String(p).includes('-scratch-'), isDirectory: () => !String(p).includes('-scratch-'), - })) as unknown as () => { - isSymbolicLink: () => boolean; - isDirectory: () => boolean; - }); + })); runCleanup('pr-123'); @@ -528,13 +1585,10 @@ describe('runCleanup', () => { // The family paths are links; their ANCESTORS are ordinary directories — // a symlink above the temp dir refuses the whole clean, which is a // different test. - mocks.lstatSync.mockImplementation(((p: string) => ({ + mocks.lstatSync.mockImplementation((p: string) => ({ isSymbolicLink: () => String(p).includes('review-pr-'), isDirectory: () => !String(p).includes('review-pr-'), - })) as unknown as () => { - isSymbolicLink: () => boolean; - isDirectory: () => boolean; - }); + })); runCleanup('pr-123'); @@ -595,13 +1649,10 @@ describe('runCleanup', () => { // the same function kept deleting under it — the base-tree lock and every // side file, all resolved through the same redirected ancestor. mocks.execFileSync.mockReturnValue(Buffer.from('')); - mocks.lstatSync.mockImplementation(((p: string) => ({ + mocks.lstatSync.mockImplementation((p: string) => ({ isSymbolicLink: () => String(p) === '/repo/.qwen', isDirectory: () => String(p) !== '/repo/.qwen', - })) as unknown as () => { - isSymbolicLink: () => boolean; - isDirectory: () => boolean; - }); + })); runCleanup('pr-123'); @@ -1014,6 +2065,7 @@ describe('runCleanup — bypass-write audit', () => { mocks.readFileSync.mockImplementation(() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); + mocks.currentUser.mockReturnValue('reviewer'); mocks.ghApiAll.mockReturnValue([]); // Same leak class: the Aone audit describe steers the dispatch, and a diff --git a/packages/cli/src/commands/review/cleanup.ts b/packages/cli/src/commands/review/cleanup.ts index a72c7ee75bf..5e9fcb65f17 100644 --- a/packages/cli/src/commands/review/cleanup.ts +++ b/packages/cli/src/commands/review/cleanup.ts @@ -19,11 +19,19 @@ import { lstatSync, readFileSync, readdirSync, + realpathSync, rmSync, statSync, + type Stats, } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + CAPTURE_SERVER_NAME_RE, + isNothingToKill, + isSocketDirUnusable, + resolveOnPath, +} from './lib/tui-capture.js'; import { clearReviewWorktreeLease, isReviewLeaseFile, @@ -511,6 +519,304 @@ function auditPrWrites(target: string, prNumber: string): void { } } +/** + * Reap the capture servers capture-tui's own reap could not reach: a + * SIGKILL'd or OOM'd harness skips finally and the signal net alike, and + * the private server then lives until its pane holder's bounded hold loop + * expires (up to three hours) — the config-free server has nothing else to + * destroy it, so this sweep (or a hand kill-server) is the only reaper in + * that window. The launcher's pid rides in the socket name for exactly + * this — a socket whose pid is dead is an orphan. A reap that fails is + * noted on stderr and suppresses the "Nothing to clean" claim — but does + * NOT hold the target-scoped worktree lease: the sweep is host-wide, and + * an unrelated review's orphan would otherwise wedge this review's lease + * with nothing connecting the two in the output. + * + * The orphan test is NAME plus a dead launcher pid, and that is sound only + * for the model capture-tui states (see its header): absent an active + * same-uid adversary, nothing but a crashed capture leaves a capture-named + * socket whose launcher pid is dead. A same-uid process that RENAMES a live + * foreign socket into a capture-shaped name with a chosen-dead pid defeats + * it — the entry is then a plain socket indistinguishable from a real + * orphan by every signal a name-addressed sweep can read, and no signal it + * could add is out of that adversary's reach (an on-disk pid record is + * same-uid writable; a live server's shape is same-uid craftable). That is + * the stated non-goal, hardened in #9274, not a defect this sweep can close + * from here. The type guard below rejects the redirections that arise + * WITHOUT such a rename; it does not pretend to more. + */ +function reapOrphanedCaptureServers(): { reaped: boolean; failed: boolean } { + const uid = process.getuid?.(); + // tmux is POSIX-only, and so is the socket dir layout below. + if (uid === undefined) return { reaped: false, failed: false }; + // BOTH candidate socket dirs, not one: tmux's own resolution takes the + // first USABLE base (TMUX_TMPDIR, else /tmp) — a stale profile-exported + // TMUX_TMPDIR pointing at an unusable path means the real sockets live + // under /tmp while a single-base sweep scans the wrong directory forever + // (measured end-to-end: 'Nothing to clean' with a live orphan). + // UNTRIMMED, matching tmux: a whitespace-padded TMUX_TMPDIR is used + // verbatim by tmux (measured: socket under '/tmp/x /tmux-'), so a + // trimming sweep scanned a directory tmux never used. + const envBase = process.env['TMUX_TMPDIR']; + // De-duplicated by the directory the scan actually opens, not the raw + // string: an alias of /tmp (`/tmp/`, `/tmp/.`, `//tmp` — the same + // profile-exported family this fallback exists for) survived a + // string-keyed Set, so both entries joined to the same tmux- dir and + // every socket in it was listed, killed and reported TWICE. + const bases: string[] = []; + const seen = new Set(); + for (const base of [envBase || '/tmp', '/tmp']) { + const dir = join(base, `tmux-${uid}`); + // Keyed on the RESOLVED directory: string normalization collapses + // `/tmp/`, `/tmp/.` and `//tmp`, but a TMUX_TMPDIR that is a symlink to + // /tmp still named a different string while opening the same directory, + // so every socket in it was listed, killed and reported twice. + let key = dir; + try { + key = realpathSync(dir); + } catch { + // Not there (or unreadable): the raw path is a fine key, and the + // scan below reports what it cannot read. + } + if (seen.has(key)) continue; + seen.add(key); + bases.push(base); + } + let reapedAny = false; + let failedAny = false; + let entries: Array<{ dir: string; name: string }> = []; + for (const base of bases) { + const dir = join(base, `tmux-${uid}`); + try { + // readdirSync FIRST, with ENOENT as the only quiet answer: existsSync + // swallows EACCES and returns false, so an untraversable ANCESTOR of + // this directory made the base look absent and skipped it silently — + // past the catch below that exists precisely to be loud about a + // directory that could be hiding an orphan, and against both nearby + // comments. "Not there" and "not allowed to look" are different + // answers and only one of them is safe to ignore. + entries = entries.concat(readdirSync(dir).map((name) => ({ dir, name }))); + } catch (e) { + // ENOENT, ENOTDIR and ELOOP are all definite "this base cannot hold a + // socket" answers — a TMUX_TMPDIR that is a regular file or a symlink + // loop is not a scan failure, and reporting it as one set sweepFailed + // permanently and suppressed "Nothing to clean" on a host where there + // was, in fact, nothing to clean. EACCES stays loud: that one CAN be + // hiding an orphan. + const code = (e as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'ELOOP') continue; + // A directory we cannot READ can be hiding an orphan — that is a + // failure to surface, not a silent nothing (the doc contract above: + // noted on stderr AND surfaced as failed). + failedAny = true; + writeStderrLine( + `note: could not scan ${dir} for orphaned capture servers: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + // The producer's own anchored shape, not a prefix — see its declaration + // for why the whole name has to be matched and why the nonce is pinned by + // alphabet rather than length. + const orphanRe = CAPTURE_SERVER_NAME_RE; + for (const { dir, name } of entries) { + const m = orphanRe.exec(name); + if (!m) continue; + // The sweep matches by NAME; inspect the entry's TYPE before anything + // connects to it. A planted entry under a capture-shaped name + // redirects the pinned kill-server to whatever socket it resolves to — + // including the user's own tmux server — and the exit-0 success branch + // then reports "Reaped" while the victim is destroyed (probe-verified + // end to end; the planter class is this PR's own documented + // daemonized descendant). Three entrances, all rejected here: a + // SYMLINK (kill-server follows it to the target), a HARD LINK to a + // foreign socket — link() succeeds on a socket and connect(2) is + // inode-addressed, so the kill lands on the foreign server race-free + // (measured on Linux) — and any non-socket entry. A tmux-created + // socket has exactly one link, so nlink > 1 is never an orphan. A + // gone entry is nothing to reap. + // + // What these checks do NOT catch, and cannot: a same-uid process that + // RENAMEs a live foreign socket into a capture-shaped name BEFORE the + // scan. The result is a plain socket, one link, sitting stably at the + // name — nothing here distinguishes it from a real orphan, and nothing + // could, because every identity signal is within that adversary's + // reach (an on-disk server-pid record is same-uid writable; the + // answering server's own shape is same-uid craftable). This is the + // active-same-uid boundary capture-tui's header states as a non-goal + // (#9274), not a hole these type checks leak: they close the + // redirections that need no rename, which is all a name-addressed + // sweep can close. The post-kill re-check below is for the narrower + // TOCTOU where a swap WINS the race between this lstat and tmux's + // connect() after the fork+exec — a rename already in place at scan + // time changes nothing between the two reads, so that re-check is + // silent on it by design, not by oversight. + let entryStat: Stats; + try { + entryStat = lstatSync(join(dir, name)); + } catch { + continue; + } + if ( + entryStat.isSymbolicLink() || + !entryStat.isSocket() || + entryStat.nlink > 1 + ) { + writeStderrLine( + `note: not reaping ${name}: not a plain socket — kill-server ` + + 'would connect whatever this entry resolves to, which may be ' + + 'an unrelated server', + ); + continue; + } + let alive = true; + try { + process.kill(Number(m[1]), 0); + } catch (e) { + // ESRCH = the pid is dead (the orphan signal). EPERM means the pid + // is alive under another user. Both mean "leave it alone" here: the + // socket named for it lives in this uid's mode-0700 tmux directory, + // so a cross-user pid at that number is pid REUSE, not the launcher — + // and reaping on that assumption would kill a server this sweep + // cannot prove is ours. Anything else (an EINVAL, a host that + // answers oddly) is likewise treated as alive: this sweep only ever + // acts on a pid it positively knows is dead. + alive = (e as NodeJS.ErrnoException).code !== 'ESRCH'; + } + if (alive) continue; + // Same rules as capture-tui's own reap: unlink the socket ONLY when + // the server is known dead — a kill that throws can leave it alive, + // and an unlinked socket makes a live server unreachable forever — and + // one retry before giving up: a transient client-spawn failure (EMFILE + // after a long review's many spawns) is the named shape, and the + // identical second attempt reaps what otherwise lives out the holder's + // bounded three-hour window. + // Resolved, never bare — the half the comment below used to claim from + // capture-tui's control calls without carrying it. execvp honours the + // empty-PATH-element → cwd rule, and `cleanup` runs with the reviewed + // worktree as its cwd: on a host whose PATH has an empty element, a + // `tmux` committed to the PR under review is what this kill executes, + // with the reviewer's environment. Resolved HERE rather than at sweep + // start so a host with no tmux and no orphans stays silent. + const tmuxBin = resolveOnPath('tmux'); + if (tmuxBin === undefined) { + failedAny = true; + writeStderrLine( + `note: could not reap orphaned capture server ${name}: tmux is not ` + + 'reachable at any absolute PATH element, and this sweep will not ' + + 'resolve it through the current directory', + ); + continue; + } + let serverDead = false; + let dirUnusable = false; + for (let attempt = 0; attempt < 2 && !serverDead; attempt++) { + try { + execFileSync(tmuxBin, ['-L', name, 'kill-server'], { + stdio: 'pipe', + // The scan finds sockets under BOTH bases, but `-L` re-resolves + // the socket directory from THIS process's environment — and tmux + // does not fall back when the env base exists (it creates it). + // The two sides then disagree: an orphan found under /tmp while a + // stale profile-exported TMUX_TMPDIR points elsewhere answered + // `error connecting to /tmux-/` and survived + // (measured on 3.3a, with the same call succeeding under the base + // it was found in). Kill it where it was FOUND — `dir` is + // `/tmux-`, so its parent is the base tmux wants. + env: { ...process.env, TMUX_TMPDIR: dirname(dir) }, + // Same belt as capture-tui's own control calls: a wedged server + // must not hang the whole cleanup behind one socket — SIGKILL, + // because a TERM-immune child blocks the sync call past any belt. + timeout: 15_000, + killSignal: 'SIGKILL', + }); + serverDead = true; + } catch (e) { + const stderrText = String((e as { stderr?: unknown }).stderr ?? ''); + // Same rule as capture-tui's own reap: a client-side refusal + // establishes nothing about the server, so it must not reach the + // unlink below — a live orphan sits behind that socket, and + // removing it makes the server unreachable forever while this + // sweep reports "Reaped". + serverDead = isNothingToKill(stderrText); + // ACCUMULATED, like capture-tui's own reap: the reassignment this + // replaces let a second attempt that failed for another reason (an + // EMFILE spawn failure, the 15s belt) reset the flag and drop the + // note's one actionable parenthetical. + if (isSocketDirUnusable(stderrText)) dirUnusable = true; + } + } + if (!serverDead) { + failedAny = true; + writeStderrLine( + `note: could not reap orphaned capture server ${name}` + + (dirUnusable + ? ' (tmux refused before reaching the socket directory — its ' + + 'permissions or type, not the server)' + : '') + + ' ' + + // WITH the base override: `-L` re-resolves the socket directory + // from the invoking environment and does not fall back, so on + // the very hosts where this note appears the bare command + // resolves elsewhere and answers 'no server running' — reading + // as "already gone" while the orphan runs out its window. + // Shell-single-quoted, never JSON.stringify: a base carrying $ + // or a backtick expands at paste time and resolves the wrong + // base — the same confusion this note exists to prevent. + `(TMUX_TMPDIR='${dirname(dir).replaceAll("'", "'\\''")}' ` + + // The name is quoted for the same reason the base above it is, and + // belt-and-braces on top of the anchored `orphanRe`: this line is + // built to be PASTED, so anything reaching it that a shell would + // read runs in the operator's cwd. The regex is the gate; this is + // the second wall behind it. + `tmux -L '${name.replaceAll("'", "'\\''")}' kill-server to reap it by hand)`, + ); + continue; + } + // tmux re-resolves the entry at connect(), after the fork+exec, so a + // racer can swap it between the guard's lstat and the kill — no + // portable close exists on the connect itself. When the entry the + // kill ran under is not the one the guard inspected, "Reaped" would + // assert a certainty the sweep does not have; name the swap instead. + let entryChanged = false; + try { + const postKill = lstatSync(join(dir, name)); + entryChanged = + postKill.ino !== entryStat.ino || postKill.mode !== entryStat.mode; + } catch { + // Gone between the kill and the re-check — only a racer removes an + // entry this sweep has not unlinked yet. + entryChanged = true; + } + if (entryChanged) { + // NEVER unlink here. The entry is no longer the plain socket the guard + // inspected — a racer renamed something onto the name in the + // connect→re-check window, and that something may be a live server + // whose socket, once unlinked, is unreachable forever (no attach, no + // `-L` control): the exact harm this function's own unlink rule + // ("ONLY when the server is known dead") forbids. Leaving the entry is + // self-healing — the next sweep re-examines it — so the WARNING stands + // and the socket is left alone. + failedAny = true; + writeStderrLine( + `WARNING: ${name} changed between the type guard and the kill — ` + + 'the server killed may not be the orphan this sweep matched, so ' + + 'its socket was left in place; check your tmux servers', + ); + } else { + try { + rmSync(join(dir, name), { force: true }); + } catch { + // Litter is cosmetic; the server itself is already gone. + } + writeStdoutLine(`Reaped orphaned capture server: ${name}`); + } + reapedAny = true; + } + return { reaped: reapedAny, failed: failedAny }; +} + /** * The tripwire's closing guidance, shared by both platform halves — the * relay instruction is contract text SKILL.md tells the model to carry @@ -703,6 +1009,28 @@ function pruneWorktrees(): void { } export function runCleanup(target: string): void { + // --- Orphaned capture servers (capture-tui) --------------------------- + // Host-wide and target-agnostic, run BEFORE the lease gate: a SIGKILL'd + // or OOM'd harness — the shape this sweep exists for — leaves BOTH the + // orphan and a lease held by the dead session, and the lease check is + // session-id only, so a gated sweep skipped on exactly the cleanup calls + // meant to reclaim the orphan and it lived out its bounded three hours + // (probe-reproduced). The sweep only touches servers whose launcher pid + // is dead — never a leased worktree — so hoisting it takes nothing from + // the lease holder. Its reaps stay off removedAny: they are not + // target-scoped facts, and a `cleanup pr-N` that found nothing of + // pr-N's still answers "Nothing to clean" for pr-N beside the + // host-wide "Reaped" line. Its failures DO still suppress that claim — + // stderr saying "could not reap" next to stdout's "nothing to clean" is + // the two streams contradicting each other, and stdout is the one a + // script reads — but never gate the target-scoped lease release, which + // keys on failedDestruction alone. It precedes the temp-dir refusal below + // for that same reason: the sockets it reaps live under tmux's own socket + // directory, never under REVIEW_TMP_DIR, so a redirected temp dir says + // nothing about them — and a refusal there must not strand an orphan for + // the whole of its bounded window. + const { failed: sweepFailed } = reapOrphanedCaptureServers(); + // A bare `pr` target's sweep prefix (`qwen-review-pr-`) is a strict prefix // of EVERY PR family, and the lease guard lives inside the `pr-` branch // below — which a bare `pr` never enters — so one `cleanup pr` deleted @@ -1060,8 +1388,10 @@ export function runCleanup(target: string): void { // "Nothing to clean" is a claim about the tree, not about this run's luck. It // is only true when there was nothing there — not when there was and we could - // not get rid of it, and not when an entry was deliberately kept. - if (!removedAny && !failedAny && preserved.size === 0) { + // not get rid of it, not when an entry was deliberately kept, and not when + // a base could not be scanned at all — an unreadable directory can be + // hiding exactly the thing this claim denies. + if (!removedAny && !failedAny && !sweepFailed && preserved.size === 0) { writeStdoutLine(`Nothing to clean for target "${target}".`); } } diff --git a/packages/cli/src/commands/review/lib/agent-briefs.ts b/packages/cli/src/commands/review/lib/agent-briefs.ts index f606ab2a020..19d0e68ae1b 100644 --- a/packages/cli/src/commands/review/lib/agent-briefs.ts +++ b/packages/cli/src/commands/review/lib/agent-briefs.ts @@ -782,6 +782,25 @@ Bind ephemeral wherever the service allows it: an OS-assigned port cannot collid The shared process is FRESH PER ARM by default — with sequential arms, whatever arm a mutates is arm b's starting state, which is a false difference manufactured by the harness, the one thing an A/B exists to rule out. Pass \`--shared-once\` only for the observer shape, where both arms merely watch one upstream and the SAMENESS of that upstream is the point. Rule only on \`observed: true\`: it is false whenever an arm did not complete or the shared process died mid-arm, and then the captures say where the harness needs repair — never anything about the diff. The two captures, quoted to their deciding lines, are the witness. +**When the claim is about what the terminal RENDERS, capture it — do not describe it.** A layout claim — "the panel clips at 80 columns", "the status line overlaps the prompt", "the colors are unreadable on dark themes" — is a claim about pixels, and reading the layout code only reproduces the author's own mental terminal, which is where rendering verdicts go wrong. Run the thing and capture what rendered: + +\`\`\`bash +"\${QWEN_CODE_CLI:-qwen}" review capture-tui \\ + --command "" \\ + --cols --until "" \\ + --out /qwen-review--capture-- +\`\`\` + +(\`\` is this review's artifact prefix — the same one the plan and findings files carry — so Step 9's sweep reclaims the captures and identical finding ids across different reviews cannot collide.) + +It drives the command in a **private tmux server** (it cannot see, resize or kill the user's own sessions — the isolation is structural, and it reaps its own server and everything left in that session), writes \`.ans\` (the pane bytes, always), renders \`.png\` when \`freeze\` is available, and records which it managed in \`.json\`. The reap stops at the session: a command that DAEMONIZES a helper (setsid, a detached unref'd spawn — browser launchers, updaters) leaves that process running after the review, and no portable kill reaches it. Capture such a command only if you will reap its daemon yourself. To drive the UI first, gate the keystrokes on a rendered marker — \`--ready '' --keys \` — keys fired into a still-mounting UI get partially eaten (measured on this repo's own onboarding dialog). A key token starting with \`-\` must use the \`--keys=\` form (\`--keys=-l Enter\`), or yargs rejects the invocation as an unknown flag. And pick markers unique to the claim: a substring that exists in BOTH arms of a pair (measured: a provider name that also appears in another entry's description) settles the control arm falsely. Three rules make the capture evidence: + +- **Capture at the width the claim names, and at a control width.** "Clips at 80 columns" is confirmed by a pair — clipped at 80, intact at 120 — not by one image; a single capture cannot distinguish "clips at 80" from "clips everywhere", and those are different findings. +- **The evidence rung is part of the verdict.** A \`png\` is rendering evidence: attach it to the finding via \`assetFiles\` (Step 7's \`publish-assets\` embeds it in the posted comment when the run is authorised; unpublished, the local path still reaches the terminal report). An \`ans-only\` capture proves the bytes but not the pixels — quote the relevant lines and say the pixel claim is unverified. A refused capture (no tmux) leaves the claim at its reading-based confidence floor; say what a capture would have measured, so the reader knows what the tooling would have bought. +- **Attach only what this verification launched.** The command's isolation makes capturing the user's own terminal impossible through it; do not go around it with bare tmux or OS-level screenshots — a capture of anything but the review's own processes is the leak the private server exists to prevent. And judge the pixels, not just their source: a TUI rendering a masked key, an absolute path carrying the user's name, or a \`git remote\` URL with a token in it is an env dump even when the capture is of the review's own process. + +A finding a capture settled cites the manifest and carries the image in \`assetFiles\`; like a probe, the observation is the verdict — "the 80-column capture shows the panel's right border at column 83" quotes pixels, not a reading. + **When the observable is an AGGREGATE, change the population rather than instrumenting the reader.** A total, a maximum across children, a count over a fleet — a claim about how one of those combines cannot be settled from a single reading, and the obvious repair (add a per-component dump and read that) costs the verdict its standing, because the numbers then come out of a build you edited. Shrink the contributing population instead: read the aggregate with every contributor live, remove exactly one — kill the process, unregister the workspace, drop the feed — and read it again. Both numbers come from unmodified code, and what they mean depends on the combining rule you are testing for: doubled with the population is a sum, flat is not a sum, and reducing the population to a single contributor makes the reading that contributor's own value outright. Only for a sum is the **difference** a contributor's value; under a maximum, removing a non-holder moves nothing and removing the holder exposes the next-largest. Identify what you removed by something the product did not choose for you — a process's own working directory, its port, its registered id — because removing the one you assumed answers a different question than the one asked. **When the claim is about GITHUB's behaviour, neither tree can settle it — only GitHub can.** A claim like "this encoding renders identically and can never ping", "GitHub strips this tag", "this markdown shape closes the fold" is about the comment pipeline's parser, sanitizer allowlist and notification path, none of which exist in this environment — a local markdown library is a model of GitHub, and judging a sanitizer claim against a model of the authority is exactly the parser-divergence failure under review. Measured live: an \`@\` → \`@\` defusal read as sound in every local trace, and GitHub's real renderer registered the mention and fired the notification. So: diff --git a/packages/cli/src/commands/review/lib/tui-capture.test.ts b/packages/cli/src/commands/review/lib/tui-capture.test.ts new file mode 100644 index 00000000000..4bfddb1bbc7 --- /dev/null +++ b/packages/cli/src/commands/review/lib/tui-capture.test.ts @@ -0,0 +1,677 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + captureServerName, + freezePlan, + isNothingToKill, + isSocketDirNeverCreated, + isSocketDirUnusable, + verdictExaminedBase, + tmuxPlan, + tmuxSupportsCaptureN, + tmuxSupportsCaptureT, + tmuxPadsWithCaptureN, + validGeometry, +} from './tui-capture.js'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('kill-server stderr classification', () => { + // The two predicates answer DIFFERENT questions, and conflating them + // unlinks live servers: "nothing to kill" authorizes removing the socket, + // so it may only contain wordings that establish there is no server. + const nothingToKill = [ + 'no server running on /tmp/tmux-501/qwen-review-capture-1-a', + // All FOUR alternates of the create-directory branch: only two were + // pinned, so narrowing the regex to those two shipped green while the + // other wordings printed a false orphan WARNING. + "can't create directory /tmp/tmux-501: Permission denied", + "couldn't create directory /tmp/tmux-501 (Permission denied)", + 'cannot create directory /tmp/tmux-501: Permission denied', + 'could not create directory /tmp/tmux-501 (Permission denied)', + 'error connecting to /very/long/... (File name too long)', + ]; + for (const line of nothingToKill) { + it(`treats as nothing to kill: ${line.slice(0, 40)}`, () => { + expect(isNothingToKill(line)).toBe(true); + expect(isSocketDirUnusable(line)).toBe(false); + }); + } + + // The ENOENT class proves only that the socket path was absent when the + // client looked — a LIVE server behind a removed socket file answers + // exactly these (probed live on tmux), so the class left isNothingToKill + // for its own predicate the way isSocketDirUnusable did for the same + // folded-in reason. Both wordings: the `error connecting` shape always + // carries the bare one, and the bare capital-N fixture pins the /i flag + // independently of that shape (strerror renders it capital N). + const pathAbsent = [ + 'error connecting to /tmp/x (No such file or directory)', + 'No such file or directory', + ]; + for (const line of pathAbsent) { + it(`never establishes death on its own: ${line.slice(0, 40)}`, () => { + expect(isNothingToKill(line)).toBe(false); + expect(isSocketDirUnusable(line)).toBe(false); + }); + } + + // Client-side refusals: tmux never looked at the server. An earlier + // revision of this PR folded these into isNothingToKill on the strength + // of "they also appear when no server was ever created" — which is true + // and beside the point: a LIVE server behind such a socket then read as + // reaped, the WARNING was skipped, and the socket was unlinked under + // both bases, making that server unreachable forever. + const dirUnusable = [ + 'directory /tmp/tmux-501 has unsafe permissions', + '/tmp/tmux-501 is not a directory', + ]; + for (const line of dirUnusable) { + it(`never authorizes an unlink: ${line.slice(0, 40)}`, () => { + expect(isSocketDirUnusable(line)).toBe(true); + expect(isNothingToKill(line)).toBe(false); + }); + } + + it('answers neither for an unrecognized failure', () => { + expect(isNothingToKill('server exited unexpectedly')).toBe(false); + expect(isSocketDirUnusable('server exited unexpectedly')).toBe(false); + }); +}); + +describe('kill-verdict base attribution', () => { + // A goal-state wording establishes death only about the base the client + // EXAMINED: tmux falls back to /tmp when the pinned base is unusable and + // answers about IT (probe-verified on 3.4 with a mid-window-deleted + // base), and crediting that verdict to the pinned base read a live + // server as reaped. + it('credits a wording naming a path under the pinned base', () => { + expect( + verdictExaminedBase( + 'error connecting to /tmp/tmux-501/srv (No such file or directory)', + '/tmp', + ), + ).toBe(true); + expect( + verdictExaminedBase( + 'no server running on /scratch/base/tmux-501/srv', + '/scratch/base', + ), + ).toBe(true); + }); + + it('refuses a wording whose path names the FALLBACK base', () => { + expect( + verdictExaminedBase( + 'error connecting to /tmp/tmux-501/srv (No such file or directory)', + '/scratch/gone', + ), + ).toBe(false); + expect( + verdictExaminedBase( + "couldn't create directory /tmp/tmux-501 (Permission denied)", + '/scratch/gone', + ), + ).toBe(false); + }); + + it('normalizes the base before comparing', () => { + expect( + verdictExaminedBase('no server running on /tmp/tmux-501/srv', '/tmp/'), + ).toBe(true); + expect( + verdictExaminedBase( + 'no server running on /scratch/base/tmux-501/srv', + '/scratch/base/', + ), + ).toBe(true); + }); + + it('keeps the old meaning for a wording that names no path', () => { + expect(verdictExaminedBase('No such file or directory', '/tmp')).toBe(true); + }); + + // win32: realpathSafe resolves with `posix.*` by design (tmux's wordings are + // POSIX paths), and this is the one fixture in the file that builds REAL + // paths — `join()` hands it backslashes and `symlinkSync` needs a privilege + // Windows does not give by default, so it fails red against healthy code. + // Its POSIX-literal siblings above stay unguarded because they never touch + // the filesystem. + it.skipIf(process.platform === 'win32')( + 'canonicalizes a symlinked base the way tmux canonicalizes its wordings', + () => { + // tmux names the REALPATH of a symlinked socket base in its wordings + // (probed on 3.4): under a linked TMUX_TMPDIR every honest verdict + // names the target while the kill was pinned to the link, and the + // lexical comparison rejected every one of them — a false orphan + // WARNING for every server that predeceased its reap. + const root = mkdtempSync(join(tmpdir(), 'tui-cap-verdict-')); + try { + const real = join(root, 'real'); + mkdirSync(real); + const link = join(root, 'link'); + symlinkSync(real, link); + expect( + verdictExaminedBase( + `no server running on ${real}/tmux-501/srv`, + link, + ), + ).toBe(true); + expect( + verdictExaminedBase( + `error connecting to ${real}/tmux-501/srv ` + + '(No such file or directory)', + link, + ), + ).toBe(true); + // A wording about an UNRELATED directory stays refused. + expect( + verdictExaminedBase( + `no server running on ${root}/elsewhere/tmux-501/srv`, + link, + ), + ).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it('recognizes every create-directory wording', () => { + for (const line of [ + "couldn't create directory /tmp/tmux-501 (Permission denied)", + "can't create directory /tmp/tmux-501: Not a directory", + 'could not create directory /tmp/tmux-501 (Permission denied)', + 'cannot create directory /tmp/tmux-501: Permission denied', + ]) { + expect(isSocketDirNeverCreated(line)).toBe(true); + } + expect(isSocketDirNeverCreated('no server running on /tmp/x')).toBe(false); + }); +}); + +describe('captureServerName', () => { + it('scopes by pid and nonce so concurrent reviews cannot collide', () => { + expect(captureServerName(123, 'abcd')).toBe('qwen-review-capture-123-abcd'); + expect(captureServerName(123, 'abcd')).not.toBe( + captureServerName(123, 'efgh'), + ); + }); +}); + +describe('validGeometry', () => { + it('accepts sane terminals and refuses the degenerate ones', () => { + expect(validGeometry(80, 24).ok).toBe(true); + expect(validGeometry(500, 200).ok).toBe(true); + // The exact lower bounds are ACCEPTED — a `v < lo` → `v <= lo` mutation + // would refuse a legal 20×5 capture with a self-contradictory message. + expect(validGeometry(20, 5).ok).toBe(true); + for (const [c, r] of [ + [0, 24], + [80, 0], + [19, 24], + [80, 4], + [501, 24], + [80, 201], + [80.5, 24], + [Number.NaN, 24], + // The ROWS branch needs its own non-integer and NaN cases: with only + // the cols ones, an asymmetric mutant that drops `Number.isInteger` + // from the rows check shipped green — and `new-session -y 24.5` + // reaches real tmux, which is not a shape this command should send. + [80, 24.5], + [80, Number.NaN], + ] as const) { + const v = validGeometry(c, r); + expect(v.ok, `${c}x${r}`).toBe(false); + } + }); + + it('names the FLAG that violated, not its sibling', () => { + // The reason is user-facing: a flag-name swap once produced + // "--rows must be an integer in [20, 500], got 10" for a --cols + // violation — the caller then "fixes" the wrong flag. + const cols = validGeometry(10, 24); + if (!cols.ok) expect(cols.reason).toContain('--cols'); + const rows = validGeometry(80, 1000); + if (!rows.ok) expect(rows.reason).toContain('--rows'); + expect(cols.ok).toBe(false); + expect(rows.ok).toBe(false); + }); +}); + +describe('tmuxSupportsCaptureN', () => { + it('accepts 3.1 and later, refuses the whole 3.0 line, ignores the unparseable', () => { + // -N landed in 3.1 (upstream CHANGES lists it under "CHANGES FROM 3.0a + // TO 3.1"; the 3.0a man page has no -N) — 3.0a/3.0b are TOO OLD, and + // Ubuntu 20.04 ships 3.0a: accepting them would die mid-capture on the + // unknown flag after paying for a server start. + for (const line of ['tmux 3.1', 'tmux 3.1b', 'tmux 3.3a', 'tmux 4.0']) { + expect(tmuxSupportsCaptureN(line), `${line}`).toBe(true); + } + for (const line of [ + 'tmux 1.8', + 'tmux 2.8', + 'tmux 3.0', + 'tmux 3.0a', + 'tmux 3.0b', + ]) { + expect(tmuxSupportsCaptureN(line), `${line}`).toBe(false); + } + // Unparseable is undefined, not false: a version that cannot be named + // is not a reason to refuse. + expect(tmuxSupportsCaptureN('')).toBeUndefined(); + expect(tmuxSupportsCaptureN('no digits here')).toBeUndefined(); + }); +}); + +describe('tmuxPlan — every call is scoped to the private server', () => { + const plan = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/work', + readyFile: '/ready', + sleepBin: '/bin/sleep', + }); + + it('carries -L on every call — start, capture, captureText, kill', () => { + // One stray unscoped call is the entire isolation property gone: an + // unscoped kill-server would kill the USER's tmux server. + for (const argv of [ + plan.start, + plan.capture, + plan.captureText, + plan.kill, + ]) { + const i = argv.indexOf('-L'); + expect(i).toBeGreaterThan(-1); + expect(argv[i + 1]).toBe('srv'); + } + // POSITION is load-bearing in start, not just presence: tmux requires + // global flags BEFORE the first command name, so a -L displaced past + // the `;` separator dies "no server running" on real tmux while every + // indexOf probe above stays green. + expect(plan.start.slice(2, 4)).toEqual(['-L', 'srv']); + }); + + it('starts CONFIG-FREE with a POSIX pane shell, in ONE client invocation', () => { + // -f /dev/null: without it the private server loads ~/.tmux.conf + // (measured: destroy-unattached killed the detached session). The + // default-shell pin rides the SAME invocation as new-session, chained + // with `;`, because a session-less server exits the moment its first + // client leaves — and it must run BEFORE the pane exists (measured: + // tcsh as default-shell killed the holder instantly). + expect(plan.start.slice(0, 2)).toEqual(['-f', '/dev/null']); + const set = plan.start.indexOf('set-option'); + const sep = plan.start.indexOf(';'); + const news = plan.start.indexOf('new-session'); + expect(set).toBeGreaterThan(-1); + expect(plan.start.slice(set, set + 4)).toEqual([ + 'set-option', + '-g', + 'default-shell', + '/bin/sh', + ]); + expect(sep).toBeGreaterThan(set); + expect(news).toBeGreaterThan(sep); + }); + + it('kills the SERVER, not the session — reaping everything it started', () => { + expect(plan.kill).toEqual(['-L', 'srv', 'kill-server']); + }); + + it('sends each key as ONE token behind `--` — no joining, no flag-eating', () => { + // Without `--`, tmux consumes a dash-leading token as a send-keys flag: + // measured, `send-keys -t cap -l` exits 0 and types NOTHING — silent + // evidence corruption. `--` makes every token a key, verbatim. + expect(plan.sendKeys('C-c')).toEqual([ + '-L', + 'srv', + 'send-keys', + '-t', + 'cap', + '--', + 'C-c', + ]); + expect(plan.sendKeys('-l')[plan.sendKeys('-l').length - 1]).toBe('-l'); + }); + + it('escapes a TRAILING `;` on the user-derived key and cwd elements', () => { + // tmux's client splits any argv element ending in `;` into a separate + // command before dispatch — `--` ends option parsing but never reaches + // that splitter. Measured on tmux 3.3a: `send-keys -- 'x;'` typed only + // `x` (exit 0, no warning), and `-c '/tmp/foo;'` turned the cwd element + // into a command boundary and failed with a misleading socket error; + // `\;` round-trips (pane_current_path came back `/tmp/foo;`). + expect(plan.sendKeys('x;').at(-1)).toBe('x\\;'); + expect(plan.sendKeys(';').at(-1)).toBe('\\;'); + // Mid-string is literal to tmux already and passes through. + expect(plan.sendKeys('a;b').at(-1)).toBe('a;b'); + // A token that ALREADY ends in `\;` still gets escaped: tmux consumes + // that backslash (measured on 3.3a — the token `x\;` types `x;`, and + // `x\\;` types `x\;`), and nothing escapes these values upstream, so + // treating it as already-escaped silently typed the wrong keys. + expect(plan.sendKeys('q\\;').at(-1)).toBe('q\\\\;'); + const withCwd = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/tmp/foo;', + readyFile: '/tmp/out.holder-ready', + sleepBin: '/bin/sleep', + }); + expect(withCwd.start[withCwd.start.indexOf('-c') + 1]).toBe('/tmp/foo\\;'); + }); + + it('escapes `#` in the cwd — the start-directory is FORMAT-EXPANDED', () => { + // Measured on tmux 3.3a and 3.4: a real directory named + // `/tmp/fmt/#{session_name}` passed the usability gate, and the pane + // started in `/tmp/fmt` — the PARENT — with exit 0 and the manifest + // recording the literal path. `##` round-trips to a literal `#` + // (verified), so a plain `#` in a dirname survives the doubling. + const fmt = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/tmp/fmt/#{session_name}', + readyFile: '/tmp/out.holder-ready', + sleepBin: '/bin/sleep', + }); + expect(fmt.start[fmt.start.indexOf('-c') + 1]).toBe( + '/tmp/fmt/##{session_name}', + ); + const plainHash = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/tmp/d/a#b', + readyFile: '/tmp/out.holder-ready', + sleepBin: '/bin/sleep', + }); + expect(plainHash.start[plainHash.start.indexOf('-c') + 1]).toBe( + '/tmp/d/a##b', + ); + }); + + it('drops -N on the tmux versions that FABRICATE trailing spaces', () => { + // 3.1-3.2.x pad each line out to the grid's allocated cells and have no + // -T to undo it: measured on 3.2a (what Ubuntu 22.04 ships), a + // three-character line came back with 17 phantom spaces. Trimming + // understates a clipped right edge; padding INVENTS one, and the + // command records the caveat as a degradation. + const opts = { + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/work', + readyFile: '/ready', + sleepBin: '/bin/sleep', + }; + expect(tmuxPlan({ ...opts, captureTrailing: false }).capture).not.toContain( + '-N', + ); + expect(tmuxPlan({ ...opts, captureTrailing: true }).capture).toContain( + '-N', + ); + // Default stays -N: only the padding versions opt out. + expect(tmuxPlan(opts).capture).toContain('-N'); + expect(tmuxPadsWithCaptureN('tmux 3.2a')).toBe(true); + expect(tmuxPadsWithCaptureN('tmux 3.1')).toBe(true); + // The lettered 3.1 line — Debian 11 ships 3.1c — on the TRUE side, + // which is the dangerous one: it drops -N and adds the degradation + // caveat. The false side already pins letters, and this predicate's + // own history includes a lettered-minor regression. + for (const v of ['tmux 3.1a', 'tmux 3.1b', 'tmux 3.1c', 'tmux 3.2']) { + expect(tmuxPadsWithCaptureN(v)).toBe(true); + } + // The documented range is exactly 3.1-3.2.x. 3.0.x answered true, + // contradicting the sibling predicate (there is no `capture-pane -N` + // before 3.1) and the version gate that refuses those hosts first. + for (const v of ['tmux 3.0', 'tmux 3.0a', 'tmux 3.0b']) { + expect(tmuxPadsWithCaptureN(v)).toBe(false); + } + expect(tmuxPadsWithCaptureN('tmux 3.3a')).toBe(false); + expect(tmuxPadsWithCaptureN('tmux 3.4')).toBe(false); + expect(tmuxPadsWithCaptureN('tmux 4.0')).toBe(false); + expect(tmuxPadsWithCaptureN('tmux next')).toBeUndefined(); + }); + + it('adds capture-pane -T only when the tmux has it (3.4+)', () => { + // -N alone pads a line out to its grid line's ALLOCATED cells: measured + // on 3.4, a row that had held 24 characters and was erased and rewritten + // with `BBB` came back as `BBB` plus four phantom spaces, so a column or + // clipping verdict would judge allocation history. -T drops exactly + // those unwritten positions. The same probe on 3.3a shows no padding — + // and passing -T there fails the call ('unknown flag -T', measured), so + // the flag must follow the version. + const opts = { + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/work', + readyFile: '/ready', + sleepBin: '/bin/sleep', + }; + const trimmed = tmuxPlan({ ...opts, captureTrim: true }); + expect(trimmed.capture).toContain('-T'); + expect(trimmed.captureText).toContain('-T'); + // -N stays: the REAL trailing spaces are the evidence. + expect(trimmed.capture).toContain('-N'); + const old = tmuxPlan({ ...opts, captureTrim: false }); + expect(old.capture).not.toContain('-T'); + expect(old.captureText).not.toContain('-T'); + expect(tmuxSupportsCaptureT('tmux 3.4')).toBe(true); + expect(tmuxSupportsCaptureT('tmux 3.5a')).toBe(true); + expect(tmuxSupportsCaptureT('tmux 4.0')).toBe(true); + expect(tmuxSupportsCaptureT('tmux 3.3a')).toBe(false); + expect(tmuxSupportsCaptureT('tmux 3.1')).toBe(false); + expect(tmuxSupportsCaptureT('tmux next')).toBeUndefined(); + }); + + it('starts the command behind `--` so a dash-leading command is not getopt fodder', () => { + const i = plan.start.indexOf('--'); + expect(i).toBeGreaterThan(-1); + expect(plan.start[i + 1]).toContain('node cli.js'); + expect(i + 2).toBe(plan.start.length); + }); + + it('PROPAGATES geometry — a hardcoded 80x24 must not pass', () => { + // Every other plan call in this file uses 80x24, so a plan that + // ignored opts.cols/opts.rows and hardcoded them passed the whole + // suite — and the fake-tmux seam tests too, since they drive the same + // default. Distinct values are the only way to see it. + const p = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 132, + rows: 43, + command: 'node cli.js', + cwd: '/work', + readyFile: '/ready', + sleepBin: '/bin/sleep', + }); + expect(p.start[p.start.indexOf('-x') + 1]).toBe('132'); + expect(p.start[p.start.indexOf('-y') + 1]).toBe('43'); + }); + + it('starts detached at the requested geometry and cwd', () => { + // new-session and its `-s cap` are the join key every later call + // targets via `-t cap` — dropping them would only fail the tmux-gated + // integration tests, so they are pinned here too. + expect(plan.start).toContain('new-session'); + const s = plan.start.indexOf('-s'); + expect(plan.start[s + 1]).toBe('cap'); + expect(plan.start).toContain('-d'); + const x = plan.start.indexOf('-x'); + expect(plan.start[x + 1]).toBe('80'); + const y = plan.start.indexOf('-y'); + expect(plan.start[y + 1]).toBe('24'); + const c = plan.start.indexOf('-c'); + expect(plan.start[c + 1]).toBe('/work'); + }); + + it('holds the pane open past the command in a NESTED shell', () => { + // tmux's remain-on-exit off destroys the session the moment the command + // exits (measured: a render-and-exit fixture was uncapturable 0/10). + // TWO shells, not one: in a single shell a command ending in `exit N` + // (or `exec`, or its own `set -e`) takes the keep-alive down with it — + // measured, deterministic "no server running" on `printf ...; exit 0`. + // The inner sh absorbs the exit; the outer holds the pane, with the + // hold on its OWN LINE so no command tail (`;`, `#`) can void it — and + // `trap : INT` so one C-c through the capture's own --keys path kills + // neither the holder nor the server (measured: untrapped, pane → + // session → server died before the capture). + expect(plan.start[plan.start.length - 1]).toBe( + `trap : INT QUIT\n( trap '' INT QUIT; '/bin/sleep' 10800; kill -9 -$$ 2>/dev/null ) &\n: > '/ready'\n/bin/sh -c 'node cli.js'\ni=0; while [ $i -lt 180 ]; do '/bin/sleep' 60; i=$((i+1)); done`, + ); + }); + + it('quote-escapes the command inside the holder script', () => { + const p = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: `printf '%s' "it's"`, + cwd: '/work', + readyFile: '/ready', + sleepBin: '/bin/sleep', + }); + // ONE layer: the plan hands tmux the holder SCRIPT, whose single + // `/bin/sh -c ''` line is the only place the command is + // quoted. A + // single quote in the command must not close that quoting. The + // expectation is COMPOSED with the same POSIX escaping rule stated + // independently ('→'\''): dropping esc() breaks the equality + // (measured: the mutant produced a holder /bin/sh rejects with an + // unmatched quote, while the structural assertions all stayed green). + const esc = (v: string): string => v.replaceAll("'", "'\\''"); + const cmd = `printf '%s' "it's"`; + const inner = `/bin/sh -c '${esc(cmd)}'`; + const held = p.start[p.start.length - 1]; + expect(held).toBe( + `trap : INT QUIT\n( trap '' INT QUIT; '/bin/sleep' 10800; kill -9 -$$ 2>/dev/null ) &\n: > '${esc('/ready')}'\n${inner}\ni=0; while [ $i -lt 180 ]; do '/bin/sleep' 60; i=$((i+1)); done`, + ); + }); + + it("runs the holder's sleeps by resolved path, never by bare name", () => { + // The pane resolves a bare name through its OWN inherited PATH — the + // hazard the `/bin/sh` pin three lines above it already answers. Under a + // PATH that finds tmux but not sleep, the watchdog's `sleep 10800` exits + // 127 in milliseconds and falls straight through to `kill -9 -$$`, + // SIGKILLing the pane process group: the window collapses to ~0ms and + // the bounded three-hour hold with it. The caller resolves it once and + // the plan embeds it, quoted like every other caller-supplied path. + const p = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/work', + readyFile: '/ready', + sleepBin: "/opt/sl eep/sl'eep", + }); + const held = p.start[p.start.length - 1]; + expect(held).toContain(`'/opt/sl eep/sl'\\''eep' 10800`); + expect(held).toContain(`'/opt/sl eep/sl'\\''eep' 60`); + // No bare invocation survives anywhere in the script. + expect(held).not.toMatch(/(^|[^'])\bsleep 10800/); + expect(held).not.toMatch(/(^|[^'])\bsleep 60/); + }); + + it('quote-escapes the readyFile in the holder script', () => { + // The holder shell re-parses the sentinel line, so the path needs its + // OWN esc() — an unescaped apostrophe broke the quoting and burned the + // full sentinel deadline blaming tmux (measured: exit 3 at ~10s, back + // when the caller built this path from the user's --out). It is minted + // under the system temp dir now, which is not a reason to drop the + // escaping: this function takes the path from its caller, mkdtemp-style + // parents are not guaranteed apostrophe-free, and the plan is pure — + // it cannot know where the next caller's path comes from. + const readyFile = "/evidence/ca'p/ready"; + const p = tmuxPlan({ + server: 'srv', + session: 'cap', + cols: 80, + rows: 24, + command: 'node cli.js', + cwd: '/work', + readyFile, + sleepBin: '/bin/sleep', + }); + const esc = (v: string): string => v.replaceAll("'", "'\\''"); + const inner = `/bin/sh -c '${esc('node cli.js')}'`; + const held = p.start[p.start.length - 1]; + // Single layer now: the plan hands tmux the SCRIPT itself (default-shell + // is pinned to /bin/sh in the same invocation), so the trap lives at + // layer 0 — QUIT included — and only the sentinel path needs escaping. + expect(held).toBe( + `trap : INT QUIT\n( trap '' INT QUIT; '/bin/sleep' 10800; kill -9 -$$ 2>/dev/null ) &\n: > '${esc(readyFile)}'\n${inner}\ni=0; while [ $i -lt 180 ]; do '/bin/sleep' 60; i=$((i+1)); done`, + ); + }); + + it('matches --until on a joined, escape-free view while .ans stays physical', () => { + // -J joins wraps and no -e keeps escapes out: a marker spanning a wrap + // boundary or an SGR change can never match the physical frame + // (measured: both miss forever). + expect(plan.captureText).toEqual([ + '-L', + 'srv', + 'capture-pane', + '-p', + '-J', + '-t', + 'cap', + ]); + }); + + it('captures with escapes and trailing spaces, wraps NOT joined', () => { + // -e escapes (freeze needs them), -N trailing spaces (a clipped right + // edge is trailing-space significant). Deliberately no -J: joining wraps + // re-flows the pane, erasing the wrap structure a layout claim is about — + // measured on the smoke capture, where -J turned a wrapped 100-char line + // back into one long line. + expect(plan.capture).toEqual([ + '-L', + 'srv', + 'capture-pane', + '-p', + '-e', + '-N', + '-t', + 'cap', + ]); + }); +}); + +describe('freezePlan', () => { + it('renders the .ans as ansi to the named output', () => { + expect(freezePlan('/x/a.ans', '/x/a.png')).toEqual([ + '--language', + 'ansi', + '/x/a.ans', + '--output', + '/x/a.png', + ]); + }); +}); diff --git a/packages/cli/src/commands/review/lib/tui-capture.ts b/packages/cli/src/commands/review/lib/tui-capture.ts new file mode 100644 index 00000000000..5340947740b --- /dev/null +++ b/packages/cli/src/commands/review/lib/tui-capture.ts @@ -0,0 +1,621 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The deterministic half of `capture-tui`: names, argument shapes, and the +// degradation ladder for terminal-rendering evidence. +// +// A verify agent asked to rule on "the panel clips at 80 columns" has, until +// now, read the layout code and imagined the terminal. Phase 1 gave findings a +// place to carry image evidence (`assetFiles` → `publish-assets`); this file +// and its command are Phase 2's producer: drive the TUI in a throwaway tmux, +// capture what it actually rendered, and hand back files a finding can carry. +// +// Everything here is deterministic so the naming, geometry and ladder rules +// are unit-testable without tmux or freeze; the one filesystem touch is +// verdict-path canonicalization, which falls back to lexical resolution when +// a path does not resolve. The command layer owns the processes. + +import { + accessSync, + constants as fsConstants, + realpathSync, + statSync, +} from 'node:fs'; +import { posix } from 'node:path'; + +/** The one server-name prefix, shared by the producer (captureServerName) + * and the orphan sweep's matcher (cleanup.ts): as two independent literals, + * a prefix rename silently turns the sweep into a permanent no-op. */ +export const CAPTURE_SERVER_PREFIX = 'qwen-review-capture-'; + +/** Whether a failed `kill-server` means there was NOTHING to kill — the + * goal state, not a failure. tmux says it several ways depending on how far + * the server got: `no server running on ` (the socket is present + * with no listener behind it) and — when the socket directory itself could + * never be created (measured with a mode-0555 TMUX_TMPDIR) — `couldn't + * create directory (Permission denied)`. Reading only the first + * wording printed a false orphan WARNING naming a server that never + * existed. The ENOENT-class wordings (`error connecting to (No such + * file or directory)`, and the bare one) are deliberately NOT here and have + * no predicate of their own any more: they prove the socket PATH was gone + * when the client looked and nothing else — a live server behind a removed + * socket answers exactly them (probed live: rm the socket under a running + * server, kill-server exits 1 with the wording, kill -0 shows it alive) — + * and every attempt to name the sub-shape where absence WOULD mean death + * conflated it with something else, so no caller credits the class at all. + * The tests below pin them out of this predicate. */ +export function isNothingToKill(stderr: string): boolean { + return ( + /no server running/i.test(stderr) || + /(couldn't|could not|can't|cannot) create directory/i.test(stderr) || + // A socket path past sun_path (~108 bytes): tmux answers this to + // new-session AND kill-server, so a start that never created a socket + // printed a false orphan WARNING (reproduced on 3.3a with a long + // TMUX_TMPDIR). + /file name too long/i.test(stderr) + ); +} + +/** Whether a failed `kill-server` says the CLIENT could not reach the + * socket directory at all — `directory has unsafe permissions` (not + * 0700) and ` is not a directory`, probed verbatim on 3.4. + * + * Deliberately NOT part of isNothingToKill, though both wordings also + * appear when there was never a server: they are refusals the client makes + * BEFORE looking, so they establish nothing about the server. Folding them + * in (an earlier revision of this PR did) made a live orphan read as + * reaped — the WARNING skipped, and the socket of a server still running + * unlinked under both candidate bases, which makes that server unreachable + * forever. A false WARNING is the cheaper wrong answer, so these get their + * own state: never reaped, always surfaced, and the socket left alone. */ +export function isSocketDirUnusable(stderr: string): boolean { + return ( + /directory .* has unsafe permissions/i.test(stderr) || + /is not a directory/i.test(stderr) + ); +} + +/** The path a goal-state kill wording names, if it names one. `no server + * running on `, `error connecting to (…)`, and the + * create-directory failures all carry it. The trailing parenthesized errno + * is NOT part of the path — a socket base may legally contain spaces + * (measured with a padded TMUX_TMPDIR) — so the capture runs to the end of + * the line and strips only a final ` (…)` group. */ +function goalStatePath(stderr: string): string | undefined { + const m = + /(?:no server running on|error connecting to|(?:couldn't|could not|can't|cannot) create directory)[ \t]+(.+)$/im.exec( + stderr, + ); + if (!m) return undefined; + const path = m[1].replace(/\s*\([^)]*\)$/, '').trim(); + return path === '' ? undefined : path; +} + +/** Canonicalize a verdict path for comparison: tmux names the CANONICAL + * socket base in its wordings — a symlinked TMUX_TMPDIR answers with its + * realpath, and on macOS the stock `/tmp` base answers `/private/tmp/…` + * (probed on 3.4) — so a lexical pin never matched an honest verdict under + * any symlinked base. realpath the deepest ancestor that still exists and + * keep the rest lexical: the socket a verdict names is usually gone by the + * time the verdict arrives, and realpathSync throws on a missing path. + * Lexical resolution when nothing resolves — the same realpath-with- + * fallback shape cleanup.ts's base dedup uses. */ +function realpathSafe(p: string): string { + const resolved = posix.resolve(p); + let dir = resolved; + let tail = ''; + for (;;) { + try { + const real = realpathSync(dir); + return tail === '' ? real : posix.join(real, tail); + } catch { + const parent = posix.dirname(dir); + if (parent === dir) return resolved; + tail = + tail === '' + ? posix.basename(dir) + : posix.join(posix.basename(dir), tail); + dir = parent; + } + } +} + +/** Whether a goal-state kill verdict ESTABLISHES anything about the base + * the kill was pinned to. tmux resolves the socket base from the client's + * environment — but only while it is USABLE: a base that vanished before + * the kill sends the client to /tmp, and the nothing-to-kill wording then + * names /tmp's path while the kill was pinned elsewhere (probe-verified on + * 3.4 with a mid-window-deleted base). Crediting such a verdict to the + * pinned base reads a live server as reaped — no WARNING, and invisible to + * the orphan sweep once the socket dir went with the base. A wording that + * names no path keeps its old meaning: nothing disproves the attribution. */ +export function verdictExaminedBase(stderr: string, base: string): boolean { + const named = goalStatePath(stderr); + if (named === undefined) return true; + const examined = realpathSafe(named); + const pinned = realpathSafe(base); + return ( + examined === pinned || + examined.startsWith(pinned === '/' ? '/' : `${pinned}/`) + ); +} + +/** Whether a failed kill is tmux's `couldn't create directory` wording: + * the client could not even create the socket directory, so it examined + * nothing behind it. Such a verdict establishes death only where a server + * could never have started (the caller knows which bases those were); + * where one COULD have started, the directory existed once, and its + * absence means it was destroyed mid-window — possibly with the server + * still alive behind it. */ +export function isSocketDirNeverCreated(stderr: string): boolean { + return /(couldn't|could not|can't|cannot) create directory/i.test(stderr); +} + +/** + * The private tmux server name for one capture run. + * + * `-L` scopes a whole tmux SERVER, not just a session: the capture must never + * enumerate, resize, or kill anything on the user's own tmux server — the + * measured failure mode of desktop-automation verification was exactly + * "drives the user's own windows". A pid+nonce-scoped socket name means even + * two concurrent reviews cannot collide. + */ +/** The first executable `bin` on an ABSOLUTE element of this process's PATH, + * or undefined. + * + * In-process rather than a spawn, because the answer needed is the PATH + * lookup itself — a path that gets handed to `spawnSync` and embedded in the + * holder script — not an exit code. + * + * Deliberately narrower than execvp in one respect: a PATH element that is + * not absolute is SKIPPED. POSIX reads an empty element as the current + * directory and resolves relative ones against it, and both `capture-tui` and + * `cleanup` run with the REVIEWED WORKTREE as their cwd — so either rule lets + * the PR under review supply the binary they execute. Two things follow, and + * both are load-bearing: the answer is absolute by CONSTRUCTION, which is what + * the holder script needs (a relative path there is re-resolved against the + * PANE's own `--cwd`), and the reviewed tree cannot supply it. A host whose + * only copy of a binary sits on a relative element gets a refusal that names + * the cause, which is the right end for an evidence tool. + * + * POSIX-only, like everything else here: the separator is `:` and the + * elements are POSIX paths. + */ +export function resolveOnPath( + bin: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + for (const dir of (env['PATH'] ?? '').split(':')) { + if (!posix.isAbsolute(dir)) continue; + const candidate = posix.join(dir, bin); + try { + accessSync(candidate, fsConstants.X_OK); + if (statSync(candidate).isFile()) return candidate; + } catch { + // Absent here, a directory, or not executable by this uid: keep going, + // exactly as execvp would. + } + } + return undefined; +} + +/** The exact shape `captureServerName` mints, anchored end to end. + * + * The orphan sweep matches THIS rather than the bare prefix. An open suffix + * let a same-uid planter choose the rest of a name that the sweep then put + * into a command built to be PASTED, plus a stdout and a stderr line — so + * `qwen-review-capture--x$(…)` reached an operator's shell. + * Anchoring the whole name closes every one of those interpolations at the + * source instead of one escape at a time, and narrows what the sweep is + * willing to kill to names this tool can have produced. + * + * The nonce is matched by its ALPHABET, not its current length: a hex run + * cannot carry a shell metacharacter, which is the whole property needed, + * while pinning the count would silently stop the sweep from reaping the + * day someone widens `randomBytes`. It lives here, beside the producer, so + * the two cannot drift — and the test mints a real name to prove it. */ +export const CAPTURE_SERVER_NAME_RE = new RegExp( + `^${CAPTURE_SERVER_PREFIX}(\\d+)-[0-9a-f]+$`, +); + +export function captureServerName(pid: number, nonce: string): string { + return `${CAPTURE_SERVER_PREFIX}${pid}-${nonce}`; +} + +/** Default geometry: the classic terminal, which is also where most layout + * bugs live. Callers override for wide/narrow claims. */ +export const DEFAULT_COLS = 80; +export const DEFAULT_ROWS = 24; + +/** Bounds that keep a typo from asking tmux for a 0x0 or 9999x9999 pane. */ +export function validGeometry( + cols: number, + rows: number, +): { ok: true } | { ok: false; reason: string } { + const bad = (name: string, v: number, lo: number, hi: number) => + !Number.isInteger(v) || v < lo || v > hi + ? `${name} must be an integer in [${lo}, ${hi}], got ${String(v)}` + : null; + const c = bad('--cols', cols, 20, 500); + if (c) return { ok: false, reason: c }; + const r = bad('--rows', rows, 5, 200); + if (r) return { ok: false, reason: r }; + return { ok: true }; +} + +/** Whether a `tmux -V` line ("tmux 3.3a") names a tmux with capture-pane + * `-N`: the trailing-space flag the physical capture is load-bearing on + * landed in 3.1 — the whole 3.0 line, letters included, is too old. + * Undefined when the version does not parse — an unnameable version is not + * a reason to refuse, but a NAMED old one is. */ +export function tmuxSupportsCaptureN(versionLine: string): boolean | undefined { + const m = /(\d+)\.(\d+)([a-z]*)/i.exec(versionLine); + if (!m) return undefined; + const major = Number(m[1]); + const minor = Number(m[2]); + if (major !== 3) return major > 3; + // capture-pane -N landed in 3.1 (upstream CHANGES lists it under "CHANGES + // FROM 3.0a TO 3.1"; the 3.0a man page's synopsis has no -N) — the whole + // 3.0 line, letters included, is too old. Ubuntu 20.04 ships 3.0a. + return minor >= 1; +} + +/** Whether a `tmux -V` line names a tmux whose `capture-pane -N` PADS each + * line out to the grid line's allocated cells. Measured: 3.2a pads (`BBB` + * came back as `BBB` + 17 spaces) and has no `-T` to undo it; 3.3a does not + * pad; 3.4+ pads but takes `-T`. So the padding-without-a-remedy window is + * exactly 3.1–3.2.x — which Ubuntu 22.04 ships. Undefined when the version + * does not parse. */ +export function tmuxPadsWithCaptureN(versionLine: string): boolean | undefined { + const m = /(\d+)\.(\d+)([a-z]*)/i.exec(versionLine); + if (!m) return undefined; + const major = Number(m[1]); + const minor = Number(m[2]); + if (major !== 3) return false; + // 3.1 is the floor, not 3.0: `capture-pane -N` does not exist before + // then (tmuxSupportsCaptureN says so, and the version gate refuses those + // hosts first), so answering "pads" for 3.0.x contradicted this + // function's own documented range. No shipped path reached the wrong + // value — but this is an exported predicate, and the next caller has no + // reason to re-derive the gate that protected it. + return minor >= 1 && minor < 3; +} + +/** Whether a `tmux -V` line names a tmux whose `capture-pane` takes `-T` + * ("ignore trailing positions that do not contain a character"), which + * landed in 3.4. Undefined when the version does not parse — the caller + * treats that as "no -T", the behaviour every pre-3.4 tmux needs anyway. */ +export function tmuxSupportsCaptureT(versionLine: string): boolean | undefined { + const m = /(\d+)\.(\d+)([a-z]*)/i.exec(versionLine); + if (!m) return undefined; + const major = Number(m[1]); + const minor = Number(m[2]); + if (major !== 3) return major > 3; + return minor >= 4; +} + +/** + * How far the capture got, in evidence terms. + * + * The ladder is explicit because each rung is a DIFFERENT claim in a review: + * a PNG is publishable rendering evidence; an `.ans` proves the bytes but not + * the pixels (and cannot be published — the assets allowlist is images only); + * `none` means the claim stays argued in prose. A verifier must say which + * rung its verdict stands on. + */ +export type CaptureEvidence = 'png' | 'ans-only' | 'none'; + +/** One capture's outcome, as the manifest records it. The manifest is the + * capture's ONLY record (stdout carries just a pointer to it), so it names + * every rendering-affecting input: a capture driven by `--keys` shows a + * different screen than the bare command, and a reproducer that does not + * know that judges honest evidence unreproducible. */ +export interface CaptureManifest { + command: string; + cwd: string; + cols: number; + rows: number; + keys?: string[]; + /** Whether the keys were actually typed — false when --ready never + * matched and they were withheld rather than fired at an unknown screen. */ + keysSent?: boolean; + ready?: string; + until?: string; + settleMs?: number; + timeoutMs?: number; + /** The raw pane text with escapes — always written; a refused capture + * writes no manifest at all. */ + ansPath: string; + /** The rendered image — null when freeze is unavailable or failed. */ + pngPath: string | null; + /** Never `none`: a refused capture writes no manifest at all — `none` is + * the rung a VERDICT stands on when there is no manifest to cite. */ + evidence: Exclude; + /** Why the ladder stopped where it did (freeze missing, timeout, …). */ + degradedBecause?: string; + /** How long the run waited before capturing, and why it stopped waiting. */ + settledBy: 'until-match' | 'timeout' | 'fixed-delay'; +} + +/** + * The tmux invocations for one capture, in order. Pure — the command layer + * execs them — so the exact argv shapes are pinned by tests, not by hope. + * Every call carries `-L `: one stray unscoped call is the entire + * isolation property gone. + */ +export function tmuxPlan(opts: { + server: string; + session: string; + cols: number; + rows: number; + command: string; + cwd: string; + /** Whether to ask for real trailing spaces at all (`-N`). False only on + * the tmux versions whose `-N` FABRICATES them and that have no `-T` to + * undo it — see tmuxPadsWithCaptureN. */ + captureTrailing?: boolean; + /** Whether this tmux takes `capture-pane -T` (3.4+) — see the capture + * argv below. False on older versions, which need no trimming and reject + * the flag. */ + captureTrim?: boolean; + /** Absolute path the holder touches AFTER its trap is installed — the + * command layer sends no key until this file exists, closing the race + * where a --keys C-c lands before the holder's first line has run + * (measured: the INTR fires the instant tmux writes 0x03 to the pty, + * not when the shell reads it — no in-script ordering can win). */ + readyFile: string; + /** Absolute path to `sleep`, resolved by the caller before any tmux start. + * The holder's watchdog and bounded hold loop run it, and a BARE name + * resolves through the PANE's inherited PATH — the same hazard the + * `/bin/sh` pin below exists for, with a worse ending: under a PATH that + * finds tmux but not `sleep`, the watchdog's sleep exits 127 in + * milliseconds and falls straight through to `kill -9 -$$`, SIGKILLing the + * whole pane process group. The capture window collapses to ~0ms and the + * bounded three-hour hold goes with it, with nothing in the manifest + * saying why. Resolved once, embedded here, so the pane never looks it + * up — and a caller that cannot resolve it refuses before starting + * anything, rather than discovering it as an empty capture. */ + sleepBin: string; +}): { + start: string[]; + capture: string[]; + captureText: string[]; + kill: string[]; + sendKeys: (key: string) => string[]; +} { + const scope = ['-L', opts.server]; + const esc = (s: string): string => s.replaceAll("'", "'\\''"); + // The pane must outlive the command: tmux's default `remain-on-exit off` + // destroys pane → window → session the moment the command exits, so a + // one-shot command (render and exit — exactly what a verify fixture looks + // like) would be uncapturable (measured: 0/10 without the holder). + // `kill-server` reaps the holder along with everything else; for an + // UNREAPED holder the bounded hold loop below is the only other reaper — + // its periods cap the orphan at three hours, and a legal capture never + // outlives its reap. + // + // TWO nested shells, not one: in a single shell, a command ending in + // `exit N` (or opening with `exec`, or running under its own `set -e`) + // takes the keep-alive down with it — pane, session, and server gone + // before the capture (measured: deterministic "no server running" refusal + // on `printf ...; exit 0`). The inner sh absorbs the exit; the outer one + // holds the pane. + // + // The outer holder ALSO survives SIGINT: non-interactive shells stay in + // the pane's foreground process group, so one C-c — a canonical --keys + // token — delivers INTR to the holder itself and would take pane → + // session → server down before the capture (measured). `trap : INT`, NOT + // `trap '' INT`: SIG_IGN inherits across exec and would silently blunt + // C-c for targets without their own handler, while a trapped signal + // resets to default in children — the command keeps its normal Ctrl-C + // behavior, and only the holder is protected. + // + // The hold sits on its OWN LINE: appended with `;` it is voided by the + // command's own tail — a trailing `;` makes `;;` (syntax error, pane dies + // instantly), a trailing `#` comment swallows it (the one-shot failure + // recurs), and both blame tmux for a valid command. Trailing backslashes + // cannot fold the hold line either: the command sits single-quoted at + // every layer, so no shell parses its text adjacent to the hold + // (probe-verified with odd-run shapes on this exact plan). + // /bin/sh, never a bare `sh`: the pane resolves a bare name through its + // inherited PATH, and a degraded PATH without sh turned the capture into + // 'sh: not found' evidence while the run reported success — the same + // invocation's default-shell pin already guarantees /bin/sh (probed live). + const inner = `/bin/sh -c '${esc(opts.command)}'`; + // tmux's CLIENT splits any argv element ending in `;` into a separate + // command before dispatch (cmd_parse_from_arguments, unchanged since 3.1 — + // the lowest version this command's gate admits); `--` ends option parsing + // but does NOT reach the command splitter. Both user-derived elements need + // it, measured on 3.3a: `--keys 'x;'` typed only `x` — exit 0, no warning, + // silent corruption of the very evidence this command guarantees — and a + // `--cwd '/tmp/foo;'` (a legal POSIX dirname that passes the usability + // gate) made the `-c` element a command boundary, failing with a + // misleading socket error. `\;` is tmux's escape and round-trips (verified: + // pane_current_path came back `/tmp/foo;`); a mid-string `;` is literal + // already. EVERY trailing `;` is escaped, including one already preceded + // by a backslash: tmux CONSUMES that backslash (measured: the token + // `x\;` types `x;`, `x\\;` types `x\;`), and nothing escapes these + // values before they reach here — so treating `\;` as already-escaped + // silently corrupted a cwd or key token that legitimately ends in it. + const escapeTrailingSemicolon = (s: string): string => + s.endsWith(';') ? `${s.slice(0, -1)}\\;` : s; + // readyFile is re-parsed by the holder shell, so it keeps its own esc() + // even now that the caller derives it from the system temp dir rather + // than from --out: mkdtemp-style parents are not guaranteed + // apostrophe-free, and an unescaped one broke the quoting and burned the + // full sentinel deadline (measured, back when --out named it). And the hold is a LOOP, + // not one sleep: after a one-shot command exits, a --keys C-c kills the + // running sleep; the trap runs and a single-sleep script would simply + // end — pane, session and server gone (measured 5/5). The loop re-enters + // sleep and the pane survives. The loop is BOUNDED — 180 periods of one + // minute — so an unreaped holder (SIGKILL'd harness, OOM) self-terminates + // after three hours instead of living indefinitely. The WATCHDOG carries + // that same cap for the other half of the lifetime: the loop only starts + // once the captured command has exited, so a command that keeps running + // (a TUI — the normal case) left the cap unreachable and an orphaned + // server lived on. `kill -9 -$$` takes the whole pane process group, and + // tmux tears the pane, session and server down behind it (probe-verified + // with a still-running command: `no server running` right after the + // watchdog fired). `$$` is the holder's pid inside the subshell — a + // subshell does not change it — and the holder is its group leader. It + // IGNORES INT and QUIT, and must: an async subshell in a non-interactive + // shell already ignores them per POSIX, so a `--keys C-c` killed only its + // `sleep` and it ran straight into the kill — measured end to end with + // bash 5.2 as /bin/sh, ONE C-c took pane, session and server down and the + // capture refused `no server running` with zero artifacts, while the same + // run with this trap captured its marker. (dash does not reproduce it, so + // a dash-only probe would have missed it.) `trap ''` is SIG_IGN and + // inherits across exec, but nothing here execs anything but `sleep`, so + // the captured command keeps its own Ctrl-C behaviour. MANY SHORT periods, + // not a few long ones: each post-exit signal consumes the period it + // interrupts, so with three hour-long sleeps the three C-c tokens this + // command explicitly supports exhausted the whole budget MID-CAPTURE — + // pane, session and server gone before capture-pane ran, refusing + // `tmux failed mid-capture: no server running` (measured 5/5 on + // `--keys C-c C-c C-c`) and blaming tmux for the holder's own budget. + // A minute per period keeps the same three-hour cap while making a + // keypress cost a minute of it. + // NO outer `sh -c` wrapper: the same invocation pins default-shell to + // /bin/sh, so tmux's direct child — the pane's session leader — runs this + // script ITSELF, and the trap lives at layer 0. Wrapped, the trap sat one + // layer deep: INT survived via the outer shell's wait-and-cooperate + // semantics, but a --keys C-\ (SIGQUIT) killed the untrapped layer 0 — + // pane, session, server gone (measured end-to-end). QUIT is trapped for + // the same reason INT is; both reset to default in the children. + // Single-quoted like readyFile: the resolved path is a filesystem path, + // and nothing upstream guarantees it is free of spaces or apostrophes. + const sleepBin = `'${esc(opts.sleepBin)}'`; + const held = `trap : INT QUIT\n( trap '' INT QUIT; ${sleepBin} 10800; kill -9 -$$ 2>/dev/null ) &\n: > '${esc(opts.readyFile)}'\n${inner}\ni=0; while [ $i -lt 180 ]; do ${sleepBin} 60; i=$((i+1)); done`; + return { + // ONE client invocation, three properties: + // - `-f /dev/null` starts the server CONFIG-FREE: without it the + // private server loads ~/.tmux.conf, and user options reach into the + // capture — measured: `set -g destroy-unattached on` killed the + // detached session with a misattributed "no server running" refusal. + // - `set-option -g default-shell /bin/sh` runs BEFORE new-session (the + // `;` chains commands inside the same client): the holder string is + // parsed by tmux's default-shell, and an exotic login shell + // (measured: tcsh via $SHELL or passwd) chokes on it. + // - Both ride the same invocation as new-session because a session-less + // server exits the moment its first client leaves (exit-empty) — a + // separate bootstrap call left "no server running" for the next one. + start: [ + '-f', + '/dev/null', + ...scope, + 'set-option', + '-g', + 'default-shell', + '/bin/sh', + ';', + 'new-session', + '-d', + '-s', + opts.session, + '-x', + String(opts.cols), + '-y', + String(opts.rows), + '-c', + // BOTH escapes, in this order: `#` doubles first (tmux + // format-expands the start-directory — measured on 3.3a and 3.4, a + // real directory named `/tmp/fmt/#{session_name}` started the pane in + // `/tmp/fmt`, the PARENT, with exit 0 and the manifest recording the + // literal path the gate had stat()ed), then the trailing `;` for the + // client's command splitter. `##` round-trips to a literal `#`, so a + // plain `#` in a dirname is unaffected (measured both ways). + escapeTrailingSemicolon(opts.cwd.replaceAll('#', '##')), + // `--` ends option parsing: a command that happens to start with `-` + // must reach the shell, not tmux's getopt (measured: without it, + // send-keys silently ate `-l` as its literal flag — exit 0, nothing + // typed — the worst kind of evidence corruption). + '--', + held, + ], + // -p print, -e escapes, -N trailing spaces. Deliberately NOT -J: joining + // wrapped lines re-flows the pane into logical lines, and for a layout + // claim the wrap structure IS the evidence — a 100-char line in an + // 80-column pane must capture as two lines, exactly as rendered (measured: + // with -J the smoke capture showed one long unwrapped line, erasing the + // very clipping it was capturing). -N keeps column claims honest — a + // clipped right edge is trailing-space significant. + capture: [ + ...scope, + 'capture-pane', + '-p', + '-e', + // -N asks tmux to keep the REAL trailing spaces — dropped only where + // it would invent them instead (tmux 3.1-3.2.x, which pad to the grid + // allocation and have no -T): there, a trimmed line understates a + // clipped right edge, while a padded one FABRICATES evidence, and the + // manifest records the caveat as a degradation. + ...(opts.captureTrailing === false ? [] : ['-N']), + // -N alone pads each line out to the grid line's ALLOCATED cell count, + // not what it rendered: measured on tmux 3.4, a row that had held 24 + // characters and was then erased and rewritten with `BBB` came back as + // `BBB` plus four phantom spaces, so a verdict about column position, + // clipping or trailing-space significance would judge allocation + // history instead of rendering. -T drops the NEVER-WRITTEN trailing + // positions while -N keeps the REAL trailing spaces — a partial + // remedy, measured on 3.4: cells written and LATER erased (CR + EL, + // the canonical TUI redraw) still capture as trailing spaces under + // -T, so the capture records the caveat as a degradation. The flag + // landed in 3.4 and the same probe on 3.3a shows no padding to + // remove, so older versions are correct without it — and passing it + // there would fail the call outright ("unknown flag -T", measured). + ...(opts.captureTrim ? ['-T'] : []), + '-t', + opts.session, + ], + // The MATCHING view for --until: `-J` joins wrapped lines and no `-e` + // keeps escapes out, so a marker that spans a wrap boundary or an SGR + // attribute change still matches (measured: both miss forever on the + // physical view). The physical frame above stays what `.ans` records. + captureText: [ + ...scope, + 'capture-pane', + '-p', + '-J', + // Same padding hazard, and worse here: -J JOINS wrapped lines, so + // phantom cells would be spliced into the middle of the text a + // --until/--ready marker is matched against. -T drops the + // never-written ones but not the erased-after-write ones (measured + // identical with and without it), so a marker authored from the real + // rendering across an erased region can still never match. + ...(opts.captureTrim ? ['-T'] : []), + '-t', + opts.session, + ], + // kill-server, not kill-session: the server is ours alone (private -L), + // and killing it reaps the session and everything still in it — no + // orphaned TUI keeps running after the review. Its reach ends at the + // session: a command that daemonizes a descendant (setsid, a detached + // unref'd spawn) leaves that process running, and no portable kill + // reaches it (measured against an attached control arm). See the + // capture-tui header. + kill: [...scope, 'kill-server'], + // One send-keys per token, verbatim — quoting-by-joining is how a key + // sequence silently becomes a different key sequence. `--` for the same + // reason as start: a dash-leading key token (`-l`) is otherwise consumed + // as a send-keys flag, silently. In the plan so the shape is pinned like + // the others: it was the one argv built ad hoc. + sendKeys: (key: string) => [ + ...scope, + 'send-keys', + '-t', + opts.session, + '--', + escapeTrailingSemicolon(key), + ], + }; +} + +/** freeze argv for rendering an .ans capture — pinned so "write the .ans + * FIRST, then render" survives (freeze has hung mid-render on this repo's + * own workflows; the text evidence must already be on disk when it does). */ +export function freezePlan(ansPath: string, pngPath: string): string[] { + return ['--language', 'ansi', ansPath, '--output', pngPath]; +} diff --git a/packages/cli/src/commands/review/run.test.ts b/packages/cli/src/commands/review/run.test.ts index b8cd87d91ad..1641ae312af 100644 --- a/packages/cli/src/commands/review/run.test.ts +++ b/packages/cli/src/commands/review/run.test.ts @@ -533,8 +533,34 @@ describe('review run (handler)', () => { const done = runHandler(); // The capture poll snapshots the verdict while the child still runs... await vi.advanceTimersByTimeAsync(1_000); - // ...then Step 9 runs the REAL cleanup, which sweeps the verdict... - runCleanup('local'); + // ...then Step 9 runs the REAL cleanup, which sweeps the verdict. + // POINT ITS HOST-WIDE CAPTURE SWEEP AT AN EMPTY DIRECTORY FIRST: the + // sweep is real here while `execFileSync` is a bare mock that never + // throws, so every socket it finds reads as successfully killed and is + // then UNLINKED from the real filesystem. Until now only the unrelated + // `process.kill` spy — returning true for every pid, so no socket ever + // looked orphaned — stood between this test and a developer's live + // tmux sockets, and nothing said so. An isolated base plus a + // /tmp-shaped decoy keeps both scan bases inside the fixture. + const sweepBase = mkdtempSync(join(tmpdir(), 'review-run-sweep-')); + // BOTH bases: the sweep always scans /tmp as well, and a developer's + // /tmp/tmux- is full of live sockets. A uid nobody owns makes the + // /tmp leg find no directory at all, while the env leg lands in this + // empty fixture. + const fakeUid = 987_654; + mkdirSync(join(sweepBase, `tmux-${String(fakeUid)}`), { recursive: true }); + const realGetuid = process.getuid; + process.getuid = () => fakeUid; + const realTmuxTmpdir = process.env['TMUX_TMPDIR']; + process.env['TMUX_TMPDIR'] = sweepBase; + try { + runCleanup('local'); + } finally { + process.getuid = realGetuid; + if (realTmuxTmpdir === undefined) delete process.env['TMUX_TMPDIR']; + else process.env['TMUX_TMPDIR'] = realTmuxTmpdir; + rmSync(sweepBase, { recursive: true, force: true }); + } outs.length = 0; // drop cleanup's "Removed temp file" stdout noise // ...and only then does the child exit. child.emit('close', 0); diff --git a/packages/core/src/skills/bundled/review/references/posting.md b/packages/core/src/skills/bundled/review/references/posting.md index 9942760bcc1..9ab2796e6ae 100644 --- a/packages/core/src/skills/bundled/review/references/posting.md +++ b/packages/core/src/skills/bundled/review/references/posting.md @@ -208,7 +208,7 @@ Then reference each finding's `assets` URLs in its inline comment body as `![evi - **The weave is last and all-or-nothing** — the `--findings-out` rewrite runs only after every file has landed and the manifest is written, so the artifact either keeps every local `assetFiles` path (any refusal or earlier failure) or carries every published URL; a run that fails partway through the push is completed by an idempotent re-run. - **Auditable** — the manifest names every file pushed and the commit they landed on, next to the other review artifacts, where Step 9's sweep and a curious human can find it. -**What you must still judge: the image's content.** The command checks extensions, sizes and image magic bytes (a shell script named `evidence.png` refuses on content) — that catches mislabeled or corrupted captures, not a deliberate payload riding behind a real image header; it cannot see that a terminal screenshot has an env dump in the scrollback. Publish only evidence the review itself produced — a capture of a rendering the verification ran, a before/after the A/B produced — and never a capture of the user's own terminal or editor. When in doubt, keep the finding's evidence as prose and local paths. +**What you must still judge: the image's content.** The command checks extensions, sizes and image magic bytes (a shell script named `evidence.png` refuses on content) — that catches mislabeled or corrupted captures, not a deliberate payload riding behind a real image header; it cannot see that a terminal screenshot has an env dump in the scrollback. Publish only evidence the review itself produced — and `qwen review capture-tui` is the sanctioned producer for terminal renderings: it drives the command in a **private tmux server** (structurally unable to see the user's own sessions), captures `.ans` always and `.png` when `freeze` is available, and manifests which evidence rung it reached. Never publish a capture of the user's own terminal or editor. When in doubt, keep the finding's evidence as prose and local paths. **Build the review JSON** with `write_file` to create `.qwen/tmp/qwen-review-{target}-review.json`. It carries three things and **no verdict** — `submit` computes the event and body itself, from the `state` you hand it and the comments you attach, and **refuses a payload that carries `event` or `body`** (a run that skipped the computation and typed its own Approve is exactly what that refusal stops). Every high-confidence Critical or Suggestion finding that maps to a diff line is an entry in `comments`: diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index de452bcef83..cdec68d455e 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -294,10 +294,13 @@ describe('GlobTool', () => { it('should allow path outside workspace (external path support)', async () => { // Shared /tmp made this walk time out on loaded runners — keep this - // dir dedicated and empty. + // dir dedicated. Seed a real file: with an EMPTY dir the assertions + // below were vacuous — any outcome, including "found nothing at + // all", passed them. const outside = await fs.mkdtemp( path.join(os.tmpdir(), 'glob-external-'), ); + await fs.writeFile(path.join(outside, 'external.txt'), 'x'); try { const params: GlobToolParams = { pattern: '*.txt', path: outside }; const invocation = globTool.build(params); @@ -307,6 +310,9 @@ describe('GlobTool', () => { expect(result.returnDisplay).not.toContain( 'Path is not within workspace', ); + // The glob really walked the external path: the seeded file comes + // back (a regression to "nothing found" now fails, not passes). + expect(result.llmContent).toContain('Found 1 file(s)'); } finally { await fs.rm(outside, { recursive: true, force: true }); }