diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index abe67f01de0..f8cc3d9df9c 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -1326,6 +1326,7 @@ jobs: KIND='' run_review_once() { local attempt_timeout="$1" + local attempt_prompt="$2" OUTCOME='fatal' REASON='' KIND='' @@ -1382,7 +1383,7 @@ jobs: --auth-type openai \ --approval-mode yolo \ "${MODEL_ARGS[@]}" \ - --prompt "$PROMPT" \ + --prompt "$attempt_prompt" \ --output-format stream-json \ | tee "$LOG_PATH" local ps=("${PIPESTATUS[@]}") @@ -1478,16 +1479,16 @@ jobs: # Retry budget: all attempts SHARE QWEN_TIMEOUT, so two tries can never # exceed the single-review budget (nor the job timeout), and that - # shared budget is the only thing that needs to bound them. A retry - # re-runs the whole review from scratch rather than resuming the failed - # one, so on a large PR it spends minutes re-fetching, re-chunking and - # re-launching agents before the first finding exists — a short retry - # cap makes the retry die on the clock instead of clearing the - # transient it was meant to clear. Every attempt therefore gets the - # whole remaining budget. Retry only a `retryable` outcome, only once, - # and only when enough budget is left for the retry to plausibly - # finish; below that, report the transient failure so the next run - # starts over with a full budget. + # shared budget is the only thing that needs to bound them. The retry + # carries `--resume`: `fetch-pr` then reuses the dead attempt's + # worktree, plan and agent evidence when the PR head has not moved + # (and silently falls back to a fresh review when it has), so the + # retry spends its remaining budget on the work still owed instead of + # re-fetching, re-chunking and re-launching what already ran. Every + # attempt still gets the whole remaining budget. Retry only a + # `retryable` outcome, only once, and only when enough budget is left + # for the retry to plausibly finish; below that, report the transient + # failure so the next run starts over with a full budget. BUDGET_SECONDS=$(( QWEN_TIMEOUT * 60 )) RETRY_BACKOFF_SECONDS=60 RETRY_MIN_SECONDS=600 @@ -1499,7 +1500,17 @@ jobs: if [ "$attempt_timeout" -lt 30 ]; then fail "${REASON:-Qwen review ran out of time budget before it could complete.}" 1 "$KIND" fi - run_review_once "$attempt_timeout" + # `--resume` is understood by the parser in this repository's own + # tree; the runner installs @qwen-code/qwen-code@latest, so between + # this landing and the next npm release the released parser will + # report `Unrecognized flag "--resume"; ignored.` and the retry runs + # from scratch — today's behaviour plus one warning line, and it + # self-heals on the first release that carries the flag. + ATTEMPT_PROMPT="$PROMPT" + if [ "$attempt" -gt 1 ]; then + ATTEMPT_PROMPT="$PROMPT --resume" + fi + run_review_once "$attempt_timeout" "$ATTEMPT_PROMPT" if [ "$OUTCOME" = "success" ]; then break fi diff --git a/docs/users/features/code-review.md b/docs/users/features/code-review.md index ed237ca439c..0b25db6f3db 100644 --- a/docs/users/features/code-review.md +++ b/docs/users/features/code-review.md @@ -18,6 +18,9 @@ # Review local changes and apply the findings to your working tree /review --fix +# Continue a review of the same PR that was interrupted, instead of starting over +/review 123 --resume + # Review a specific file /review src/utils/auth.ts @@ -222,6 +225,18 @@ A finding is skipped when its fix would change intended behavior, would need cha **Every finding gets an outcome, and this is enforced rather than requested.** The ledger goes through `qwen review findings --outcomes`, which refuses a set that does not cover all of them — a fixer that applies six of nine findings and reports six has not lied about any one of them, it has silently shortened the list, and you would have no way to see the three that fell off. +## Resuming an interrupted review (`--resume`) + +A long review that dies part-way — a dropped connection, a timeout, a killed terminal — leaves everything it had done on disk: the worktree, the captured diff, and the harness's own record of every agent that ran. `--resume` continues from there instead of starting over: + +```bash +/review 123 --resume +``` + +It applies to **PR targets only** (a local review's diff comes from a live working tree, which has no stable interrupted state to continue), and it is safe to pass whenever you are unsure: the review rules on the on-disk state itself — the worktree still at the fetched commit and clean, the captured diff unchanged byte for byte, the PR head unmoved, the resume limit unspent — and silently starts fresh whenever anything no longer matches, telling you which check refused. A continuation reuses the earlier attempt's certified agent results, so the report says how many were recovered; it is disclosed, never a coverage gap. + +Two things to know. A continuation keeps the interrupted run's **effort**: passing a different `--effort` refuses the resume and runs fresh at the level you asked for, because different effort is different work. And if the PR head moved while the review was down, the resume refuses (`head-moved`) and the fresh run reviews the new commits — which is what you want, and it counts as this review's one restart. + ## Findings as Data Confirmed findings are canonicalized into `.qwen/tmp/qwen-review--findings.json` before anything else consumes them — the terminal report, the saved Markdown report, and the PR review JSON all read that one artifact instead of re-typing the list. Each finding carries a unique `id` (what outcomes and resolved anchors join on), `severity`, `confidence`, `source`, `summary`, a `shortSummary` capped at 60 characters for list rendering, `failureScenario`, and one or more `locations` — a pattern-aggregated finding keeps **one location per occurrence**, so each still gets its own inline comment. @@ -371,7 +386,7 @@ Every run ends with one machine-readable line (`Review complete: { 'test-efficacy', 'test-plan', 'findings', + 'recover-findings', 'publish-assets', 'compose-review', 'save-artifact', diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index b7b015962df..c7300ccfa9f 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -13,6 +13,7 @@ import { parseArgsCommand } from './review/parse-args.js'; import { matchRemoteCommand } from './review/match-remote.js'; import { composeReviewCommand } from './review/compose-review.js'; 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 { planDiffCommand } from './review/plan-diff.js'; @@ -79,6 +80,7 @@ export const reviewCommand: CommandModule = { .command(testEfficacyCommand) .command(testPlanCommand) .command(findingsCommand) + .command(recoverFindingsCommand) .command(publishAssetsCommand) .command(composeReviewCommand) .command(saveArtifactCommand) @@ -86,7 +88,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, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, 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, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, 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/cleanup.test.ts b/packages/cli/src/commands/review/cleanup.test.ts index b9ace62c522..8bf75afb0c8 100644 --- a/packages/cli/src/commands/review/cleanup.test.ts +++ b/packages/cli/src/commands/review/cleanup.test.ts @@ -42,6 +42,27 @@ vi.mock('node:child_process', async (importOriginal) => { }; }); +vi.mock('./lib/contained-read.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // `readBudgetStopUnfenced` reads the marker through this now; the cleanup + // fixtures serve it via the readFileSync mock, so delegate. + readContainedFileOrNull: (path: string) => { + try { + return { + content: String(mocks.readFileSync(path)), + mtimeMs: 0, + size: 0, + }; + } catch { + return null; + } + }, + }; +}); + vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 85e74071deb..de5e4942c1d 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -16,6 +16,7 @@ import { utimesSync, writeFileSync, } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -335,6 +336,30 @@ describe('cost-ledger — the spend, from the records already on disk', () => { ); }); + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked chats DIRECTORY rather than billing through it', + () => { + // `O_NOFOLLOW` refuses a linked chat FILE, but `chats/` is a directory + // this process never created: one link there redirects every session's + // stream, and the leaf guard never sees it. Fatal, not skipped — the + // main loop's cost is not optional, and a silent agents-only total + // would read as the review's whole spend. + const { plan, env, project } = fixture(); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + writeFileSync( + join(elsewhere, `${SESSION}.jsonl`), + event('2026-08-03T10:01:00Z', { input: 500, output: 50 }), + ); + rmSync(join(project, 'chats'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(project, 'chats')); + + expect(() => computeLedger(plan, env)).toThrow( + /could not read the chat transcript/, + ); + }, + ); + it('throws TranscriptsUnavailable through to the caller when the env is bare', () => { const { plan } = fixture(); expect(() => computeLedger(plan, {} as NodeJS.ProcessEnv)).toThrow( @@ -342,6 +367,36 @@ describe('cost-ledger — the spend, from the records already on disk', () => { ); }); + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked or FIFO plan path, promptly', + () => { + // The floor read is descriptor-first for two reasons, and neither is + // pinned by the primitive's own tests at this call site: a FIFO at the + // predictable plan path blocked `readFileSync` forever, and a split + // stat/read took the billing floor from a different object than the + // bytes whose shape it validated. + const { plan, project, env } = fixture(); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + const realPlan = join(elsewhere, 'plan.json'); + writeFileSync(realPlan, readFileSync(plan, 'utf8')); + rmSync(plan); + symlinkSync(realPlan, plan); + expect(() => computeLedger(plan, env)).toThrow( + /could not read the plan report/, + ); + + const fifoPlan = join(project, 'plan-fifo.json'); + execFileSync('mkfifo', [fifoPlan]); + const started = Date.now(); + expect(() => computeLedger(fifoPlan, env)).toThrow( + /could not read the plan report/, + ); + expect(Date.now() - started).toBeLessThan(2000); + }, + 5000, + ); + it('names a missing plan as the plan, not the usage records', () => { const { env } = fixture(); expect(() => computeLedger('/nonexistent/plan.json', env)).toThrow( @@ -1637,6 +1692,248 @@ describe('cost-ledger — a resumed run bills the whole review', () => { expect(ledger.totals.inputTokens).toBe(1500); }); + it('bills a prior attempt that died before launching any agent', () => { + // The ledger entry lands at fetch-pr time; `subagents/` appears only + // on the first launch. An attempt interrupted in between — the state this + // whole feature exists to recover — has a real chat stream and no + // directory, and an accessor that dropped it lost its main-loop cost with + // no disclosure: the review reads as cheaper than it was. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + runLedger(plan); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(1); + expect(ledger.totals.inputTokens).toBe(1500); + }); + + it.skipIf(process.platform === 'win32')( + 'discloses a prior chat it cannot read, rather than dropping it', + () => { + // Routing this read through the contained reader created refusal + // classes that did not exist when it was a bare `readFileSync`: a + // linked leaf, a FIFO, a file over the ceiling. Skipping those in + // silence prints a lower total as a complete one, while the summary + // still announces the earlier session as included. + const { plan, project, env } = fixture(); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + writeFileSync( + join(elsewhere, 'foreign.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + symlinkSync( + join(elsewhere, 'foreign.jsonl'), + join(project, 'chats', 'S0.jsonl'), + ); + runLedger(plan); + + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let ledger; + let printed: string; + try { + ledger = computeLedger(plan, env); + } finally { + // Read the calls BEFORE restoring: vitest's `mockRestore` clears the + // recorded calls as well as restoring the original, so a capture + // taken afterwards is always empty — and an assertion on it always + // passes. + printed = err.mock.calls.map((c) => String(c[0])).join(''); + err.mockRestore(); + } + + expect(ledger.missingStreams).toBeGreaterThan(0); + expect(printed).toContain('S0'); + expect(printed).toContain('missing from this ledger'); + // The forged bytes behind the link are not billed. + expect(ledger.totals.inputTokens).toBe(500); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'discloses a REFUSED prior subagent directory, not just an absent one', + () => { + // The accessor refuses the directory, so the listing never runs and the + // fault-disclosing catch below it is unreachable for this shape. Before + // this, the chat still billed and the summary still announced the + // session as included while every one of its agents was missing. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + symlinkSync(elsewhere, join(project, 'subagents', 'S0')); + runLedger(plan); + + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let ledger; + let printed: string; + try { + ledger = computeLedger(plan, env); + } finally { + printed = err.mock.calls.map((c) => String(c[0])).join(''); + err.mockRestore(); + } + + expect(ledger.missingStreams).toBeGreaterThan(0); + expect(printed).toContain('not contained in the harness tree'); + expect(printed).toContain('S0'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'discloses a REFUSED session ledger rather than billing as if alone', + () => { + // The refusal empties the prior iteration at its source, so none of the + // per-session disclosures can fire: without this the review's whole + // cost reads as the current session's, with no floor mark and no + // warning — and spent money cannot be re-owed. + const { plan, project, env } = fixture(); + runLedger(plan); + const ledgerPath = join(project, 'plan-prompts', 'run-sessions.json'); + rmSync(ledgerPath); + execFileSync('mkfifo', [ledgerPath]); + + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let ledger; + let printed: string; + try { + ledger = computeLedger(plan, env); + } finally { + printed = err.mock.calls.map((c) => String(c[0])).join(''); + err.mockRestore(); + } + + expect(ledger.missingStreams).toBeGreaterThan(0); + expect(printed).toContain('session ledger'); + expect(printed).toContain('contained regular file'); + }, + 5000, + ); + + it('names an ABSENT chats directory as absent, not as a redirect', () => { + // The mundane state this branch exists for — chat recording off, so the + // directory was never created. Every other fixture mkdirs it, and the + // symlink test reaches only the containment arm. + const { plan, project, env } = fixture(); + rmSync(join(project, 'chats'), { recursive: true, force: true }); + expect(() => computeLedger(plan, env)).toThrow(/chat recording off/); + }); + + it.skipIf(process.platform === 'win32')( + 'counts a refused prior AGENT stream, not only a refused chat', + () => { + // `readAgentDir`'s per-file refusal → `missingStreams++` has no driving + // test: the symlinked-directory case nulls the directory before the + // listing runs, and the unreadable-directory case fails at the listing. + // This plants the link one level down, on the stream itself. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + const priorDir = join(project, 'subagents', 'S0'); + mkdirSync(priorDir, { recursive: true }); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + const foreign = join(elsewhere, 'foreign.jsonl'); + writeFileSync( + foreign, + event('2026-08-03T10:06:00Z', { input: 7, output: 1 }), + ); + symlinkSync(foreign, join(priorDir, 'agent-x.jsonl')); + runLedger(plan); + + const ledger = computeLedger(plan, env); + expect(ledger.missingStreams).toBeGreaterThan(0); + // ...and the forged stream is not billed. + expect(ledger.totals.inputTokens).toBe(1500); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'discloses a prior chat the accessor refused outright', + () => { + // The other half of the redirected-`chats/` story: the accessor nulls + // `chatFile` for every prior session at once, and this asserts the + // ledger says so rather than quietly billing none of them. + const { plan, project, env } = fixture(); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + dirs.push(elsewhere); + writeFileSync( + join(elsewhere, `${SESSION}.jsonl`), + event('2026-08-03T10:10:00Z', { input: 500, output: 50 }), + ); + runLedger(plan); + rmSync(join(project, 'chats'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(project, 'chats')); + + // The CURRENT session's chat is fatal on the same verdict, which is the + // documented direction — the main loop's cost is not optional. + expect(() => computeLedger(plan, env)).toThrow( + /not a contained directory/, + ); + }, + ); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'marks the ledger a floor when a prior agent dir cannot be LISTED', + () => { + // The directory passes containment — `lstat` on it succeeds — and the + // listing then fails. Every other disclosed refusal in this function + // increments the counter; without it here the rendered ledger and the + // archived JSON both say `missingStreams: 0` while that attempt's agent + // cost is missing, so the "this ledger is a floor" mark never appears. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + const priorDir = join(project, 'subagents', 'S0'); + mkdirSync(priorDir, { recursive: true }); + runLedger(plan); + chmodSync(priorDir, 0o000); + try { + const ledger = computeLedger(plan, env); + expect(ledger.missingStreams).toBeGreaterThan(0); + expect(renderLedger(ledger)).toContain('floor'); + } finally { + chmodSync(priorDir, 0o755); + } + }, + ); + + it('stays silent when a prior attempt simply recorded no chat', () => { + // ENOENT is the ordinary state, and the two tests that drive this path + // assert only totals — so a mutation that disclosed every absence would + // ship green and print a warning for every healthy resumed run. + const { plan, project, env } = fixture(); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'subagents', 'S0', 'agent-x.jsonl'), + event('2026-08-03T10:05:00Z', { input: 300, output: 30 }), + ); + runLedger(plan); + + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let ledger; + let printed: string; + try { + ledger = computeLedger(plan, env); + } finally { + printed = err.mock.calls.map((c) => String(c[0])).join(''); + err.mockRestore(); + } + + expect(ledger.missingStreams).toBe(0); + expect(printed).toBe(''); + }); + it("folds the interrupted attempt's main loop and agents into the totals", () => { const { plan, env, project } = fixture(); runLedger(plan); diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index 533d7944cc2..325e1e4a328 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -29,7 +29,7 @@ // not an exact API-call tally. import type { CommandModule } from 'yargs'; -import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { parseLineTolerant } from '@qwen-code/qwen-code-core'; import { @@ -44,7 +44,17 @@ import { textOf, } from './lib/transcripts.js'; import { labelFromIdentityLine } from './lib/agent-identity.js'; -import { currentSessionEntry, priorSessionEntries } from './lib/run-ledger.js'; +import { + MAX_STREAM_BYTES, + containedDir, + containedRoot, + readContainedFile, +} from './lib/contained-read.js'; +import { + currentSessionEntry, + priorSessionEntries, + resumeBookkeepingAnomaly, +} from './lib/run-ledger.js'; interface CostLedgerArgs { plan: string; @@ -115,8 +125,23 @@ function readUsage( file: string, floorMs: number, ceilingMs?: number, -): { events: UsageEvent[]; launch: string } { - const raw = readFileSync(file, 'utf8'); +): { events: UsageEvent[]; launch: string; mtimeMs: number } { + // One `O_NOFOLLOW` open, `fstat` on that descriptor, bytes from the same + // descriptor. The caller used to `statSync` the pathname for its membership + // check and then reopen it here to read: two resolutions of one name, with + // a symlink or a swap free to land between them. Refusals throw, which + // every caller already routes as "this stream is lost". + // `minMtimeMs`: a stream whose last write predates the floor cannot hold an + // above-floor record, so the descriptor is opened, `fstat`ed and closed + // without reading a byte. That restores the cheap skip the pathname `stat` + // used to provide, without reintroducing the second name resolution it cost. + const opened = readContainedFile(file, MAX_STREAM_BYTES, { + minMtimeMs: floorMs, + }); + if (opened.stale === true) { + return { events: [], launch: '', mtimeMs: opened.mtimeMs }; + } + const raw = opened.content; const events: UsageEvent[] = []; let launch = ''; for (const line of raw.split('\n')) { @@ -184,7 +209,7 @@ function readUsage( }); } } - return { events, launch }; + return { events, launch, mtimeMs: opened.mtimeMs }; } /** @@ -295,8 +320,12 @@ function planFloorMs(planPath: string): number { let raw: string; let floorMs: number; try { - raw = readFileSync(planPath, 'utf8'); - floorMs = statSync(planPath).mtimeMs; + // The billing floor and the bytes that prove this file IS a plan report, + // off one descriptor. Read separately, a swap between them would validate + // one file's shape and take another file's mtime as the floor. + const opened = readContainedFile(planPath, MAX_STREAM_BYTES); + raw = opened.content; + floorMs = opened.mtimeMs; } catch (err) { throw new Error( `could not read the plan report ${planPath}: ${(err as Error).message}`, @@ -345,9 +374,43 @@ export function computeLedger( const own = currentSessionEntry(planPath, env); const floorMs = own === null ? planMs : Math.max(planMs, own.atMs); - const chatFile = join(projectDir, 'chats', `${sessionId}.jsonl`); + // The current session's chat gets the same ancestor walk a prior session's + // does. `O_NOFOLLOW` in `readUsage` refuses a linked leaf, but `chats/` + // itself is a directory this process never created, and a link there + // redirects every session's stream at once — including this one's. + const rootVerdict = containedRoot(projectDir); + const root = rootVerdict.ok ? rootVerdict.root : null; + const chatsDir = root === null ? null : join(root, 'chats'); + // The project dir's own verdict travels through: absent is mundane (a + // cleaned-up harness tree) and must not be reported with the word reserved + // for redirects. + const chatsVerdict = + root === null || chatsDir === null + ? ({ + ok: false, + reason: rootVerdict.ok ? 'uncontained' : rootVerdict.reason, + } as const) + : containedDir(root, chatsDir); + const chatFile = + chatsVerdict.ok && chatsDir !== null + ? join(chatsDir, `${sessionId}.jsonl`) + : null; let mainEvents: UsageEvent[]; try { + if (chatFile === null) { + // The two verdicts get different sentences. `chats/` is created lazily + // on the first recorded turn, so ABSENT is a mundane configuration + // fact (recording off), and printing the containment word for it sends + // an operator hunting for a planted link that does not exist — in a + // subcommand whose whole purpose is diagnosability. + throw new Error( + !chatsVerdict.ok && chatsVerdict.reason === 'missing' + ? `the harness chat directory ${chatsDir ?? join(projectDir, 'chats')} ` + + 'does not exist (chat recording off?)' + : `the harness chat directory under ${projectDir} is not a ` + + 'contained directory', + ); + } mainEvents = readUsage(chatFile, floorMs).events; } catch (err) { // The plan's existence proves the main loop ran: a missing or unreadable @@ -355,13 +418,14 @@ export function computeLedger( // not a verdict that the loop made no calls. Agents-only totals would // read as the review's whole cost, so say the ledger cannot be computed. throw new Error( - `could not read the chat transcript ${chatFile}: ` + + `could not read the chat transcript ` + + `${chatFile ?? join(projectDir, 'chats', `${sessionId}.jsonl`)}: ` + `${(err as Error).message}`, ); } let files: string[]; try { - files = listAgentTranscriptFiles(dir); + files = listAgentTranscriptFiles(dir, projectDir); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { // No subagent dir is a real state (a low-effort review runs no agents); @@ -391,25 +455,22 @@ export function computeLedger( let streams = 0; for (const f of names) { const full = join(agentDir, f); - let mtimeMs: number; + let read: { events: UsageEvent[]; launch: string; mtimeMs: number }; try { - mtimeMs = statSync(full).mtimeMs; + read = readUsage(full, streamFloorMs ?? floorMs, ceilingMs); } catch { missingStreams++; - continue; // Gone between listing and stat. + // Gone between listing and open, or not a contained regular file. + continue; // This agent's record is lost; the rest still count. } // The transcript dir is session-scoped and never pruned: files from // earlier reviews this session predate the floor, and a file whose last // write predates it cannot hold an above-floor record — the same - // membership test `readTranscripts` applies. Skip it without opening. - if (mtimeMs < (streamFloorMs ?? floorMs)) continue; - let read: { events: UsageEvent[]; launch: string }; - try { - read = readUsage(full, streamFloorMs ?? floorMs, ceilingMs); - } catch { - missingStreams++; - continue; // This agent's record is lost; the rest still count. - } + // membership test `readTranscripts` applies. The mtime comes off the + // descriptor the bytes came from, so this costs one open rather than + // the stat-then-open pair it replaced, and no swap can sit between the + // membership check and the content it admits. + if (read.mtimeMs < (streamFloorMs ?? floorMs)) continue; if (read.events.length === 0) continue; const id = f.replace(/^agent-/, '').replace(/\.jsonl$/, ''); agents.push(foldEvents(id, labelOf(read.launch, id), read.events)); @@ -437,32 +498,102 @@ export function computeLedger( const priorDirs = new Map( priorSessionDirs(planPath, env).map((p) => [p.sessionId, p]), ); + // A refused ledger empties the iteration below at its source: no entries, + // so none of the per-session disclosures downstream can fire, and the + // summary would present a current-session-only figure as the whole review's + // cost. It also drops the billing floor back to the plan's mtime, which + // bills the session's pre-review turns. + let firstAnomaly: string | null = null; + { + // Any cell of the ledger×marker matrix that empties the prior iteration + // while the other half proves there was something to iterate: a refused + // or deleted half renders a resumed review as a fresh single-session + // run, and this is the only place that can say so. + const anomaly = resumeBookkeepingAnomaly(planPath, env); + if (anomaly !== null) { + missingStreams++; + writeStderrLineSafe( + `WARNING: ${anomaly}; any earlier attempt of this review may be ` + + `missing from this ledger, and the billing floor may fall back ` + + `to the plan's own timestamp.`, + ); + } + firstAnomaly = anomaly; + } + let iterated = 0; for (const entry of priorSessionEntries(planPath, env)) { + iterated++; const paths = priorDirs.get(entry.sessionId); let contributed = 0; let events: UsageEvent[] = []; - try { - events = readUsage( - paths?.chatFile ?? - join(projectDir, 'chats', `${entry.sessionId}.jsonl`), - // Floored at the moment THIS attempt began — the plan floor plus - // that session's own start. NOT the current attempt's floor, which is - // later and would erase the prior attempt entirely. - Math.max(planMs, entry.atMs), - entry.endsAtMs ?? undefined, - ).events; - priorMainEvents.push(...events); - contributed += events.length; - } catch { - // The prior attempt's chat is lost; its agents may still count. + // Only the path the shared accessor validated. The `?? join(...)` fallback + // this replaced rebuilt the pathname locally, which handed back exactly + // what the accessor exists to withhold: a session whose `chats/` failed + // containment got its guard bypassed by the very next expression, and a + // link there is read as this attempt's usage. + const chatFile = paths?.chatFile ?? null; + if (chatFile === null) { + // `chats/` itself failed containment. Every prior session loses its + // main loop at once, and the summary would otherwise announce that + // this attempt is included while none of its main-loop cost is. + missingStreams++; + writeStderrLineSafe( + `WARNING: the harness chat directory under ${projectDir} is not a ` + + `contained directory; the prior attempt ${entry.sessionId}'s ` + + `main-loop cost is missing from this ledger.`, + ); + } else { + try { + events = readUsage( + chatFile, + // Floored at the moment THIS attempt began — the plan floor plus + // that session's own start. NOT the current attempt's floor, which + // is later and would erase the prior attempt entirely. + Math.max(planMs, entry.atMs), + entry.endsAtMs ?? undefined, + ).events; + priorMainEvents.push(...events); + contributed += events.length; + } catch (err) { + // Absent is ordinary — a prior attempt that recorded no chat at all. + // Every OTHER refusal is a fact: routing this read through the + // contained reader created classes that did not exist before (a + // linked leaf, a FIFO, a file over the ceiling), and a silent skip + // presents a lower total as a complete one while the summary still + // says the session is included. + const code = (err as { cause?: NodeJS.ErrnoException })?.cause?.code; + if (code !== 'ENOENT') { + missingStreams++; + writeStderrLineSafe( + `WARNING: could not read the prior attempt ${entry.sessionId}'s ` + + `chat transcript at ${chatFile} (${(err as Error).message}); ` + + `that attempt's main-loop cost is missing from this ledger.`, + ); + } + } } let priorAgentEvents: UsageEvent[] = []; - if (paths !== undefined) { + const priorDir = paths?.dir ?? null; + if (priorDir === null && paths?.dirRefused === true) { + // The accessor refused the directory, so the listing below never runs + // and the catch that discloses a listing fault is unreachable for this + // shape. Without this, the summary announces the attempt as included + // while every one of its agents is missing from the total — the exact + // shape `missingStreams` exists to prevent, on the one input that is + // adversarial rather than accidental. + missingStreams++; + writeStderrLineSafe( + `WARNING: the prior attempt ${entry.sessionId}'s subagent directory ` + + `is not contained in the harness tree; that attempt's agent cost ` + + `is missing from this ledger.`, + ); + } + if (priorDir !== null) { const before = agentEvents.length; try { contributed += readAgentDir( - paths.dir, - listAgentTranscriptFiles(paths.dir), + priorDir, + listAgentTranscriptFiles(priorDir, projectDir), // The same window the chat gets: an interrupted CLI session whose // operator kept working would otherwise fold unrelated subagent // cost into this review. @@ -475,9 +606,14 @@ export function computeLedger( // floored: the summary would otherwise announce that this session's // cost is included while omitting all of its agents. if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + // Counted as well as printed: every other disclosed refusal in this + // function increments, and without it the rendered ledger and the + // archived JSON both record `missingStreams: 0` — no floor mark — + // while that attempt's agent cost is missing. + missingStreams++; writeStderrLineSafe( `WARNING: could not list the prior session's subagent transcripts at ` + - `${paths.dir} (${(err as NodeJS.ErrnoException)?.code ?? (err as Error).message}); ` + + `${priorDir} (${(err as NodeJS.ErrnoException)?.code ?? (err as Error).message}); ` + `that attempt's agent cost is missing from this ledger.`, ); } @@ -494,6 +630,23 @@ export function computeLedger( } if (contributed > 0) priorSessions++; } + // The anomaly check and the iteration each read the bookkeeping afresh; a + // concurrent toggle (healthy when the check read, refused/absent when the + // iteration read) empties the loop with no disclosure. Re-check after the + // loop: if it iterated nothing yet a fresh read now finds an anomaly the + // first pass did not disclose, say so — the resumed review must not archive + // as a fresh single-session run just because the fault moved between reads. + if (iterated === 0) { + const postAnomaly = resumeBookkeepingAnomaly(planPath, env); + if (postAnomaly !== null && postAnomaly !== firstAnomaly) { + missingStreams++; + writeStderrLineSafe( + `WARNING: ${postAnomaly}; any earlier attempt of this review may be ` + + `missing from this ledger (the bookkeeping changed between the ` + + `anomaly check and the prior-session read).`, + ); + } + } agents.sort((a, b) => b.inputTokens - a.inputTokens); // A present-but-empty window is not a lighter version of a missing one. diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 6c52d261a17..c5825d14579 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -5,8 +5,9 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'node:crypto'; import type { Argv, CommandModule } from 'yargs'; -import { resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fetchPrCommand, countDiffChangedLines, @@ -25,7 +26,12 @@ import { } from '../../services/review-worktree-lease.js'; import { classifyHeavy } from './lib/heavy.js'; import { buildRoleBrief } from './agent-prompt.js'; -import { PARSE_ARGS_REPORT, worktreePath } from './lib/paths.js'; +import { PARSE_ARGS_REPORT, tmpFile, worktreePath } from './lib/paths.js'; +import { NULL_DEVICE } from './lib/diff-flags.js'; +import { buildDiffPlan } from './lib/diff-plan.js'; +import { buildPlanReport } from './lib/report.js'; +import { operatorReviewSettings } from './lib/review-settings.js'; +import { hasReviewDeadline } from './lib/deadline.js'; describe('classifyHeavy', () => { it('flags a substantially rewritten existing file', () => { @@ -226,6 +232,7 @@ describe('fetchPrCommand builder', () => { // --------------------------------------------------------------------------- const producerMocks = vi.hoisted(() => ({ + mkdirSync: vi.fn(), writeFileSync: vi.fn(), readFileSync: vi.fn((_path?: unknown): string => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); @@ -236,6 +243,12 @@ const producerMocks = vi.hoisted(() => ({ refExists: vi.fn(() => false), releaseWorktree: vi.fn(() => ({ existed: false, freed: true })), gitOpt: vi.fn((..._args: string[]): string | null => null), + statSync: vi.fn((path?: unknown): { mtimeMs: number } | undefined => + String(path).endsWith('-fetch.json') || + String(path).endsWith('fetch-report.json') + ? { mtimeMs: Date.parse('2026-08-13T00:00:00.000Z') } + : undefined, + ), gitRaw: vi.fn((..._args: string[]): Buffer => Buffer.from('')), resolveMergeBase: vi.fn( (): { sha: string | null; baseFetchFailed: boolean } => ({ @@ -256,14 +269,21 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, default: { ...actual, - mkdirSync: vi.fn(), + mkdirSync: producerMocks.mkdirSync, readFileSync: producerMocks.readFileSync, writeFileSync: producerMocks.writeFileSync, + statSync: statSyncThroughMock, }, - mkdirSync: vi.fn(), + mkdirSync: producerMocks.mkdirSync, readFileSync: producerMocks.readFileSync, writeFileSync: producerMocks.writeFileSync, + statSync: statSyncThroughMock, }; + function statSyncThroughMock(path?: unknown, ...rest: unknown[]) { + const mocked = producerMocks.statSync(path); + if (mocked !== undefined) return mocked; + return (actual.statSync as (...a: unknown[]) => unknown)(path, ...rest); + } }); vi.mock('node:child_process', async (importOriginal) => { @@ -294,6 +314,36 @@ vi.mock('../../services/review-worktree-lease.js', () => ({ `${repositoryRoot}/.qwen/tmp/qwen-review-lease-${target}.json`, })); +vi.mock('./lib/contained-read.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // The resume path reads the plan report and the diff through these; the + // fixtures serve both via the readFileSync mock, so delegate to it so a + // test's virtual files reach `tryResume`. + readContainedFileOrNull: (path: string) => { + try { + return { + content: String(producerMocks.readFileSync(path)), + mtimeMs: 0, + size: 0, + }; + } catch { + return null; + } + }, + readContainedBytesOrNull: (path: string) => { + try { + const v = producerMocks.readFileSync(path) as unknown; + return Buffer.isBuffer(v) ? v : Buffer.from(String(v)); + } catch { + return null; + } + }, + }; +}); + vi.mock('./lib/gh.js', () => ({ ensureAuthenticated: vi.fn(), gh: producerMocks.gh, @@ -311,8 +361,12 @@ vi.mock('./lib/git.js', () => ({ return { out, status: out === null ? 1 : 0 }; }, gitRaw: producerMocks.gitRaw, + gitWithInput: vi.fn((): string => ''), + gitRawWithInput: vi.fn((): Buffer => Buffer.from('')), refExists: producerMocks.refExists, releaseWorktree: producerMocks.releaseWorktree, + untrustedLocalConfig: vi.fn((): string[] => []), + plantedHooks: vi.fn((): string[] => []), })); vi.mock('./lib/merge-base.js', () => ({ @@ -322,8 +376,71 @@ vi.mock('./lib/merge-base.js', () => ({ // The ledger append is the wiring under test here, not the ledger itself // (run-ledger.test.ts owns that): a silently unwritten ledger would make a // later --resume find no prior sessions and re-run everything. -vi.mock('./lib/run-ledger.js', () => ({ - appendRunSession: vi.fn(), +vi.mock('./lib/run-ledger.js', async (importOriginal) => { + // Take RESUME_MAX from the real module: hardcoding it here made the + // production constant unfalsifiable — changing it shipped this suite green. + const actual = await importOriginal(); + const sessionEntryCount = vi.fn((_p?: string) => 1); + const ledgerResumeCount = vi.fn( + (_p?: string, _o?: { excludeSessionId?: string }) => 0, + ); + const resumeBookkeepingRefused = vi.fn((_p?: string) => false); + const readResumeMarker = vi.fn((_p?: string) => ({ + schemaVersion: 1, + resumes: [] as Array<{ sessionId: string; atMs: number }>, + restarts: [], + })); + return { + ...actual, + appendRunSession: vi.fn(), + priorSessionIds: vi.fn(() => []), + sessionEntryCount, + ledgerResumeCount, + resumeBookkeepingRefused, + readResumeMarker, + // The cap now reads one snapshot; compose it from the per-function mocks + // so every existing per-test override (counts, marker, refused) still + // drives the ruling. + readResumeCapSnapshot: vi.fn( + (planPath: string, env?: NodeJS.ProcessEnv) => { + if (resumeBookkeepingRefused(planPath)) { + return { + refused: true, + ledgerEntryCount: 0, + ledgerResumes: 0, + markerResumes: 0, + marker: { schemaVersion: 1, resumes: [], restarts: [] }, + }; + } + const marker = readResumeMarker(planPath); + const cur = env?.['QWEN_CODE_SESSION_ID']?.trim()?.toLowerCase(); + return { + refused: false, + ledgerEntryCount: sessionEntryCount(planPath), + ledgerResumes: ledgerResumeCount(planPath, { + excludeSessionId: env?.['QWEN_CODE_SESSION_ID']?.trim(), + }), + markerResumes: marker.resumes.filter( + (r: { sessionId: string }) => r.sessionId.toLowerCase() !== cur, + ).length, + marker, + }; + }, + ), + recordResume: vi.fn(), + recordRestart: vi.fn(), + }; +}); + +// The budget-hygiene branch runs in these tests; unmocked it performs REAL +// filesystem deletes against the hardcoded report path's record dir, and +// nothing could observe whether it ran. +vi.mock('./lib/deadline.js', () => ({ + readBudgetStop: vi.fn(() => null), + clearBudgetStop: vi.fn(), + clearRoundStamps: vi.fn(), + hasReviewDeadline: vi.fn(() => false), + stampsCorroborateRoundCap: vi.fn(() => true), })); vi.mock('./lib/diff-plan.js', async (importOriginal) => { const actual = await importOriginal(); @@ -367,6 +484,7 @@ describe('fetch-pr report assembly', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 1, deletions: 0, changedFiles: 1, @@ -752,6 +870,23 @@ describe('fetch-pr report assembly', () => { expect(report.auditSince).toBe('2020-01-01T00:00:00.000Z'); }); + it('does not inherit an extended-year forgery that sorts before today', async () => { + // `'+275760-09-13…'` parses to the maximum Date yet sorts + // lexicographically BEFORE any `'2026-…'` string — a string-compared + // bound inherited exactly the far-future forgery it exists to reject, + // and cleanup's `comments?since=` audit then returned + // nothing. The bound compares numerically. + producerMocks.readFileSync.mockReturnValue( + JSON.stringify({ + prNumber: '42', + auditSince: '+275760-09-13T00:00:00.000Z', + fetchedAt: '+275760-09-13T00:00:00.000Z', + }), + ); + const report = await reportFor({}); + expect(report.auditSince).toBe(report.fetchedAt); + }); + it('does not inherit a window from a DIFFERENT PR left at the same path', async () => { producerMocks.readFileSync.mockReturnValue( JSON.stringify({ @@ -859,6 +994,7 @@ describe('fetch-pr report assembly', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 400, deletions: 100, changedFiles: 9, @@ -1393,6 +1529,7 @@ describe('fetch-pr report assembly', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 800, deletions: 100, changedFiles: 9, @@ -1780,6 +1917,7 @@ describe('fetch-pr report assembly', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 400, deletions: 100, changedFiles: 9, @@ -3268,6 +3406,7 @@ describe('fetch-pr diff identity (diffSha256)', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 1, deletions: 0, changedFiles: 1, @@ -3275,6 +3414,13 @@ describe('fetch-pr diff identity (diffSha256)', () => { body: '', }), ); + // Self-containment: one test here matches a `resume` filter, and the + // shared buildDiffPlan delegation is otherwise installed only by a + // preceding describe's beforeEach — a filtered run of this suite alone + // partitioned with an implementation-less mock and crashed the report. + producerMocks.buildDiffPlan.mockImplementation((...a: unknown[]) => + producerMocks.actualBuildDiffPlan(...a), + ); }); afterEach(() => { @@ -3406,6 +3552,7 @@ describe('fetch-pr run-session ledger wiring', () => { headRefName: 'feat/x', headRefOid: 'f00df00df00d', baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', additions: 1, deletions: 0, changedFiles: 1, @@ -3459,3 +3606,1570 @@ describe('fetch-pr run-session ledger wiring', () => { expect(appendOrder).toBeGreaterThan(writeOrder); }); }); + +// The plan payload a genuine capture of the resume fixtures' diff records: +// the ruling re-plans the re-derived bytes under this invocation's context +// and compares the report field for field, so the fixture must carry what a +// real fetch-pr wrote — built through the same functions, with the line +// count the gitRaw mock answers every `git show` with (the diff's own five +// lines). +function resumePlanFields(diffBytes: string): Record { + return buildPlanReport(buildDiffPlan(diffBytes, 400), () => 5, { + operatorRoundCap: operatorReviewSettings().reverseAuditRounds, + hasDeadline: hasReviewDeadline(process.env), + }) as unknown as Record; +} + +describe('fetch-pr --resume', () => { + const OUT = '/tmp/fetch-report.json'; + const DIFF_BYTES = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n'; + + function prevReport(over: Record = {}): string { + return JSON.stringify({ + prNumber: '42', + ownerRepo: 'acme/widgets', + host: null, + fetchedSha: 'f00df00df00d', + diffSha256: createHash('sha256') + .update(Buffer.from(DIFF_BYTES)) + .digest('hex'), + worktreePath: '.qwen/tmp/review-pr-42', + diffPathAbsolute: resolve(tmpFile('pr-42', 'diff.txt')), + mergeBaseSha: 'baseb45eb45e', + baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', + headRefName: 'feat/x', + baseFetchFailed: false, + auditSince: '2026-08-12T00:00:00.000Z', + fetchedAt: '2026-08-13T00:00:00.000Z', + prDescriptionHasHan: false, + isCrossRepository: false, + diffStat: { files: 1, additions: 1, deletions: 0 }, + ...resumePlanFields(DIFF_BYTES), + ...over, + }); + } + + beforeEach(async () => { + vi.clearAllMocks(); + // The path-switched fs: the previous report and the diff exist; every + // other read (resume marker, session ledger) is ENOENT. + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' ? 'f00df00df00d' : '', + ); + producerMocks.gh.mockReturnValue( + JSON.stringify({ + headRefName: 'feat/x', + headRefOid: 'f00df00df00d', + baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', + additions: 1, + deletions: 0, + changedFiles: 1, + isCrossRepository: false, + body: '', + headRefOidOnly: undefined, + }), + ); + const { gitOpt, gitRaw } = await import('./lib/git.js'); + // `status --porcelain` → clean; `ls-files -v` → ordinary tags; the + // identity probes agree on ONE repository; rev-parse → the fetched SHA. + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + // The re-derivation terms: a resolvable merge-base and a `git diff` + // whose bytes match the recorded capture. + const { resolveMergeBase } = await import('./lib/merge-base.js'); + vi.mocked(resolveMergeBase).mockImplementation(() => ({ + sha: 'baseb45eb45e', + baseFetchFailed: false, + })); + vi.mocked(gitRaw).mockImplementation((...args: string[]) => + args.includes('ls-tree') || args.includes('cat-file') + ? Buffer.from('') + : Buffer.from(DIFF_BYTES), + ); + // clearAllMocks resets call history but NOT implementations; re-assert + // the ledger defaults so a mockReturnValue set by one test cannot leak + // into the next — the same discipline the fs mock above follows. + const { + priorSessionIds, + readResumeMarker, + ledgerResumeCount, + sessionEntryCount, + resumeBookkeepingRefused, + } = await import('./lib/run-ledger.js'); + vi.mocked(priorSessionIds).mockImplementation(() => []); + vi.mocked(ledgerResumeCount).mockImplementation(() => 0); + vi.mocked(sessionEntryCount).mockImplementation(() => 1); + vi.mocked(resumeBookkeepingRefused).mockImplementation(() => false); + const { untrustedLocalConfig, plantedHooks, gitWithInput } = await import( + './lib/git.js' + ); + vi.mocked(untrustedLocalConfig).mockImplementation(() => []); + vi.mocked(plantedHooks).mockImplementation(() => []); + vi.mocked(gitWithInput).mockImplementation(() => ''); + vi.mocked(readResumeMarker).mockImplementation(() => ({ + schemaVersion: 1, + resumes: [], + restarts: [], + })); + // Self-containment: a filtered run of ONLY this suite never executes the + // preceding describes' beforeEach, which is the only place the shared + // buildDiffPlan delegation is installed — clearAllMocks does not + // reinstall it. A refused resume falls through to the fresh path and + // partitions the diff, so the suite owns the implementation it consumes. + producerMocks.buildDiffPlan.mockImplementation((...a: unknown[]) => + producerMocks.actualBuildDiffPlan(...a), + ); + // The cap's marker term excludes THIS session, so these tests need a + // current session id for it to exclude. The lease gate (#9205) demands + // BOTH ids before any step runs. + vi.stubEnv('QWEN_CODE_SESSION_ID', 'S-test'); + vi.stubEnv('QWEN_CODE_PROMPT_ID', 'P-test'); + }); + + async function run(extraArgs: Record = {}) { + const handler = fetchPrCommand.handler; + if (!handler) throw new Error('fetch-pr handler missing'); + await handler({ + _: [], + $0: 'qwen', + pr_number: '42', + owner_repo: 'acme/widgets', + remote: 'origin', + out: OUT, + maxChunkLines: 400, + resume: true, + ...extraArgs, + } as unknown as Parameters[0]); + } + + function reportWritten(): boolean { + return producerMocks.writeFileSync.mock.calls.some( + ([path]) => path === OUT, + ); + } + + async function stdoutJsonLines(): Promise>> { + const { writeStdoutLine } = await import('../../utils/stdioHelpers.js'); + return vi + .mocked(writeStdoutLine) + .mock.calls.map((c) => String(c[0])) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l) as Record); + } + + it('resumes without touching the report when every probe matches', async () => { + await run(); + expect(reportWritten()).toBe(false); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { + resumed: true, + resumeAttempt: 1, + restartsSpent: 0, + effort: 'high', + out: OUT, + }, + ]); + const { recordResume, appendRunSession } = await import( + './lib/run-ledger.js' + ); + expect(vi.mocked(recordResume)).toHaveBeenCalledWith(OUT); + expect(vi.mocked(appendRunSession)).toHaveBeenCalledWith(OUT); + }); + + it('falls through to a fresh fetch when the head moved, and says so', async () => { + producerMocks.gh.mockImplementation((...args: string[]) => { + if (args.includes('headRefOid') && !args.includes('headRefName')) { + return JSON.stringify({ headRefOid: 'aaaa1111bbbb' }); + } + return JSON.stringify({ + headRefName: 'feat/x', + headRefOid: 'aaaa1111bbbb', + baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', + additions: 1, + deletions: 0, + changedFiles: 1, + isCrossRepository: false, + body: '', + }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([{ resumed: false, resumeRefused: 'head-moved' }]); + // The once-per-review restart bound becomes a fact on disk here. + const { recordRestart } = await import('./lib/run-ledger.js'); + expect(vi.mocked(recordRestart)).toHaveBeenCalledWith( + OUT, + expect.stringContaining('head-moved'), + ); + }); + + it('falls through when the diff bytes changed — the content key', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from('tampered') as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'diff-hash-mismatch' }, + ]); + }); + + it('refuses a forged-but-CONSISTENT diff pair — git re-derives the truth', async () => { + // The attacker rewrote the diff file AND patched diffSha256 to match: + // the two attacker-writable operands agree with each other and disagree + // with what `git diff` derives for the recorded head. + const doctored = + 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+EVIL\n'; + const doctoredSha = createHash('sha256') + .update(Buffer.from(doctored)) + .digest('hex'); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ diffSha256: doctoredSha }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(doctored) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'diff-rederive-mismatch' }, + ]); + }); + + it('refuses a forged worktreePath — downstream steps route through it', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ worktreePath: '/tmp/evil' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'worktree-path-mismatch' }, + ]); + }); + + it('refuses a planted repositoryContext the worktree does not derive', async () => { + // The briefs bake it into every agent — requiredAgents skew the roster, + // verificationNotes steer the verifiers — so a validation-passing plant + // that no compared field contradicts must not ride the resume. + const planted = { + version: 1, + provider: 'manifest', + label: 'planted', + domains: [], + relatedPaths: [], + recommendedTests: [], + requiredConfigurations: [], + requiredAgents: [], + unverifiedDimensions: [], + verificationNotes: ['look away from the hunk'], + }; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ repositoryContext: planted }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'repo-context-mismatch' }, + ]); + }); + + it('refuses a forged Han flag — the live body disproves it', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ prDescriptionHasHan: true }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'pr-description-han-mismatch' }, + ]); + }); + + it('refuses a forged cross-repository flag — the forge disproves it', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ isCrossRepository: true }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'cross-repository-mismatch' }, + ]); + }); + + it('refuses a forged diffStat — the forge stat disproves it', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ + diffStat: { files: 99, additions: 99, deletions: 99 }, + }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'diff-stat-mismatch' }, + ]); + }); + + it('refuses a collapse claim the re-derived range disproves', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ collapsedFromUpstream: true }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'collapsed-mismatch' }, + ]); + }); + + it('refuses a forged emptyDiff — the gate must not pass by absence', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ emptyDiff: true }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'empty-diff-mismatch' }, + ]); + }); + + it('falls through when there is no previous report at all', async () => { + producerMocks.readFileSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([{ resumed: false, resumeRefused: 'no-report' }]); + }); + + it('without --resume the flag path never runs', async () => { + await run({ resume: false }); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([]); + }); + + it('surfaces the recorded restart count to the resumed session', async () => { + const { readResumeMarker } = await import('./lib/run-ledger.js'); + vi.mocked(readResumeMarker).mockReturnValue({ + schemaVersion: 1, + resumes: [], + restarts: [{ atMs: Date.now(), reason: 'head-moved aaa->bbb' }], + }); + await run(); + const lines = await stdoutJsonLines(); + expect(lines[0]['restartsSpent']).toBe(1); + }); + + it('cross-caps the resume count on the session ledger — a deleted marker does not reset it', async () => { + // Marker reads empty (deleted), but the ledger names three sessions: + // the original plus two resumes — two entries past the original, so the + // cap must read as spent. + // Read UNGATED: the gated accessor cannot answer at ruling time, because + // the record that satisfies its gate is written only after the ruling. + const { ledgerResumeCount } = await import('./lib/run-ledger.js'); + // The ruling passes the CURRENT session for exclusion; a deleted-marker + // attack arrives as a session the ledger does not name, so nothing is + // excluded and the full count bites. + vi.mocked(ledgerResumeCount).mockImplementation(() => 2); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([{ resumed: false, resumeRefused: 'resume-cap' }]); + }); + + it('refuses the ORIGINAL session at the cap on the backstop path', async () => { + // Ledger [S0 (original), S1, S2], marker deleted — the exact backstop + // state the ledger term exists for — and S0 itself resumes again. The + // exclusion already removed S0's entry, so the count answers 2; the old + // unconditional minus one read 1 and admitted a third resume through + // the cap's own backstop. + const { ledgerResumeCount } = await import('./lib/run-ledger.js'); + vi.mocked(ledgerResumeCount).mockImplementation(() => 2); + await run(); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([{ resumed: false, resumeRefused: 'resume-cap' }]); + }); + + it('spends the resume budget from the ledger, not one early', async () => { + // The ledger's first entry is the original run's own session, not a + // resume — so [original, resume1] is ONE resume spent, and RESUME_MAX = 2 + // still allows this one. Counting the first entry reads it as two and + // refuses a legitimate continuation; the three-session case cannot see + // the difference, because there both counts rule alike. + const { ledgerResumeCount } = await import('./lib/run-ledger.js'); + vi.mocked(ledgerResumeCount).mockImplementation(() => 1); + await run(); + const lines = await stdoutJsonLines(); + expect(lines[0]).toMatchObject({ resumed: true }); + }); + + it('refuses the resume outright when the bookkeeping tree is refused', async () => { + // Both counters read zero through a redirected record tree, so the cap + // silently un-caps; a cap that cannot read its bookkeeping fails CLOSED. + const { resumeBookkeepingRefused } = await import('./lib/run-ledger.js'); + vi.mocked(resumeBookkeepingRefused).mockReturnValue(true); + await run(); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'bookkeeping-unreadable' }, + ]); + }); + + it('a same-session retry at the cap is the SAME resume in both terms', async () => { + // Sessions S0/S1/S2 all inside the fence (a resume never rewrites the + // plan), marker [S1, S2], current session S2 retrying: the ledger term + // must exclude S2 too — counting it pushed the retry to the cap, and + // the fresh fall-through force-removed the worktree being resumed. + const { readResumeMarker, ledgerResumeCount } = await import( + './lib/run-ledger.js' + ); + vi.mocked(ledgerResumeCount).mockImplementation((_p, opts) => + opts?.excludeSessionId?.toLowerCase() === 's-test' ? 1 : 2, + ); + vi.mocked(readResumeMarker).mockReturnValue({ + schemaVersion: 1, + resumes: [ + { sessionId: 'S-prev', atMs: Date.now() }, + { sessionId: 'S-test', atMs: Date.now() }, + ], + restarts: [], + }); + await run(); + const lines = await stdoutJsonLines(); + expect(lines[0]).toMatchObject({ resumed: true }); + }); + + it('does not count the CURRENT session against its own resume cap', async () => { + // A same-session retry of the last permitted resume is that same resume — + // `recordResume` dedupes on exactly this. Counting the session's own + // marker entry refused the retry as `resume-cap`, and the fall-through + // then force-removed the worktree and rewrote the plan, fencing out every + // attempt's evidence: a review restarted from zero by a retry. + const { readResumeMarker } = await import('./lib/run-ledger.js'); + vi.mocked(readResumeMarker).mockReturnValue({ + schemaVersion: 1, + resumes: [ + { sessionId: 'S-prev', atMs: Date.now() }, + { sessionId: 'S-test', atMs: Date.now() }, + ], + restarts: [], + }); + await run(); + const lines = await stdoutJsonLines(); + expect(lines[0]).toMatchObject({ resumed: true }); + }); + + it('asks git for untracked files EXPLICITLY, immune to user config', async () => { + // `status.showUntrackedFiles=no` hides untracked residue from a bare + // `--porcelain`, and untracked files are the one dirty state no other + // probe can see. + await run(); + const { gitOpt } = await import('./lib/git.js'); + const statusCall = vi + .mocked(gitOpt) + .mock.calls.find((c) => c.includes('status') && c.includes('-C')); + expect(statusCall).toContain('--untracked-files=normal'); + }); + + it('refuses on an explicit effort different from the recorded run', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ effort: 'medium' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run({ effort: 'high' }); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'effort-mismatch' }, + ]); + }); + + it('resumes at the recorded effort when none is passed, and says so', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ effort: 'medium' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + const lines = await stdoutJsonLines(); + expect(lines[0]['resumed']).toBe(true); + expect(lines[0]['effort']).toBe('medium'); + }); + + it('falls through when the worktree holds uncommitted changes', async () => { + // Right HEAD, right diff bytes, moved content: this pipeline's own probe + // and build/test agents mutate worktrees, and a death between an apply + // and its revert leaves exactly this. + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ' M packages/cli/src/x.ts'; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'worktree-dirty' }, + ]); + }); + + it('falls through when the captured diff is gone', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'diff-unreadable' }, + ]); + }); + + it('falls through when the worktree is not at the fetched SHA', async () => { + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'someothersha'; + }); + await run(); + expect(reportWritten()).toBe(true); + const lines = await stdoutJsonLines(); + expect(lines).toEqual([ + { resumed: false, resumeRefused: 'worktree-sha-mismatch' }, + ]); + }); + + // -- The report is attempt-1-writable: every field the resumed pipeline + // -- consumes is compared against a fact this run derives itself. Each + // -- test forges ONE field and expects the refusal that names it. + + it('refuses a forged ownerRepo — the audit tripwire must query the real repo', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ ownerRepo: 'evil/repo' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'owner-repo-mismatch' }, + ]); + }); + + it('refuses a forged host', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ host: 'evil.example.com' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'owner-repo-mismatch' }, + ]); + }); + + it('refuses a forged diffPathAbsolute — every diff read routes through it', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ diffPathAbsolute: '/tmp/evil-diff.txt' }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'diff-path-mismatch' }, + ]); + }); + + it('refuses a forged mergeBaseSha — the revert/A-B base is consumed downstream', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ mergeBaseSha: 'deadbeef'.repeat(5) }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'merge-base-mismatch' }, + ]); + }); + + it('refuses forged-future audit-window fields — they would blind the audit', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ + auditSince: '2099-01-01T00:00:00.000Z', + fetchedAt: '2099-01-01T00:00:00.000Z', + }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'window-corrupt' }, + ]); + }); + + it('refuses chunks thinned to drop a hunk from the obligation universe', async () => { + // The re-derived diff has 5 lines; the forged chunks cover only 3 — a + // hole where a malicious hunk would sit, neither dispatched nor owed. + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ + chunks: [{ id: 1, startLine: 1, endLine: 3, lines: 3, chars: 6 }], + }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'chunks-mismatch' }, + ]); + }); + + it('refuses a recorded effort no writer emits', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ effort: 'turbo' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'effort-corrupt' }, + ]); + }); + + it("refuses a forged plan payload — the round cap is the attacker's to choose", async () => { + // The report's budget feeds the reverse-audit round cap at every + // admission gate; the ruling re-plans the re-derived bytes and compares + // the payload whole, so a rewritten `reverseAuditRounds` (or any plan + // field the launches consume) is a mismatch, however consistent it + // reads with its siblings. + const forged = { + ...(resumePlanFields(DIFF_BYTES)['budget'] as Record), + reverseAuditRounds: 3, + }; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ budget: forged }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'plan-mismatch' }, + ]); + }); + + it('refuses a file kind rewritten to light — the invariant agents vanish', async () => { + const files = (resumePlanFields(DIFF_BYTES)['files'] as unknown[]).map( + (f) => ({ ...(f as Record), kind: 'docs' }), + ); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ files }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'plan-mismatch' }, + ]); + }); + + it('refuses a report claiming the base fetch failed — this run just fetched it', async () => { + // `baseFetchFailed: true` degrades the base tree and the merge-base + // identity source downstream; the passing re-derivation has proven the + // base fetchable now, so the claim is a forgery aimed at the + // verification machinery of the very PR that planted it. + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ baseFetchFailed: true }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'base-fetch-mismatch' }, + ]); + }); + + it('refuses a forged incremental delta — its diffBase welds into the probe base', async () => { + // Attached to an otherwise genuine full-range report: `effective` without + // `upToDate` contradicts the re-derived capture, and Agent 7 welds + // `diffBase` unquoted into its `--base` whenever the shape stands. + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) { + return prevReport({ + incremental: { + since: 'a'.repeat(40), + effective: true, + diffBase: 'f00df00df00d', + }, + }); + } + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'incremental-delta' }, + ]); + }); + + it('refuses a forged baseRefName — the rules load would resolve nothing', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ baseRefName: 'no-such-branch' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'base-ref-mismatch' }, + ]); + }); + + it('refuses a forged headRefName', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport({ headRefName: 'evil/head' }); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'head-ref-mismatch' }, + ]); + }); + + it('refuses when the base OBJECT is absent — a fetch failure cannot fall back to refs', async () => { + // The left side of the re-derivation is the forge's `baseRefOid`, never + // a ref name — a failed or redirected fetch therefore cannot bend it to + // attempt-1-writable local refs. What a failed fetch CAN do is leave the + // base object absent, and `cat-file -e` then refuses the derivation. + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('cat-file')) return null; + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'diff-underivable' }, + ]); + }); + + it('does NOT refuse ordinary ignored build artifacts — node_modules is expected', async () => { + // The residue probe respects `.gitignore` (`--exclude-standard`): a + // resume after `npm install` left node_modules must not read as tamper. + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + // The unexcluded pathspec listing (planted .gitignore probe) and the + // exclude-standard residue listing both come back empty; node_modules + // is ignored, so exclude-standard omits it. + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(reportWritten()).toBe(false); + expect(await stdoutJsonLines()).toEqual([ + { + resumed: true, + resumeAttempt: 1, + restartsSpent: 0, + effort: 'high', + out: OUT, + }, + ]); + }); + + it('refuses a planted .gitignore that hides residue from the status probe', async () => { + // The `*`-ignore blanks status AND exclude-standard; the pathspec-limited + // UNexcluded listing lists the planted ignore file itself. + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others') && args.includes(':(glob)**/.gitignore')) { + return 'sub/.gitignore'; + } + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'worktree-dirty' }, + ]); + }); + + it('refuses tracked content that does not hash to HEAD — the forged-index shape', async () => { + // A forged per-worktree index with patched stat fields blanks status + // and ls-files -v; the object-store cross-check reads bytes, not index + // metadata. HEAD records blob aaaa… for f.txt; hash-object returns + // bbbb… — the worktree file was tampered. (The real-binary invocation + // is pinned separately in worktree-content.test.ts; this checks the + // ruling routes a mismatch to worktree-dirty.) + const { gitRaw, gitRawWithInput } = await import('./lib/git.js'); + vi.mocked(gitRaw).mockImplementation((...args: string[]) => { + if (args.includes('ls-tree')) { + return Buffer.from( + '100644 blob aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tf.txt\0', + 'latin1', + ); + } + if (args.includes('cat-file')) return Buffer.from(''); + return Buffer.from(DIFF_BYTES); + }); + vi.mocked(gitRawWithInput).mockImplementation(() => + Buffer.from('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'), + ); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'worktree-dirty' }, + ]); + }); + + it('refuses command-executing repo-local config — hard-fails, no fresh fetch', async () => { + const { untrustedLocalConfig } = await import('./lib/git.js'); + vi.mocked(untrustedLocalConfig).mockImplementation(() => [ + 'core.fsmonitor', + ]); + await expect(run()).rejects.toThrow(/command-executing entries/); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'repo-config-untrusted' }, + ]); + expect(reportWritten()).toBe(false); + }); + + it('refuses a live hook in the common hooks dir — hard-fails, no fresh git', async () => { + const { plantedHooks } = await import('./lib/git.js'); + vi.mocked(plantedHooks).mockImplementation(() => ['reference-transaction']); + await expect(run()).rejects.toThrow(/command-executing entries/); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'repo-config-untrusted' }, + ]); + expect(reportWritten()).toBe(false); + }); + + it('refuses a dirty host .gitattributes chain — the re-derivation runs at host cwd', async () => { + // An UNTRACKED root `.gitattributes` with `* -diff` collapses the + // re-derived hunks to `Binary files differ` under the exact pinned + // command; it is part of no diff and no other probe checks it. + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes(':(glob)**/.gitattributes')) { + return '?? .gitattributes'; + } + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'diff-underivable' }, + ]); + }); + + it('does not EXECUTE the base fetch or the status refresh under a dirty screen', async () => { + // The demonstrated entrances run during probe gathering — hooks fire on + // the fetch's ref update, filter.clean inside the status refresh — so a + // dirty screen must suppress the probes' execution, not merely distrust + // their answers. + const { untrustedLocalConfig, gitOpt } = await import('./lib/git.js'); + vi.mocked(untrustedLocalConfig).mockImplementation(() => [ + 'filter.evil.clean', + ]); + await expect(run()).rejects.toThrow(/command-executing entries/); + const calls = vi.mocked(gitOpt).mock.calls; + expect(calls.some((c) => c.includes('fetch'))).toBe(false); + expect(calls.some((c) => c.includes('status') && c.includes('-C'))).toBe( + false, + ); + // And no fresh-path git ran either — the hard-fail precedes cleanStale. + const { git } = await import('./lib/git.js'); + expect(vi.mocked(git).mock.calls.some((c) => c.includes('fetch'))).toBe( + false, + ); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'repo-config-untrusted' }, + ]); + }); + + it('refuses a shallow boundary in the common dir', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + if (String(path) === '/repo/.git/shallow') { + return 'ba5e0f0ba5e0ba5e0f0ba5e0ba5e0f0ba5e0ba5e\n'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'shallow-present' }, + ]); + }); + + it('refuses a valid report beside an empty ledger — deleted bookkeeping', async () => { + const { sessionEntryCount } = await import('./lib/run-ledger.js'); + vi.mocked(sessionEntryCount).mockImplementation(() => 0); + await run(); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'ledger-absent' }, + ]); + }); + + it('refuses on a planted info/grafts — the merge-base redirect', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + if (String(path).endsWith(join('.git', 'info', 'grafts'))) { + return 'aaa bbb'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'grafts-present' }, + ]); + }); + + it('refuses on a planted info/attributes — the re-derivation is untrusted', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + if (String(path).endsWith(join('.git', 'info', 'attributes'))) { + return '*.ts -diff'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'diff-underivable' }, + ]); + }); + + it('refuses a relinked worktree — its answers address another repository', async () => { + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) { + // The worktree's common dir disagrees with this repo's. + return args.includes('-C') ? '/attacker/.git' : '/repo/.git'; + } + if (args.includes('--git-dir')) { + return '/attacker/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'worktree-identity-mismatch' }, + ]); + }); + + it('treats skip-worktree bits as dirty — status cannot see a tampered file', async () => { + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'S src/tampered.ts'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'worktree-dirty' }, + ]); + }); + + it('treats a planted exclude rule as dirty — residue hidden from status', async () => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + if (String(path).endsWith(join('.git', 'info', 'exclude'))) { + return 'planted-residue.txt'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + await run(); + expect(reportWritten()).toBe(true); + expect(await stdoutJsonLines()).toEqual([ + { resumed: false, resumeRefused: 'worktree-dirty' }, + ]); + }); + + it('pins the cleanliness probes against config that hides or executes', async () => { + await run(); + const { gitOpt } = await import('./lib/git.js'); + const statusCall = vi + .mocked(gitOpt) + .mock.calls.find((c) => c.includes('status') && c.includes('-C')); + expect(statusCall).toContain('core.fsmonitor=false'); + expect(statusCall).toContain(`core.excludesFile=${NULL_DEVICE}`); + expect(statusCall).toContain('--ignore-submodules=none'); + }); + + it('pins the re-derivation diff against fsmonitor and attribute lookup', async () => { + await run(); + const { gitRaw } = await import('./lib/git.js'); + const diffCall = vi + .mocked(gitRaw) + .mock.calls.find((c) => c.includes('diff')); + expect(diffCall).toContain('core.fsmonitor=false'); + expect(diffCall).toContain(`core.attributesFile=${NULL_DEVICE}`); + }); + + it('clears an UNCORROBORATED round-cap stop before wiping the stamps', async () => { + // A planted round-cap marker buys the silence of the audit rounds it + // claims ran; the stamps are the admission evidence, and the check must + // precede clearRoundStamps, which destroys it. + const { readBudgetStop, clearBudgetStop, stampsCorroborateRoundCap } = + await import('./lib/deadline.js'); + vi.mocked(readBudgetStop).mockReturnValue({ + cause: 'round-cap', + cap: 5, + entry: 'round cap', + entryZh: '轮数上限', + round: 5, + remainingSeconds: 0, + reserveSeconds: 0, + atMs: Date.now(), + }); + vi.mocked(stampsCorroborateRoundCap).mockReturnValue(false); + await run(); + expect(vi.mocked(stampsCorroborateRoundCap)).toHaveBeenCalledWith(OUT, 5); + expect(vi.mocked(clearBudgetStop)).toHaveBeenCalledWith(OUT); + const { clearRoundStamps } = await import('./lib/deadline.js'); + const clearOrder = vi.mocked(clearBudgetStop).mock.invocationCallOrder[0]; + const stampsOrder = vi.mocked(clearRoundStamps).mock.invocationCallOrder[0]; + expect(clearOrder).toBeLessThan(stampsOrder); + }); +}); + +describe('fetch-pr --resume bookkeeping is counted, not merely called', () => { + const OUT = '/tmp/fetch-report.json'; + const DIFF_BYTES = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n'; + + function prevReport(over: Record = {}): string { + return JSON.stringify({ + prNumber: '42', + ownerRepo: 'acme/widgets', + host: null, + fetchedSha: 'f00df00df00d', + diffSha256: createHash('sha256') + .update(Buffer.from(DIFF_BYTES)) + .digest('hex'), + worktreePath: '.qwen/tmp/review-pr-42', + diffPathAbsolute: resolve(tmpFile('pr-42', 'diff.txt')), + mergeBaseSha: 'baseb45eb45e', + baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', + headRefName: 'feat/x', + baseFetchFailed: false, + auditSince: '2026-08-12T00:00:00.000Z', + fetchedAt: '2026-08-13T00:00:00.000Z', + prDescriptionHasHan: false, + isCrossRepository: false, + diffStat: { files: 1, additions: 1, deletions: 0 }, + ...resumePlanFields(DIFF_BYTES), + ...over, + }); + } + + beforeEach(async () => { + vi.clearAllMocks(); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' ? 'f00df00df00d' : '', + ); + producerMocks.gh.mockReturnValue( + JSON.stringify({ + headRefName: 'feat/x', + headRefOid: 'f00df00df00d', + baseRefName: 'main', + baseRefOid: 'ba5e0f0ba5e0', + additions: 1, + deletions: 0, + changedFiles: 1, + isCrossRepository: false, + body: '', + }), + ); + const { gitOpt } = await import('./lib/git.js'); + // `status --porcelain` → clean; `ls-files -v` → ordinary tags; the + // identity probes agree on ONE repository; rev-parse → the fetched SHA. + vi.mocked(gitOpt).mockImplementation((...args: string[]) => { + if (args.includes('--others')) return ''; + if (args.includes('ls-tree')) return ''; + if (args.includes('config')) return ''; + if (args.includes('merge-base')) return 'baseb45eb45e'; + if (args.includes('status')) return ''; + if (args.includes('ls-files')) return 'H f.txt'; + if (args.includes('--git-common-dir')) return '/repo/.git'; + if (args.includes('--git-dir')) { + return '/repo/.git/worktrees/review-pr-42'; + } + return 'f00df00df00d'; + }); + const { + priorSessionIds, + readResumeMarker, + ledgerResumeCount, + sessionEntryCount, + resumeBookkeepingRefused, + } = await import('./lib/run-ledger.js'); + vi.mocked(priorSessionIds).mockImplementation(() => []); + vi.mocked(ledgerResumeCount).mockImplementation(() => 0); + vi.mocked(sessionEntryCount).mockImplementation(() => 1); + vi.mocked(resumeBookkeepingRefused).mockImplementation(() => false); + const { untrustedLocalConfig, plantedHooks, gitWithInput } = await import( + './lib/git.js' + ); + vi.mocked(untrustedLocalConfig).mockImplementation(() => []); + vi.mocked(plantedHooks).mockImplementation(() => []); + vi.mocked(gitWithInput).mockImplementation(() => ''); + vi.mocked(readResumeMarker).mockImplementation(() => ({ + schemaVersion: 1, + resumes: [], + restarts: [], + })); + // Self-containment, the same reason as the first resume suite: these + // re-derivation probes and the partitioner are installed only by + // preceding describes' beforeEach in a full-file run; a filtered run of + // this suite must install them itself. + const { resolveMergeBase } = await import('./lib/merge-base.js'); + vi.mocked(resolveMergeBase).mockImplementation(() => ({ + sha: 'baseb45eb45e', + baseFetchFailed: false, + })); + const { gitRaw } = await import('./lib/git.js'); + vi.mocked(gitRaw).mockImplementation((...args: string[]) => + args.includes('ls-tree') || args.includes('cat-file') + ? Buffer.from('') + : Buffer.from(DIFF_BYTES), + ); + producerMocks.buildDiffPlan.mockImplementation((...a: unknown[]) => + producerMocks.actualBuildDiffPlan(...a), + ); + // The cap's marker term excludes THIS session, so these tests need a + // current session id for it to exclude. The lease gate (#9205) demands + // BOTH ids before any step runs. + vi.stubEnv('QWEN_CODE_SESSION_ID', 'S-test'); + vi.stubEnv('QWEN_CODE_PROMPT_ID', 'P-test'); + }); + + async function run(extra: Record = {}) { + const handler = fetchPrCommand.handler; + if (!handler) throw new Error('fetch-pr handler missing'); + await handler({ + _: [], + $0: 'qwen', + pr_number: '42', + owner_repo: 'acme/widgets', + remote: 'origin', + out: OUT, + maxChunkLines: 400, + resume: true, + ...extra, + } as unknown as Parameters[0]); + } + + it('writes the resume bookkeeping exactly once, and only on a continuation', async () => { + const { appendRunSession, recordResume, recordRestart } = await import( + './lib/run-ledger.js' + ); + await run(); + expect(vi.mocked(recordResume)).toHaveBeenCalledTimes(1); + expect(vi.mocked(appendRunSession)).toHaveBeenCalledTimes(1); + expect(vi.mocked(recordRestart)).not.toHaveBeenCalled(); + }); + + it('records nothing when the resume is refused', async () => { + // Hoisting the bookkeeping above the ruling shipped green before this. + const { recordResume } = await import('./lib/run-ledger.js'); + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => + args.includes('status') ? '' : 'someothersha', + ); + await run(); + expect(vi.mocked(recordResume)).not.toHaveBeenCalled(); + }); + + it('records a restart ONLY for head movement, not for any refusal', async () => { + const { recordRestart } = await import('./lib/run-ledger.js'); + const { gitOpt } = await import('./lib/git.js'); + vi.mocked(gitOpt).mockImplementation((...args: string[]) => + args.includes('status') ? ' M src/x.ts' : 'f00df00df00d', + ); + await run(); + expect(vi.mocked(recordRestart)).not.toHaveBeenCalled(); + }); + + it('honours the marker term of the cap independently of the ledger', async () => { + const { readResumeMarker } = await import('./lib/run-ledger.js'); + vi.mocked(readResumeMarker).mockReturnValue({ + schemaVersion: 1, + resumes: [ + { sessionId: 'A', atMs: 1 }, + { sessionId: 'B', atMs: 2 }, + ], + restarts: [], + }); + await run(); + const { writeStdoutLine } = await import('../../utils/stdioHelpers.js'); + const lines = vi + .mocked(writeStdoutLine) + .mock.calls.map((c) => String(c[0])) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l) as Record); + expect(lines).toEqual([{ resumed: false, resumeRefused: 'resume-cap' }]); + }); + + it('numbers the attempt from the marker AFTER the write', async () => { + // recordResume deduplicates by session, so a second --resume in the same + // session is the same resume — not attempt 2. + const { readResumeMarker } = await import('./lib/run-ledger.js'); + vi.mocked(readResumeMarker).mockReturnValue({ + schemaVersion: 1, + resumes: [{ sessionId: 'S-current', atMs: 1 }], + restarts: [], + }); + await run(); + const { writeStdoutLine } = await import('../../utils/stdioHelpers.js'); + const line = JSON.parse( + vi + .mocked(writeStdoutLine) + .mock.calls.map((c) => String(c[0])) + .filter((l) => l.startsWith('{'))[0], + ) as Record; + expect(line['resumeAttempt']).toBe(1); + }); + + it('runs the budget hygiene on a continuation, and only there', async () => { + const { clearRoundStamps, clearBudgetStop, readBudgetStop } = await import( + './lib/deadline.js' + ); + vi.mocked(readBudgetStop).mockReturnValue({ + cause: 'time-budget', + entry: 'stopped', + entryZh: '停止', + round: 3, + remainingSeconds: 10, + reserveSeconds: 4800, + atMs: Date.now(), + }); + await run(); + expect(vi.mocked(clearRoundStamps)).toHaveBeenCalledWith(OUT); + expect(vi.mocked(clearBudgetStop)).toHaveBeenCalledWith(OUT); + }); + + it('keeps a round-cap stop across the resume WHEN ITS STAMPS CORROBORATE', async () => { + // A round-cap marker is attempt-1-writable; it survives the resume only + // if the admission stamps name every round it claims ran. + const { clearBudgetStop, readBudgetStop, stampsCorroborateRoundCap } = + await import('./lib/deadline.js'); + vi.mocked(readBudgetStop).mockReturnValue({ + cause: 'round-cap', + cap: 5, + entry: 'round cap', + entryZh: '轮数上限', + round: 5, + remainingSeconds: 900, + reserveSeconds: 1200, + atMs: Date.now(), + }); + vi.mocked(stampsCorroborateRoundCap).mockReturnValue(true); + await run(); + expect(vi.mocked(stampsCorroborateRoundCap)).toHaveBeenCalledWith(OUT, 5); + expect(vi.mocked(clearBudgetStop)).not.toHaveBeenCalled(); + }); + + it('keeps a corroborated round-cap stop AND its stamps across TWO resumes', async () => { + // Real stamp files, not the corroboration mock: resume 1 must keep the + // stop's corroboration WITH the stop. Clearing the stamps on resume 1 + // made resume 2 read an empty set, drop the genuine stop, and silently + // reset the exhausted round cap — the check-before-clear discipline is + // satisfied for one read and disarmed for every later one unless the + // kept stop keeps its stamps. + const actualDeadline = + await vi.importActual( + './lib/deadline.js', + ); + const realFs = await vi.importActual('node:fs'); + const recordDir = `${OUT.replace(/\.json$/, '')}-prompts`; + const routeRecordDirToDisk = (): void => { + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).startsWith(recordDir)) { + return realFs.readFileSync(String(path), 'utf8'); + } + if (path === OUT) return prevReport(); + if (String(path).endsWith('qwen-review-pr-42-diff.txt')) { + return Buffer.from(DIFF_BYTES) as unknown as string; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.writeFileSync.mockImplementation( + (path: unknown, data: unknown) => { + if (String(path).startsWith(recordDir)) { + realFs.writeFileSync(String(path), String(data)); + } + }, + ); + producerMocks.mkdirSync.mockImplementation((dir: unknown) => { + if (String(dir).startsWith(recordDir)) { + realFs.mkdirSync(String(dir), { recursive: true }); + } + }); + }; + const { + readBudgetStop, + clearBudgetStop, + clearRoundStamps, + stampsCorroborateRoundCap, + } = await import('./lib/deadline.js'); + try { + routeRecordDirToDisk(); + vi.mocked(readBudgetStop).mockImplementation(() => + actualDeadline.readBudgetStop(OUT), + ); + vi.mocked(clearBudgetStop).mockImplementation(() => + actualDeadline.clearBudgetStop(OUT), + ); + vi.mocked(clearRoundStamps).mockImplementation(() => + actualDeadline.clearRoundStamps(OUT), + ); + vi.mocked(stampsCorroborateRoundCap).mockImplementation((_p, cap) => + actualDeadline.stampsCorroborateRoundCap(OUT, cap), + ); + // Attempt 1 exhausted the round cap: stamps 1..2 and the stop marker. + const now = Date.now(); + actualDeadline.stampRound(OUT, 1, now); + actualDeadline.stampRound(OUT, 2, now + 1000); + actualDeadline.writeRoundCapStop(OUT, 2, 2, now + 2000); + + await run(); + const stampsFile = `${recordDir}/budget-rounds.json`; + // Resume 1 kept the corroborated stop — and must keep the stamps that + // corroborate it. + expect(actualDeadline.readBudgetStop(OUT)).not.toBeNull(); + expect(realFs.existsSync(stampsFile)).toBe(true); + + // The resumed run dies again — the exact population RESUME_MAX exists + // for — and resume 2 re-reads the same state. + await run(); + expect(vi.mocked(clearBudgetStop)).not.toHaveBeenCalled(); + expect(actualDeadline.readBudgetStop(OUT)).not.toBeNull(); + + const { writeStdoutLine } = await import('../../utils/stdioHelpers.js'); + const lines = vi + .mocked(writeStdoutLine) + .mock.calls.map((c) => String(c[0])) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l) as Record); + expect(lines).toEqual([ + { + resumed: true, + resumeAttempt: 1, + restartsSpent: 0, + effort: 'high', + out: OUT, + }, + { + resumed: true, + resumeAttempt: 1, + restartsSpent: 0, + effort: 'high', + out: OUT, + }, + ]); + } finally { + realFs.rmSync(recordDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index 1b014f6152b..b1853822fb6 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -28,8 +28,15 @@ import type { CommandModule } from 'yargs'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { + mkdirSync, + readFileSync, + readlinkSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { clearReviewWorktreeLeaseIfOwned, @@ -45,10 +52,17 @@ import { gitOpt, gitProbe as gitExit, gitRaw, + gitRawWithInput, + plantedHooks, refExists, releaseWorktree, + untrustedLocalConfig, } from './lib/git.js'; -import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './lib/diff-flags.js'; +import { + PINNED_DIFF_CONFIG, + PINNED_DIFF_FLAGS, + NULL_DEVICE, +} from './lib/diff-flags.js'; import { REVIEW_TMP_DIR, reviewBranch, @@ -59,8 +73,10 @@ import { planEffortField } from './lib/effort.js'; import { buildDiffPlan, parseDiff, + chunksCoverDiff, DEFAULT_MAX_CHUNK_LINES, READ_FILE_CHAR_CAP, + type DiffChunk, } from './lib/diff-plan.js'; import { buildPlanReport, @@ -69,10 +85,35 @@ import { stringifyPlanReport, } from './lib/report.js'; import { resolveMergeBase, type GitProbe } from './lib/merge-base.js'; +import { deriveRepositoryContext } from './repo-context.js'; import { operatorReviewSettings } from './lib/review-settings.js'; -import { hasReviewDeadline } from './lib/deadline.js'; -import { appendRunSession } from './lib/run-ledger.js'; import { SHA_RE } from './lib/ledger.js'; +import { + readContainedFileOrNull, + readContainedBytesOrNull, + MAX_STREAM_BYTES, +} from './lib/contained-read.js'; +import { + appendRunSession, + resumeMarkerPath, + readResumeMarker, + recordResume, + recordRestart, + RESUME_MAX, + readResumeCapSnapshot, +} from './lib/run-ledger.js'; +import { + assessResume, + type PreviousReport, + type ResumeRefusal, +} from './lib/resume.js'; +import { + hasReviewDeadline, + readBudgetStop, + clearBudgetStop, + clearRoundStamps, + stampsCorroborateRoundCap, +} from './lib/deadline.js'; interface PrMetadata { headRefName: string; @@ -101,6 +142,15 @@ interface FetchPrArgs { * array and the recovery flow can produce one; `runFetchPr` normalizes. */ since?: string | string[]; + /** + * Continue the interrupted run at this plan path when its state still + * matches (worktree at `fetchedSha`, diff bytes unhashed-unchanged, live + * head unmoved): keep the worktree, do NOT rewrite the plan — its mtime is + * the run epoch every fence keys on — and re-announce the existing report. + * When the state does not match, fall through to a fresh fetch; the flag + * never fails a run that could start over. + */ + resume?: boolean; } type FetchPrResult = PlanReport & { @@ -569,7 +619,8 @@ function sectionsContained( const gitProbe: GitProbe = { fetch: (remote, ref) => gitOpt('fetch', remote, ref) !== null, refExists, - mergeBase: (a, b) => gitOpt('merge-base', a, b), + mergeBase: (a, b) => + gitOpt('-c', 'core.commitGraph=false', 'merge-base', a, b), }; function tryRemove(action: () => void): void { @@ -590,6 +641,841 @@ function cleanStale(prNumber: string): void { } } +/** sha256 of a file's raw bytes, or null when it cannot be read. */ +function sha256OfFile(path: string): string | null { + // Contained RAW bytes: a FIFO planted at the predictable diff path would + // block a bare `readFileSync` forever, and a UTF-8 round trip would change + // the hash of a non-UTF-8 diff. `null` (absent or non-regular) is the same + // "cannot corroborate" the ruling already refuses on. + const bytes = readContainedBytesOrNull(path, MAX_STREAM_BYTES); + if (bytes === null) return null; + return createHash('sha256').update(bytes).digest('hex'); +} + +type ResumeOutcome = + | { resumed: true } + | { resumed: false; reason: ResumeRefusal; priorFetchedSha: string | null }; + +/** + * A planted-file probe. TRUE when the file is present with non-comment, + * non-blank content, or when the question cannot be answered: `null` names + * an underivable path and a read error short of ENOENT names an occupant + * this run cannot clear, and a file that cannot be cleared cannot be ruled + * absent — the probe it shapes fails closed instead. + */ +function plantedFileActive(path: string | null): boolean { + if (path === null) return true; + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ENOENT'; + } + return text.split('\n').some( + // Git's comment rule is BYTE 0: a ` #rule` line (leading space) is a + // live pattern, and trimming before the test classified it as a + // comment while git applied it — a probe reading the planted file as + // inactive is worse than no probe. + (line) => line.trim() !== '' && !line.startsWith('#'), + ); +} + +/** + * A `shallow` file with ANY content marks a shallow boundary — its lines are + * commit SHAs, with no comment syntax, so unlike the exclude probes there is + * nothing to parse: presence with bytes is the fact. Unreadable (short of + * absent) is present — a boundary this run cannot rule out. + */ +function shallowFilePresent(path: string): boolean { + try { + return readFileSync(path, 'utf8').trim() !== ''; + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ENOENT'; + } +} + +/** The report file's own mtime, or null when it cannot be statted. */ +function reportMtime(out: string): number | null { + try { + return statSync(out).mtimeMs; + } catch { + return null; + } +} + +/** + * Does every tracked BLOB in the worktree hash to the object recorded at + * HEAD? `git status` answers through the index, and a forged per-worktree + * index with patched stat fields hides tracked-file tamper from it — + * `hash-object` reads the bytes and the object store is content-addressed, + * so this comparison cannot be answered from forged metadata. Symlinks + * (mode 120000) and gitlinks (160000) are skipped: `hash-object` follows a + * link to its target, which legitimately differs from the link text the + * tree records. False on any read/parse failure — an unverifiable tree is + * not a verified one. + */ +export function worktreeMatchesHead(wt: string): boolean { + // Raw bytes end to end: the string wrappers UTF-8-decode and + // CRLF-normalize, so a non-UTF-8 or CRLF filename would lossy-round-trip + // and fail to re-open. `ls-tree -z` NUL-terminates entries and leaves the + // path raw; parse the Buffer directly. + let listing: Buffer; + try { + listing = gitRaw('-C', wt, 'ls-tree', '-r', '-z', 'HEAD'); + } catch { + return false; + } + const files: Array<{ oid: string; path: Buffer }> = []; + const symlinks: Array<{ oid: string; path: Buffer }> = []; + let start = 0; + for (let i = 0; i <= listing.length; i++) { + if (i < listing.length && listing[i] !== 0) continue; + if (i === start) { + start = i + 1; + continue; + } + const entry = listing.subarray(start, i); + start = i + 1; + const tab = entry.indexOf(0x09); // '\t' separates meta from the raw path + if (tab < 0) return false; + const meta = entry.subarray(0, tab).toString('latin1'); + const [mode, type, oid] = meta.split(' '); + if (mode === undefined || type === undefined || oid === undefined) { + return false; + } + if (mode === '160000') continue; // gitlink: submodule probes own it + if (type !== 'blob') return false; + const path = entry.subarray(tab + 1); + // `--stdin-paths` reads NEWLINE-separated paths, so a path containing a + // newline byte cannot be fed to it — refuse closed rather than hash the + // wrong file. + if (path.includes(0x0a)) return false; + if (mode === '120000') symlinks.push({ oid, path }); + else files.push({ oid, path }); + } + // Symlinks: `hash-object` FOLLOWS the link to its target, so a retargeted + // link would hash whatever it now points at. Compare the recorded blob + // (the link's own target text) against `readlink` instead. + for (const link of symlinks) { + let recorded: Buffer; + try { + recorded = gitRaw('-C', wt, 'cat-file', 'blob', link.oid); + } catch { + return false; + } + let actual: Buffer; + try { + actual = Buffer.from( + readlinkSync(join(wt, link.path.toString('latin1')), 'buffer'), + ); + } catch { + return false; + } + if (!recorded.equals(actual)) return false; + } + if (files.length === 0) return true; + // Newline-separated raw path bytes on stdin; the SHA output is ASCII, so + // decoding it as utf8 is lossless. + const stdin = Buffer.concat( + files.flatMap((f) => [f.path, Buffer.from('\n')]), + ); + let hashed: Buffer; + try { + hashed = gitRawWithInput(stdin, ['-C', wt, 'hash-object', '--stdin-paths']); + } catch { + return false; + } + const got = hashed + .toString('latin1') + .split('\n') + .filter((l) => l !== ''); + if (got.length !== files.length) return false; + return files.every((f, i) => got[i] === f.oid); +} + +/** + * Do the report's `chunks` tile the re-derived diff — well-formed ranges + * covering every line exactly? The chunks are the dispatch AND obligation + * universes, the tiling guarantee runs at plan time only, and the plan sits + * on attempt-1-writable disk: deleting the chunk that covers a malicious + * hunk leaves it neither dispatched nor owed unless the ruling re-checks + * the cover against bytes it derived itself. + */ +function reportChunksTile(chunks: unknown, diffText: string): boolean { + if (!Array.isArray(chunks)) return false; + const ranges: DiffChunk[] = []; + for (const entry of chunks) { + if (typeof entry !== 'object' || entry === null) return false; + const { startLine, endLine } = entry as { + startLine?: unknown; + endLine?: unknown; + }; + if ( + typeof startLine !== 'number' || + typeof endLine !== 'number' || + !Number.isSafeInteger(startLine) || + !Number.isSafeInteger(endLine) || + startLine < 1 || + endLine < startLine + ) { + return false; + } + ranges.push(entry as DiffChunk); + } + return chunksCoverDiff(ranges, parseDiff(diffText).diffLines); +} + +/** + * The plan fields the report carries and the resumed launches consume. + * Compared whole against a re-planning of the re-derived diff: the chunk + * tiling check proves line ranges only, while territory weighting reads the + * chunks' file spans, the roster reads `kind`/`heavy`, and the budgets and + * the reverse-audit round cap re-derive from the tallies at every gate. + */ +const PLAN_REPORT_FIELDS = [ + 'diffLines', + 'diffChars', + 'srcDiffLines', + 'testDiffLines', + 'docsDiffLines', + 'generatedDiffLines', + 'chunks', + 'files', + 'budget', +] as const; + +function reportPlanMatches( + prev: PreviousReport, + rederivedText: string, + headSha: string, + maxChunkLines: number, +): boolean { + try { + const rederived = buildPlanReport( + buildDiffPlan(rederivedText, maxChunkLines), + (path) => fileLineCount(headSha, path), + { + operatorRoundCap: operatorReviewSettings().reverseAuditRounds, + hasDeadline: hasReviewDeadline(process.env), + }, + ); + const recorded = prev as Record; + return PLAN_REPORT_FIELDS.every((field) => + isDeepStrictEqual(recorded[field], rederived[field]), + ); + } catch { + // A re-derived diff the planner rejects cannot corroborate any plan. + return false; + } +} + +/** + * The `--resume` fast path: rule on the interrupted attempt's state and, when + * it holds, continue it — every probe is a fact this command gathers itself + * (git, gh, file hashes, the CLI-written marker), never the orchestrator's + * account. On a continuation the plan file is NOT touched: its mtime is the + * run epoch that keeps the first attempt's records, stamps and transcripts + * inside every reader's fence. + */ +function tryResume(args: FetchPrArgs, wt: string): ResumeOutcome { + const { pr_number: prNumber, owner_repo: ownerRepo, out } = args; + let prev: PreviousReport | null = null; + try { + // Contained: a FIFO at the predictable plan path hangs a bare read + // before any guard can fire. Absent/non-regular → prev null → the + // ruling refuses `no-report`. + { + const contained = readContainedFileOrNull(out, MAX_STREAM_BYTES); + prev = + contained === null + ? null + : (JSON.parse(contained.content) as PreviousReport); + } + } catch { + prev = null; + } + // An unreachable forge reads as "unmoved": the worktree and diff hashes pin + // the content, and presubmit's headDrift re-checks before anything posts. + let liveHeadSha: string | null = null; + let liveBaseRefName: string | null = null; + let liveHeadRefName: string | null = null; + let liveBaseRefOid: string | null = null; + let liveIsCrossRepository: boolean | null = null; + let livePrDescriptionHasHan: boolean | null = null; + let liveDiffStat: { + files: number; + additions: number; + deletions: number; + } | null = null; + try { + // The same query also fetches the facts the four consumed report + // fields below re-derive from — one forge read, never an extra round + // trip per field. + const view = JSON.parse( + gh( + 'pr', + 'view', + prNumber, + '--repo', + ownerRepo, + '--json', + 'headRefOid,headRefName,baseRefName,baseRefOid,additions,deletions,changedFiles,isCrossRepository,body', + ), + ) as { + headRefOid?: unknown; + headRefName?: unknown; + baseRefName?: unknown; + baseRefOid?: unknown; + additions?: unknown; + deletions?: unknown; + changedFiles?: unknown; + isCrossRepository?: unknown; + body?: unknown; + }; + liveHeadSha = + typeof view.headRefOid === 'string' && view.headRefOid !== '' + ? view.headRefOid + : null; + liveBaseRefName = + typeof view.baseRefName === 'string' && view.baseRefName !== '' + ? view.baseRefName + : null; + liveHeadRefName = + typeof view.headRefName === 'string' && view.headRefName !== '' + ? view.headRefName + : null; + liveBaseRefOid = + typeof view.baseRefOid === 'string' && SHA_RE.test(view.baseRefOid) + ? view.baseRefOid + : null; + // The head OID is the one field every genuine view carries; its absence + // names an unreachable or malformed forge, which leaves every derived + // value null — the ruling then refuses the fields it cannot compare. + if (liveHeadSha !== null) { + liveIsCrossRepository = + typeof view.isCrossRepository === 'boolean' + ? view.isCrossRepository + : null; + livePrDescriptionHasHan = /\p{Script=Han}/u.test( + typeof view.body === 'string' ? view.body : '', + ); + const additions = view.additions; + const deletions = view.deletions; + const changedFiles = view.changedFiles; + if ( + typeof additions === 'number' && + typeof deletions === 'number' && + typeof changedFiles === 'number' + ) { + liveDiffStat = { + files: changedFiles, + additions, + deletions, + }; + } + } + } catch { + liveHeadSha = null; + liveBaseRefName = null; + liveHeadRefName = null; + liveBaseRefOid = null; + liveIsCrossRepository = null; + livePrDescriptionHasHan = null; + liveDiffStat = null; + } + // Worktree identity BEFORE any worktree answer is trusted: the `.git` + // pointer file lives inside the attempt-1-writable tree, and a relinked + // worktree — an attacker clone checked out at the recorded head — answers + // rev-parse, status and ls-files from the attacker's repository while the + // probes believe they address the real one. The common dirs must agree. + const wtCommonDirRaw = gitOpt('-C', wt, 'rev-parse', '--git-common-dir'); + const ownCommonDirRaw = gitOpt('rev-parse', '--git-common-dir'); + const worktreeIdentityMatches = + wtCommonDirRaw !== null && + ownCommonDirRaw !== null && + resolve(wt, wtCommonDirRaw) === resolve(process.cwd(), ownCommonDirRaw); + const commonDir = worktreeIdentityMatches + ? resolve(process.cwd(), ownCommonDirRaw as string) + : null; + const wtGitDirRaw = gitOpt('-C', wt, 'rev-parse', '--git-dir'); + const wtGitDir = + worktreeIdentityMatches && wtGitDirRaw !== null + ? resolve(wt, wtGitDirRaw) + : null; + // The command-executing and fetch-redirecting config surface, screened + // BEFORE any further git probe runs: the demonstrated entrances execute + // DURING probe gathering — `reference-transaction` hooks fire on the base + // fetch's ref update, `filter.*.clean` runs inside the status refresh — + // so a dirty screen must not merely refuse at ruling time, it must stop + // the probes from executing at all. Screened at the scopes the reviewed + // PR's code can write (local + worktree); global and system config are + // the operator's, and that residual is disclosed rather than screened. + const localConfig = untrustedLocalConfig(wt); + // The re-derivation and every fresh-path git run at the HOST cwd and + // consume the MAIN checkout's config, so it is screened too — a + // `core.worktree`/`core.hooksPath` planted there fires against the host's + // own ops. The worktree and the host share a git common dir (linked + // worktree), so one hooks probe covers both. + const hostConfig = untrustedLocalConfig(process.cwd()); + const hooks = + commonDir !== null ? plantedHooks(join(commonDir, 'hooks')) : null; + const repoConfigClean = + localConfig !== null && + localConfig.length === 0 && + hostConfig !== null && + hostConfig.length === 0 && + hooks !== null && + hooks.length === 0; + // A planted `info/grafts` redirects the merge-base the re-derivation diffs + // against (to the head itself: an empty diff matching a forged empty + // pair). Replace refs are pinned out of every git wrapper; grafts have no + // flag, so a present file refuses the resume outright. + const graftsAbsent = !plantedFileActive( + commonDir !== null ? join(commonDir, 'info', 'grafts') : null, + ); + // A planted `info/attributes` shapes the bytes git derives (a `-diff` rule + // collapses hunks to `Binary files differ`) and no pinned flag covers + // attribute lookup, so a present file makes the re-derivation untrusted — + // it stays null and the ruling refuses `diff-underivable`. + // Untracked .gitattributes anywhere in the host checkout — UNexcluded, so a + // co-planted `.gitignore *` cannot hide it (`--exclude-standard` omitted). + const hostAttributesUntracked = repoConfigClean + ? gitOpt( + '-c', + 'core.fsmonitor=false', + 'ls-files', + '--others', + '--', + '.gitattributes', + ':(glob)**/.gitattributes', + ) + : null; + // Tracked-but-modified .gitattributes — a dirty attribute file the capture + // did not run under. (A COMMITTED one is symmetric: the fresh capture ran + // under it too, so the recorded diff already reflects it — the residual + // where attempt-1 advances the HOST repo's committed history is the same + // out-of-tree class as the ledger/transcript anchors, disclosed in DESIGN.) + const hostAttributesModified = repoConfigClean + ? gitOpt( + '-c', + 'core.fsmonitor=false', + 'status', + '--porcelain', + '--', + '.gitattributes', + ':(glob)**/.gitattributes', + ) + : null; + const attributesPlanted = + plantedFileActive( + commonDir !== null ? join(commonDir, 'info', 'attributes') : null, + ) || + plantedFileActive( + wtGitDir !== null ? join(wtGitDir, 'info', 'attributes') : null, + ) || + hostAttributesUntracked === null || + hostAttributesUntracked.trim() !== '' || + hostAttributesModified === null || + hostAttributesModified.trim() !== ''; + // Re-derive the diff from git objects, keyed on the RECORDED head and the + // recomputed merge-base against the FORGE's base ref — never the report's + // own mergeBaseSha, which sits on the same attacker-writable disk as the + // hash it would be asked to corroborate. Underivable stays null and the + // ruling refuses: a resume that cannot prove its input authentic from a + // source outside the attempt-1 blast radius does not happen. A FAILED base + // fetch is underivable too: the left side then resolves from attempt-1- + // writable local refs, which an `update-ref` plus a sabotaged remote bends + // to re-derive an empty diff matching a forged empty pair. + const recordedSha = + prev !== null && + typeof prev.fetchedSha === 'string' && + prev.fetchedSha !== '' + ? prev.fetchedSha + : null; + let diffSha256Rederived: string | null = null; + let rederivedDiffEmpty: boolean | null = null; + let rederivedText: string | null = null; + let rederivedMergeBase: string | null = null; + // The merge base is computed between two forge-verified OIDs — the + // recorded head (corroborated against `headRefOid`) and the base branch's + // `baseRefOid` — never through a ref NAME. Every name is resolved through + // attempt-1-writable state: `remote..url`/`insteadOf` redirect the + // fetch, a refspec-sabotaged fetch leaves a planted remote-tracking ref in + // place, and a planted `refs/heads//` shadows it — while an + // OBJECT is content-addressed, so whichever remote supplied it, an object + // with the base's OID is the base. The fetch is still issued (best-effort) + // to make the objects present; its success is not trusted for anything. + if ( + repoConfigClean && + !attributesPlanted && + recordedSha !== null && + liveBaseRefOid !== null + ) { + if (liveBaseRefName !== null) { + gitProbe.fetch(args.remote, liveBaseRefName); + } + const baseObjPresent = + gitOpt('cat-file', '-e', `${liveBaseRefOid}^{commit}`) !== null; + const mbSha = baseObjPresent + ? gitOpt( + '-c', + 'core.commitGraph=false', + 'merge-base', + liveBaseRefOid, + recordedSha, + ) + : null; + if (mbSha !== null && mbSha !== '') { + rederivedMergeBase = mbSha; + try { + const buf = gitRaw( + ...PINNED_DIFF_CONFIG, + 'diff', + ...PINNED_DIFF_FLAGS, + `${mbSha}..${recordedSha}`, + ); + diffSha256Rederived = createHash('sha256').update(buf).digest('hex'); + rederivedDiffEmpty = buf.length === 0; + rederivedText = buf.toString('utf8'); + } catch { + diffSha256Rederived = null; + rederivedDiffEmpty = null; + rederivedText = null; + } + } + } + // The resumed launches consume the WHOLE plan payload — chunk file spans, + // per-file kinds and heavy flags, the tool budget, the round cap — so + // re-plan the re-derived bytes under this invocation's context and compare + // the result against the report field for field. The re-planning reads + // post-image line counts from the object store (`git show :`), + // the same source the capture used; a forged kind/heavy/budget field the + // range-only chunk check cannot see is a mismatch here. + let planReportMatches: boolean | null = null; + if (rederivedText !== null && prev !== null && recordedSha !== null) { + planReportMatches = reportPlanMatches( + prev, + rederivedText, + recordedSha, + args.maxChunkLines, + ); + } + // The recorded repository context is compared whole against what the + // fresh enrichment's own providers derive from THIS worktree and the + // re-derived merge base. A derivation failure is a mismatch: an + // uncomparable field is an attacker's. + let repositoryContextMatches = false; + if (prev !== null) { + try { + const derived = deriveRepositoryContext( + wt, + prev as { files?: unknown }, + rederivedMergeBase, + ); + repositoryContextMatches = isDeepStrictEqual( + prev.repositoryContext ?? null, + derived, + ); + } catch { + repositoryContextMatches = false; + } + } + // The collapse flag re-derives from the re-derived range and the + // forge's live stat — the same predicate the fresh path applies — and is + // underivable when either side is. + const collapsedRederived = + rederivedText !== null && liveDiffStat !== null + ? isCollapsedFromUpstream({ + diffText: rederivedText, + baseFetchFailed: false, + additions: liveDiffStat.additions, + deletions: liveDiffStat.deletions, + }) + : null; + // A cap that cannot READ its bookkeeping cannot enforce itself: with the + // record tree redirected (a symlinked `-prompts`), both counters read + // zero through the refusal, `max(0, 0)` never reaches RESUME_MAX, and the + // clobber guard skips every marker write — the review resumes forever, + // each attempt announcing "resume 1". Refuse outright and fall through to + // the fresh path: the cap fails CLOSED. + // ONE snapshot of both bookkeeping files decides the cap AND the refusal, + // so a concurrent relinker cannot keep the tree healthy for the guard's + // read and refuse it for the counters' read (both would then degrade to + // zero and the cap would never fire). The refusal verdict and the two + // counts come from the same bytes. + const capSnapshot = readResumeCapSnapshot(out, process.env); + if (capSnapshot.refused) { + return { + resumed: false, + reason: 'bookkeeping-unreadable', + priorFetchedSha: + prev !== null && typeof prev.fetchedSha === 'string' + ? prev.fetchedSha + : null, + }; + } + const marker = capSnapshot.marker; + // The resume marker FILE, present-or-absent. A same-session resume (the + // original session retrying its own dead run) appends NO ledger entry — + // `appendRunSession`'s dedupe returns early — so the ledger backstop's + // `slice(1)` count is blind to it and undercounts the cap by one whenever + // the marker is deleted. But a run that has resumed at all wrote the + // marker (recordResume), and it persists across attempts, so an ABSENT + // marker beside a ledger that names two or more sessions is the + // deleted-bookkeeping tamper — the cap cannot be trusted, and the resume + // fails closed. (A first resume has ledgerEntryCount 1 and no marker yet: + // not refused.) + let markerFileAbsent = false; + try { + statSync(resumeMarkerPath(out)); + } catch (err) { + markerFileAbsent = (err as NodeJS.ErrnoException).code === 'ENOENT'; + } + // The cap reads BOTH counters: the marker is the primary record, and the + // session ledger cross-caps it — a deleted marker must not read as an + // unspent cap while the ledger still names every session that ran. The + // ledger's first entry is the original run's own session, not a resume. + // UNGATED: `priorSessionIds` is gated on this session already being a + // recorded resume, and that record is written only after the ruling below + // passes — so read through the gate this term was zero at every ruling, and + // the two-counter cap it was supposed to backstop collapsed to one counter + // that deleting `resume.json` resets. A count is not evidence. + // + // The ledger term counts entries PAST the first — the first entry is the + // original run's own session, which is not a resume — with the resuming + // session excluded from the remainder. The exclusion and the minus-one + // must not double-count the original: when the resuming session IS the + // original, the exclusion has already removed the first entry, and + // subtracting it again undercounted the cap by one — admitting a resume + // past the cap through the exact backstop path (a deleted marker, the + // original session resuming) the ledger term exists to hold. + // The current session is excluded from BOTH terms or neither — stated + // below, and previously true of only the marker term: a resume leaves the + // plan untouched, so a resumed session's own ledger entry stays inside the + // fence, and counting it pushed a same-session retry of the LAST permitted + // resume over the cap. The retry's fresh fall-through then force-removed + // the very worktree being resumed. + const ledgerResumes = capSnapshot.ledgerResumes; + const markerResumes = capSnapshot.markerResumes; + // `--porcelain` prints nothing on a clean tree. A null (the command could + // not run at all) is treated as dirty by `assessResume`: an unverifiable + // tree is not a clean one. + // `--untracked-files=normal` EXPLICITLY: `status.showUntrackedFiles=no` (a + // common large-repo tuning) hides untracked files from a bare + // `--porcelain`, and untracked residue is exactly the dirty state no other + // probe can see — resuming there reviews files that are not in the PR. + // `--ignore-submodules=none` for the same reason the diff capture pins it: + // `diff.ignoreSubmodules=all` in the config hides a tampered submodule. + // `core.fsmonitor` is pinned off because it names a COMMAND git executes + // on the index refresh this probe triggers, and `core.excludesFile` is + // pointed at the null device because an exclude pattern hides untracked + // residue from this very listing. + // Null (probe-not-run) when the config screen is dirty: `filter.*.clean` + // executes inside the status refresh itself, so a screened-dirty repo + // must not have this probe RUN, not merely have its answer distrusted. + const status = repoConfigClean + ? gitOpt( + '-c', + 'core.fsmonitor=false', + '-c', + `core.excludesFile=${NULL_DEVICE}`, + '-C', + wt, + 'status', + '--porcelain', + '--untracked-files=normal', + '--ignore-submodules=none', + ) + : null; + // Two more hiders of the same state, invisible to `--porcelain`: + // skip-worktree and assume-unchanged index bits mask a tampered tracked + // file (`ls-files -v` tags them `S` and lowercase), and a planted exclude + // rule masks untracked residue. Either reads as dirty — resuming there + // reviews content that is not in the PR. + const lsFiles = repoConfigClean + ? gitOpt('-c', 'core.fsmonitor=false', '-C', wt, 'ls-files', '-v') + : null; + const indexHidesFiles = + lsFiles === null || + lsFiles.split('\n').some((line) => { + const tag = line.charAt(0); + return tag === 'S' || (tag >= 'a' && tag <= 'z'); + }); + const excludesPlanted = + plantedFileActive( + commonDir !== null ? join(commonDir, 'info', 'exclude') : null, + ) || + plantedFileActive( + wtGitDir !== null ? join(wtGitDir, 'info', 'exclude') : null, + ); + // A shallow boundary severs the recorded head's ancestry: the merge-base + // degrades to null or bends to a planted commit and the re-derived range + // omits what the boundary cuts off. Any content refuses — a legitimate + // review clone is never shallow (the fresh path full-fetches). + const shallowAbsent = + commonDir !== null && !shallowFilePresent(join(commonDir, 'shallow')); + // Untracked residue, two listings. The reviewed PR's build/test agents + // create legitimately-ignored artifacts inside the worktree by design + // (node_modules, dist), so the residue probe respects `.gitignore` — + // `--exclude-standard` — and any NON-ignored untracked file is tamper. + // But a planted per-directory `.gitignore` (`*`) could hide real residue + // from that listing, so a SECOND listing, pathspec-limited to the ignore + // files themselves and UNexcluded, catches the plant without indicting + // node_modules. Either non-empty is dirty. + const untrackedResidue = repoConfigClean + ? gitOpt( + '-c', + 'core.fsmonitor=false', + '-C', + wt, + 'ls-files', + '--others', + '--exclude-standard', + ) + : null; + const plantedIgnores = repoConfigClean + ? gitOpt( + '-c', + 'core.fsmonitor=false', + '-C', + wt, + 'ls-files', + '--others', + '--', + ':(glob)**/.gitignore', + '.gitignore', + ) + : null; + // Tracked content anchored to the OBJECT STORE: hash every blob in the + // worktree and compare against `ls-tree -r HEAD`. The status probe trusts + // the index, and a forged per-worktree index with patched stat fields + // hides arbitrary tracked-file tamper from it — `hash-object` does not + // consult the index at all, and HEAD here is the forge-corroborated + // recorded head. Symlink and gitlink entries are skipped (their tamper + // surfaces through status/submodule probes); a read failure fails closed. + const worktreeContentMatches = + repoConfigClean && worktreeIdentityMatches && recordedSha !== null + ? worktreeMatchesHead(wt) + : false; + const ruling = assessResume(prev, { + prNumber, + ownerRepo, + host: args.host?.trim() || null, + worktreeHeadSha: gitOpt('-C', wt, 'rev-parse', 'HEAD'), + worktreeIdentityMatches, + worktreeClean: + status === null + ? null + : status.trim() === '' && + !indexHidesFiles && + !excludesPlanted && + untrackedResidue !== null && + untrackedResidue.trim() === '' && + plantedIgnores !== null && + plantedIgnores.trim() === '' && + worktreeContentMatches === true, + diffSha256OnDisk: sha256OfFile(tmpFile(`pr-${prNumber}`, 'diff.txt')), + diffSha256Rederived, + rederivedDiffEmpty, + worktreePath: wt, + diffPathAbsolute: resolve(tmpFile(`pr-${prNumber}`, 'diff.txt')), + liveHeadSha, + liveBaseRefName, + liveHeadRefName, + mergeBaseSha: rederivedMergeBase, + chunksTile: + rederivedText !== null + ? reportChunksTile(prev?.chunks, rederivedText) + : null, + planReportMatches, + repositoryContextMatches, + prDescriptionHasHan: livePrDescriptionHasHan, + isCrossRepository: liveIsCrossRepository, + diffStat: liveDiffStat, + collapsedRederived, + nowMs: Date.now(), + reportMtimeMs: reportMtime(out), + graftsAbsent, + shallowAbsent, + repoConfigClean, + ledgerEntryCount: capSnapshot.ledgerEntryCount, + markerFileAbsent, + resumeCount: Math.max(markerResumes, ledgerResumes), + requestedEffort: args.effort ?? null, + }); + if (!ruling.ok) { + return { + resumed: false, + reason: ruling.reason, + priorFetchedSha: + prev !== null && typeof prev.fetchedSha === 'string' + ? prev.fetchedSha + : null, + }; + } + + // Budget hygiene: the continuation runs under a fresh deadline, so a + // time-budget stop is the dead attempt's, not this run's — a round-cap + // stop is about rounds, not time, and stands WHEN CORROBORATED: the + // marker is attempt-1-writable, and a planted one buys the silence of the + // audit rounds it claims ran, so it stands only if the admission stamps + // name every round 1..cap. The check precedes `clearRoundStamps` — the + // stamps are the corroboration, and clearing them first destroys it. The + // admission stamps span the death gap and would price a round at hours; + // without them the gate falls back to its conservative constant. + const stop = readBudgetStop(out); + const roundCapStands = + stop !== null && + stop.cause === 'round-cap' && + stampsCorroborateRoundCap(out, stop.cap); + if (stop !== null && !roundCapStands) { + clearBudgetStop(out); + } + // The stamps are the kept stop's corroboration — the NEXT resume re-reads + // them to decide whether it still stands. Clearing them here would make + // that read see an empty set, drop the genuine stop, and silently reset + // the exhausted cap on the second resume; the check-before-clear + // discipline above is disarmed for every later resume unless the kept + // stop keeps its corroboration with it. While the stop stands no round + // is admitted, so the death gap the clearing paces cannot fire through + // it. + if (!roundCapStands) { + clearRoundStamps(out); + } + appendRunSession(out); + recordResume(out); + // Read the marker back: `recordResume` deduplicates by session, so a + // second `--resume` in the SAME session is the same resume, and deriving + // the number from the pre-write count would announce attempt 2 for it. + const attempt = Math.max(1, readResumeMarker(out).resumes.length); + // `restartsSpent` is the resume marker's ONE consumer beyond idempotency: + // the resumed session initialises Step 7's once-per-review restart bound + // from it — without a reader here, the recorded restart would silently + // reset on every resume. `effort` names the level the continuation is + // pinned to (the plan's, deliberately untouched), so a continuation never + // silently runs at a level the caller did not expect. + const pinnedEffort = + prev !== null && typeof prev.effort === 'string' && prev.effort !== '' + ? prev.effort + : 'high'; + writeStdoutLine( + JSON.stringify({ + resumed: true, + resumeAttempt: attempt, + restartsSpent: marker.restarts.length, + effort: pinnedEffort, + out, + }), + ); + writeStderrLine( + `Resumed PR #${prNumber} review (resume ${attempt} of ${RESUME_MAX}): ` + + `worktree, plan and the interrupted attempt's agent evidence are reused; ` + + `the report at ${out} is unchanged, and the run continues at its ` + + `recorded effort (${pinnedEffort}).`, + ); + return { resumed: true }; +} + async function runFetchPr(args: FetchPrArgs): Promise { const { pr_number: prNumber, owner_repo: ownerRepo, remote, out } = args; @@ -670,6 +1556,49 @@ async function runFetchPr(args: FetchPrArgs): Promise { branch: ref, }); + // A `--resume` rules before any destructive step: a continuation must + // reach neither the cleanup below (the worktree is the state being + // resumed) nor the plan write (its mtime is the run epoch). The lease + // above already covers the resumed run — a continuation keeps working + // in this worktree after this command returns, and cleanup releases + // the lease with the rest. A refused resume falls through to the fresh + // path and announces why; head movement is recorded AFTER the fresh + // plan lands, so the marker entry postdates the new epoch. + let resumeRefusal: ResumeRefusal | null = null; + let priorFetchedSha: string | null = null; + if (args.resume) { + const outcome = tryResume(args, wt); + if (outcome.resumed) return; + // A config/hook-screen refusal must NOT fall through in the same + // invocation: `cleanStale`'s `branch -D` (a ref transaction), the + // `fetch` (a ref update) and `worktree add` (a checkout) all fire the + // exact hooks the screen just detected — code execution with the + // reviewer's credentials, in the reviewer's own process, before any + // agent launches. The plant sits in the shared git common dir, so a + // fresh checkout does not escape it. Abort instead; the operator (or a + // fresh runner with a clean tree) can retry. + if (outcome.reason === 'repo-config-untrusted') { + writeStdoutLine( + JSON.stringify({ resumed: false, resumeRefused: outcome.reason }), + ); + throw new Error( + `Refusing to review PR #${prNumber}: the review worktree's git ` + + `config or hooks carry command-executing entries (attempt-1 ` + + `state on a resumable run). No fresh fetch is attempted, because ` + + `the fall-through's git commands would execute the planted ` + + `hooks. Remove the planted config/hooks or run on a clean tree.`, + ); + } + resumeRefusal = outcome.reason; + priorFetchedSha = outcome.priorFetchedSha; + writeStdoutLine( + JSON.stringify({ resumed: false, resumeRefused: outcome.reason }), + ); + writeStderrLine( + `Cannot resume PR #${prNumber} (${outcome.reason}); starting a fresh review.`, + ); + } + // 1. Clean any stale worktree / branch from an earlier run. cleanStale(prNumber); @@ -1166,8 +2095,12 @@ async function runFetchPr(args: FetchPrArgs): Promise { // attempt, never forward. A corrupted far-future `auditSince` // (`"2099-…"`) is therefore rejected here — it would push the window // ahead of every real comment and silently report a clean audit. - // (ISO-8601 strings from `toISOString()` compare chronologically.) - prevSince < auditSince + // Compared NUMERICALLY: `toISOString()` output happens to sort + // chronologically, but a forged extended-year form + // (`"+275760-…"`) parses to the far future while sorting + // lexicographically BEFORE any `"2026-…"` string — a string + // comparison inherits exactly the forgery this bound rejects. + Date.parse(prevSince) < Date.parse(auditSince) ) { auditSince = prevSince; } @@ -1274,6 +2207,14 @@ async function runFetchPr(args: FetchPrArgs): Promise { // reads the ledger to find this attempt's transcripts. After the plan // write, so the entry sits inside the run-epoch fence it is read through. appendRunSession(out); + if (resumeRefusal === 'head-moved') { + // The once-per-review restart bound, now a fact on disk. Recorded after + // the plan write for the same fence reason as the session entry. + recordRestart( + out, + `head-moved ${priorFetchedSha?.slice(0, 7) ?? 'unknown'}->${fetchedSha.slice(0, 7)}`, + ); + } writeStdoutLine(`Wrote fetch-pr report to ${out}`); if (diffPath) writeStdoutLine(`Wrote review diff to ${diffPath}`); // Surface diff stats to stderr so a human running the command interactively @@ -1443,6 +2384,12 @@ export const fetchPrCommand: CommandModule = { describe: 'Target size, in diff lines, of each review chunk. A chunk boundary falls on a hunk boundary; a hunk larger than this is split only at a top-level declaration, never inside a function.', }) + .option('resume', { + type: 'boolean', + default: false, + describe: + 'Continue an interrupted run of this PR when its on-disk state still matches (worktree at the fetched SHA, diff bytes unchanged, PR head unmoved): keep the worktree, leave the plan untouched, and print {"resumed":true}. Falls through to a normal fresh fetch — printing {"resumed":false,"resumeRefused":""} — whenever the state does not match.', + }) .option('effort', { type: 'string', choices: ['low', 'medium', 'high'], diff --git a/packages/cli/src/commands/review/lib/contained-read.test.ts b/packages/cli/src/commands/review/lib/contained-read.test.ts new file mode 100644 index 00000000000..3dbeb30c112 --- /dev/null +++ b/packages/cli/src/commands/review/lib/contained-read.test.ts @@ -0,0 +1,570 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + MAX_LEDGER_BYTES, + ContainedReadError, + containedDir, + containedRoot, + listContainedDir, + readContainedFile, + readContainedFileOrNull, + readContainedBytesOrNull, +} from './contained-read.js'; + +/** + * POSIX-only facts: symlink refusal rides `O_NOFOLLOW` and FIFO refusal rides + * `fstat` on a non-blocking descriptor. Windows has neither primitive, and the + * module says so in prose rather than pretending otherwise. + */ +const isWindows = process.platform === 'win32'; + +describe('readContainedBytesOrNull', () => { + let root: string; + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'crb-'))); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('returns raw bytes unchanged — no UTF-8 round trip', () => { + const p = join(root, 'diff.txt'); + const bytes = Buffer.from([0xff, 0xfe, 0x00, 0x41, 0x0a]); // invalid UTF-8 + writeFileSync(p, bytes); + const got = readContainedBytesOrNull(p, MAX_LEDGER_BYTES); + expect(got).not.toBeNull(); + expect(Buffer.compare(got as Buffer, bytes)).toBe(0); + }); + + it.skipIf(isWindows)('refuses a FIFO without blocking', () => { + const fifo = join(root, 'diff.txt'); + execFileSync('mkfifo', [fifo]); + const started = Date.now(); + expect(readContainedBytesOrNull(fifo, MAX_LEDGER_BYTES)).toBeNull(); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('returns null for an absent file', () => { + expect( + readContainedBytesOrNull(join(root, 'nope'), MAX_LEDGER_BYTES), + ).toBeNull(); + }); +}); + +describe('readContainedFile', () => { + let root: string; + + beforeEach(() => { + // realpath: on macOS `/tmp` is itself a symlink, and the component walk + // under test refuses a linked ancestor — so a raw mkdtemp path would fail + // the very check it is meant to exercise, for a reason that has nothing to + // do with the fixture. + root = realpathSync(mkdtempSync(join(tmpdir(), 'contained-'))); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('returns the bytes and the metadata of the same descriptor', () => { + const file = join(root, 'a.jsonl'); + writeFileSync(file, 'hello\nworld\n'); + // A distinctive mtime, so "came off this descriptor" is checkable rather + // than coincidentally equal to now. + const when = new Date('2020-01-02T03:04:05Z'); + utimesSync(file, when, when); + + const opened = readContainedFile(file, MAX_LEDGER_BYTES); + + expect(opened.content).toBe('hello\nworld\n'); + expect(opened.size).toBe(12); + expect(opened.mtimeMs).toBe(when.getTime()); + }); + + it.skipIf(isWindows)('refuses a symlink to a real file', () => { + const real = join(root, 'real.jsonl'); + writeFileSync(real, 'evidence'); + const link = join(root, 'link.jsonl'); + symlinkSync(real, link); + + // The link resolves to a perfectly readable file; that is the point. The + // read is refused because of what the NAME is, not what it points at. + expect(() => readContainedFile(link, MAX_LEDGER_BYTES)).toThrow( + ContainedReadError, + ); + expect(readContainedFileOrNull(link, MAX_LEDGER_BYTES)).toBeNull(); + expect(readContainedFileOrNull(real, MAX_LEDGER_BYTES)?.content).toBe( + 'evidence', + ); + }); + + it.skipIf(isWindows)( + 'refuses a FIFO without blocking on it', + () => { + const fifo = join(root, 'run-sessions.json'); + execFileSync('mkfifo', [fifo]); + + // The assertion is as much the test TIMING OUT as the value: a blocking + // open on a FIFO with no writer never returns, and this suite would hang + // rather than fail. Non-blocking, it comes back at once and `fstat` + // rejects the type. + const started = Date.now(); + expect(readContainedFileOrNull(fifo, MAX_LEDGER_BYTES)).toBeNull(); + expect(Date.now() - started).toBeLessThan(2000); + + try { + readContainedFile(fifo, MAX_LEDGER_BYTES); + expect.unreachable('a FIFO must not read as a contained file'); + } catch (err) { + expect((err as ContainedReadError).reason).toBe('not-regular'); + } + }, + 5000, + ); + + it('skips a stale file without reading its bytes', () => { + const file = join(root, 'agent-old.jsonl'); + writeFileSync(file, 'records from an earlier review in this session'); + const old = new Date('2020-01-02T03:04:05Z'); + utimesSync(file, old, old); + + const opened = readContainedFile(file, MAX_LEDGER_BYTES, { + minMtimeMs: old.getTime() + 1, + }); + + // Metadata yes, bytes no: the membership fence is answered off the same + // descriptor, which is what keeps a never-pruned session directory cheap. + expect(opened.stale).toBe(true); + expect(opened.mtimeMs).toBe(old.getTime()); + expect(opened.content).toBe(''); + expect(opened.size).toBe(0); + + // At the boundary the file is in scope and IS read: `<` not `<=`. + const fresh = readContainedFile(file, MAX_LEDGER_BYTES, { + minMtimeMs: old.getTime(), + }); + expect(fresh.stale).toBeUndefined(); + expect(fresh.content).toContain('earlier review'); + }); + + it('skips a stale file even when it is over the byte ceiling', () => { + // Order matters: the staleness test comes first, so a huge stale file + // costs an fstat rather than a refusal the caller has to disclose. + const file = join(root, 'agent-huge.jsonl'); + writeFileSync(file, 'x'.repeat(64)); + const old = new Date('2020-01-02T03:04:05Z'); + utimesSync(file, old, old); + + const opened = readContainedFile(file, 8, { + minMtimeMs: old.getTime() + 1, + }); + expect(opened.stale).toBe(true); + }); + + it('skips a stale file WITHOUT reading it — proven by the seam', () => { + // The `stale` docstring promises the bytes are never touched, and the two + // staleness tests assert only the returned shape: reordering the check + // after the read leaves both green while the promise is broken. + const file = join(root, 'agent-old.jsonl'); + writeFileSync(file, 'records from an earlier review'); + const old = new Date('2020-01-02T03:04:05Z'); + utimesSync(file, old, old); + let reads = 0; + + const opened = readContainedFile(file, MAX_LEDGER_BYTES, { + minMtimeMs: old.getTime() + 1, + read: (fd, buf, off, len, pos) => { + reads++; + return readSync(fd, buf, off, len, pos as number); + }, + }); + + expect(opened.stale).toBe(true); + expect(reads).toBe(0); + }); + + it('refuses a directory', () => { + const dir = join(root, 'subagents'); + mkdirSync(dir); + expect(readContainedFileOrNull(dir, MAX_LEDGER_BYTES)).toBeNull(); + }); + + it('refuses a file over the byte ceiling, without reading it', () => { + const file = join(root, 'big.json'); + writeFileSync(file, 'x'.repeat(64)); + + try { + readContainedFile(file, 32); + expect.unreachable('the ceiling must refuse'); + } catch (err) { + expect((err as ContainedReadError).reason).toBe('too-large'); + } + // One byte under is fine: the bound is a ceiling, not an approximation. + expect(readContainedFile(file, 64).size).toBe(64); + }); + + it('returns only the bytes it actually read on a short read', () => { + // The incremental-flush case the module names: `fstat` promises a size, + // the file is still being appended to (or was truncated), and the read + // comes back short. Without the loop-and-`subarray`, the uninitialized + // tail of an `allocUnsafe` buffer is returned AS TRANSCRIPT CONTENT — + // fabricated records, on the one path this module exists to keep honest. + const file = join(root, 'agent-partial.jsonl'); + writeFileSync(file, 'abcdefghij'); + let calls = 0; + const opened = readContainedFile(file, MAX_LEDGER_BYTES, { + read: (fd, buf, off, len, pos) => { + calls++; + // First call delivers 4 of the 10 bytes; the second reports EOF. + if (calls === 1) return readSync(fd, buf, off, 4, pos as number); + return 0; + }, + }); + + expect(opened.content).toBe('abcd'); + expect(opened.size).toBe(4); + expect(opened.content.length).toBe(opened.size); + }); + + it.skipIf(isWindows)( + 'refuses a stale FIFO by TYPE, before the staleness short-circuit', + () => { + // Order matters between these two: reversed, a stale FIFO returns + // `{stale: true}` instead of throwing, and the agent path turns a + // disclosed floor (`missingStreams` → "this ledger is a floor") into an + // undisclosed skip. + const fifo = join(root, 'agent-old.jsonl'); + execFileSync('mkfifo', [fifo]); + const old = new Date('2020-01-02T03:04:05Z'); + utimesSync(fifo, old, old); + + try { + readContainedFile(fifo, MAX_LEDGER_BYTES, { + minMtimeMs: old.getTime() + 1, + }); + expect.unreachable('a FIFO must be refused by type'); + } catch (err) { + expect((err as ContainedReadError).reason).toBe('not-regular'); + } + }, + 5000, + ); + + it('separates an ABSENT file from a refused one, by reason', () => { + // The distinction a discloser needs: absence is the ordinary state of a + // run that recorded nothing, a refusal is a fact worth printing. Both + // used to arrive as `open-failed`, so the documented purpose of this + // field was inexpressible and callers reached into `cause.code` instead. + try { + readContainedFile(join(root, 'nope.json'), MAX_LEDGER_BYTES); + expect.unreachable('absent must not read as empty'); + } catch (err) { + expect((err as ContainedReadError).reason).toBe('absent'); + } + + if (!isWindows) { + const real = join(root, 'target.json'); + writeFileSync(real, 'x'); + const link = join(root, 'linked.json'); + symlinkSync(real, link); + try { + readContainedFile(link, MAX_LEDGER_BYTES); + expect.unreachable('a link must be refused'); + } catch (err) { + expect((err as ContainedReadError).reason).toBe('open-failed'); + } + } + }); +}); + +describe('containedDir', () => { + let root: string; + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'contained-dir-'))); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('accepts a real nested directory and returns its identity', () => { + const dir = join(root, 'subagents', 'S-1'); + mkdirSync(dir, { recursive: true }); + + const res = containedDir(root, dir); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.identity.ino).toBeGreaterThan(0); + } + }); + + it('separates a missing directory from an uncontained one', () => { + // The distinction the resume path depends on: a session directory that + // does not exist yet is the ordinary pre-launch state and callers absorb + // it; a redirected one must never hide inside that tolerance. + const res = containedDir(root, join(root, 'subagents', 'S-absent')); + expect(res).toEqual({ ok: false, reason: 'missing' }); + }); + + it.skipIf(isWindows)('refuses a symlinked ANCESTOR', () => { + // The shape a final-component check misses: every `subagents/` under + // this link stats as an ordinary directory, so validating the leaf alone + // passes while the whole subtree has been redirected. + const outside = realpathSync(mkdtempSync(join(tmpdir(), 'elsewhere-'))); + mkdirSync(join(outside, 'S-1'), { recursive: true }); + symlinkSync(outside, join(root, 'subagents')); + + try { + expect(containedDir(root, join(root, 'subagents', 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it.skipIf(isWindows)('refuses a symlinked final component', () => { + const outside = realpathSync(mkdtempSync(join(tmpdir(), 'elsewhere-'))); + mkdirSync(join(root, 'subagents')); + symlinkSync(outside, join(root, 'subagents', 'S-1')); + + try { + expect(containedDir(root, join(root, 'subagents', 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it.skipIf(isWindows)('refuses a symlinked ROOT', () => { + // The i=0 component of the walk. `readLedgerFile` roots at the plan's own + // parent and `readTranscripts` passes a raw `projectDir`, so for those + // callers this check IS the ancestor defence: with the root itself + // planted as a link, every deeper component lstats as an ordinary + // directory through it and forged evidence reads as contained. + const base = realpathSync(mkdtempSync(join(tmpdir(), 'linked-root-'))); + try { + const real = join(base, 'project'); + mkdirSync(join(real, 'subagents', 'S-1'), { recursive: true }); + const linked = join(base, 'linked-project'); + symlinkSync(real, linked); + + expect(containedDir(linked, join(linked, 'subagents', 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it.skipIf(isWindows || process.getuid?.() === 0)( + 'reports an UNREADABLE ancestor as uncontained, not as missing', + () => { + // The non-ENOENT arm of the lstat routing. The consumer makes the two + // reasons opposites — `missing` is absorbed as "no agents ran", + // `uncontained` throws — so a future edit collapsing lstat failures to + // one reason would silently absorb an unreadable tree. + const blocked = join(root, 'subagents'); + mkdirSync(join(blocked, 'S-1'), { recursive: true }); + chmodSync(blocked, 0o000); + try { + expect(containedDir(root, join(blocked, 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + chmodSync(blocked, 0o755); + } + }, + ); + + it('refuses a path outside the root by shape', () => { + const outside = realpathSync(mkdtempSync(join(tmpdir(), 'outside-'))); + try { + expect(containedDir(root, outside)).toEqual({ + ok: false, + reason: 'uncontained', + }); + // ...including one that only leaves via `..`. + expect(containedDir(root, join(root, '..'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('refuses a path under a DIFFERENT root by shape', () => { + // The cross-drive case on Windows: `relative('C:\\root', 'D:\\other')` + // returns an absolute path, which is neither empty nor `..`-prefixed and + // read as contained. Exercised here through two independent temp roots, + // which is the same shape `relative` reports as non-relative whenever the + // two share no common ancestor inside the root. + const other = realpathSync(mkdtempSync(join(tmpdir(), 'other-root-'))); + try { + mkdirSync(join(other, 'subagents', 'S-1'), { recursive: true }); + expect(containedDir(root, join(other, 'subagents', 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); + + it('refuses a file standing where a directory must be', () => { + const notDir = join(root, 'subagents'); + writeFileSync(notDir, 'not a directory'); + expect(containedDir(root, join(notDir, 'S-1'))).toEqual({ + ok: false, + reason: 'uncontained', + }); + }); +}); + +describe('listContainedDir', () => { + let root: string; + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'contained-list-'))); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('lists a validated directory', () => { + const dir = join(root, 'S-1'); + mkdirSync(dir); + writeFileSync(join(dir, 'agent-a.jsonl'), ''); + const res = containedDir(root, dir); + expect(res.ok).toBe(true); + if (!res.ok) return; + + expect(listContainedDir(dir, res.identity)).toEqual(['agent-a.jsonl']); + }); + + it('propagates a readdir failure rather than reading it as empty', () => { + // The documented contract: callers separate ENOENT ("no agents ran") from + // a live I/O fault themselves, and swallowing it here would erase that + // distinction before they ever see it. + const dir = join(root, 'S-listing'); + mkdirSync(dir); + const res = containedDir(root, dir); + expect(res.ok).toBe(true); + if (!res.ok) return; + rmSync(dir, { recursive: true, force: true }); + + expect(() => listContainedDir(dir, res.identity)).toThrow(/ENOENT/); + }); + + it('discards the listing on an INODE mismatch alone', () => { + // The load-bearing half: the dev clause catches only cross-device swaps, + // while a same-device swap — the ordinary rename-over — moves the inode + // and nothing else. The existing case fabricates both fields, so the dev + // comparison short-circuits and this one never decides. + const dir = join(root, 'S-ino'); + mkdirSync(dir); + writeFileSync(join(dir, 'agent-a.jsonl'), ''); + const res = containedDir(root, dir); + expect(res.ok).toBe(true); + if (!res.ok) return; + + expect(() => + listContainedDir(dir, { + dev: res.identity.dev, + ino: res.identity.ino + 1, + }), + ).toThrow(/replaced between validation and listing/); + }); + + it('discards the listing on a DEVICE mismatch alone', () => { + // The `dev` half of the re-check, exercised on its own: a swap to a + // same-inode directory on another device (a bind or overlay mount, or + // deliberate inode reuse) is invisible to the inode comparison. + const dir = join(root, 'S-1'); + mkdirSync(dir); + writeFileSync(join(dir, 'agent-a.jsonl'), ''); + const res = containedDir(root, dir); + expect(res.ok).toBe(true); + if (!res.ok) return; + + // A detected swap THROWS: it is the one unambiguous signal of + // interference this re-check produces, and returning `[]` made it + // indistinguishable from a mundane empty directory. + expect(() => + listContainedDir(dir, { + dev: res.identity.dev + 1, + ino: res.identity.ino, + }), + ).toThrow(/replaced between validation and listing/); + }); + + it('discards the listing when the directory is no longer the same one', () => { + const dir = join(root, 'S-1'); + mkdirSync(dir); + writeFileSync(join(dir, 'agent-a.jsonl'), ''); + + // A swap between the walk and the listing: `readdirSync` resolves the name + // again, so the entries could come from a different directory than the one + // that was validated. A different inode is the detection. + const identity = { dev: 1, ino: -1 }; + expect(() => listContainedDir(dir, identity)).toThrow( + /replaced between validation and listing/, + ); + }); +}); + +describe('containedRoot', () => { + it('refuses a linked or absent root', () => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'contained-root-'))); + try { + const real = join(base, 'project'); + mkdirSync(real); + expect(containedRoot(real)).toEqual({ ok: true, root: real }); + // Absent is not a containment verdict: reporting it as one sends an + // operator hunting for a planted link that does not exist. + expect(containedRoot(join(base, 'missing'))).toEqual({ + ok: false, + reason: 'missing', + }); + + if (!isWindows) { + const link = join(base, 'linked-project'); + symlinkSync(real, link); + // The whole tree hangs off this: a linked project dir means nothing + // below it can be called contained evidence. + expect(containedRoot(link)).toEqual({ + ok: false, + reason: 'uncontained', + }); + } + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/review/lib/contained-read.ts b/packages/cli/src/commands/review/lib/contained-read.ts new file mode 100644 index 00000000000..1d773299e8b --- /dev/null +++ b/packages/cli/src/commands/review/lib/contained-read.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Every read of on-disk evidence, confined to regular files inside the +// harness's own tree. +// +// The resume feature's threat model rests on one sentence: "a fabricated +// session id can at most point a reader at a directory inside the harness's +// own `subagents/` tree." Path-shape validation alone does not deliver that. +// A ledger id that is lexically clean still names a path, and a path is +// resolved by the filesystem — through whatever symlinks sit on it. Three +// separate shapes defeat the sentence: +// +// - a symlinked ANCESTOR (`subagents` itself, or the project dir) redirects +// the whole subtree, so checking the final component proves nothing; +// - a symlinked LEAF (`agent-.jsonl`, `run-sessions.json`) feeds foreign +// content to a reader that believes it read the harness's record; +// - a check-then-open pair (`lstatSync` then `readFileSync`, `statSync` then +// `readUsage`) validates one object and reads another, because the two +// calls resolve the pathname independently. A swap between them — to +// another file, or to a FIFO that never returns — lands in the gap. +// +// So reads go through here instead. One `openSync` with `O_NOFOLLOW`, one +// `fstatSync` on THAT descriptor, and the bytes read from the same descriptor: +// the object validated is the object read, with no second resolution of the +// name. Directories are validated component by component from a root that must +// itself be real, and the identity captured before `readdirSync` is re-checked +// after it, so a swap during the listing is detected rather than trusted. +// +// The failure direction is uniform and deliberate: anything that cannot be +// proven to be a contained regular file reads as ABSENT, never as empty +// evidence that happens to certify. Invisible evidence re-owes the work, which +// every gate downstream already implements. + +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + readdirSync, +} from 'node:fs'; +import { isAbsolute, parse, relative, resolve, sep } from 'node:path'; + +/** + * Bookkeeping files (`run-sessions.json`, `resume.json`) hold a handful of + * small entries. A planted multi-gigabyte one would otherwise stall every + * command that touches them. + */ +export const MAX_LEDGER_BYTES = 256 * 1024; + +/** + * Transcript and chat streams. Not a policy about how much an agent may say — + * it is the ceiling above which a file is certainly not one of these. Real + * ones run to a few MB; a JSONL stream at a quarter gigabyte is a plant or a + * corruption, and reading it would exhaust the process before any gate saw a + * record. Sits under V8's own string ceiling, so the read fails as a refusal + * here rather than as an opaque allocation throw deeper in. + */ +export const MAX_STREAM_BYTES = 256 * 1024 * 1024; + +/** + * Open flags for a contained read. + * + * Windows does not expose `O_NOFOLLOW`, so there the leaf is opened WITHOUT + * symlink protection and the `fstat` regular-file check below is what remains. + * That is a deliberate trade, not a no-loss fallback: refusing to read + * evidence at all on Windows would make every gate uncertifiable there. Key it + * on the platform, not on the flag's presence — every other platform Node runs + * on exposes `O_NOFOLLOW`, and a bare `?? 0` would silently drop the hardening + * on some future platform that lacked it for an unrelated reason. + * + * `O_NONBLOCK` is what makes the descriptor-first design safe against the FIFO + * this whole guard exists to refuse. Opening a FIFO for reading BLOCKS until a + * writer arrives, so checking the type after the open would hang exactly where + * the old check-then-open pair merely failed — the fix would have introduced + * the hang it was meant to prevent. Non-blocking, the open returns at once, + * `fstat` sees a FIFO, and the descriptor is closed unread. It is a no-op for + * the regular files this ever legitimately opens. + */ +function readFlags(): number { + const noFollow = + process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0); + return (constants.O_RDONLY ?? 0) | noFollow | (constants.O_NONBLOCK ?? 0); +} + +/** What a contained read returns: the bytes, and the metadata of the same object. */ +export interface ContainedFile { + content: string; + /** From `fstat` on the descriptor that produced `content`, not a later stat. */ + mtimeMs: number; + size: number; + /** + * True when `minMtimeMs` was supplied and this file predates it: the + * descriptor was opened and validated, and the BYTES WERE NEVER READ. + * + * This is what keeps the membership fence cheap. A session-scoped transcript + * directory is never pruned, so it accumulates streams from earlier reviews + * in the same session, and a file whose last write predates the floor cannot + * hold an above-floor record. The caller used to skip those with a + * pathname `stat` and never open them — which is precisely the split this + * module exists to remove. Folding the test into the descriptor keeps both + * properties: one resolution of the name, and no bytes read for a file that + * cannot contribute a record. + */ + stale?: boolean; +} + +/** + * Why a read was refused. Callers that disclose (the cost ledger) need to tell + * "not there" from "there but not readable as evidence" — the second is a + * fact worth printing, the first is the ordinary state of a run that launched + * no agents. + */ +export class ContainedReadError extends Error { + constructor( + message: string, + readonly reason: /** The file is not there. The ordinary state, not a fault. */ + | 'absent' + /** There, and the open refused — a link, a permission, an I/O fault. */ + | 'open-failed' + | 'not-regular' + | 'too-large' + | 'read-failed', + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'ContainedReadError'; + } +} + +/** + * Read one file as a bounded, regular, non-symlinked file — opened once. + * + * Throws `ContainedReadError` for every refusal so a caller can disclose the + * distinction; use {@link readContainedFileOrNull} where absence and refusal + * are the same answer. + */ +export function readContainedFile( + path: string, + maxBytes: number, + opts: { + minMtimeMs?: number; + /** + * The `readSync` this uses. A seam, and only that: the short-read branch + * below is the one piece of this module no fixture can reach from the + * outside, because a fully-present file always comes back in one call — + * so without it, the loop that keeps a half-flushed transcript from + * being reported with an uninitialized buffer tail is unpinnable. + */ + read?: ( + fd: number, + buffer: NodeJS.ArrayBufferView, + offset: number, + length: number, + position: number | null, + ) => number; + } = {}, +): ContainedFile { + let fd: number; + try { + // Windows has no `O_NOFOLLOW`, so without this the open FOLLOWS a leaf + // link and `fstat` then validates the TARGET — a planted ledger link + // inside a genuinely contained directory would parse as entries there. + // `lstat` does not follow on any platform, and refusing is this module's + // documented direction anyway. + if (process.platform === 'win32' && lstatSync(path).isSymbolicLink()) { + throw Object.assign(new Error(`${path} is a symbolic link`), { + code: 'ELOOP', + }); + } + fd = openSync(path, readFlags()); + } catch (err) { + // ELOOP here IS the symlink refusal on POSIX: `O_NOFOLLOW` fails the open + // rather than resolving the link. ENOENT is the ordinary absent case; the + // caller decides which of the two matters to it. + throw new ContainedReadError( + `could not open ${path}: ${(err as Error).message}`, + // ENOENT is absence; every other errno is a refusal. Different reasons, + // so a caller that discloses can ask this type rather than reaching + // into an undocumented `cause.code` chain that routes correctly only by + // accident. + (err as NodeJS.ErrnoException).code === 'ENOENT' + ? 'absent' + : 'open-failed', + { cause: err }, + ); + } + try { + // The descriptor, not the name. This is the whole point: no second + // resolution of the pathname can happen between the check and the read, + // because there is no second resolution at all. + const st = fstatSync(fd); + if (!st.isFile()) { + // A FIFO would block the read forever — a hang, not an error, in a + // command a review is waiting on. A directory or device is not evidence + // either. + throw new ContainedReadError( + `${path} is not a regular file`, + 'not-regular', + ); + } + // Before the size ceiling and before the read: a stale file is skipped + // whatever its size, and skipping it costs one `fstat` on a descriptor + // that is about to be closed. + if (opts.minMtimeMs !== undefined && st.mtimeMs < opts.minMtimeMs) { + return { content: '', mtimeMs: st.mtimeMs, size: 0, stale: true }; + } + if (st.size > maxBytes) { + throw new ContainedReadError( + `${path} is ${st.size} bytes, over the ${maxBytes}-byte ceiling`, + 'too-large', + ); + } + const buf = Buffer.allocUnsafe(st.size); + let off = 0; + // `readSync` returns short reads. Loop to the size `fstat` reported, and + // stop at EOF: a file being appended to while it is read (the harness + // flushes transcripts incrementally) yields fewer bytes than the stat + // promised, which is a partial record, not a fault — `parseTranscript` + // and `parseLineTolerant` already drop a torn last line. + while (off < st.size) { + const n = (opts.read ?? readSync)(fd, buf, off, st.size - off, off); + if (n <= 0) break; + off += n; + } + return { + content: buf.subarray(0, off).toString('utf8'), + mtimeMs: st.mtimeMs, + size: off, + }; + } catch (err) { + if (err instanceof ContainedReadError) throw err; + throw new ContainedReadError( + `could not read ${path}: ${(err as Error).message}`, + 'read-failed', + { cause: err }, + ); + } finally { + closeSync(fd); + } +} + +/** {@link readContainedFile}, with every refusal flattened to `null`. */ +export function readContainedFileOrNull( + path: string, + maxBytes: number, + opts: { minMtimeMs?: number } = {}, +): ContainedFile | null { + try { + return readContainedFile(path, maxBytes, opts); + } catch { + return null; + } +} + +/** + * The RAW bytes of a contained regular file, or null on any refusal. Same + * O_NOFOLLOW + fstat + descriptor read as {@link readContainedFile}, but the + * bytes are captured undecoded — for a sha256 of a diff whose contents may + * not be valid UTF-8 (a UTF-8 round trip changes the hash). A FIFO, link or + * directory refuses instead of hanging. + */ +export function readContainedBytesOrNull( + path: string, + maxBytes: number, +): Buffer | null { + const chunks: Buffer[] = []; + try { + readContainedFile(path, maxBytes, { + read: (fd, buffer, offset, length, position) => { + const n = readSync(fd, buffer, offset, length, position); + if (n > 0) { + const view = buffer as unknown as Uint8Array; + chunks.push(Buffer.from(view.subarray(offset, offset + n))); + } + return n; + }, + }); + } catch { + return null; + } + return Buffer.concat(chunks); +} + +/** + * A path that is not inside the tree it must be inside — or that is reached + * through a link. + * + * Carries a `code` like an errno so it travels through the callers that route + * on one, and deliberately NOT `ENOENT`: absence is the ordinary state of a + * run that launched no agents, while this is a directory that exists and is + * not the one it claims to be. Those two must not collapse. + */ +export class UncontainedPathError extends Error { + readonly code = 'EUNCONTAINED'; + constructor(message: string) { + super(message); + this.name = 'UncontainedPathError'; + } +} + +/** A directory's identity, as captured before a listing and re-checked after it. */ +interface DirIdentity { + dev: number; + ino: number; +} + +/** + * Is `child` inside `parent` (or equal to it), by path shape? + * + * Shape only — the component walk below is what proves no link redirects the + * way there. Both sides are resolved first so `..` cannot smuggle a path out. + */ +function isWithin(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + // An ABSOLUTE `rel` means the two paths share no root at all: on Windows, + // `relative('C:\\root', 'D:\\other')` returns `'D:\\other'`, which is + // neither empty nor `..`-prefixed and would otherwise read as contained. + // POSIX `relative` never returns an absolute path, so this clause only + // bites on the platform where the leaf guard is already weakest. + if (isAbsolute(rel)) return false; + return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..'); +} + +/** + * Why a directory is not usable, when it is not. + * + * `missing` and `uncontained` must stay apart. A directory that does not exist + * yet is the ordinary state of a resumed run before its first agent launches, + * and callers legitimately absorb it; a directory that exists but is reached + * through a link, or is not a directory at all, is a fact a run must not + * quietly proceed past. Collapsing the two would let the second hide inside + * the first's tolerance. + */ +export type ContainedDirResult = + | { ok: true; identity: DirIdentity } + | { ok: false; reason: 'missing' | 'uncontained' }; + +/** + * Validate a directory that must live under `root`, following no links. + * + * Walks every component from `root` down to `dir` with `lstat`, so a symlinked + * ancestor — `subagents -> /elsewhere`, the project dir itself — is refused + * where checking only the final component would pass. `root` must itself be a + * real directory: a run whose whole harness tree is a link has no contained + * evidence to offer. + */ +export function containedDir(root: string, dir: string): ContainedDirResult { + const rootPath = resolve(root); + const dirPath = resolve(dir); + if (!isWithin(rootPath, dirPath)) { + return { ok: false, reason: 'uncontained' }; + } + // Components between root and dir, root itself included: `subagents` is an + // ancestor of `subagents/` and is exactly the link a forged ledger entry + // would ride. + const parts = relative(rootPath, dirPath).split(sep).filter(Boolean); + let current = rootPath; + let last: DirIdentity | null = null; + for (let i = 0; i <= parts.length; i++) { + if (i > 0) current = resolve(current, parts[i - 1] as string); + let st; + try { + st = lstatSync(current); + } catch (err) { + // Absent is not a containment verdict — nothing is there to redirect + // anything. Anything else (EACCES, EIO) is: the path exists in some + // form this process cannot vouch for. + return { + ok: false, + reason: + (err as NodeJS.ErrnoException).code === 'ENOENT' + ? 'missing' + : 'uncontained', + }; + } + // `lstat` does not follow, so a link reports as a link even when it points + // at a perfectly good directory. Both checks matter: a non-directory + // ancestor cannot be walked, and a linked one is the redirect itself. + if (st.isSymbolicLink() || !st.isDirectory()) { + return { ok: false, reason: 'uncontained' }; + } + last = { dev: st.dev, ino: st.ino }; + } + return { ok: true, identity: last as DirIdentity }; +} + +/** + * List a validated directory, then prove it was still the same directory. + * + * `readdirSync` takes a pathname, so the object it lists is resolved anew — + * a swap between {@link containedDir} and this call would list a different + * directory under the name that was validated. Re-`lstat` afterwards and + * compare `dev`/`ino`: a replaced directory is a different inode, and the + * listing is discarded rather than trusted. + * + * Throws whatever `readdirSync` throws — callers already distinguish ENOENT + * ("no agents ran") from a live I/O fault, and flattening that here would + * erase the distinction. A detected swap is reported as ENOENT-shaped + * absence: the safe direction, since the contents were never proven to be the + * harness's. + */ +export function listContainedDir(dir: string, identity: DirIdentity): string[] { + const names = readdirSync(dir); + let after; + try { + after = lstatSync(dir); + } catch (err) { + // It was there a moment ago and cannot be stat'ed now. Not "no agents". + throw new UncontainedPathError( + `${dir} could not be re-validated after listing: ` + + `${(err as Error).message}`, + ); + } + if (after.dev !== identity.dev || after.ino !== identity.ino) { + // A DETECTED swap — the one unambiguous signal of interference this + // re-check exists to produce. Returning `[]` made it indistinguishable + // from a mundane empty directory, so the detection fired and nothing + // downstream ever said so. Callers already route `EUNCONTAINED`. + throw new UncontainedPathError( + `${dir} was replaced between validation and listing`, + ); + } + return names; +} + +/** + * The root every evidence path must sit under: the harness's project + * directory, validated as a real directory. + * + * Returns null when it is absent or is itself a link — in which case nothing + * below it can be read as contained evidence. + */ +export function containedRoot( + projectDir: string, +): + | { ok: true; root: string } + | { ok: false; reason: 'missing' | 'uncontained' } { + const root = resolve(projectDir); + try { + const st = lstatSync(root); + if (st.isSymbolicLink() || !st.isDirectory()) { + return { ok: false, reason: 'uncontained' }; + } + } catch (err) { + // Absent is mundane — a cleaned-up harness tree — and printing the + // containment word for it sends an operator hunting for a planted link + // that does not exist. + return { + ok: false, + reason: + (err as NodeJS.ErrnoException).code === 'ENOENT' + ? 'missing' + : 'uncontained', + }; + } + return { ok: true, root }; +} + +/** + * The directory a file sits in, for callers that hold a leaf path and need its + * parent validated before the leaf is opened. + */ +export function parentDirOf(filePath: string): string { + return parse(resolve(filePath)).dir; +} diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index f192589f5d0..d68c273710f 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -281,7 +281,7 @@ const DIGEST_WINDOW_MS = 5000; export const CHUNK_RE = /\bchunk\s+(\d+)\s+of\s+\d+\b/i; /** The chunk this agent owns, when it was launched to own one. */ -function assignedChunk(rec: AgentRecord): number | null { +export function assignedChunk(rec: AgentRecord): number | null { const m = CHUNK_RE.exec(rec.launchPrompt); return m ? Number(m[1]) : null; } @@ -295,7 +295,10 @@ function assignedChunk(rec: AgentRecord): number | null { * recoverable from the harness's own copy of its launch prompt, in either * topology, without the agent having to claim anything afterwards. */ -function pointedAt(prompt: string, plan: Plan): Array<[number, number]> { +export function pointedAt( + prompt: string, + plan: { chunks: Array<{ id: number; startLine: number; endLine: number }> }, +): Array<[number, number]> { const out: Array<[number, number]> = []; const re = /offset\s*[=:]\s*(\d+)\s*,\s*limit\s*[=:]\s*(\d+)/gi; for (const m of prompt.matchAll(re)) { diff --git a/packages/cli/src/commands/review/lib/deadline.test.ts b/packages/cli/src/commands/review/lib/deadline.test.ts index c22cfc56dc7..23fd67ef022 100644 --- a/packages/cli/src/commands/review/lib/deadline.test.ts +++ b/packages/cli/src/commands/review/lib/deadline.test.ts @@ -28,6 +28,7 @@ import { budgetStopEntry, budgetStopEntryZh, clearBudgetStop, + clearRoundStamps, expectedAdmissionSeconds, expectedRoundSeconds, readBudgetStop, @@ -39,6 +40,7 @@ import { roundCapStopDisclosure, roundCapStopEntry, roundCapStopEntryZh, + stampsCorroborateRoundCap, writeRoundCapStop, stampRound, verifyBudgetExhausted, @@ -886,3 +888,100 @@ describe('verifyBudgetExhausted — the compose floor the verifier answers to', expect(msg).toContain('0 minute(s) remain'); }); }); + +describe('clearRoundStamps — the resume hygiene', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + function plan(): string { + const dir = mkdtempSync(join(tmpdir(), 'deadline-clear-')); + dirs.push(dir); + const p = join(dir, 'plan.json'); + writeFileSync(p, '{}'); + backdatePlan(p); + return p; + } + + it('removes the stamps so the gate falls back to its constant', () => { + const p = plan(); + stampRound(p, 1, NOW_MS - 2_400_000); + expect(expectedRoundSeconds(p, 2, NOW_MS)).toBe(2400); + clearRoundStamps(p); + expect(expectedRoundSeconds(p, 2, NOW_MS)).toBe(DEFAULT_ROUND_SECONDS); + }); + + it('is silent when there is nothing to remove', () => { + expect(() => clearRoundStamps(plan())).not.toThrow(); + }); +}); + +describe('stampsCorroborateRoundCap — the round-cap marker is not self-proving', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + function plan(): string { + const dir = mkdtempSync(join(tmpdir(), 'deadline-cap-')); + dirs.push(dir); + const p = join(dir, 'plan.json'); + writeFileSync(p, '{}'); + backdatePlan(p); + return p; + } + + it('holds when every round 1..cap was stamped as admitted', () => { + const p = plan(); + stampRound(p, 1, NOW_MS - 3_000_000); + stampRound(p, 2, NOW_MS - 2_000_000); + stampRound(p, 3, NOW_MS - 1_000_000); + expect(stampsCorroborateRoundCap(p, 3)).toBe(true); + }); + + it('fails when a claimed round has no stamp', () => { + // The plant shape: a round-cap marker claiming five rounds ran, with + // stamps for two of them. The silence of the missing rounds is exactly + // what the marker buys. + const p = plan(); + stampRound(p, 1, NOW_MS - 3_000_000); + stampRound(p, 2, NOW_MS - 2_000_000); + expect(stampsCorroborateRoundCap(p, 5)).toBe(false); + }); + + it('fails with no stamps at all', () => { + expect(stampsCorroborateRoundCap(plan(), 5)).toBe(false); + }); + + it('fails on a cap that is not a positive integer', () => { + const p = plan(); + stampRound(p, 1, NOW_MS - 100); + expect(stampsCorroborateRoundCap(p, undefined)).toBe(false); + expect(stampsCorroborateRoundCap(p, 0)).toBe(false); + expect(stampsCorroborateRoundCap(p, 2.5)).toBe(false); + }); + + it('corroborates rounds an earlier resume already cleared — the watermark', () => { + // A stop can outlive several resumes; each resume's `clearRoundStamps` + // deletes the stamps that corroborate its early rounds. Without the + // watermark a later resume, seeing only the post-wipe stamps, drops the + // genuine stop. cap=3: rounds 1-2 stamped, then cleared (resume 1's + // hygiene), then round 3 stamped (the continuation) — corroboration must + // still hold on resume 2. + const p = plan(); + stampRound(p, 1, NOW_MS - 3_000_000); + stampRound(p, 2, NOW_MS - 2_000_000); + clearRoundStamps(p); // resume 1: watermark := 2, stamps gone + stampRound(p, 3, NOW_MS - 1_000_000); + expect(stampsCorroborateRoundCap(p, 3)).toBe(true); + // And a plant still fails: no stamp and no watermark for round 4. + expect(stampsCorroborateRoundCap(p, 4)).toBe(false); + }); + + it('reads stamps through the run-epoch fence like every other reader', () => { + // A PREVIOUS run's stamps must not corroborate THIS run's cap: the + // fence drops them, and the cap fails. + const p = plan(); + stampRound(p, 1, PLAN_CAPTURED_MS - 28_800_000); + expect(stampsCorroborateRoundCap(p, 1)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/review/lib/deadline.ts b/packages/cli/src/commands/review/lib/deadline.ts index 177d3a20fbc..85803fbbb2e 100644 --- a/packages/cli/src/commands/review/lib/deadline.ts +++ b/packages/cli/src/commands/review/lib/deadline.ts @@ -44,6 +44,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { parsePositiveIntegerEnv } from '@qwen-code/qwen-code-core'; import { promptRecordDir, runEpochMs } from './prompt-record.js'; +import { readContainedFileOrNull, MAX_LEDGER_BYTES } from './contained-read.js'; /** Unix seconds at which the review process will be killed. Set by CI. */ export const DEADLINE_ENV = 'QWEN_REVIEW_DEADLINE_EPOCH'; @@ -150,6 +151,13 @@ interface RoundStamp { } const STAMPS_FILE = 'budget-rounds.json'; +// The highest round whose admission stamp a resume-hygiene wipe has already +// cleared. A round-cap stop can outlive several resumes, but each resume's +// `clearRoundStamps` deletes the stamps that corroborate it; without a +// record of what was cleared, `stampsCorroborateRoundCap` on a LATER resume +// sees only the rounds admitted AFTER the wipe and drops the genuine stop. +// The watermark carries the cleared rounds forward. +const STAMP_WATERMARK_FILE = 'budget-rounds-watermark.json'; const STOP_FILE = 'budget-stop.json'; // The run-epoch fence is shared with every other per-run artifact (the @@ -663,11 +671,16 @@ export function writeBudgetStop( */ export function readBudgetStopUnfenced(planPath: string): BudgetStop | null { try { - const raw = readFileSync( + // Contained: a FIFO planted at the predictable `budget-stop.json` path + // would block a bare `readFileSync` open forever — the hang this + // command's containment exists to abolish. `null` (absent, or any + // non-regular occupant) reads as "no stop", the safe direction. + const contained = readContainedFileOrNull( join(promptRecordDir(planPath), STOP_FILE), - 'utf8', + MAX_LEDGER_BYTES, ); - const parsed = JSON.parse(raw) as unknown; + if (contained === null) return null; + const parsed = JSON.parse(contained.content) as unknown; if ( typeof parsed !== 'object' || parsed === null || @@ -718,6 +731,92 @@ export function clearBudgetStop(planPath: string): void { } } +/** + * Remove the admission stamps beside the prompt records. Called by the + * `--resume` path in `fetch-pr`: the span from the interrupted attempt's last + * stamp to the continuation's first admission contains the death gap and the + * retry backoff, which would price a "round" at hours and refuse round 1 of a + * fresh deadline. Without stamps the gate falls back to its conservative + * constant — the failure direction is an early stop with a disclosure, never + * a kill-before-compose. Errors are swallowed like `clearBudgetStop`'s. + */ +function readStampWatermark(planPath: string): number { + try { + const raw = readFileSync( + join(promptRecordDir(planPath), STAMP_WATERMARK_FILE), + 'utf8', + ); + const v = (JSON.parse(raw) as { round?: unknown }).round; + return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : 0; + } catch { + return 0; + } +} + +export function clearRoundStamps(planPath: string): void { + try { + // Carry the cleared rounds forward before deleting them: the highest + // round stamped so far, folded into any prior watermark, so a round-cap + // stop that survives THIS resume can still be corroborated on the next + // one (whose stamps will only cover rounds admitted after this wipe). + const dir = promptRecordDir(planPath); + const maxStamped = readRoundStamps(planPath).reduce( + (m, st) => (typeof st.round === 'number' ? Math.max(m, st.round) : m), + readStampWatermark(planPath), + ); + if (maxStamped > 0) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, STAMP_WATERMARK_FILE), + JSON.stringify({ round: maxStamped }), + ); + } + rmSync(join(dir, STAMPS_FILE), { force: true }); + } catch { + // Best-effort: stale stamps only make the gate MORE conservative. + } +} + +/** + * Whether the admission stamps corroborate a round-cap stop: every round + * 1..cap was stamped as admitted. + * + * A round-cap marker is attempt-1-writable like everything beside the prompt + * records, and a planted one is relayed as genuine — compose-review posts a + * false "did not converge within the round cap" disclosure and caps the + * verdict, and the round builder refuses further rounds once ANY stop marker + * stands. The attacker pays only the cap a genuine stop would also impose, + * and buys the silence of exactly the audit rounds that would have found the + * malicious hunk. The stamps raise the bar — a plant that writes only the + * marker fails here, and a genuine stop carries one stamp per admitted + * round — but they are NOT a forgery proof: they live beside the marker + * under the same permissions, so a planter that writes the marker can write + * matching stamps and self-corroborate. Closing that takes the run state + * outside the attempt-1 blast radius. A cap with no stamp for every round + * it claims is still refused. Callers that clear the stamps (the resume + * hygiene) must check BEFORE clearing, or this reads false for a genuine + * stop too. + */ +export function stampsCorroborateRoundCap( + planPath: string, + cap: number | undefined, +): boolean { + if (typeof cap !== 'number' || !Number.isInteger(cap) || cap < 1) { + return false; + } + const stamped = new Set( + readRoundStamps(planPath).map((s) => s.round), + ); + // Rounds already cleared by an earlier resume's hygiene are corroborated + // by the watermark — otherwise a stop that outlives one resume loses the + // stamps that vouch for its early rounds and is dropped on the next. + const watermark = readStampWatermark(planPath); + for (let round = 1; round <= cap; round++) { + if (!stamped.has(round) && round > watermark) return false; + } + return true; +} + /** * The refusal, spelled as the termination rule it is. Printed to stderr by * `agent-prompt` alongside exit code 4; the disclosure sentence matches the diff --git a/packages/cli/src/commands/review/lib/diff-flags.test.ts b/packages/cli/src/commands/review/lib/diff-flags.test.ts index 4b22a2add1a..301ccc0e1c6 100644 --- a/packages/cli/src/commands/review/lib/diff-flags.test.ts +++ b/packages/cli/src/commands/review/lib/diff-flags.test.ts @@ -11,7 +11,11 @@ // asserted where they are declared. import { describe, it, expect } from 'vitest'; -import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './diff-flags.js'; +import { + PINNED_DIFF_CONFIG, + PINNED_DIFF_FLAGS, + NULL_DEVICE, +} from './diff-flags.js'; describe('the pinned diff config', () => { const config = PINNED_DIFF_CONFIG.join(' '); @@ -32,6 +36,20 @@ describe('the pinned diff config', () => { expect(config).toContain('diff.suppressBlankEmpty=false'); }); + it('pins core.fsmonitor off — the probe config that runs code', () => { + // `core.fsmonitor` names a command git executes on index refresh; the + // resume ruling's probes run where attempt 1 wrote the config, so an + // unpinned probe is a code-execution entrance. + expect(config).toContain('core.fsmonitor=false'); + }); + + it('points core.attributesFile at the null device — planted rules shape bytes', () => { + // A `-diff` attribute collapses hunks to `Binary files differ` in the + // derived bytes; the lookup is pinned away, and per-dir info/attributes + // files are refused by the resume ruling's own probes. + expect(config).toContain(`core.attributesFile=${NULL_DEVICE}`); + }); + it('passes every pin as a -c pair, so none can be read as a path', () => { expect(PINNED_DIFF_CONFIG.length % 2).toBe(0); for (let i = 0; i < PINNED_DIFF_CONFIG.length; i += 2) { diff --git a/packages/cli/src/commands/review/lib/diff-flags.ts b/packages/cli/src/commands/review/lib/diff-flags.ts index bcb4f6a9e59..ff8d2974107 100644 --- a/packages/cli/src/commands/review/lib/diff-flags.ts +++ b/packages/cli/src/commands/review/lib/diff-flags.ts @@ -13,6 +13,17 @@ // would simply report fewer chunks. Keeping the list in two places invites // exactly that drift, so it lives here. +/** + * The name git accepts for "the empty side" of a diff. + * + * `git diff --no-index -- ` is how a file git does not track gets + * rendered as a new file without writing to the index. Git special-cases both + * spellings in `diff-no-index.c`'s `get_mode()` rather than stat-ing them, but + * only `nul` is special-cased on native Windows — so pick by platform instead of + * betting the Windows CI leg on which branch of that function is compiled in. + */ +export const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; + /** * Config overrides that have no command-line equivalent. * @@ -35,6 +46,21 @@ export const PINNED_DIFF_CONFIG: readonly string[] = [ 'diff.suppressBlankEmpty=false', '-c', 'core.quotePath=false', + // `core.fsmonitor` names a COMMAND git executes on index refresh — config + // that runs code. The resume ruling's probes run in a process holding the + // session env after attempt 1 (the reviewed PR's own code) has written the + // config, so an unpinned probe is a code-execution entrance, not a + // rendering knob. + '-c', + 'core.fsmonitor=false', + // Attributes shape the derived bytes — a planted `-diff` rule collapses + // hunks to `Binary files differ` — and the lookup reads the git dirs' + // `info/attributes` plus the user's configured file, all attempt-1- + // writable. Point the lookup at the null device; per-tree `.gitattributes` + // (part of the diff itself) still applies. The per-dir `info/attributes` + // files are separately probed by the resume ruling and refuse it. + '-c', + `core.attributesFile=${NULL_DEVICE}`, ]; /** @@ -63,17 +89,6 @@ export const PINNED_DIFF_FLAGS: readonly string[] = [ '--submodule=short', ]; -/** - * The name git accepts for "the empty side" of a diff. - * - * `git diff --no-index -- ` is how a file git does not track gets - * rendered as a new file without writing to the index. Git special-cases both - * spellings in `diff-no-index.c`'s `get_mode()` rather than stat-ing them, but - * only `nul` is special-cased on native Windows — so pick by platform instead of - * betting the Windows CI leg on which branch of that function is compiled in. - */ -export const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; - /** * Read every pathspec as a plain name. * diff --git a/packages/cli/src/commands/review/lib/git.ts b/packages/cli/src/commands/review/lib/git.ts index 74a01fc9e45..501dedbf187 100644 --- a/packages/cli/src/commands/review/lib/git.ts +++ b/packages/cli/src/commands/review/lib/git.ts @@ -9,7 +9,8 @@ // across platforms. import { execFileSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; /** Deadline for a single `git` invocation. Generous; a hang must still end. */ const GIT_TIMEOUT_MS = 120_000; @@ -33,7 +34,17 @@ const GIT_TIMEOUT_MS = 120_000; function gitOpts() { return { timeout: GIT_TIMEOUT_MS, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + // Replace refs rewrite object resolution for diff / merge-base / + // status while `rev-parse HEAD` stays honest, so a planted + // `refs/replace/` serves sanitized objects to every probe + // the resume ruling runs. The review commands never create or consult + // replace refs of their own; pinning them out for every wrapper is + // strictly subtractive. + GIT_NO_REPLACE_OBJECTS: '1', + }, }; } @@ -220,6 +231,20 @@ export function releaseWorktree(worktreePath: string): WorktreeRelease { * `execFileSync`'s 1 MB `maxBuffer` default, so any diff past ~1 MB dies with * ENOBUFS rather than returning a short read. Diff capture uses this instead. */ +/** + * Like {@link gitRaw} but feeds `input` on stdin and returns raw stdout — + * no UTF-8 decode, no CRLF normalization. For byte-exact paths in and SHA + * bytes out (`hash-object --stdin-paths` over raw `ls-tree -z` paths). + */ +export function gitRawWithInput(input: Buffer, args: string[]): Buffer { + return execFileSync('git', args, { + ...gitOpts(), + maxBuffer: 512 * 1024 * 1024, + input, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + export function gitRaw(...args: string[]): Buffer { return execFileSync('git', args, { ...gitOpts(), @@ -257,3 +282,100 @@ export function gitRawTolerateDiff(...args: string[]): Buffer { throw err; } } + +/** + * Git config keys whose VALUES git executes (directly or through a shell) + * during the read-only commands the resume ruling runs — status, diff, + * fetch, merge-base, ls-files, ls-tree, rev-parse — plus the two include + * keys that pull in config this screen cannot read (undecidable, so they + * fail it), and the redirect keys that re-route a fetch to another remote. + * The pattern family mirrors `daemon-git-worktree-guard`'s blocklist, cut + * to the commands this module actually issues. Matched against lowercased + * keys: git folds config key case. + */ +const RESUME_UNTRUSTED_CONFIG_PATTERNS: RegExp[] = [ + /^core\.(askpass|editor|fsmonitor|pager|sshcommand|gitproxy|hookspath)$/, + /^credential\./, + /^diff\..+\.(command|textconv)$/, + /^diff\.external$/, + /^difftool\./, + /^filter\./, + /^gpg\.(.+\.)?program$/, + /^merge\..+\.driver$/, + /^mergetool\./, + /^pager\./, + /^sequence\.editor$/, + /^uploadpack\.packobjectshook$/, + /^gc\.recentobjectshook$/, + /^interactive\.difffilter$/, + /^remote\..+\.(proxy|receivepack|uploadpack|vcs)$/, + /^url\..+\.insteadof$/, + /^protocol\.ext\.allow$/, + /^ssh\.variant$/, + /^include\.path$/, + /^includeif\..+\.path$/, +]; + +/** + * The repo-local (and worktree-scope) config keys the resume ruling must not + * run git under — the scopes the reviewed PR's own code can write. Returns + * the matching keys, `[]` for a clean config, and `null` when the config + * could not be read at all (the caller fails closed: an unreadable screen + * clears nothing). System and global scopes are deliberately NOT screened: + * they are the operator's (a global `credential.helper` is near-universal), + * and refusing on them would refuse every resume on an ordinary machine — + * while the demonstrated plants all wrote the repository's own config. + */ +export function untrustedLocalConfig(worktree: string): string[] | null { + const found = new Set(); + let sawAnyScope = false; + for (const scope of ['--local', '--worktree'] as const) { + // `--list -z` emits `key\nvalue\0` per entry: the key is everything up + // to the FIRST newline, so a subsection name containing `=` + // (`[diff "a=b"] command=…` → `diff.a=b.command`) parses whole. Splitting + // an `=`-joined `--list` line at the first `=` truncated such a key to a + // non-matching prefix and let the command-executing entry through. + const listing = gitOpt('-C', worktree, 'config', scope, '--list', '-z'); + if (listing === null) continue; + sawAnyScope = true; + for (const entry of listing.split('\0')) { + if (entry === '') continue; + const nl = entry.indexOf('\n'); + const key = (nl < 0 ? entry : entry.slice(0, nl)).toLowerCase(); + if (RESUME_UNTRUSTED_CONFIG_PATTERNS.some((re) => re.test(key))) { + found.add(key); + } + } + } + // `--worktree` may refuse (no worktree, old git); `--local` refusing too + // means the repository config itself could not be read — fail closed. + if (!sawAnyScope) return null; + return [...found].sort(); +} + +/** + * Executable non-sample entries in the repository's hooks directory. A hook + * fires on the very commands the ruling runs (`reference-transaction` on the + * base fetch's ref update, `post-index-change` on the status refresh), so a + * planted one is code execution mid-ruling. `null` when the directory could + * not be read for a reason other than absence — unreadable fails closed. + */ +export function plantedHooks(hooksDir: string): string[] | null { + let names: string[]; + try { + names = readdirSync(hooksDir); + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null; + } + const planted: string[] = []; + for (const name of names) { + if (name.endsWith('.sample')) continue; + try { + const st = statSync(join(hooksDir, name)); + if (st.isFile() && (st.mode & 0o111) !== 0) planted.push(name); + } catch { + planted.push(name); // unreadable entry: cannot be ruled benign + } + } + return planted.sort(); +} diff --git a/packages/cli/src/commands/review/lib/prompt-record.ts b/packages/cli/src/commands/review/lib/prompt-record.ts index c7d6c649251..108e19a372b 100644 --- a/packages/cli/src/commands/review/lib/prompt-record.ts +++ b/packages/cli/src/commands/review/lib/prompt-record.ts @@ -34,6 +34,7 @@ import { statSync, writeFileSync, } from 'node:fs'; +import { createHash } from 'node:crypto'; import { dirname, join, basename, resolve } from 'node:path'; import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js'; @@ -159,6 +160,20 @@ export function writeFindingsFile( try { mkdirSync(promptRecordDir(planPath), { recursive: true }); writeFileSync(p, content); + // The content digest, recorded beside the list at WRITE time. The + // recovery path refuses a findings file whose bytes no longer hash to + // it: the round-7 corroboration bound only the NAME a certified agent + // was pointed at, and an overwrite landing after the certified read + // relayed forged cumulative state under a genuinely corroborated path. + // The sidecar lives in the same attempt-1-writable dir — a planter who + // rewrites both still forges the pair — so this binds CONTENT to the + // builder's write against the demonstrated post-read overwrite, and the + // residual (a two-file forgery) is disclosed in the design doc rather + // than pretended away. + writeFileSync( + `${p}.sha256`, + createHash('sha256').update(content).digest('hex'), + ); } catch (err) { // A read-only tmp dir must not stop a review being BUILT — and it must // not send a whole round pointing at a file that does not exist either: @@ -241,7 +256,20 @@ export function recordPrompt( try { const dir = promptRecordDir(planPath); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, fileFor(key)), prompt); + const file = join(dir, fileFor(key)); + writeFileSync(file, prompt); + // A content digest beside the record, same shape as `writeFindingsFile`. + // Recovery on a resumed run refuses a record whose bytes no longer hash + // to it: the pairing and the whole certification bar derive their + // requirements from these `.txt` bytes (a line-DELETION keeps pairing + // but vacates the findings-pointer and diff-read floors), and the record + // dir is attempt-1-writable between attempts. The sidecar shares the dir + // — a two-file rewrite is the disclosed residual — so this binds the + // trim-without-sidecar-rewrite shape, not the whole class. + writeFileSync( + `${file}.sha256`, + createHash('sha256').update(prompt).digest('hex'), + ); } catch { // A read-only tmp dir must not stop a review from being *built*. The check // that reads these back reports "no prompt was recorded" and fails there, @@ -266,6 +294,7 @@ export function recordPrompt( export function readRecordedPrompts( planPath: string, sinceMs?: number, + requireDigest = false, ): Map { const out = new Map(); const dir = promptRecordDir(planPath); @@ -294,7 +323,26 @@ export function readRecordedPrompts( } const file = join(dir, name); if (sinceMs !== undefined && statSync(file).mtimeMs < sinceMs) continue; - out.set(key, readFileSync(file, 'utf8')); + const body = readFileSync(file, 'utf8'); + // On the recovery path the records are read as HISTORY across the + // attempt gap the reviewed PR's code can write in. A record whose + // sidecar is missing or whose bytes no longer hash to it cannot be + // trusted to define the certification bar — skip it, so its key falls + // to `missingKeys` and is re-owed rather than certified on trimmed + // requirements. The live pipeline (no attacker gap within a run) + // reads with `requireDigest` false and is unchanged. + if (requireDigest) { + let recorded: string; + try { + recorded = readFileSync(`${file}.sha256`, 'utf8').trim(); + } catch { + continue; + } + if (createHash('sha256').update(body).digest('hex') !== recorded) { + continue; + } + } + out.set(key, body); } catch { /* raced with a cleanup */ } diff --git a/packages/cli/src/commands/review/lib/resume.test.ts b/packages/cli/src/commands/review/lib/resume.test.ts new file mode 100644 index 00000000000..5d0097377c0 --- /dev/null +++ b/packages/cli/src/commands/review/lib/resume.test.ts @@ -0,0 +1,711 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The resume ruling, check by check. Each test breaks exactly one link in +// the chain and expects the ruling to name THAT link — the reason is what an +// operator acts on, so a later check must not shadow an earlier one. + +import { describe, it, expect } from 'vitest'; +import { assessResume, type ResumeProbes } from './resume.js'; +import { RESUME_MAX } from './run-ledger.js'; + +const SHA = 'f00df00df00df00d'; +const DIFF_SHA = 'a'.repeat(64); + +const WT = '.qwen/tmp/review-pr-42'; + +const MERGE_BASE = 'baseb45eb45e'; +const DIFF_PATH = '.qwen/tmp/qwen-review-pr-42-diff.txt'; + +const prev = () => ({ + prNumber: '42', + fetchedSha: SHA, + diffSha256: DIFF_SHA, + worktreePath: WT, + ownerRepo: 'acme/widgets', + host: null, + diffPathAbsolute: DIFF_PATH, + mergeBaseSha: MERGE_BASE, + baseRefName: 'main', + headRefName: 'feat/x', + baseFetchFailed: false, + auditSince: '2026-01-01T00:00:00.000Z', + fetchedAt: '2026-01-01T00:00:00.000Z', + chunks: [{ id: 1, startLine: 1, endLine: 5, lines: 5, chars: 10 }], + prDescriptionHasHan: false, + isCrossRepository: false, + diffStat: { files: 2, additions: 7, deletions: 3 }, +}); + +const probes = (over: Partial = {}): ResumeProbes => ({ + prNumber: '42', + ownerRepo: 'acme/widgets', + host: null, + worktreeHeadSha: SHA, + worktreeIdentityMatches: true, + worktreeClean: true, + diffSha256OnDisk: DIFF_SHA, + diffSha256Rederived: DIFF_SHA, + rederivedDiffEmpty: false, + worktreePath: WT, + diffPathAbsolute: DIFF_PATH, + liveHeadSha: SHA, + liveBaseRefName: 'main', + liveHeadRefName: 'feat/x', + mergeBaseSha: MERGE_BASE, + chunksTile: true, + planReportMatches: true, + repositoryContextMatches: true, + prDescriptionHasHan: false, + isCrossRepository: false, + diffStat: { files: 2, additions: 7, deletions: 3 }, + collapsedRederived: false, + nowMs: Date.now(), + reportMtimeMs: Date.parse('2026-01-01T00:00:00.000Z'), + graftsAbsent: true, + shallowAbsent: true, + repoConfigClean: true, + ledgerEntryCount: 1, + markerFileAbsent: false, + resumeCount: 0, + requestedEffort: null, + ...over, +}); + +describe('assessResume — the empty-string shapes, named by the FIRST break', () => { + // Unreachable from today's writers, which is the point: the guards exist so + // a hand-edited or externally rewritten report is diagnosed by the artifact + // that is actually broken. The suites otherwise pass `undefined`/`null`, + // which take the `typeof` branches and leave these clauses free to be + // deleted. + it('an empty fetchedSha is a broken REPORT, not a moved worktree', () => { + expect(assessResume({ ...prev(), fetchedSha: '' }, probes())).toEqual({ + ok: false, + reason: 'no-report', + }); + }); + + it('an empty diffSha256 is a missing hash, not a mismatched one', () => { + expect(assessResume({ ...prev(), diffSha256: '' }, probes())).toEqual({ + ok: false, + reason: 'no-diff-hash', + }); + }); + + it('an empty recorded effort reads as the default, not as a mismatch', () => { + // The documented acceptance: nothing recorded means nothing to disagree + // with, so an explicit `high` matches the default a resumed run inherits. + expect( + assessResume( + { ...prev(), effort: '' }, + probes({ requestedEffort: 'high' }), + ), + ).toEqual({ ok: true }); + }); +}); + +describe('assessResume', () => { + it('resumes when every probe matches the previous report', () => { + expect(assessResume(prev(), probes())).toEqual({ ok: true }); + }); + + it('refuses with no-report when there is nothing to resume', () => { + expect(assessResume(null, probes())).toEqual({ + ok: false, + reason: 'no-report', + }); + }); + + it('refuses with no-report when the report has no fetchedSha', () => { + expect(assessResume({ prNumber: '42' }, probes())).toEqual({ + ok: false, + reason: 'no-report', + }); + }); + + it("refuses with pr-mismatch on another PR's report at the same path", () => { + expect(assessResume({ ...prev(), prNumber: '999' }, probes())).toEqual({ + ok: false, + reason: 'pr-mismatch', + }); + }); + + it('refuses with effort-mismatch when an explicit effort differs', () => { + // A different effort is a request for different work; the fresh + // fall-through honors it instead of silently pinning the old level. + expect( + assessResume( + { ...prev(), effort: 'medium' }, + probes({ requestedEffort: 'high' }), + ), + ).toEqual({ ok: false, reason: 'effort-mismatch' }); + }); + + it('resumes when the explicit effort matches the recorded one', () => { + expect( + assessResume( + { ...prev(), effort: 'medium' }, + probes({ requestedEffort: 'medium' }), + ), + ).toEqual({ ok: true }); + }); + + it('reads a plan with no recorded effort as the default high', () => { + expect(assessResume(prev(), probes({ requestedEffort: 'high' }))).toEqual({ + ok: true, + }); + expect(assessResume(prev(), probes({ requestedEffort: 'medium' }))).toEqual( + { ok: false, reason: 'effort-mismatch' }, + ); + }); + + it('never refuses on effort when none was passed', () => { + expect(assessResume({ ...prev(), effort: 'medium' }, probes())).toEqual({ + ok: true, + }); + }); + + it('refuses with no-diff-hash on a pre-diffSha256 report', () => { + expect( + assessResume({ ...prev(), diffSha256: undefined }, probes()), + ).toEqual({ ok: false, reason: 'no-diff-hash' }); + }); + + it('refuses with no-diff-hash when the run captured no diff', () => { + expect(assessResume({ ...prev(), diffSha256: null }, probes())).toEqual({ + ok: false, + reason: 'no-diff-hash', + }); + }); + + it('refuses with worktree-gone when the worktree cannot answer rev-parse', () => { + expect(assessResume(prev(), probes({ worktreeHeadSha: null }))).toEqual({ + ok: false, + reason: 'worktree-gone', + }); + }); + + it('refuses with worktree-sha-mismatch when the worktree moved', () => { + expect(assessResume(prev(), probes({ worktreeHeadSha: 'other' }))).toEqual({ + ok: false, + reason: 'worktree-sha-mismatch', + }); + }); + + it('refuses with diff-hash-mismatch when the diff bytes changed', () => { + // The content key: input that changed re-runs, by construction. + expect( + assessResume(prev(), probes({ diffSha256OnDisk: 'b'.repeat(64) })), + ).toEqual({ ok: false, reason: 'diff-hash-mismatch' }); + }); + + it('names a missing diff capture apart from a changed one', () => { + // Local state loss and upstream input change are different facts. + expect(assessResume(prev(), probes({ diffSha256OnDisk: null }))).toEqual({ + ok: false, + reason: 'diff-unreadable', + }); + }); + + it('refuses with worktree-dirty on uncommitted changes at the right SHA', () => { + // This pipeline's own probe and build/test agents mutate worktrees; a + // death between an apply and its revert leaves exactly this state, and + // the HEAD SHA plus the diff hash both still match. + expect(assessResume(prev(), probes({ worktreeClean: false }))).toEqual({ + ok: false, + reason: 'worktree-dirty', + }); + }); + + it('treats an unrunnable cleanliness probe as dirty', () => { + expect(assessResume(prev(), probes({ worktreeClean: null }))).toEqual({ + ok: false, + reason: 'worktree-dirty', + }); + }); + + it('reports the FIRST broken link when several are broken at once', () => { + // The reason is what an operator acts on, so a later check must not + // shadow an earlier one — a test that breaks exactly one link is by + // construction insensitive to that ordering. + expect( + assessResume( + prev(), + probes({ + worktreeHeadSha: 'other', + worktreeClean: false, + diffSha256OnDisk: null, + liveHeadSha: 'moved', + resumeCount: 99, + }), + ), + ).toEqual({ ok: false, reason: 'worktree-sha-mismatch' }); + expect( + assessResume( + prev(), + probes({ + worktreeClean: false, + diffSha256OnDisk: null, + liveHeadSha: 'moved', + resumeCount: 99, + }), + ), + ).toEqual({ ok: false, reason: 'worktree-dirty' }); + expect( + assessResume( + prev(), + probes({ + diffSha256OnDisk: null, + liveHeadSha: 'moved', + resumeCount: 99, + }), + ), + ).toEqual({ ok: false, reason: 'diff-unreadable' }); + expect( + assessResume(prev(), probes({ liveHeadSha: 'moved', resumeCount: 99 })), + ).toEqual({ ok: false, reason: 'head-moved' }); + }); + + it('refuses with head-moved when the live head advanced', () => { + expect(assessResume(prev(), probes({ liveHeadSha: 'newhead' }))).toEqual({ + ok: false, + reason: 'head-moved', + }); + }); + + it('does NOT refuse on an unreachable forge — the content checks pin it', () => { + expect(assessResume(prev(), probes({ liveHeadSha: null }))).toEqual({ + ok: true, + }); + }); + + it('refuses with resume-cap at the marker limit', () => { + expect(assessResume(prev(), probes({ resumeCount: RESUME_MAX }))).toEqual({ + ok: false, + reason: 'resume-cap', + }); + }); + + it('still resumes one short of the cap', () => { + expect( + assessResume(prev(), probes({ resumeCount: RESUME_MAX - 1 })), + ).toEqual({ ok: true }); + }); +}); + +describe('assessResume — resume state is untrusted where the reviewed code ran', () => { + // In CI the report, the diff file and the worktree all sit on a disk the + // reviewed PR's own code wrote during attempt 1 (yolo-mode agents, no + // sandbox). Self-consistency between two attacker-writable operands + // proves nothing; the terms below come from the forge and the object + // store instead. + + it('refuses a forged-but-consistent diff pair — the re-derived hash disagrees', () => { + // The attacker rewrites the diff file AND patches diffSha256 to match. + const doctored = 'b'.repeat(64); + expect( + assessResume( + { ...prev(), diffSha256: doctored }, + probes({ diffSha256OnDisk: doctored }), + ), + ).toEqual({ ok: false, reason: 'diff-rederive-mismatch' }); + }); + + it('refuses when the diff cannot be re-derived at all', () => { + expect(assessResume(prev(), probes({ diffSha256Rederived: null }))).toEqual( + { ok: false, reason: 'diff-underivable' }, + ); + }); + + it('refuses a worktreePath this run did not derive', () => { + expect( + assessResume({ ...prev(), worktreePath: '/tmp/evil' }, probes()), + ).toEqual({ ok: false, reason: 'worktree-path-mismatch' }); + }); + + it('refuses a report with NO worktreePath — routing needs the field', () => { + const { worktreePath: _dropped, ...rest } = prev(); + expect(assessResume(rest, probes())).toEqual({ + ok: false, + reason: 'worktree-path-mismatch', + }); + }); + + it('refuses a forged emptyDiff — the gate must not pass by absence', () => { + expect(assessResume({ ...prev(), emptyDiff: true }, probes())).toEqual({ + ok: false, + reason: 'empty-diff-mismatch', + }); + }); + + it('accepts a GENUINE empty diff recorded as one', () => { + expect( + assessResume( + { ...prev(), emptyDiff: true }, + probes({ rederivedDiffEmpty: true }), + ), + ).toEqual({ ok: true }); + }); +}); + +describe('assessResume — every report field the pipeline consumes is compared', () => { + // The report sits on a disk attempt 1 could write; a field the ruling + // does not compare is a field the attacker chooses. Each test forges ONE + // field and expects the refusal that names it. + + it('refuses a forged ownerRepo', () => { + expect( + assessResume({ ...prev(), ownerRepo: 'evil/repo' }, probes()), + ).toEqual({ ok: false, reason: 'owner-repo-mismatch' }); + }); + + it('refuses a forged host', () => { + expect( + assessResume({ ...prev(), host: 'evil.example.com' }, probes()), + ).toEqual({ ok: false, reason: 'owner-repo-mismatch' }); + }); + + it('refuses a report whose host disagrees with the invocation host', () => { + expect(assessResume(prev(), probes({ host: 'ghe.example.com' }))).toEqual({ + ok: false, + reason: 'owner-repo-mismatch', + }); + }); + + it('reads an absent recorded host as github.com', () => { + const { host: _dropped, ...rest } = prev(); + expect(assessResume(rest, probes())).toEqual({ ok: true }); + }); + + it('refuses a recorded effort no writer emits', () => { + expect(assessResume({ ...prev(), effort: 'turbo' }, probes())).toEqual({ + ok: false, + reason: 'effort-corrupt', + }); + }); + + it('refuses a relinked worktree BEFORE trusting any of its answers', () => { + // Broken identity AND a moved head: identity names the first fact, + // because the sha answer came from wherever the pointer was relinked. + expect( + assessResume( + prev(), + probes({ worktreeIdentityMatches: false, worktreeHeadSha: 'other' }), + ), + ).toEqual({ ok: false, reason: 'worktree-identity-mismatch' }); + }); + + it('refuses a shallow boundary — severed ancestry bends the merge base', () => { + expect(assessResume(prev(), probes({ shallowAbsent: false }))).toEqual({ + ok: false, + reason: 'shallow-present', + }); + }); + + it('refuses command-executing repo-local config — the probes run under it', () => { + expect(assessResume(prev(), probes({ repoConfigClean: false }))).toEqual({ + ok: false, + reason: 'repo-config-untrusted', + }); + }); + + it('refuses an absent resume marker beside a ledger of two or more sessions', () => { + // A same-session resume appends no ledger entry, so the backstop's + // slice(1) undercounts the cap by one when the marker is deleted; any + // run that resumed wrote the marker, so its absence with 2+ ledger + // sessions is the deleted-bookkeeping tamper. + expect( + assessResume( + prev(), + probes({ markerFileAbsent: true, ledgerEntryCount: 2 }), + ), + ).toEqual({ ok: false, reason: 'ledger-absent' }); + }); + + it('does NOT refuse an absent marker on a first resume (one ledger session)', () => { + // ledgerEntryCount 1 with no marker yet is the legitimate first resume. + expect( + assessResume( + prev(), + probes({ markerFileAbsent: true, ledgerEntryCount: 1 }), + ), + ).toEqual({ ok: true }); + }); + + it('refuses a valid report beside an EMPTY session ledger', () => { + // The deleted-bookkeeping pair: both cap counters read zero through it + // and the resume chain uncaps. fetch-pr appends the original session's + // entry in the same breath that writes the report, so absence is a + // tampered pair, not a fresh run. + expect(assessResume(prev(), probes({ ledgerEntryCount: 0 }))).toEqual({ + ok: false, + reason: 'ledger-absent', + }); + }); + + it('refuses a fetchedAt far after the report file existed — the forward shift', () => { + // Two-field self-consistency is the forger's to arrange; the file's own + // mtime is the writer's. A recorded time an hour past it is a shift + // that would blind the bypass-write audit to every earlier write. + expect( + assessResume( + prev(), + probes({ + reportMtimeMs: Date.parse('2026-01-01T00:00:00.000Z') - 3_600_000, + }), + ), + ).toEqual({ ok: false, reason: 'window-corrupt' }); + }); + + it('refuses when the report cannot be statted — nothing corroborates the window', () => { + expect(assessResume(prev(), probes({ reportMtimeMs: null }))).toEqual({ + ok: false, + reason: 'window-corrupt', + }); + }); + + it('refuses when grafts could redirect the re-derivation', () => { + expect(assessResume(prev(), probes({ graftsAbsent: false }))).toEqual({ + ok: false, + reason: 'grafts-present', + }); + }); + + it('refuses a forged mergeBaseSha', () => { + expect( + assessResume({ ...prev(), mergeBaseSha: 'deadbeef'.repeat(5) }, probes()), + ).toEqual({ ok: false, reason: 'merge-base-mismatch' }); + }); + + it('refuses a report with no recorded mergeBaseSha', () => { + const { mergeBaseSha: _dropped, ...rest } = prev(); + expect(assessResume(rest, probes())).toEqual({ + ok: false, + reason: 'merge-base-mismatch', + }); + }); + + it('refuses a forged diffPathAbsolute', () => { + expect( + assessResume( + { ...prev(), diffPathAbsolute: '/tmp/evil-diff.txt' }, + probes(), + ), + ).toEqual({ ok: false, reason: 'diff-path-mismatch' }); + }); + + it('refuses chunks that do not tile the re-derived diff', () => { + expect(assessResume(prev(), probes({ chunksTile: false }))).toEqual({ + ok: false, + reason: 'chunks-mismatch', + }); + }); + + it('refuses while the diff is underivable and the tiling unknown', () => { + expect( + assessResume( + prev(), + probes({ diffSha256Rederived: null, chunksTile: null }), + ), + ).toEqual({ ok: false, reason: 'diff-underivable' }); + }); + + it('refuses a forged-future audit window', () => { + expect( + assessResume( + { ...prev(), auditSince: '2099-01-01T00:00:00.000Z' }, + probes(), + ), + ).toEqual({ ok: false, reason: 'window-corrupt' }); + }); + + it('refuses an auditSince AFTER fetchedAt — the opening only moves backward', () => { + // The writers maintain `auditSince <= fetchedAt` by construction (the + // window opening is a min over inherited openings). A forward-shifted + // forgery still in the past clears both <=now checks while blinding + // cleanup's bypass-write audit to every write before the forgery. + expect( + assessResume( + { + ...prev(), + auditSince: '2026-01-02T00:00:00.000Z', + fetchedAt: '2026-01-01T00:00:00.000Z', + }, + probes(), + ), + ).toEqual({ ok: false, reason: 'window-corrupt' }); + }); + + it('refuses an unparsable fetchedAt', () => { + expect( + assessResume({ ...prev(), fetchedAt: 'not-a-date' }, probes()), + ).toEqual({ ok: false, reason: 'window-corrupt' }); + }); + + it('refuses a report missing its audit-window fields', () => { + const { auditSince: _a, fetchedAt: _f, ...rest } = prev(); + expect(assessResume(rest, probes())).toEqual({ + ok: false, + reason: 'window-corrupt', + }); + }); + + it('refuses a report claiming the base fetch failed — the re-derivation disproves it', () => { + // `baseFetchFailed: true` degrades the base tree and the merge-base + // identity source downstream; a passing re-derivation has just proven + // the base fetchable, so the disabling direction is refused. + expect( + assessResume({ ...prev(), baseFetchFailed: true }, probes()), + ).toEqual({ ok: false, reason: 'base-fetch-mismatch' }); + }); + + it('refuses a forged incremental delta attached to a genuine report', () => { + // `effective` without `upToDate` claims a delta-scoped capture the + // full-range re-derivation contradicts, and `diffBase` is welded into + // Agent 7's probe base unquoted whenever the shape stands. + expect( + assessResume( + { + ...prev(), + incremental: { since: SHA, effective: true, diffBase: SHA }, + }, + probes(), + ), + ).toEqual({ ok: false, reason: 'incremental-delta' }); + }); + + it('accepts the incremental shapes that carry no delta and no weld', () => { + // `upToDate: true` and `effective: false` records scope nothing and weld + // nothing — Agent 7's predicate is exactly the shape refused above. + expect( + assessResume( + { + ...prev(), + incremental: { since: SHA, effective: true, upToDate: true }, + }, + probes(), + ), + ).toEqual({ ok: true }); + expect( + assessResume( + { + ...prev(), + incremental: { + since: SHA, + effective: false, + reason: 'not-an-ancestor', + }, + }, + probes(), + ), + ).toEqual({ ok: true }); + }); + + it('refuses when the plan payload is not what the diff re-plans to', () => { + expect(assessResume(prev(), probes({ planReportMatches: false }))).toEqual({ + ok: false, + reason: 'plan-mismatch', + }); + }); + + it('refuses a recorded repository context the worktree does not derive', () => { + // The briefs bake it into every agent and compose relays its gate — a + // planted context steers the resumed run while every compared field + // stays genuine. + expect( + assessResume(prev(), probes({ repositoryContextMatches: false })), + ).toEqual({ ok: false, reason: 'repo-context-mismatch' }); + }); + + it('refuses a recorded Han flag the live body contradicts', () => { + expect( + assessResume( + { ...prev(), prDescriptionHasHan: true }, + probes({ prDescriptionHasHan: false }), + ), + ).toEqual({ ok: false, reason: 'pr-description-han-mismatch' }); + }); + + it('refuses the Han comparison when the forge is unreachable', () => { + expect(assessResume(prev(), probes({ prDescriptionHasHan: null }))).toEqual( + { ok: false, reason: 'pr-description-han-mismatch' }, + ); + }); + + it('refuses a recorded cross-repo flag the forge contradicts', () => { + expect( + assessResume( + { ...prev(), isCrossRepository: true }, + probes({ isCrossRepository: false }), + ), + ).toEqual({ ok: false, reason: 'cross-repository-mismatch' }); + }); + + it('refuses a recorded diff stat the forge contradicts', () => { + expect( + assessResume( + { ...prev(), diffStat: { files: 99, additions: 99, deletions: 99 } }, + probes(), + ), + ).toEqual({ ok: false, reason: 'diff-stat-mismatch' }); + }); + + it('refuses a collapse claim the re-derived range contradicts', () => { + expect( + assessResume( + { ...prev(), collapsedFromUpstream: true }, + probes({ collapsedRederived: false }), + ), + ).toEqual({ ok: false, reason: 'collapsed-mismatch' }); + }); + + it('refuses a withheld collapse flag over a genuinely collapsed range', () => { + expect(assessResume(prev(), probes({ collapsedRederived: true }))).toEqual({ + ok: false, + reason: 'collapsed-mismatch', + }); + }); + + it('refuses while the diff is underivable and the plan comparison unknown', () => { + expect( + assessResume( + prev(), + probes({ diffSha256Rederived: null, planReportMatches: null }), + ), + ).toEqual({ ok: false, reason: 'diff-underivable' }); + }); + + it('refuses a forged baseRefName — the rules load reads it', () => { + expect( + assessResume({ ...prev(), baseRefName: 'no-such-branch' }, probes()), + ).toEqual({ ok: false, reason: 'base-ref-mismatch' }); + }); + + it('refuses a report with no recorded baseRefName when the forge answered', () => { + const { baseRefName: _dropped, ...rest } = prev(); + expect(assessResume(rest, probes())).toEqual({ + ok: false, + reason: 'base-ref-mismatch', + }); + }); + + it('refuses a forged headRefName', () => { + expect( + assessResume({ ...prev(), headRefName: 'evil/head' }, probes()), + ).toEqual({ ok: false, reason: 'head-ref-mismatch' }); + }); + + it('does NOT refuse ref names on an unreachable forge — content pins it', () => { + // Same fail-open as head-moved: unreachable is indistinguishable from + // unmoved, and the worktree/diff checks have already pinned the input. + expect( + assessResume( + { ...prev(), baseRefName: 'no-such-branch' }, + probes({ liveBaseRefName: null, liveHeadRefName: null }), + ), + ).toEqual({ ok: true }); + }); +}); diff --git a/packages/cli/src/commands/review/lib/resume.ts b/packages/cli/src/commands/review/lib/resume.ts new file mode 100644 index 00000000000..32ead4c5bdb --- /dev/null +++ b/packages/cli/src/commands/review/lib/resume.ts @@ -0,0 +1,666 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// May this run continue the interrupted one, or must it start over? +// +// The ruling is pure: `fetch-pr --resume` gathers the probes (git, gh, file +// hashes, the resume marker) and this function only compares them. Every +// check fails toward a FRESH run — resuming on stale state would continue a +// review of code nobody is reviewing anymore, which is strictly worse than +// re-fetching. The checkpoint key is content (the diff's sha256, the head +// SHA), never a path or a timestamp: input that changed re-runs, by +// construction rather than by invalidation logic. +// +// Every field of the previous report the resumed pipeline would consume is +// verified here against a fact this run derived itself. The report sits on a +// disk the reviewed PR's own code could write during attempt 1, so a field +// this ruling does not compare is a field the attacker chooses: the resumed +// agents would route through it while the verdict cites the genuine head. + +import { isDeepStrictEqual } from 'node:util'; +import { EFFORT_LEVELS } from '../parse-args.js'; +import { RESUME_MAX } from './run-ledger.js'; + +/** Why a resume was refused. Stable identifiers: the report carries one. */ +export type ResumeRefusal = + | 'no-report' // no previous fetch report at the plan path + | 'pr-mismatch' // the report on disk is another PR's + | 'owner-repo-mismatch' // the report names another repo or host + | 'effort-corrupt' // the recorded effort is not a level writers emit + | 'effort-mismatch' // an explicit --effort differs from the recorded run's + | 'no-diff-hash' // the previous run predates diffSha256 (or captured no diff) + | 'worktree-gone' // the interrupted attempt's worktree no longer exists + | 'worktree-identity-mismatch' // the worktree belongs to another repository + | 'worktree-sha-mismatch' // the worktree is not checked out at fetchedSha + | 'worktree-dirty' // the worktree holds uncommitted or hidden changes + | 'diff-unreadable' // the captured diff is gone or cannot be read + | 'diff-hash-mismatch' // the diff file changed since it was captured + | 'grafts-present' // info/grafts could redirect the re-derivation's base + | 'shallow-present' // a shallow boundary could sever the merge-base's ancestry + | 'repo-config-untrusted' // repo-local config carries command-executing keys + | 'ledger-absent' // a valid report with an empty session ledger is a tampered pair + | 'diff-underivable' // the diff could not be re-derived, or not trusted + | 'diff-rederive-mismatch' // git derives a different diff than was recorded + | 'base-fetch-mismatch' // the report claims a base fetch failure this run disproved + | 'merge-base-mismatch' // the report's mergeBaseSha is not the recomputed one + | 'incremental-delta' // the report claims a delta scope the capture cannot have + | 'worktree-path-mismatch' // the report names a worktree this run did not choose + | 'diff-path-mismatch' // the report names a diff path this run did not choose + | 'chunks-mismatch' // the report's chunks do not tile the re-derived diff + | 'plan-mismatch' // the report's plan payload is not what the diff re-plans to + | 'repo-context-mismatch' // the recorded repository context is not what the worktree derives + | 'pr-description-han-mismatch' // the recorded Han flag is not what the live body reads as + | 'cross-repository-mismatch' // the recorded cross-repo flag is not the forge's live one + | 'diff-stat-mismatch' // the recorded diff stat is not the forge's live one + | 'collapsed-mismatch' // the recorded collapse claim is not what the range re-derives + | 'base-ref-mismatch' // the report's baseRefName is not the forge's live one + | 'head-ref-mismatch' // the report's headRefName is not the forge's live one + | 'window-corrupt' // auditSince/fetchedAt unparsable or in the future + | 'empty-diff-mismatch' // the report's emptyDiff disagrees with the derived diff + | 'head-moved' // the PR head advanced — the once-per-review restart case + | 'resume-cap' // this review has already resumed RESUME_MAX times + | 'bookkeeping-unreadable'; // the ledger/marker tree is present but refused + +export type ResumeAssessment = + | { ok: true } + | { ok: false; reason: ResumeRefusal }; + +/** What the previous fetch report claims. All fields as parsed, unvalidated. */ +export interface PreviousReport { + prNumber?: unknown; + fetchedSha?: unknown; + diffSha256?: unknown; + effort?: unknown; + worktreePath?: unknown; + emptyDiff?: unknown; + ownerRepo?: unknown; + host?: unknown; + diffPathAbsolute?: unknown; + mergeBaseSha?: unknown; + auditSince?: unknown; + fetchedAt?: unknown; + chunks?: unknown; + /** + * The report claims the base branch could not be fetched. Downstream + * consumers key their degraded shapes on exactly `=== true` — the base + * tree refuses and the merge-base identity source degrades to none — so + * the ruling refuses the claim whenever the re-derivation it just ran + * proves the base fetchable now. + */ + baseFetchFailed?: unknown; + /** + * The recorded ref names the resumed run's rules load and base fetch route + * through; the forge's live names are the compared facts. + */ + baseRefName?: unknown; + headRefName?: unknown; + /** + * The incremental-scoping decision, present when the run was launched with + * `--since`. An EFFECTIVE delta (not up-to-date) scopes the captured diff + * to `diffBase..head`, which the full-range re-derivation can neither + * prove nor continue; `diffBase` is welded into Agent 7's probe base + * unquoted, so an unrefused forgery rides into the probe command. + */ + incremental?: unknown; + /** + * The plan payload the resumed launches consume — chunk file spans, + * per-file kinds and heavy flags, the tool budget and the reverse-audit + * round cap all derive from these. Re-derived from the diff bytes this + * run hashed, and compared whole; see the `planReportMatches` probe. + */ + diffLines?: unknown; + diffChars?: unknown; + /** + * The enriched repository context the resumed briefs bake into every + * agent's prompt — `requiredAgents` force role launches, `domains` gate + * modeled-system content, `verificationNotes`/`relatedPaths` steer the + * verification pass — and compose relays into the posted review. It sits + * on the same attempt-1-writable disk as everything else here, so the + * ruling re-derives it with the same providers the fresh enrichment runs + * and compares whole. + */ + repositoryContext?: unknown; + /** + * The PR-body Han flag gates the posted body's bilingual rendering — + * `compose-review` returns the recorded boolean as-is, the live-body + * fallback runs only when the field is ABSENT — so a forged `false` on a + * Chinese-authored PR suppresses the bilingual comment. Re-derived from + * the forge's live body and compared. + */ + prDescriptionHasHan?: unknown; + /** + * The collapse disclosure, written into the posted summary and read by + * Agent 0 as "treat the body's claims as description-of-history". + * Re-derived from the re-derived range and the forge's live stat. + */ + collapsedFromUpstream?: unknown; + /** The cross-repository flag selects the roster's lightweight mode. */ + isCrossRepository?: unknown; + /** The forge-advertised diff stat, surfaced in the posted counts. */ + diffStat?: unknown; + srcDiffLines?: unknown; + testDiffLines?: unknown; + docsDiffLines?: unknown; + generatedDiffLines?: unknown; + files?: unknown; + budget?: unknown; +} + +/** What the world looks like now, probed by the caller. */ +export interface ResumeProbes { + /** The PR number this invocation was asked to review. */ + prNumber: string; + /** The "owner/repo" this invocation was asked to review. */ + ownerRepo: string; + /** The host this invocation was called with, or null for github.com. */ + host: string | null; + /** `git -C rev-parse HEAD`, or null when the worktree is gone. */ + worktreeHeadSha: string | null; + /** + * The worktree's git common dir is THIS repository's. The worktree's + * `.git` pointer file lives inside the attempt-1-writable tree; relinking + * it redirects every other worktree probe — rev-parse, status, ls-files — + * into an attacker-chosen repository that answers whatever the ruling + * asks, so no worktree answer is trusted before this holds. False when the + * common dirs disagree or either could not be probed. + */ + worktreeIdentityMatches: boolean; + /** sha256 of the diff file's bytes on disk, or null when unreadable. */ + diffSha256OnDisk: string | null; + /** + * `git status --porcelain` on the worktree reported no changes. A tree at + * the right HEAD can still hold uncommitted edits — this pipeline's own + * probe and build/test agents mutate worktrees by design, and a death + * between an apply and its revert leaves exactly that. Resuming there + * would review code that is not in the PR. Null when the probe could not + * run, which is treated as dirty: an unverifiable tree is not a clean one. + * Dirty also covers what `--porcelain` cannot see: skip-worktree and + * assume-unchanged index bits hide a tampered tracked file, and an + * exclude-rule plant hides untracked residue — the caller probes both and + * reports the union. + */ + worktreeClean: boolean | null; + /** The PR's live head OID from the forge, or null when unavailable. */ + liveHeadSha: string | null; + /** + * The PR's live base ref name from the forge, or null when unavailable. + * The resumed run's rules load reads `/` from the + * report — a forged name resolves no rules, silently enforcing none over + * the diff, so the recorded name is compared against the forge's. + */ + liveBaseRefName: string | null; + /** The PR's live head ref name from the forge, or null when unavailable. */ + liveHeadRefName: string | null; + /** + * The worktree path THIS invocation derived from the PR number — the only + * worktree the pipeline will operate on. The recorded `worktreePath` is + * consumed by downstream steps (`agent-prompt`'s working_dir, + * `build-test --worktree`), and in CI the report sits on disk the + * reviewed PR's own code could write during attempt 1 — a forged path + * redirects every one of those steps into an attacker-chosen directory + * while the verdict still certifies the real head SHA. + */ + worktreePath: string; + /** + * The diff path THIS invocation derived from the PR number. Every + * downstream diff read (chunk reads, coverage's was-given-the-diff, the + * compose-time hash) routes through the report's `diffPathAbsolute`, so a + * forged one points every agent at an attacker-chosen file while the hash + * probes hash the canonical path. + */ + diffPathAbsolute: string; + /** + * sha256 of the diff RE-DERIVED from git objects — `git diff` between the + * recomputed merge-base and the recorded head, under the same pinned + * flags the capture used — or null when it could not be derived, or when + * the derivation cannot be trusted: the base fetch failed (the left side + * then resolves from attempt-1-writable local refs) or an attribute file + * was planted in the git dirs (a `-diff` attribute collapses hunks to + * `Binary files differ` in the re-derived bytes). The recorded hash and + * the on-disk file are BOTH attacker-writable in CI (same disk, same + * attempt-1 code execution), so their agreement proves self-consistency, + * not authenticity; the object store keyed by the forge-verified head SHA + * is the term the attacker cannot rewrite to match — with replace refs, + * grafts and attribute lookup pinned out of the derivation. + */ + diffSha256Rederived: string | null; + /** The re-derived diff had zero bytes. Null when underivable. */ + rederivedDiffEmpty: boolean | null; + /** + * The merge-base THIS run recomputed against the forge's base ref, or + * null when it could not be faithfully derived (base fetch failed, no + * candidate ref resolved). The report's `mergeBaseSha` is consumed as the + * revert/A-B base by test-efficacy and welded into its `--base` command, + * so a forged one the ruling never compared rides into both. + */ + mergeBaseSha: string | null; + /** + * The report's `chunks` tile the re-derived diff — contiguous line ranges + * covering it exactly. The chunks are the dispatch AND obligation + * universes; deleting the chunk that covers a malicious hunk leaves it + * neither dispatched nor owed, and the tiling guarantee runs at plan time + * only, so the ruling re-checks it against the bytes it derived itself. + * Null when the diff is underivable, false when the chunks are malformed. + */ + chunksTile: boolean | null; + /** + * The report's full plan payload — files, budget, chunk entries and the + * line/char tallies — equals what the planner derives from the re-derived + * diff bytes under this invocation's context. The resumed launches + * consume every part of it: chunk file spans weight the territory, file + * kinds and heavy flags pick the roster and the invariant agents, the + * budget sets tool ceilings, and the reverse-audit round cap re-derives + * from it at every admission gate. Tiling alone (`chunksTile`) proves the + * ranges only; a forged `kind`, `heavy` or budget field suppresses + * exactly the scrutiny a malicious hunk owes. Null when the diff is + * underivable. + */ + planReportMatches: boolean | null; + /** + * The report's recorded `repositoryContext` deep-equals what the SAME + * providers the fresh enrichment runs derive from this worktree and merge + * base (both null when neither has one). False on any disagreement, and + * when the derivation itself fails — a context the ruling cannot re-derive + * cannot be compared, and an uncomparable field is an attacker's. + */ + repositoryContextMatches: boolean; + /** + * The Han test over the forge's LIVE PR body, or null when the forge was + * unreachable — the same query the ruling already makes for the head. + */ + prDescriptionHasHan: boolean | null; + /** The forge's live cross-repository flag, or null when unreachable. */ + isCrossRepository: boolean | null; + /** The forge's live diff stat, or null when unreachable. */ + diffStat: { files: number; additions: number; deletions: number } | null; + /** + * The collapse flag re-computed from the re-derived diff bytes and the + * forge's live stat — the same predicate the fresh path applies. Null when + * the diff is underivable or the forge unreachable. + */ + collapsedRederived: boolean | null; + /** + * The invocation's wall clock. The report's `auditSince`/`fetchedAt` + * open cleanup's bypass-write audit window; a forged-future value blinds + * the audit to a silent clean — the exact forgery the fresh path rejects + * when it inherits the window. + */ + nowMs: number; + /** + * No non-empty `info/grafts` sits in the worktree's git common dir. A + * graft redirects the merge-base the re-derivation diffs against (to the + * head itself: an empty diff matching a forged empty pair), and replace + * refs are pinned out by the git wrappers; grafts have no flag, so a + * present file refuses. False when the file is non-empty or the dir could + * not be probed. + */ + graftsAbsent: boolean; + /** + * No shallow boundary sits in the git common dir. A planted `shallow` + * file severs the recorded head's ancestry, the merge-base degrades to + * null or a planted commit, and the re-derived range omits exactly the + * hunks the boundary cuts off. Like grafts, no flag pins it out. + */ + shallowAbsent: boolean; + /** + * The repo-local (and worktree-scope) git config carries none of the + * command-executing keys — `core.fsmonitor`, `core.sshCommand`, + * `credential.*.helper`, `filter.*`, hooks-path and remote-transport + * overrides — and the hooks directory holds no live hook. Every probe + * this ruling trusts runs git UNDER that config; a planted key executes + * the reviewed PR's code inside the ruling process, and a redirect key + * bends the base fetch to an attacker remote. Screened at local and + * worktree scope only: global and system config are the operator's. + */ + repoConfigClean: boolean; + /** + * Session-ledger entries recorded for this plan, current session + * included — a COUNT, not evidence. A parsable report whose ledger is + * EMPTY is a tampered pair, not a fresh run: `fetch-pr` appends the + * original session's entry in the same breath that writes the report, so + * the legitimate report-written-but-ledger-empty window is microseconds. + * Deleting both bookkeeping files was the demonstrated reset of the + * resume cap; an absent ledger now fails closed instead of counting zero. + */ + ledgerEntryCount: number; + /** + * The resume marker FILE is absent (not merely empty). A same-session + * resume leaves no ledger entry, so the ledger backstop undercounts the + * cap by one when the marker is deleted; but any run that has resumed + * wrote the marker, and it persists, so an absent marker beside a ledger + * of two or more sessions is deletion — the cap fails closed. + */ + markerFileAbsent: boolean; + /** + * The report file's own mtime — the run epoch every fence keys on, and + * the corroborating fact for `fetchedAt`: the writer stamps `fetchedAt` + * moments before writing the file, so a recorded time far AFTER the + * file's mtime is a forward-shifted forgery blinding the bypass-write + * audit window (the mtime itself is restorable by `utimesSync`, but + * backdating it only WIDENS the audited window — the safe direction). + */ + reportMtimeMs: number | null; + /** + * How many times this review has already resumed. The caller computes the + * MAX of the resume marker's count and the session ledger's entry count + * minus one (the original run's own session is not a resume): the marker + * alone is deletable, and a deleted marker must not read as an unspent + * cap while the ledger still names every session that ran. + */ + resumeCount: number; + /** + * The --effort this invocation was called with, or null when the caller + * passed none. An EXPLICIT effort different from the recorded run's is a + * request for different work, not a continuation — the resume refuses and + * the fresh fall-through honors the request. Absent effort never refuses: + * the continuation keeps the recorded level. + */ + requestedEffort: string | null; +} + +/** + * True when the report's audit-window fields are present, parsable, not in + * the future, and ordered. Nothing legitimate writes the future; a + * forged-future opening pushes the audit window past every real write and + * reports it clean — the exact forgery the fresh path rejects when it + * inherits the window. `auditSince <= fetchedAt` is the writer's own + * invariant — the window opening is a MIN over inherited openings, so it + * only ever moves BACKWARD — and a forward-shifted-but-past forgery clears + * the <=now checks while blinding cleanup's bypass-write audit to every + * write made before the forgery. + */ +function windowSound( + prev: PreviousReport, + nowMs: number, + reportMtimeMs: number | null, +): boolean { + if (typeof prev.auditSince !== 'string' || prev.auditSince === '') { + return false; + } + if (typeof prev.fetchedAt !== 'string' || prev.fetchedAt === '') { + return false; + } + const auditSince = Date.parse(prev.auditSince); + const fetchedAt = Date.parse(prev.fetchedAt); + if (Number.isNaN(auditSince) || Number.isNaN(fetchedAt)) return false; + // `fetchedAt` corroborated against the report file's own mtime — the one + // clock the writers maintain by construction (the enrichment restores it + // to the microsecond). Self-consistency of two recorded strings proves + // only that the forger wrote both; a recorded time meaningfully AFTER the + // file existed is a forward shift that would blind the bypass-write audit + // to every write made before it. One minute of slack covers the stamp- + // then-write gap; an unstatable report corroborates nothing and fails. + if (reportMtimeMs === null) return false; + if (fetchedAt > reportMtimeMs + 60_000) return false; + return auditSince <= fetchedAt && auditSince <= nowMs && fetchedAt <= nowMs; +} + +/** + * The ruling. Checks are ordered from "there is nothing to resume" through + * "the state is not the state that was left" to "resuming is not allowed + * again" — so the reported reason names the FIRST fact that broke the chain, + * which is the one an operator can act on. + */ +export function assessResume( + prev: PreviousReport | null, + probes: ResumeProbes, +): ResumeAssessment { + if ( + prev === null || + typeof prev.fetchedSha !== 'string' || + prev.fetchedSha === '' + ) { + return { ok: false, reason: 'no-report' }; + } + if (prev.prNumber !== probes.prNumber) { + return { ok: false, reason: 'pr-mismatch' }; + } + // The report names the repo and host the cleanup audit queries and the + // compose-time anchor links cite; a forged pair sends the tripwire at a + // repo with zero writes (silent clean) and the links at the wrong forge. + const prevHost = + typeof prev.host === 'string' && prev.host !== '' ? prev.host : null; + if (prev.ownerRepo !== probes.ownerRepo || prevHost !== probes.host) { + return { ok: false, reason: 'owner-repo-mismatch' }; + } + // The resumed run trusts the recorded effort when no explicit one is + // passed; a level the writers never emit is a corrupt report, whatever it + // would select. Valid-but-forged levels (high→medium) are undetectable on + // disk and are the documented residual of resuming from attempt-1-writable + // state at all. + if ( + typeof prev.effort === 'string' && + prev.effort !== '' && + !EFFORT_LEVELS.has(prev.effort) + ) { + return { ok: false, reason: 'effort-corrupt' }; + } + // A plan with no recorded effort ran the default (high) roster; compare + // against that rather than refusing every resume of a default-effort run. + if ( + probes.requestedEffort !== null && + probes.requestedEffort !== + (typeof prev.effort === 'string' && prev.effort !== '' + ? prev.effort + : 'high') + ) { + return { ok: false, reason: 'effort-mismatch' }; + } + // A pre-diffSha256 report (or a run that captured no diff) has no content + // identity to verify against; a resume that cannot prove its input is + // unchanged does not happen. + if (typeof prev.diffSha256 !== 'string' || prev.diffSha256 === '') { + return { ok: false, reason: 'no-diff-hash' }; + } + if (probes.worktreeHeadSha === null) { + return { ok: false, reason: 'worktree-gone' }; + } + // BEFORE any worktree answer is trusted: a relinked `.git` pointer makes + // rev-parse, status and ls-files address an attacker's repository. + if (!probes.worktreeIdentityMatches) { + return { ok: false, reason: 'worktree-identity-mismatch' }; + } + // BEFORE any probe answer is believed: the gatherer SKIPS the fetch and + // the worktree probes under a dirty screen (their execution is itself the + // attack — hooks on the fetch's ref update, filter.clean inside the + // status refresh), so every later probe reads null/false here. Naming the + // screen first keeps the reason actionable: the operator fixes the config + // plant, not a phantom dirty worktree. + if (!probes.repoConfigClean) { + return { ok: false, reason: 'repo-config-untrusted' }; + } + if (probes.worktreeHeadSha !== prev.fetchedSha) { + return { ok: false, reason: 'worktree-sha-mismatch' }; + } + if (probes.worktreeClean !== true) { + return { ok: false, reason: 'worktree-dirty' }; + } + // Absent local state and changed upstream input are different facts and + // get different names: one says this run lost its own capture, the other + // says what it captured is no longer what it captured. + if (probes.diffSha256OnDisk === null) { + return { ok: false, reason: 'diff-unreadable' }; + } + if (probes.diffSha256OnDisk !== prev.diffSha256) { + return { ok: false, reason: 'diff-hash-mismatch' }; + } + if (!probes.graftsAbsent) { + return { ok: false, reason: 'grafts-present' }; + } + if (!probes.shallowAbsent) { + return { ok: false, reason: 'shallow-present' }; + } + // A parsable report beside an EMPTY session ledger is the deleted- + // bookkeeping shape: both cap counters read zero through it, and the + // resume chain uncaps. fetch-pr appends the original session's entry in + // the same breath that writes the report, so absence is not a fresh run. + if (probes.ledgerEntryCount === 0) { + return { ok: false, reason: 'ledger-absent' }; + } + // A multi-session run always wrote the resume marker; its absence beside a + // ledger of two or more sessions is the deleted-bookkeeping tamper the + // same-session-resume undercount would otherwise slip past. + if (probes.markerFileAbsent && probes.ledgerEntryCount >= 2) { + return { ok: false, reason: 'ledger-absent' }; + } + // The recorded hash and the disk file agree — but both live on a disk the + // reviewed PR's own code could write during attempt 1, so their agreement + // is self-consistency, not authenticity. The diff must also be what git + // itself derives for the recorded head: a doctored pair passes the check + // above and fails this one, because the object store keyed by the + // forge-verified head is not attacker-writable to match. Null also covers + // an UNTRUSTED derivation — a failed base fetch resolves the left side + // from attempt-1-writable local refs, and a planted attribute file shapes + // the bytes git derives. + if (probes.diffSha256Rederived === null) { + return { ok: false, reason: 'diff-underivable' }; + } + if (probes.diffSha256Rederived !== prev.diffSha256) { + return { ok: false, reason: 'diff-rederive-mismatch' }; + } + // The report's `baseFetchFailed` degrades the resumed run where the + // consumers key on it — the base tree refuses, and the merge-base identity + // source falls back to none — so a value of true disables verification + // machinery on the very PR that planted it. The re-derivation reaching + // here has itself proven the base fetchable now, so the claim is either a + // forgery or a stale capture; either way the continuation does not carry + // it. (The reverse forgery — false over a real failure — dies earlier as + // `diff-underivable`: a failed base fetch leaves the re-derivation null.) + if (prev.baseFetchFailed === true) { + return { ok: false, reason: 'base-fetch-mismatch' }; + } + // The report's merge-base is consumed as the revert/A-B base downstream; + // compare it against the one this run recomputed against the forge's base + // ref, never against itself. + if (prev.mergeBaseSha !== probes.mergeBaseSha) { + return { ok: false, reason: 'merge-base-mismatch' }; + } + // An effective delta (not up-to-date) scopes the captured diff to + // `diffBase..head` — but the re-derivation that reached this point proved + // the captured bytes are the FULL merge-base range, so the claim + // contradicts a fact this run derived. Consumed it is either way: + // Agent 7's probe base welds `diffBase` — a field no comparison reaches — + // whenever `effective === true && upToDate !== true`, exactly the shape + // refused here. (`upToDate: true` and `effective: false` shapes carry no + // delta and no weld; the full-range capture they describe is the one the + // re-derivation proved.) + const incremental = prev.incremental as + | { effective?: unknown; upToDate?: unknown } + | undefined; + if ( + typeof incremental === 'object' && + incremental !== null && + incremental.effective === true && + incremental.upToDate !== true + ) { + return { ok: false, reason: 'incremental-delta' }; + } + // The report's own routing fields, verified against facts this run + // derived itself: a forged worktreePath redirects every downstream step, + // and a forged emptyDiff stops the resumed run before any agent launches + // — the gate passing by absence. + if ( + typeof prev.worktreePath !== 'string' || + prev.worktreePath !== probes.worktreePath + ) { + return { ok: false, reason: 'worktree-path-mismatch' }; + } + if ( + typeof prev.diffPathAbsolute !== 'string' || + prev.diffPathAbsolute !== probes.diffPathAbsolute + ) { + return { ok: false, reason: 'diff-path-mismatch' }; + } + // The chunks are the dispatch and obligation universes; the tiling + // guarantee ran at plan time only, and the plan is attempt-1-writable. + if (probes.chunksTile !== true) { + return { ok: false, reason: 'chunks-mismatch' }; + } + // Tiling proves the ranges only. The resumed launches consume the WHOLE + // plan — chunk file spans, per-file kinds and heavy flags, the tool + // budget, the round cap — so the ruling re-plans the re-derived bytes and + // compares the payload field for field; a disagreement is a forged or + // stale plan, and either re-runs. + if (probes.planReportMatches !== true) { + return { ok: false, reason: 'plan-mismatch' }; + } + // The resumed briefs bake the recorded repository context into every + // agent's prompt and compose relays its gate into the posted review, so + // the ruling re-derives it with the fresh enrichment's own providers and + // compares whole — a field this ruling does not compare is a field the + // attacker chooses. + if (!probes.repositoryContextMatches) { + return { ok: false, reason: 'repo-context-mismatch' }; + } + // Four more consumed fields, re-derived from facts this run already + // trusts — the forge's live `gh pr view` and the re-derived range. A + // forged Han flag suppresses the bilingual comment on a Chinese-authored + // PR (the live-body fallback runs only when the field is ABSENT), a + // forged collapse claim writes a false disclosure and re-steers Agent 0, + // forged stats surface attacker-chosen counts, and a forged cross-repo + // flag flips the roster's mode. + if ( + probes.prDescriptionHasHan === null || + prev.prDescriptionHasHan !== probes.prDescriptionHasHan + ) { + return { ok: false, reason: 'pr-description-han-mismatch' }; + } + if ( + probes.isCrossRepository === null || + prev.isCrossRepository !== probes.isCrossRepository + ) { + return { ok: false, reason: 'cross-repository-mismatch' }; + } + if ( + probes.diffStat === null || + !isDeepStrictEqual(prev.diffStat, probes.diffStat) + ) { + return { ok: false, reason: 'diff-stat-mismatch' }; + } + if ( + probes.collapsedRederived === null || + (prev.collapsedFromUpstream === true) !== probes.collapsedRederived + ) { + return { ok: false, reason: 'collapsed-mismatch' }; + } + // The resumed run's rules load reads `/` from the + // report, and the fetch path re-reads it when the base fetch failed; a + // forged name resolves nothing, and "no rules found" is indistinguishable + // from a repo that has none — the project's rules would silently not + // apply to the diff under review. Compared against the forge's live + // names; an unreachable forge reads as unmoved, exactly like the + // head-moved fail-open below — the content checks have already pinned + // the input. + if ( + probes.liveBaseRefName !== null && + prev.baseRefName !== probes.liveBaseRefName + ) { + return { ok: false, reason: 'base-ref-mismatch' }; + } + if ( + probes.liveHeadRefName !== null && + prev.headRefName !== probes.liveHeadRefName + ) { + return { ok: false, reason: 'head-ref-mismatch' }; + } + if (!windowSound(prev, probes.nowMs, probes.reportMtimeMs)) { + return { ok: false, reason: 'window-corrupt' }; + } + if ((prev.emptyDiff === true) !== (probes.rederivedDiffEmpty === true)) { + return { ok: false, reason: 'empty-diff-mismatch' }; + } + // An unreachable forge is NOT a head-moved: it is indistinguishable from + // "unchanged", and the worktree/diff checks above already pin the content. + // presubmit's headDrift re-checks against the live head before anything is + // posted, so failing open here costs nothing that gate does not catch. + if (probes.liveHeadSha !== null && probes.liveHeadSha !== prev.fetchedSha) { + return { ok: false, reason: 'head-moved' }; + } + if (probes.resumeCount >= RESUME_MAX) { + return { ok: false, reason: 'resume-cap' }; + } + return { ok: true }; +} diff --git a/packages/cli/src/commands/review/lib/run-ledger.race.test.ts b/packages/cli/src/commands/review/lib/run-ledger.race.test.ts index 81f678a169d..00a10edce21 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.race.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.race.test.ts @@ -66,7 +66,7 @@ describe('single-read ledger writers', () => { // transient fault to clear in. A reintroduced guard read turns this red // before any race does. appendRunSession(plan, envOf('S1')); - const spy = vi.spyOn(ledgerIoForTests, 'readFileSync'); + const spy = vi.spyOn(ledgerIoForTests, 'readContainedFile'); appendRunSession(plan, envOf('S2')); const ledgerReads = spy.mock.calls.filter( (c) => c[0] === runSessionsPath(plan), @@ -78,7 +78,7 @@ describe('single-read ledger writers', () => { it('a transient fault on that one read refuses the append, never clobbers', () => { appendRunSession(plan, envOf('S1')); const before = readFileSync(runSessionsPath(plan), 'utf8'); - vi.spyOn(ledgerIoForTests, 'readFileSync').mockImplementationOnce(() => { + vi.spyOn(ledgerIoForTests, 'readContainedFile').mockImplementationOnce(() => { throw transient('EMFILE'); }); appendRunSession(plan, envOf('S2')); @@ -91,7 +91,7 @@ describe('single-read ledger writers', () => { it('a transient fault on the marker read refuses the record, never clobbers', () => { recordResume(plan, envOf('S1')); const before = readFileSync(resumeMarkerPath(plan), 'utf8'); - vi.spyOn(ledgerIoForTests, 'readFileSync').mockImplementationOnce(() => { + vi.spyOn(ledgerIoForTests, 'readContainedFile').mockImplementationOnce(() => { throw transient('EPERM'); }); recordResume(plan, envOf('S2')); diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 3dae6de598a..5ba9486a231 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -12,6 +12,7 @@ // an empty history. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, @@ -28,11 +29,13 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { promptRecordDir } from './prompt-record.js'; import { appendRunSession, priorSessionEntries, priorSessionIds, sessionEntryCount, + ledgerResumeCount, runSessionsPath, readResumeMarker, recordResume, @@ -40,6 +43,11 @@ import { resumeMarkerPath, RESUME_MAX, currentSessionEntry, + sessionLedgerRefused, + readResumeCapSnapshot, + resumeBookkeepingRefused, + resumeBookkeepingRefused, + resumeBookkeepingAnomaly, } from './run-ledger.js'; let root: string; @@ -128,6 +136,52 @@ describe('sessionEntryCount — the cap term the gate must not swallow', () => { }); }); +describe('ledgerResumeCount — entries past the original, no double subtraction', () => { + // The ledger's first entry is the original run's own session, which is + // not a resume. The resuming session is excluded from the REMAINDER — and + // when it IS the original, the exclusion has already removed the first + // entry, so subtracting the original again counts one resume short: the + // exact backstop shape (a deleted marker, the original session resuming) + // passed a cap it had already exhausted. + const now = Date.now(); + + function threeSessions(): void { + appendRunSession(plan, envOf('S0'), now); + appendRunSession(plan, envOf('S1'), now + 1000); + appendRunSession(plan, envOf('S2'), now + 2000); + } + + it('counts the resumes past the original session', () => { + threeSessions(); + expect(ledgerResumeCount(plan)).toBe(2); + }); + + it('excludes the original resuming without subtracting it twice', () => { + threeSessions(); + // S0 resumes again: the exclusion removes the first entry, leaving S1 + // and S2 — two resumes, the cap already out. The double subtraction + // read 1 here and admitted a third resume. + expect(ledgerResumeCount(plan, { excludeSessionId: 'S0' })).toBe(2); + }); + + it('excludes a resuming session that is not the original', () => { + threeSessions(); + // S1 retries its own resume: S2 is the only OTHER resume. + expect(ledgerResumeCount(plan, { excludeSessionId: 'S1' })).toBe(1); + }); + + it('is zero on a fresh ledger, whatever excludes', () => { + appendRunSession(plan, envOf('S0'), now); + expect(ledgerResumeCount(plan)).toBe(0); + expect(ledgerResumeCount(plan, { excludeSessionId: 'S0' })).toBe(0); + expect(ledgerResumeCount(plan, { excludeSessionId: 'S9' })).toBe(0); + }); + + it('is zero when there is no ledger at all', () => { + expect(ledgerResumeCount(plan, { excludeSessionId: 'S0' })).toBe(0); + }); +}); + describe('appendRunSession / priorSessionIds', () => { it('records a session and surfaces it to a LATER session as prior', () => { appendRunSession(plan, envOf('S1')); @@ -491,6 +545,51 @@ describe('the properties the threat model rests on', () => { authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked prompt-record DIRECTORY', + () => { + // The leaf guard protects the wrong object here: `run-sessions.json` + // inside the linked directory is an ordinary regular file, so only the + // ancestor walk sees that both ledgers have been redirected at once. + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'elsewhere-'))); + try { + writeFileSync( + join(elsewhere, 'run-sessions.json'), + JSON.stringify([ + { + sessionId: 'FORGED', + atMs: Date.now(), + planMtimeMs: statSync(plan).mtimeMs, + }, + ]), + ); + symlinkSync(elsewhere, join(root, 'qwen-review-pr-7-fetch-prompts')); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not block on a FIFO planted at the ledger path', + () => { + // A real FIFO, not a symlink standing in for one: opening it for + // reading blocks until a writer arrives, so the guard has to refuse it + // by TYPE on a non-blocking descriptor. The test hanging is the + // failure mode this pins. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + execFileSync('mkfifo', [runSessionsPath(plan)]); + authorize('S2'); + const started = Date.now(); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + expect(Date.now() - started).toBeLessThan(2000); + }, + 5000, + ); it('reads an over-budget ledger as empty, before parsing it', () => { // The byte bound: a planted multi-gigabyte file would otherwise be read @@ -804,6 +903,111 @@ describe('the properties the threat model rests on', () => { expect(entries[0].endsAtMs).toBe(base + 510_000); }); + it.skipIf(process.platform === 'win32')( + 'hardens the resume MARKER on the same terms as the session ledger', + () => { + // Both files go through one reader, and every containment probe so far + // aimed at `run-sessions.json`. A marker read through a planted link + // would let a forged resume history reset the cap this feature bounds + // itself with. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'elsewhere-'))); + try { + writeFileSync( + join(elsewhere, 'resume.json'), + JSON.stringify({ + schemaVersion: 1, + resumes: [{ sessionId: 'FORGED', atMs: Date.now() }], + restarts: [], + }), + ); + symlinkSync(join(elsewhere, 'resume.json'), resumeMarkerPath(plan)); + expect(readResumeMarker(plan).resumes).toEqual([]); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not block on a FIFO planted at the marker path', + () => { + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + execFileSync('mkfifo', [resumeMarkerPath(plan)]); + const started = Date.now(); + expect(readResumeMarker(plan).resumes).toEqual([]); + expect(Date.now() - started).toBeLessThan(2000); + }, + 5000, + ); + + it('reports an ABSENT record dir as no ledger, not as a refusal', () => { + // A plan built outside a session, or a read-only tmp where the swallowed + // writes never landed: no `-prompts` at all is the ordinary state, + // and the accounting layer must not print the refusal sentence (nor mark + // the ledger a floor) over it. + expect(sessionLedgerRefused(plan)).toBe(false); + expect(resumeBookkeepingRefused(plan)).toBe(false); + expect(resumeBookkeepingAnomaly(plan, envOf('S1'))).toBeNull(); + }); + + it.skipIf(process.platform === 'win32')( + 'flags a REDIRECTED record dir as refused bookkeeping', + () => { + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'else-'))); + try { + symlinkSync(elsewhere, join(root, 'qwen-review-pr-7-fetch-prompts')); + expect(resumeBookkeepingRefused(plan)).toBe(true); + expect(resumeBookkeepingAnomaly(plan, envOf('S1'))).toContain( + 'could not be read', + ); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it('flags a deleted marker beside a multi-attempt ledger', () => { + // The canonical deletion attack: the ledger proves earlier attempts, the + // marker that authorizes reading them is gone, and the prior iteration + // silently empties — this is the one place that can say so. + appendRunSession(plan, envOf('S0')); + appendRunSession(plan, envOf('S1')); + expect(resumeBookkeepingAnomaly(plan, envOf('S1'))).toContain( + 'resume marker is missing', + ); + }); + + it('flags a multi-attempt ledger when the marker does not record this session (5th cell)', () => { + // Both files ok, ledger names 2+ attempts, but the marker authorizes a + // DIFFERENT session (this session's recordResume was swallowed on a + // transient fault). priorSessionEntries yields nothing while the ledger + // proves there was something — the cost ledger under-bills silently. + appendRunSession(plan, envOf('S0')); + appendRunSession(plan, envOf('S1')); + recordResume(plan, envOf('S0')); // marker authorizes S0, not S2 + appendRunSession(plan, envOf('S2')); + expect(resumeBookkeepingAnomaly(plan, envOf('S2'))).toContain( + 'not recorded as an authorized resume', + ); + }); + + it('flags a session the marker authorizes but the ledger omits (6th cell)', () => { + // The mirror: the marker records this session, the ledger has no entry + // for it (appendRunSession swallowed while recordResume landed) — the + // cost floor falls back to the plan mtime and over-bills. + appendRunSession(plan, envOf('S0')); + recordResume(plan, envOf('S1')); // marker authorizes S1 + // S1 has no ledger entry (its appendRunSession never landed). + expect(resumeBookkeepingAnomaly(plan, envOf('S1'))).toContain( + 'missing from the session ledger', + ); + }); + it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); @@ -875,6 +1079,38 @@ describe('plant shapes — heal what cannot be legitimate, preserve what can', ( expect(sessionEntryCount(plan)).toBe(1); }); + it('heals a regular-file plant AT the record directory itself', () => { + // A plain file (not a symlink, not a directory) at `-prompts` + // fails the component walk as uncontained, but it is not a redirect — + // nothing lands outside the tree through it. Left refused it froze ALL + // bookkeeping for the life of the plan; it is removed and treated as + // absent, like the leaf plant one component down. + writeFileSync(promptRecordDir(plan), 'not a directory'); + appendRunSession(plan, envOf('S1')); + expect(lstatSync(promptRecordDir(plan)).isDirectory()).toBe(true); + expect(sessionEntryCount(plan)).toBe(1); + }); + + it('keeps a SYMLINK at the record directory refused — it is a redirect', () => { + const elsewhere = join(root, 'evil-prompts'); + mkdirSync(elsewhere, { recursive: true }); + symlinkSync(elsewhere, promptRecordDir(plan)); + expect(resumeBookkeepingRefused(plan)).toBe(true); + // The link is not healed away (unlike a plain file). + expect(lstatSync(promptRecordDir(plan)).isSymbolicLink()).toBe(true); + }); + + it('the cap snapshot reports refused from ONE read when a ledger is a link', () => { + // The single-snapshot cap: a refused bookkeeping file makes the whole + // decision refused, so the counters cannot separately degrade to zero. + mkdirSync(promptRecordDir(plan), { recursive: true }); + const target = join(root, 'evil.json'); + writeFileSync(target, '[]'); + symlinkSync(target, runSessionsPath(plan)); + const snap = readResumeCapSnapshot(plan, envOf('S1')); + expect(snap.refused).toBe(true); + }); + it('heals both plant shapes at the resume marker too', () => { mkdirSync(resumeMarkerPath(plan), { recursive: true }); recordResume(plan, envOf('S1')); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index fdb3202278c..f69da284c8f 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -30,12 +30,19 @@ // skill used to hold only in transcript memory: how many times this review has // resumed, and whether it already restarted once for head movement. -import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { lstatSync, mkdirSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { atomicWriteFileSync, sanitizeFilenameComponent, } from '@qwen-code/qwen-code-core'; +import { + ContainedReadError, + MAX_LEDGER_BYTES, + containedDir, + parentDirOf, + readContainedFile, +} from './contained-read.js'; import { promptRecordDir, runEpochMs } from './prompt-record.js'; const SESSIONS_FILE = 'run-sessions.json'; @@ -124,7 +131,7 @@ const PLAN_MTIME_TOLERANCE_MS = 1; * injected by mocking the module. Same idea as `contained-read`'s injectable * read seam; production code never reassigns these. */ -export const ledgerIoForTests = { readFileSync, statSync }; +export const ledgerIoForTests = { readContainedFile, statSync }; function planMtimeMs(planPath: string): number | null { try { @@ -152,13 +159,18 @@ export function runSessionsPath(planPath: string): string { } /** - * Read one ledger file, refusing anything that is not a regular file. + * Read one ledger file, refusing anything that is not a contained regular file. * * The write side is hardened with `noFollow`; the read side must match, or a * planted symlink redirects the read and a planted FIFO blocks it forever — * a hang, not an error, in a command a review is waiting on. + * + * The prompt-record DIRECTORY is validated before the leaf, because a leaf + * guard alone protects the wrong object: `-prompts` is itself a path + * this process creates, and a link there redirects both ledgers at once. The + * plan's own directory is the root — the plan is the run's anchor, and this + * module never reads anything above it. */ -const MAX_LEDGER_BYTES = 256 * 1024; const MAX_LEDGER_ENTRIES = 64; /** @@ -188,27 +200,69 @@ const MAX_LEDGER_ENTRIES = 64; type LedgerOccupant = | { kind: 'ok'; text: string } | { kind: 'absent' } - | { kind: 'plant'; shape: 'directory' | 'special' | 'oversize' } + | { kind: 'plant' } | { kind: 'refused' }; -function ledgerOccupant(path: string): LedgerOccupant { - let st; - try { - st = lstatSync(path); - } catch { - return { kind: 'absent' }; +function ledgerOccupant(planPath: string, path: string): LedgerOccupant { + // Every component from the plan's directory down to `-prompts`, with + // no link on the way: a leaf guard alone protects the wrong object, since a + // link at `-prompts` redirects both ledgers at once. A missing record + // dir is the ordinary first-write state; an uncontained one is a refusal — + // writing through it would land outside the tree the run owns. + const dirVerdict = containedDir( + parentDirOf(planPath), + promptRecordDir(planPath), + ); + if (!dirVerdict.ok) { + if (dirVerdict.reason === 'missing') return { kind: 'absent' }; + // A regular file or FIFO planted AT `-prompts` (not a symlink, not + // a directory) fails the component walk as `uncontained`, but unlike a + // symlink it is NOT a redirect — nothing lands outside the tree through + // it, the writers' `mkdirSync` just throws EEXIST. Left refused it froze + // ALL bookkeeping for the life of the plan path (stable across CI + // retries), never healed. Remove it and proceed as absent — the same + // heal the leaf-level plant one component down already gets. A SYMLINK + // keeps the refused/fail-closed treatment (it redirects). + const recordDir = promptRecordDir(planPath); + try { + const st = lstatSync(recordDir); + if (!st.isSymbolicLink() && !st.isDirectory()) { + rmSync(recordDir, { force: true }); + return { kind: 'absent' }; + } + } catch { + // Raced away, or unreadable — fall through to refused. + } + return { kind: 'refused' }; } - if (st.isDirectory()) return { kind: 'plant', shape: 'directory' }; - // A symlink would redirect the read and a FIFO would block it forever — a - // hang, not an error, in a command a review waits on. - if (!st.isFile()) return { kind: 'plant', shape: 'special' }; - // Bounded before the read: these files are bookkeeping (a handful of - // small entries), and a planted multi-gigabyte one would otherwise stall - // or exhaust every command that touches them. - if (st.size > MAX_LEDGER_BYTES) return { kind: 'plant', shape: 'oversize' }; + // ONE `O_NOFOLLOW` open, `fstat` on that descriptor, bytes off the same + // descriptor — the object validated is the object read, and the whole + // decision below is made from this single read. try { - return { kind: 'ok', text: ledgerIoForTests.readFileSync(path, 'utf8') }; - } catch { + return { + kind: 'ok', + text: ledgerIoForTests.readContainedFile(path, MAX_LEDGER_BYTES).content, + }; + } catch (err) { + if (err instanceof ContainedReadError) { + // The ordinary first-write state — the type names it directly. + if (err.reason === 'absent') return { kind: 'absent' }; + // Not a regular file (directory, FIFO, device — this module never + // writes one) or over the byte ceiling (a legitimate ledger is ≤64 + // capped entries, a few KB): provably a plant, healable. + if (err.reason === 'not-regular' || err.reason === 'too-large') { + return { kind: 'plant' }; + } + if (err.reason === 'open-failed') { + // ELOOP is `O_NOFOLLOW` refusing a planted symlink — the noFollow + // atomic write replaces it, so it heals like the other plants. + const code = (err.cause as { code?: unknown } | undefined)?.code; + if (code === 'ELOOP') return { kind: 'plant' }; + } + } + // A present, plausible regular file whose read failed (EMFILE, an AV + // scanner's EPERM): it holds every previously recorded entry, and the + // writers must preserve it. return { kind: 'refused' }; } } @@ -219,14 +273,157 @@ function ledgerOccupant(path: string): LedgerOccupant { * survives it (EISDIR on every attempt), so only a directory needs removing. */ function healPlant(path: string, occ: LedgerOccupant): void { - if (occ.kind === 'plant' && occ.shape === 'directory') { - rmSync(path, { recursive: true, force: true }); + if (occ.kind !== 'plant') return; + try { + if (lstatSync(path).isDirectory()) { + rmSync(path, { recursive: true, force: true }); + } + } catch { + // Gone already — the noFollow rename handles every other shape. } } -function readLedgerFile(path: string): string | null { - const occ = ledgerOccupant(path); - return occ.kind === 'ok' ? occ.text : null; +/** + * A ledger read that keeps ABSENT and REFUSED apart. + * + * Both produce no entries, and for the evidence gates that is the same + * answer: invisible evidence re-owes the work. The ACCOUNTING layer is where + * they differ. A refused ledger empties the prior-session iteration at its + * source, so the cost ledger prints a current-session-only figure as the + * review's complete cost — no "totals include N earlier sessions", no floor + * mark, no warning — and spent money, unlike owed work, cannot be re-owed. + */ +type LedgerRead = + | { kind: 'ok'; text: string } + | { kind: 'absent' } + | { kind: 'refused' }; + +function readLedgerFileResult(planPath: string, path: string): LedgerRead { + const occ = ledgerOccupant(planPath, path); + if (occ.kind === 'ok') return { kind: 'ok', text: occ.text }; + if (occ.kind === 'absent') return { kind: 'absent' }; + // A plant reads as refused here: whatever entries ever existed are not + // where this process may read them. The WRITERS still see the plant kind + // and heal it; the accounting side must only know the entries are gone. + return { kind: 'refused' }; +} + +function readLedgerFile(planPath: string, path: string): string | null { + const res = readLedgerFileResult(planPath, path); + return res.kind === 'ok' ? res.text : null; +} + +/** + * Was this run's session ledger present but unreadable? + * + * For the consumer that spends rather than certifies. `false` covers both a + * healthy ledger and a run that never wrote one; `true` means the entries + * exist somewhere this process may not follow, so every prior attempt is + * missing from any total computed here. + */ +export function sessionLedgerRefused( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + void env; + return ( + readLedgerFileResult(planPath, runSessionsPath(planPath)).kind === 'refused' + ); +} + +/** + * The resume bookkeeping's health, as one fact per anomaly cell. + * + * The two files vouch for each other: a refused or missing HALF empties the + * prior-session iteration at its source while the other half proves there was + * something to iterate — and each such cell renders a resumed review as a + * fresh single-session run with no warning anywhere. The accounting layer + * discloses on these; the CAP consumer refuses resumes outright on `refused` + * (a bookkeeping tree it cannot read is a cap it cannot enforce — fail + * CLOSED, toward a fresh run). + */ +/** + * Is either bookkeeping file PRESENT-but-unreadable (or its tree redirected)? + * + * The cap consumer refuses resumes on this outright: with both counters + * reading zero through a refused tree, `max(0, 0)` silently un-caps the + * resume chain — the two-counter redundancy collapsing to a single point of + * failure, the containment verdict of one directory. A cap that cannot read + * its own bookkeeping fails CLOSED, toward a fresh run. + */ +export function resumeBookkeepingRefused(planPath: string): boolean { + return ( + readLedgerFileResult(planPath, runSessionsPath(planPath)).kind === + 'refused' || + readLedgerFileResult(planPath, resumeMarkerPath(planPath)).kind === + 'refused' + ); +} + +export function resumeBookkeepingAnomaly( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const sessions = readLedgerFileResult(planPath, runSessionsPath(planPath)); + const marker = readLedgerFileResult(planPath, resumeMarkerPath(planPath)); + if (sessions.kind === 'refused') { + return 'the session ledger could not be read as a contained regular file'; + } + if (marker.kind === 'refused') { + return 'the resume marker could not be read as a contained regular file'; + } + if (marker.kind === 'absent' && sessionEntryCount(planPath) >= 2) { + return ( + 'the session ledger records earlier attempts but the resume marker is ' + + 'missing (deleted, or its write was swallowed)' + ); + } + if ( + sessions.kind === 'absent' && + marker.kind === 'ok' && + resumeAuthorized(planPath, env) + ) { + return ( + 'the resume marker authorizes this session but the session ledger is ' + + 'missing' + ); + } + // Fifth cell: both files read `ok`, the ledger names two or more attempts, + // but the marker does not record THIS session as an authorized resume. + // `priorSessionEntries` gates on `resumeAuthorized`, so it yields nothing + // while the ledger proves there was something to iterate — the cost ledger + // then bills only the current attempt and presents it as complete. Reached + // without an attacker: `recordResume` swallows its marker write on a + // transient fault, or a >2s clock step-back fences the entry out. + if ( + sessions.kind === 'ok' && + marker.kind === 'ok' && + sessionEntryCount(planPath) >= 2 && + !resumeAuthorized(planPath, env) + ) { + return ( + 'the session ledger records earlier attempts but this session is not ' + + 'recorded as an authorized resume' + ); + } + // Sixth cell (the mirror): both `ok`, the marker authorizes this session, + // but the ledger has no entry for it — `currentSessionEntry` null makes the + // cost floor fall back to the plan mtime (billing pre-review turns), and + // `priorSessionEntries` treats every entry as a prior with the newest + // unclamped. Reached when `appendRunSession`'s fault is swallowed while + // `recordResume` lands. + if ( + sessions.kind === 'ok' && + marker.kind === 'ok' && + resumeAuthorized(planPath, env) && + currentSessionEntry(planPath, env) === null + ) { + return ( + 'the resume marker authorizes this session but its entry is missing ' + + 'from the session ledger' + ); + } + return null; } /** @@ -236,7 +433,7 @@ function readLedgerFile(path: string): string | null { */ function readSessions(planPath: string): SessionEntry[] { return parseSessions( - readLedgerFile(runSessionsPath(planPath)), + readLedgerFile(planPath, runSessionsPath(planPath)), planPath, planMtimeMs(planPath), ); @@ -363,7 +560,7 @@ export function appendRunSession( // a SECOND read opened a race: a fault clearing between the two reads // made the guard see a healthy file while `entries` held the empty // fallback. - const occ = ledgerOccupant(path); + const occ = ledgerOccupant(planPath, path); if (occ.kind === 'refused') return; const entries = parseSessions( occ.kind === 'ok' ? occ.text : null, @@ -422,6 +619,94 @@ export function sessionEntryCount( return entries.filter((e) => sessionPathKey(e.sessionId) !== key).length; } +/** + * How many RESUMES this run's ledger records — the entries PAST the first. + * The ledger's first entry is the original run's own session, which is not + * a resume. `excludeSessionId` removes the resuming session's own entry + * from that remainder — and when the resuming session IS the original, the + * exclusion has already removed the first entry, so the original must not + * be subtracted AGAIN: the double subtraction undercounted the cap by one + * and admitted a resume past the cap through the exact backstop path + * (deleted marker, original session resuming) this term exists to hold. + */ +export function ledgerResumeCount( + planPath: string, + opts: { excludeSessionId?: string } = {}, +): number { + const past = readSessions(planPath).slice(1); + if (opts.excludeSessionId === undefined) return past.length; + const key = sessionPathKey(opts.excludeSessionId); + return past.filter((e) => sessionPathKey(e.sessionId) !== key).length; +} + +/** + * The whole resume-cap decision from ONE read of each bookkeeping file. + * + * `resumeBookkeepingRefused` and the two counters each re-resolved the same + * two paths, so a concurrent relinker that kept the tree healthy across the + * guard's read and refused it before the counters read degraded both counts + * to zero — the ruling passed and the cap never fired (an unbounded chain). + * Here every value is derived from the same bytes: refusal and the counts + * cannot observe different states. + */ +export interface ResumeCapSnapshot { + /** Either bookkeeping file is present-but-unreadable (a redirect/fault). */ + refused: boolean; + /** Total ledger entries (current session included). */ + ledgerEntryCount: number; + /** Resume entries past the first, the resuming session excluded. */ + ledgerResumes: number; + /** Recorded resumes in the marker, the resuming session excluded. */ + markerResumes: number; + /** The marker's own resume count, for the post-write attempt number. */ + marker: ResumeMarker; +} + +export function readResumeCapSnapshot( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): ResumeCapSnapshot { + const sessionsRead = readLedgerFileResult( + planPath, + runSessionsPath(planPath), + ); + const markerRead = readLedgerFileResult(planPath, resumeMarkerPath(planPath)); + if (sessionsRead.kind === 'refused' || markerRead.kind === 'refused') { + return { + refused: true, + ledgerEntryCount: 0, + ledgerResumes: 0, + markerResumes: 0, + marker: emptyMarker(), + }; + } + const mtime = planMtimeMs(planPath); + const sessions = parseSessions( + sessionsRead.kind === 'ok' ? sessionsRead.text : null, + planPath, + mtime, + ); + const marker = parseMarker( + markerRead.kind === 'ok' ? markerRead.text : null, + planPath, + mtime, + ); + const currentKey = env['QWEN_CODE_SESSION_ID']?.trim()?.toLowerCase(); + const ledgerResumes = sessions + .slice(1) + .filter((e) => sessionPathKey(e.sessionId) !== (currentKey ?? '')).length; + const markerResumes = marker.resumes.filter( + (r) => r.sessionId.toLowerCase() !== currentKey, + ).length; + return { + refused: false, + ledgerEntryCount: sessions.length, + ledgerResumes, + markerResumes, + marker, + }; +} + /** * Session ids of EARLIER attempts of this same run — the current session * excluded, order preserved, deduplicated by the ledger's own append guard. @@ -563,7 +848,7 @@ export function resumeMarkerPath(planPath: string): string { */ export function readResumeMarker(planPath: string): ResumeMarker { return parseMarker( - readLedgerFile(resumeMarkerPath(planPath)), + readLedgerFile(planPath, resumeMarkerPath(planPath)), planPath, planMtimeMs(planPath), ); @@ -689,7 +974,7 @@ export function recordResume( // empty fallback — which `writeMarker` would then commit, erasing every // recorded resume and restart. const markerPath = resumeMarkerPath(planPath); - const occ = ledgerOccupant(markerPath); + const occ = ledgerOccupant(planPath, markerPath); if (occ.kind === 'refused') return; const marker = parseMarker( occ.kind === 'ok' ? occ.text : null, @@ -721,7 +1006,7 @@ export function recordRestart( if (mtime === null) return; // Single-read decision; see `recordResume`. const markerPath = resumeMarkerPath(planPath); - const occ = ledgerOccupant(markerPath); + const occ = ledgerOccupant(planPath, markerPath); if (occ.kind === 'refused') return; const marker = parseMarker( occ.kind === 'ok' ? occ.text : null, diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index ace5d152da6..a1f4eab1930 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -10,7 +10,7 @@ // that is not a transcript at all — and the reader must degrade to "this is not // evidence" rather than throw and take the whole coverage check down. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { chmodSync, mkdtempSync, @@ -269,11 +269,14 @@ describe('wasGivenTheDiff', () => { }); describe('readRunTranscripts — the run across its sessions', () => { - // A minimal valid transcript: launch prompt only. - const transcript = (agentId: string): string => + // A minimal valid transcript: launch prompt only. Stamped with its owning + // session, as the harness writes them — run-wide reads fail closed on a + // missing stamp, so an unstamped fixture no longer models a real record. + const transcript = (agentId: string, sessionId = 'S1'): string => JSON.stringify({ agentId, agentName: 'general-purpose', + sessionId, type: 'user', message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, }) + '\n'; @@ -315,7 +318,7 @@ describe('readRunTranscripts — the run across its sessions', () => { it('unions prior-session transcripts, marked fromPriorSession', () => { const plan = planWithLedger('S0', 'S1'); - priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + priorFile('S0', 'agent-a0.jsonl', transcript('a0', 'S0')); file('agent-a1.jsonl', transcript('a1')); const recs = readRunTranscripts(plan, undefined, ENV); expect(recs.map((r) => [r.agentId, r.fromPriorSession === true])).toEqual([ @@ -329,7 +332,7 @@ describe('readRunTranscripts — the run across its sessions', () => { // session's transcripts do not exist to any reader. const plan = join(dir, 'qwen-review-pr-7-fetch.json'); writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); - priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + priorFile('S0', 'agent-a0.jsonl', transcript('a0', 'S0')); file('agent-a1.jsonl', transcript('a1')); const recs = readRunTranscripts(plan, undefined, ENV); expect(recs.map((r) => r.agentId)).toEqual(['a1']); @@ -344,7 +347,7 @@ describe('readRunTranscripts — the run across its sessions', () => { it('still throws when the CURRENT session dir is absent', () => { const plan = planWithLedger('S0', 'S1'); - priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + priorFile('S0', 'agent-a0.jsonl', transcript('a0', 'S0')); expect(() => readRunTranscripts(plan, undefined, { QWEN_CODE_PROJECT_DIR: dir, @@ -355,7 +358,7 @@ describe('readRunTranscripts — the run across its sessions', () => { it('applies the since fence to prior-session records too', () => { const plan = planWithLedger('S0', 'S1'); - priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + priorFile('S0', 'agent-a0.jsonl', transcript('a0', 'S0')); const past = new Date(Date.now() - 3600_000); utimesSync(join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), past, past); file('agent-a1.jsonl', transcript('a1')); @@ -435,7 +438,7 @@ describe('readRunTranscripts — the run across its sessions', () => { // fixture writes prior files before endsAtMs, so deleting the clamp // shipped green. const plan = planWithLedger('S0', 'S1'); - priorFile('S0', 'agent-late.jsonl', transcript('late')); + priorFile('S0', 'agent-late.jsonl', transcript('late', 'S0')); // The prior attempt's window closed at the S1 entry (now + 1500 in the // ledger fixture); stamp the file well past it. const future = new Date(Date.now() + 3600_000); @@ -473,11 +476,136 @@ describe('readRunTranscripts — the run across its sessions', () => { expect(recs.map((r) => r.agentId).sort()).toEqual(['a1', 'ap']); }); + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked `subagents` ANCESTOR, not just the leaf directory', + () => { + // The gap a final-component check leaves: each `subagents/` below + // the link stats as an ordinary directory, so the leaf guard passes + // while the whole evidence tree has been redirected out of the harness. + const plan = planWithLedger('S0', 'S1'); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + try { + mkdirSync(join(elsewhere, 'S0'), { recursive: true }); + mkdirSync(join(elsewhere, 'S1'), { recursive: true }); + writeFileSync( + join(elsewhere, 'S0', 'agent-a0.jsonl'), + transcript('a0'), + ); + rmSync(join(dir, 'subagents'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(dir, 'subagents')); + + // The session stays listed — its chat is a separate fact — but no + // directory this run may read. + expect(priorSessionDirs(plan, ENV).map((p) => p.dir)).toEqual([null]); + // ...and the current session's own directory, reached the same way, + // is an infrastructure fault rather than "this run has no agents". + expect(() => readRunTranscripts(plan, undefined, ENV)).toThrow( + TranscriptsUnavailableError, + ); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked transcript LEAF inside a contained directory', + () => { + // The directory is genuinely the harness's; only the file is a link. + // Enumerating a contained directory says nothing about what its entries + // point at, so the leaf open has to refuse this on its own. + const plan = planWithLedger('S0', 'S1'); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + try { + const foreign = join(elsewhere, 'foreign.jsonl'); + writeFileSync(foreign, transcript('forged')); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + symlinkSync(foreign, join(dir, 'subagents', 'S0', 'agent-a0.jsonl')); + file('agent-a1.jsonl', transcript('a1')); + + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'nulls the prior chat when `chats/` is redirected', + () => { + // `O_NOFOLLOW` guards the LEAF; a redirected `chats/` puts an ordinary + // regular file behind an ordinary name, so only the accessor's + // directory check stands between a forged stream and the cost ledger. + const plan = planWithLedger('S0', 'S1'); + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + const elsewhere = mkdtempSync(join(tmpdir(), 'elsewhere-')); + try { + writeFileSync(join(elsewhere, 'S0.jsonl'), '{"forged":true}\n'); + mkdirSync(join(dir, 'chats'), { recursive: true }); + rmSync(join(dir, 'chats'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(dir, 'chats')); + + const priors = priorSessionDirs(plan, ENV); + expect(priors.map((p) => p.sessionId)).toEqual(['S0']); + expect(priors[0].chatFile).toBeNull(); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'discloses a REFUSED prior directory on the ordinary detection path', + () => { + // The refusal is found by the accessor's walk, so the listing — and the + // catch that discloses a listing fault — never runs. Without a + // disclosure here, the one path this machinery exists to detect is the + // one path that says nothing, and every gate silently re-demands the + // redirected attempt's chunks. + const plan = planWithLedger('S0', 'S1'); + const outside = mkdtempSync(join(tmpdir(), 'elsewhere-')); + file('agent-a1.jsonl', transcript('a1')); + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let printed: string; + try { + writeFileSync(join(outside, 'agent-foreign.jsonl'), transcript('x')); + symlinkSync(outside, join(dir, 'subagents', 'S0')); + readRunTranscripts(plan, undefined, ENV); + } finally { + printed = err.mock.calls.map((c) => String(c[0])).join(''); + err.mockRestore(); + rmSync(outside, { recursive: true, force: true }); + } + + expect(printed).toContain('S0'); + expect(printed).toContain('not contained in the harness tree'); + }, + ); + + it('keeps a prior session that never launched an agent', () => { + // The ledger entry lands at fetch-pr time; the harness creates + // `subagents/` only on the first launch. An attempt interrupted + // before that — the state resume exists to recover — has a real chat + // stream and no directory, and dropping it from the listing lost its + // main-loop cost with nothing said. + const plan = planWithLedger('S0', 'S1'); + file('agent-a1.jsonl', transcript('a1')); + + const priors = priorSessionDirs(plan, ENV); + expect(priors.map((p) => [p.sessionId, p.dir])).toEqual([['S0', null]]); + expect(priors[0].chatFile).toBe(join(dir, 'chats', 'S0.jsonl')); + // ...and it contributes no transcripts, without faulting. + expect( + readRunTranscripts(plan, undefined, ENV).map((r) => r.agentId), + ).toEqual(['a1']); + }); + it('lists each prior session once, and only those that exist', () => { const plan = planWithLedger('S0', 'S0', 'S1'); // A prior session that exists on disk: the accessor skips a ledgered id // whose directory is absent or symlinked, so the fixture must be real. - priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + priorFile('S0', 'agent-a0.jsonl', transcript('a0', 'S0')); expect(priorSessionDirs(plan, ENV).map((p) => p.dir)).toEqual([ join(dir, 'subagents', 'S0'), ]); @@ -485,10 +613,11 @@ describe('readRunTranscripts — the run across its sessions', () => { }); describe('readRunTranscripts — currentDirOptional', () => { - const transcript = (agentId: string): string => + const transcript = (agentId: string, sessionId = 'S1'): string => JSON.stringify({ agentId, agentName: 'general-purpose', + sessionId, type: 'user', message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, }) + '\n'; @@ -501,7 +630,7 @@ describe('readRunTranscripts — currentDirOptional', () => { mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); writeFileSync( join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), - transcript('a0'), + transcript('a0', 'S0'), ); const env = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S-new' }; @@ -536,10 +665,11 @@ describe('readRunTranscripts — currentDirOptional', () => { }); describe('readRunTranscripts — containment and fault handling', () => { - const transcript = (agentId: string): string => + const transcript = (agentId: string, sessionId = 'S1'): string => JSON.stringify({ agentId, agentName: 'general-purpose', + sessionId, type: 'user', message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, }) + '\n'; @@ -568,14 +698,20 @@ describe('readRunTranscripts — containment and fault handling', () => { const plan = planWithLedger('S0', 'S1'); const outside = join(dir, 'outside'); mkdirSync(outside, { recursive: true }); - writeFileSync(join(outside, 'agent-foreign.jsonl'), transcript('foreign')); + writeFileSync( + join(outside, 'agent-foreign.jsonl'), + transcript('foreign', 'S0'), + ); symlinkSync(outside, join(dir, 'subagents', 'S0')); mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); file('agent-a1.jsonl', transcript('a1')); const recs = readRunTranscripts(plan, undefined, ENV); expect(recs.map((r) => r.agentId)).toEqual(['a1']); - expect(priorSessionDirs(plan, ENV)).toEqual([]); + // No readable directory. The session itself stays listed: whether its + // chat can be billed is an independent question from whether its + // subagent directory is a redirect. + expect(priorSessionDirs(plan, ENV).map((p) => p.dir)).toEqual([null]); }); it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( @@ -587,7 +723,7 @@ describe('readRunTranscripts — containment and fault handling', () => { mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); writeFileSync( join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), - transcript('a0'), + transcript('a0', 'S0'), ); const cur = join(dir, 'subagents', 'S1'); mkdirSync(cur, { recursive: true }); diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index 8351a255acb..2f358f93d32 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -37,12 +37,22 @@ // This module never takes a path from the model. The session id and project dir // come from the environment the CLI itself exported. -import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { ToolNames, sanitizeFilenameComponent, } from '@qwen-code/qwen-code-core'; +import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js'; import { join } from 'node:path'; +import { readdirSync } from 'node:fs'; +import { + MAX_LEDGER_BYTES, + MAX_STREAM_BYTES, + UncontainedPathError, + containedDir, + containedRoot, + listContainedDir, + readContainedFileOrNull, +} from './contained-read.js'; import { priorSessionEntries } from './run-ledger.js'; /** One subagent, as the harness recorded it. */ @@ -259,12 +269,14 @@ function rangeOf(args: Record): [number, number] | null { * it and `diffToolCalls` is populated; omit it and the field stays 0. */ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { - let raw: string; - try { - raw = readFileSync(file, 'utf8'); - } catch { - return null; - } + // One contained open for the bytes AND the mtime. The pair this replaced — + // `readFileSync` here, `statSync` at the end — resolved the pathname twice, + // so the record's membership stamp could come from a different object than + // its content. `O_NOFOLLOW` also refuses a leaf link outright: enumerating a + // contained directory says nothing about what its entries point at. + const opened = readContainedFileOrNull(file, MAX_STREAM_BYTES); + if (opened === null) return null; + const raw = opened.content; const lines = raw.split('\n').filter((l) => l.trim()); if (lines.length === 0) return null; @@ -407,12 +419,9 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { if (!agentId) return null; if (sessionConflict) return null; - let mtimeMs = 0; - try { - mtimeMs = statSync(file).mtimeMs; - } catch { - /* gone between readdir and stat */ - } + // From the descriptor the content came off, so the membership fence and the + // evidence it fences are the same object by construction. + const mtimeMs = opened.mtimeMs; return { agentId, @@ -447,11 +456,14 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { */ function diedPerSidecar(transcriptFile: string): boolean { const metaPath = transcriptFile.replace(/\.jsonl$/, '.meta.json'); + // The same contained discipline as the transcript leaf beside it: a + // planted symlink or FIFO at the sidecar path must not redirect or hang + // this read. Unreadable stays "proves nothing" — content inference stands. + const meta = readContainedFileOrNull(metaPath, MAX_LEDGER_BYTES); + if (meta === null) return false; try { - const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as { - status?: unknown; - }; - return typeof meta.status === 'string' && meta.status !== 'completed'; + const parsed = JSON.parse(meta.content) as { status?: unknown }; + return typeof parsed.status === 'string' && parsed.status !== 'completed'; } catch { return false; } @@ -468,8 +480,33 @@ function diedPerSidecar(transcriptFile: string): boolean { * does with that (name the fault, or treat an absent dir as "no agents") is * its decision. */ -export function listAgentTranscriptFiles(dir: string): string[] { - return readdirSync(dir).filter((name) => name.endsWith('.jsonl')); +export function listAgentTranscriptFiles(dir: string, root: string): string[] { + // Ancestors first: `subagents` sits between the project dir and this one, + // and a link there redirects every session's evidence at once. Refused as a + // FAULT, not as "no agents" — the callers already separate an absent + // directory (a run that launched nothing) from one that cannot be read, and + // a containment failure belongs on the loud side of that line. + const res = containedDir(root, dir); + if (!res.ok) { + // A directory that is simply not there yet keeps the errno contract this + // function has always had: `readdirSync` raises the real ENOENT, and the + // callers that legitimately absorb a pre-launch absence keep absorbing + // exactly that and nothing else. + if (res.reason === 'missing') { + // Raise the real errno rather than a synthetic one: `readdirSync` on an + // absent path throws the ENOENT the callers route on. If it somehow + // succeeds — created between the walk and this line — the listing was + // never validated, so nothing is returned from it. + readdirSync(dir); + return []; + } + throw new UncontainedPathError( + `${dir} is not a contained directory under ${root}`, + ); + } + return listContainedDir(dir, res.identity).filter((name) => + name.endsWith('.jsonl'), + ); } /** @@ -486,18 +523,33 @@ export function readTranscripts( env: NodeJS.ProcessEnv = process.env, diffPath?: string, ): AgentRecord[] { - const dir = transcriptDir(env); + const { projectDir, dir } = transcriptPaths(env); let names: string[]; try { - names = listAgentTranscriptFiles(dir); + // Rooted at the harness's project dir: this session's own evidence gets + // the same ancestor walk a prior session's does. A guard that applies to + // recovered evidence but not to live evidence is how a threat model rots. + names = listAgentTranscriptFiles(dir, projectDir); } catch (err) { // No directory at all is an *infrastructure* fact, not a verdict about the // agents. Conflating the two would let a read-only HOME or a full disk read // as "every agent idled" and block every review with no diagnosable cause. + // A CONTAINMENT refusal is not an absence, and the message must not say + // it is: every consumer branches on the error type, never on the cause, + // so this sentence is the diagnosis that reaches the operator. Framing a + // detected redirect as "the harness could not write them" points at the + // write path when the actual fact is the security-relevant one. + const uncontained = + ((err as { code?: string }).code ?? + (err as { cause?: { code?: string } }).cause?.code) === 'EUNCONTAINED'; throw new TranscriptsUnavailableError( - `no subagent transcripts at ${dir} (${(err as Error).message}). The ` + - 'harness writes one per agent; if there are none, either no agents ran ' + - 'or the harness could not write them.', + uncontained + ? `the subagent transcript directory ${dir} is not contained in the ` + + `harness tree (${(err as Error).message}). This run cannot read ` + + 'evidence through a path it cannot vouch for.' + : `no subagent transcripts at ${dir} (${(err as Error).message}). The ` + + 'harness writes one per agent; if there are none, either no agents ran ' + + 'or the harness could not write them.', // The original errno travels with it: a caller that tolerates "the dir // does not exist yet" must be able to tell that apart from EACCES/EIO, // and the flattened message string cannot say which it was. @@ -579,35 +631,84 @@ export function priorSessionDirs( env: NodeJS.ProcessEnv = process.env, ): Array<{ sessionId: string; - dir: string; - chatFile: string; + /** + * The attempt's subagent directory, or null when there is none this run may + * read — absent (it never launched an agent) or not contained. + * + * An attempt has a ledger entry from the moment `fetch-pr` runs, while the + * harness creates `subagents/` only on the FIRST launch. An attempt + * interrupted before that — the very state resume exists to recover — is + * therefore a real session with a real chat stream and no directory here. + * Dropping it from the listing entirely lost its main-loop cost silently. + */ + dir: string | null; + /** + * Why `dir` is null: true when a directory IS there and was refused, false + * when there is simply none. + * + * The two must not collapse. An attempt that never launched an agent owes + * nothing and costs nothing; a REDIRECTED one is the fabrication event this + * machinery exists to detect, and its agents' cost is real money the ledger + * would otherwise omit from a total it still presents as complete. The + * consumers disclose on this flag — without it the detection path is the + * one path that says nothing. + */ + dirRefused: boolean; + /** + * The attempt's chat stream, or null when `chats/` is not a contained + * directory. Null means "do not read this session's chat", and callers must + * not rebuild the path themselves — a locally re-joined pathname is exactly + * the guard bypass this accessor exists to prevent. + */ + chatFile: string | null; /** When the NEXT attempt began — this one's upper window. */ endsAtMs: number | null; }> { const { projectDir } = transcriptPaths(env); const out: Array<{ sessionId: string; - dir: string; - chatFile: string; + dir: string | null; + dirRefused: boolean; + chatFile: string | null; endsAtMs: number | null; }> = []; + // The harness tree's own root. A linked or absent project dir leaves nothing + // below it that can be called contained evidence. + const rootVerdict = containedRoot(projectDir); + if (!rootVerdict.ok) return out; + const root = rootVerdict.root; + // `chats/` is validated once, not per session: it is the same directory for + // every entry, and the leaf open below still refuses a per-file link. + const chatsDir = join(root, 'chats'); + // ABSENT is not a containment verdict. `chats/` is created lazily on the + // first recorded turn, so a run with recording off has no directory and no + // file — the leaf open then fails with ENOENT, which every consumer already + // treats as the ordinary "this attempt recorded no chat" state. Nulling the + // path for that would make the consumers' disclosure fire on a mundane + // configuration fact. Null here means exactly one thing: a redirect was + // found where the harness's own directory should be. + const chatsVerdict = containedDir(root, chatsDir); + const chatsOk = chatsVerdict.ok || chatsVerdict.reason === 'missing'; for (const { sessionId, endsAtMs } of priorSessionEntries(planPath, env)) { - const dir = join( - projectDir, - 'subagents', - sanitizeFilenameComponent(sessionId), - ); - try { - if (lstatSync(dir).isSymbolicLink()) continue; - } catch { - // Absent (the attempt died before launching any agent) or unstattable: - // either way there is nothing here this run may read. - continue; - } + // The harness writes the directory under the SANITIZED id, so the lookup + // applies the same mapping before the containment walk. + const dir = join(root, 'subagents', sanitizeFilenameComponent(sessionId)); + // Every component from the project dir down — `subagents` included. The + // final-component check this replaced left the shared parent open: one + // link at `subagents` redirects every prior session at once, and each + // individual `subagents/` under it stats as a perfectly ordinary + // directory. + // + // A failure here nulls the DIRECTORY and keeps the session: the two facts + // are independent, and folding them lost the chat of every attempt that + // died before its first agent launch. + const verdict = containedDir(root, dir); out.push({ sessionId, - dir, - chatFile: join(projectDir, 'chats', `${sessionId}.jsonl`), + dir: verdict.ok ? dir : null, + // ABSENT is not a refusal — the attempt simply never launched an agent. + dirRefused: !verdict.ok && verdict.reason !== 'missing', + chatFile: chatsOk ? join(chatsDir, `${sessionId}.jsonl`) : null, endsAtMs, }); } @@ -647,8 +748,10 @@ export function readRunTranscripts( opts: { currentDirOptional?: boolean } = {}, ): AgentRecord[] { // Validates the env first, so the optional-dir branch below can only ever - // be absorbing "no directory yet", never "no environment". - transcriptPaths(env); + // be absorbing "no directory yet", never "no environment" — and the + // validated value is KEPT rather than re-derived per prior session, which + // is what `transcriptPaths`' own contract asks callers to do. + const { projectDir } = transcriptPaths(env); const priors = priorSessionDirs(planPath, env); let out: AgentRecord[]; try { @@ -677,13 +780,43 @@ export function readRunTranscripts( out = []; } for (const prior of priors) { + if (prior.dir === null) { + // A REFUSED directory is the detection this machinery exists for, and + // it used to be the one path that said nothing: the listing never runs, + // so the fault-disclosing catch below could only fire inside a TOCTOU + // race between the accessor's walk and its own. Absent stays silent — + // an attempt that launched no agent owes nothing. + if (prior.dirRefused) { + writeStderrLineSafe( + `WARNING: the prior attempt ${prior.sessionId}'s subagent ` + + `directory is not contained in the harness tree; that attempt's ` + + `evidence is not visible to this run, so its work is required ` + + `again.`, + ); + } + continue; + } + const priorDir = prior.dir; let names: string[]; try { - names = listAgentTranscriptFiles(prior.dir); - } catch { + names = listAgentTranscriptFiles(priorDir, projectDir); + } catch (err) { + // Absent is the ordinary state. A CONTAINMENT fault is not: it is the + // swap this machinery exists to detect, and swallowing it makes every + // resumed run silently re-demand the same chunks with nothing said. + // The cost ledger discloses the identical shape; so does this. + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + writeStderrLineSafe( + `WARNING: could not read the prior attempt's subagent transcripts ` + + `at ${priorDir} ` + + `(${(err as NodeJS.ErrnoException)?.code ?? (err as Error).message}); ` + + `that attempt's evidence is not visible to this run, so its work ` + + `is required again.`, + ); + } continue; // Earlier attempt's evidence invisible → its work is re-owed. } - for (const rec of recordsIn(prior.dir, names, since, diffPath, { + for (const rec of recordsIn(priorDir, names, since, diffPath, { sessionId: prior.sessionId, until: prior.endsAtMs ?? undefined, })) { @@ -691,7 +824,14 @@ export function readRunTranscripts( out.push(rec); } } - return out; + // Run-wide reads fail CLOSED on a missing session stamp. The acceptance of + // unstamped records exists for older harness writes on the LIVE path; a + // run-wide reader only ever meets transcripts a run new enough to keep the + // session ledger produced, and those carry stamps — while a planted + // transcript in the same blast radius pairs by shape alone unless a stamp + // it does not have is demanded. Invisible evidence re-owes the work, the + // failure direction every reader here takes. + return out.filter((rec) => rec.recordedSession !== ''); } /** diff --git a/packages/cli/src/commands/review/parse-args.test.ts b/packages/cli/src/commands/review/parse-args.test.ts index e33a129235e..6cb1246ade0 100644 --- a/packages/cli/src/commands/review/parse-args.test.ts +++ b/packages/cli/src/commands/review/parse-args.test.ts @@ -1401,3 +1401,68 @@ describe('parse-args warns when the bundle is not built from these sources', () expect(writeStdoutLine).toHaveBeenCalled(); }); }); + +describe('--resume', () => { + it('is effective on a PR target', () => { + const r = parseReviewArgs('6711 --resume'); + expect(r.resume).toEqual({ requested: true, effective: true }); + expect(r.warnings).toEqual([]); + }); + + it('is effective on a PR URL target', () => { + const r = parseReviewArgs( + 'https://github.com/QwenLM/qwen-code/pull/6711 --resume', + ); + expect(r.resume).toEqual({ requested: true, effective: true }); + }); + + it('is ignored with a warning on a local target', () => { + const r = parseReviewArgs('--resume'); + expect(r.resume).toEqual({ requested: true, effective: false }); + expect(r.warnings.some((w) => w.includes('`--resume`'))).toBe(true); + }); + + it('is ignored with a warning on a FILE target too', () => { + // The other member of the `!isPr` class, which SKILL.md names alongside + // local. A gate written as `target.type !== 'local'` reports the flag + // effective here — on a target shape with no `fetch-pr` call to consume + // it — and every local-target test stays green. + const r = parseReviewArgs('src/foo.ts --resume'); + expect(r.resume).toEqual({ requested: true, effective: false }); + expect(r.warnings.some((w) => w.includes('`--resume`'))).toBe(true); + }); + + it('is absent by default', () => { + const r = parseReviewArgs('6711'); + expect(r.resume).toEqual({ requested: false, effective: false }); + }); + + it('keeps an explicit effort untouched on the EFFECTIVE path', () => { + // The missing corner of the matrix: the other three cells are covered, + // and this is the one an effort-forcing mutation on the effective path + // would slip through — the shape the sibling `--comment` bug took when + // it shipped. + const r = parseReviewArgs('6711 --resume --effort low'); + expect(r.resume).toEqual({ requested: true, effective: true }); + expect(r.effort).toBe('low'); + expect(r.effortSource).toBe('explicit'); + }); + + it('does not change the effort resolution', () => { + const r = parseReviewArgs('6711 --resume'); + expect(r.effort).toBe('high'); // the PR default, not a resume effect + expect(r.effortSource).toBe('default'); + }); + + it('an IGNORED --resume must not change the effort either', () => { + // The sibling `--comment` has this test because the bug shipped once: + // a flag ignored for the target still forced the level. + const r = parseReviewArgs('--resume --effort low'); + expect(r.resume).toEqual({ requested: true, effective: false }); + expect(r.effort).toBe('low'); + expect(r.effortSource).toBe('explicit'); + const d = parseReviewArgs('--resume'); + expect(d.effort).toBe('medium'); // the local default, untouched + expect(d.effortSource).toBe('default'); + }); +}); diff --git a/packages/cli/src/commands/review/parse-args.ts b/packages/cli/src/commands/review/parse-args.ts index cf05a13e3a5..5dab985aa35 100644 --- a/packages/cli/src/commands/review/parse-args.ts +++ b/packages/cli/src/commands/review/parse-args.ts @@ -99,6 +99,24 @@ export interface ParsedReviewArgs { */ severityFloor: ReviewSeverityFloor | 'auto'; severityFloorSource: 'explicit' | 'configured' | 'default'; + /** + * `--resume`: continue an interrupted run of this same target instead of + * starting over — Step 1 passes it to `fetch-pr --resume`, which rules on + * the on-disk state itself and silently falls back to a fresh run when the + * state no longer matches. Gated on PR targets: only `fetch-pr` has a + * resume path (a local review's diff is captured from a live working tree + * that has no stable interrupted state to continue). `effective` is a + * TARGET-SHAPE gate, not a promise: a cross-repo `pr-url` with no matching + * remote routes to lightweight mode, which never calls `fetch-pr` — the + * parser cannot see remotes, so Step 1's lightweight branch owns telling + * the user the flag is inert there. + */ + resume: { + /** `--resume` appeared in the arguments. */ + requested: boolean; + /** `--resume` applies (the target is a PR). */ + effective: boolean; + }; /** Non-flag tokens beyond the first target token, reported not guessed. */ extraTokens: string[]; /** Unrecognized `--flags`, reported not guessed. */ @@ -267,6 +285,7 @@ export function parseReviewArgs( let commentRequestedByFlag = false; let fixRequested = false; + let resumeRequested = false; let explicitEffort: ReviewEffort | null = null; let explicitFloor: ReviewSeverityFloor | 'auto' | null = null; @@ -341,6 +360,11 @@ export function parseReviewArgs( continue; } + if (token === '--resume') { + resumeRequested = true; + continue; + } + if (token === '--effort' || token.startsWith('--effort=')) { if (token.includes('=')) { // `--effort=`: self-contained; never consumes a second token. @@ -554,6 +578,13 @@ export function parseReviewArgs( ); } + const resumeEffective = resumeRequested && isPr; + if (resumeRequested && !isPr) { + warnings.push( + 'Warning: `--resume` flag is ignored because the review target is not a PR — only a PR review has interrupted state to continue.', + ); + } + // `--fix` edits a working tree, so it needs one that outlives the review. A // PR review's tree is the ephemeral worktree Step 9 removes; a `local` or // `file` review's tree is the user's own checkout. @@ -715,6 +746,7 @@ export function parseReviewArgs( fix: { requested: fixRequested, effective: fixEffective }, severityFloor, severityFloorSource, + resume: { requested: resumeRequested, effective: resumeEffective }, extraTokens, unknownFlags, warnings, @@ -759,7 +791,7 @@ function reviewDefaultsFromSettings(): { export const parseArgsCommand: CommandModule = { command: 'parse-args [raw]', describe: - 'Parse the /review skill argument string (--comment, --fix, --effort, --severity-floor, target disambiguation) and emit the verdict as JSON; pass the string on stdin via --stdin (a positional that begins with a dash never reaches this handler — yargs rejects it as an unknown flag)', + 'Parse the /review skill argument string (--comment, --fix, --resume, --effort, --severity-floor, target disambiguation) and emit the verdict as JSON; pass the string on stdin via --stdin (a positional that begins with a dash never reaches this handler — yargs rejects it as an unknown flag)', builder: (yargs) => yargs .positional('raw', { diff --git a/packages/cli/src/commands/review/recover-findings.test.ts b/packages/cli/src/commands/review/recover-findings.test.ts new file mode 100644 index 00000000000..15ab10d6951 --- /dev/null +++ b/packages/cli/src/commands/review/recover-findings.test.ts @@ -0,0 +1,1183 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The recovery command against the shapes real interrupted runs leave: a +// certified agent in the dead session, an uncertified one, a transcript that +// verbatim-matches two records (the injectivity refusal), and the findings +// lists earlier rounds wrote. Fixtures are files in a real temp dir — the +// same discipline as check-coverage.test.ts, whose pairing this reuses. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + chmodSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + statSync, + writeFileSync, + readFileSync, + mkdirSync, + utimesSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { createHash } from 'node:crypto'; +import yargs from 'yargs'; +import type { Argv } from 'yargs'; +import { recoverFindings, recoverFindingsCommand } from './recover-findings.js'; +import { + promptRecordDir, + briefPath, + findingsFilePath, + recordPrompt, +} from './lib/prompt-record.js'; +import { appendRunSession, recordResume } from './lib/run-ledger.js'; + +let dir: string; +let ENV: NodeJS.ProcessEnv; +let plan: string; +let DIFF: string; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'recover-'))); + ENV = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S1' }; + mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + plan = join(dir, 'plan.json'); + DIFF = join(dir, 'diff.txt'); + writeFileSync( + plan, + JSON.stringify({ diffPathAbsolute: DIFF, diffLines: 10, chunks: [] }), + ); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + const recordDir = promptRecordDir(plan); + mkdirSync(recordDir, { recursive: true }); + // Written by the real writers: the entries carry the plan mtime they are + // keyed on, and reading prior evidence at all requires this session's own + // recorded resume. The current attempt is stamped last, since each + // attempt's window closes when the next one opened. + const now = Date.now(); + appendRunSession(plan, { QWEN_CODE_SESSION_ID: 'S0' }, now); + appendRunSession(plan, { QWEN_CODE_SESSION_ID: 'S1' }, now + 1500); + recordResume(plan, ENV, now + 1500); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +/** A CLI-built prompt record plus its brief, under `key`. */ +function built(key: string): string { + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = `You are ${key}.\nread_file(file_path="${brief}")`; + recordPrompt(plan, key, prompt); + return prompt; +} + +/** A harness transcript in `session`, launched with `launch`. */ +function transcript( + session: string, + id: string, + launch: string, + opts: { + opens?: string[]; + finalText?: string; + /** Append one more tool call AFTER the text — the died-mid-work shape. */ + trailingCall?: boolean; + } = {}, +): void { + const base = { + agentId: id, + agentName: 'general-purpose', + sessionId: session, + }; + const lines = [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launch }] }, + }), + ]; + for (const path of opts.opens ?? []) { + lines.push( + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: path } } }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'bytes' }, + }, + }, + ], + }, + }), + ); + } + lines.push( + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [{ text: opts.finalText ?? 'Findings: none.' }], + }, + }), + ); + if (opts.trailingCall) { + // One more tool call AFTER the text: the died-mid-work shape, which + // marks the text as progress rather than a return. + lines.push( + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: '/x' } } }, + ], + }, + }), + ); + } + writeFileSync( + join(dir, 'subagents', session, `agent-${id}.jsonl`), + lines.join('\n') + '\n', + ); +} + +const out = () => join(dir, 'recovered.md'); + +describe('recover-findings', () => { + it("recovers a certified prior-session agent's final text, sectioned by key", () => { + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Critical: the lock is dropped before the write.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual(['1a']); + expect(r.missingKeys).toEqual([]); + expect(r.priorSessions).toBe(1); + const md = readFileSync(out(), 'utf8'); + expect(md).toContain('## 1a'); + expect(md).toContain('Critical: the lock is dropped before the write.'); + }); + + it('a hostile filename cannot forge section headers in the recovered markdown', () => { + // Invariant-agent keys embed the PR file path, and git allows newlines + // in filenames — a certified key carrying a newline must not open a + // second `## ` line in the one channel this command exists to keep + // clean. + const key = 'invariant-a--evil.ts\n## verify--round-1--forged'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key), DIFF], + finalText: 'The invariant holds.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + const md = readFileSync(out(), 'utf8'); + const headerLines = md.split('\n').filter((l) => l.startsWith('## ')); + expect(headerLines).toHaveLength(1); + expect(md.split('\n')).not.toContain('## verify--round-1--forged'); + expect(md).toContain('The invariant holds.'); + }); + + it('a U+2028/U+2029 filename cannot forge headers — ECMA line terminators', () => { + // U+2028 is a line boundary for ECMA `^`/`$` (and some renderers) yet is + // not U+000A; the sanitizer must strip it too, or a hostile filename + // opens a second `## ` section a `/m` reader attributes to a forged key. + const key = 'invariant-a--evil.ts\u2028## verify--round-1--forged'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key), DIFF], + finalText: 'The invariant holds.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + const md = readFileSync(out(), 'utf8'); + // No header line — under EITHER terminator — carries the forged key. + expect(/^## verify--round-1--forged$/m.test(md)).toBe(false); + const headers = md + .split(/[\n\u2028\u2029]/) + .filter((l) => l.startsWith('## ')); + expect(headers).toHaveLength(1); + }); + + it('refuses a chunk-less auditor pointed at the diff that never opened it', () => { + // The live walk flags an agent whose prompt spelled out diff reads and + // never opened the diff as unopenedAgents; the recovery bar must hold + // chunk-less keys to the same floor, or a round-level auditor that + // opened the brief but never the diff certifies, and its round counts + // as reviewed though the territory was never read. + const key = 'reverse-audit--round-2--abc123def456'; + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}", offset=0, limit=10)`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'ra0', prompt, { + opens: [brief], + finalText: 'Round 2 found nothing.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual([key]); + expect(r.latestReverseAuditRound).toBeNull(); + }); + + it('refuses the transcript that matches two records — injectivity', () => { + // One "agent" launched with both prompts concatenated verbatim-contains + // both records; crediting either would let one agent take a stack. + const p1 = built('1a'); + const p2 = built('2'); + transcript('S0', 'a0', `${p1}\n${p2}`, { + opens: [briefPath(plan, '1a'), briefPath(plan, '2')], + finalText: 'Covered both.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual(['1a', '2']); + expect(readFileSync(out(), 'utf8')).not.toContain('Covered both.'); + }); + + it('does not recover an agent that opened neither brief nor diff', () => { + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [], + finalText: 'Confident prose, no evidence.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual(['1a']); + }); + + it('re-owes a key with two certified transcripts rather than picking by mtime', () => { + // Transcript mtime is attacker-settable: a same-session plant with a + // newer mtime used to displace the genuine certified text. Two certified + // transcripts for one key are now ambiguous — the key falls to + // missingKeys and the resumed run relaunches it (a legitimate relaunch + // pays the same re-run; the planter cannot win the tie). + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Genuine: Critical found.', + }); + transcript('S0', 'a0b', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Planted: No issues found.', + }); + + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual(['1a']); + }); + + it('enumerates the findings lists with their rounds, in-dir only', () => { + const recordDir = promptRecordDir(plan); + const key = 'reverse-audit--round-2--abc123def456'; + const list = join(recordDir, `${encodeURIComponent(key)}.findings.md`); + writeFileSync(list, '- R1-1 …'); + // The builder records a content digest beside every list it writes; + // recovery refuses a list without one (fail closed on authorship). + writeFileSync( + `${list}.sha256`, + createHash('sha256').update('- R1-1 …').digest('hex'), + ); + // Enumeration is corroborated: the file is listed because a CERTIFIED + // transcript's recorded prompt points at it (the brief and the list + // both read, so the bar clears). + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'ra2', prompt, { + opens: [brief, list], + finalText: 'Round 2: no new issues after a full walk.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.findingsFiles).toEqual([{ key, path: list, round: 2 }]); + }); + + it('refuses a built record whose bytes no longer hash to its digest sidecar', () => { + // The digest gate in isolation: a normally-recoverable key, then the + // `.txt` is overwritten AFTER `recordPrompt` wrote the genuine digest. + // The transcript still pairs against the ORIGINAL prompt it was launched + // with, but `readRecordedPrompts` (digest-required on the recovery path) + // reads the tampered bytes, hashes them, and — mismatch — drops the key + // from `built`, so nothing pairs and the key is re-owed. + const prompt = built('1a'); // writes .txt + genuine .sha256 + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Critical: the lock is dropped before the write.', + }); + // Sanity: it recovers before tampering. + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + '1a', + ]); + // Remove a line — pairing still holds (the launch is a superset) and the + // meetsBar floors are unaffected, so ONLY the digest gate can catch it. + // The genuine sidecar no longer matches the trimmed bytes. + const trimmed = prompt.split('\n').slice(1).join('\n'); + writeFileSync( + join(promptRecordDir(plan), `${encodeURIComponent('1a')}.txt`), + trimmed, + ); + const r = recoverFindings({ plan, out: out() }, ENV); + // Digest mismatch drops the record from the built set, so the forged + // requirements never certify anything and the key is re-derived fresh. + expect(r.recoveredKeys).toEqual([]); + }); + + it('refuses everything when the plan mtime was rewound below fetchedAt — epoch resurrection', () => { + // The run epoch (plan mtime) is what the fences key on; rewinding it to + // a prior run's epoch resurrects that run's records. `fetchedAt` is the + // corroborating clock — a live mtime meaningfully earlier than it is a + // rewind, and every fenced read is skipped. + const key = '1a'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key)], + finalText: 'Critical: the lock is dropped before the write.', + }); + // Baseline: with the plan's own (2020) mtime, the S0 transcript recovers. + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + key, + ]); + // Now the report claims a fetchedAt an hour AFTER the live plan mtime — + // the rewind shape. Recovery comes back empty and flags it. + writeFileSync( + plan, + JSON.stringify({ + diffPathAbsolute: DIFF, + diffLines: 10, + chunks: [], + fetchedAt: new Date(statSync(plan).mtimeMs + 3_600_000).toISOString(), + }), + ); + utimesSync(plan, new Date(2020, 0, 1), new Date(2020, 0, 1)); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.epochRewound).toBe(true); + expect(r.recoveredKeys).toEqual([]); + expect(r.findingsFiles).toEqual([]); + }); + + it('refuses a corroborated list whose CONTENT was overwritten after the read', () => { + // The R8-34 shape: certification stays genuine (the agent read the + // genuine list), the pointer still corroborates the PATH — and the + // overwrite lands after the read, before the resume. The digest the + // builder recorded at write time no longer matches, so the forged + // snapshot reads as absent. + const recordDir = promptRecordDir(plan); + const key = 'reverse-audit--round-2--abc123def456'; + const list = join(recordDir, `${encodeURIComponent(key)}.findings.md`); + writeFileSync(list, '- the genuine cumulative list'); + writeFileSync( + `${list}.sha256`, + createHash('sha256') + .update('- the genuine cumulative list') + .digest('hex'), + ); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'ra2', prompt, { + opens: [brief, list], + finalText: 'Round 2: no new issues after a full walk.', + }); + // The overwrite: content replaced, sidecar left behind (rewriting it + // too is the disclosed two-file residual, not this probe's shape). + writeFileSync(list, '- FORGED: real Criticals erased'); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + expect(r.findingsFiles).toEqual([]); + }); + + it('reports the latest certified reverse-audit round', () => { + for (const round of [1, 2]) { + const key = `reverse-audit--round-${round}--abc123def456`; + const prompt = built(key); + transcript('S0', `ra${round}`, prompt, { + opens: [briefPath(plan, key)], + finalText: `Round ${round}: no new issues after a full territory walk.`, + }); + } + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.latestReverseAuditRound).toBe(2); + }); + + it('recovers with the NEW session dir absent — the pre-launch state', () => { + // A resumed run calls this before launching any agent, so the current + // session's transcript dir does not exist yet. That must not read as an + // infrastructure failure. + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Recovered before any new launch.', + }); + const freshEnv = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S9' }; + appendRunSession(plan, freshEnv, Date.now() + 1500); + recordResume(plan, freshEnv, Date.now() + 1500); + const r = recoverFindings({ plan, out: out() }, freshEnv); + expect(r.recoveredKeys).toEqual(['1a']); + expect(readFileSync(out(), 'utf8')).toContain( + 'Recovered before any new launch.', + ); + }); + + it('refuses an --out that would overwrite the plan', () => { + expect(() => recoverFindings({ plan, out: plan }, ENV)).toThrow( + /must not overwrite the plan/, + ); + }); + + it('the CLI option contract: yargs-parsed flags drive the pure function', () => { + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Recovered through the parsed flags.', + }); + const parsed = (recoverFindingsCommand.builder as (y: Argv) => Argv)( + yargs([]), + ).parseSync(['--plan', plan, '--out', out()]) as unknown as Parameters< + typeof recoverFindings + >[0]; + + const r = recoverFindings(parsed, ENV); + expect(r.recoveredKeys).toEqual(['1a']); + expect(readFileSync(out(), 'utf8')).toContain( + 'Recovered through the parsed flags.', + ); + }); +}); + +describe('recover-findings — the guarantees, made falsifiable', () => { + it('refuses a launch that diverged from the recorded prompt', () => { + // The verbatim-delivery check is the core of the certification; no + // fixture diverged from it before, so dropping it shipped green. + const prompt = built('1a'); + transcript('S0', 'a0', prompt.replace('You are', 'You were'), { + opens: [briefPath(plan, '1a')], + finalText: 'Rewritten launch, plausible prose.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual(['1a']); + }); + + it('does not recover an agent whose final text is empty', () => { + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: ' ', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + }); + + it('vetoes a return that declares a chunk uncoverable', () => { + // Production shape: a chunk agent's launch prompt carries its `chunk N + // of M` line — that line is how BOTH pipeline authorities assign the + // record, and the veto keys on the record's own assignment exactly as + // they do. + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, 'chunk-1'); + writeFileSync(brief, 'The chunk-1 brief.'); + const prompt = + `You are reviewing chunk 1 of 2.\n` + `read_file(file_path="${brief}")`; + writeFileSync( + join(recordDir, `${encodeURIComponent('chunk-1')}.txt`), + prompt, + ); + writeFileSync( + join(recordDir, `${encodeURIComponent('chunk-1')}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'a0', prompt, { + opens: [brief], + finalText: 'Uncoverable: chunk 1 — a line exceeds the read limit', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + }); + + it('refuses a CHUNK agent that opened its brief but never the diff', () => { + // Coverage requires `diffToolCalls > 0` of a chunk-assigned record. The + // recovery bar was `openedBrief || diffToolCalls > 0`, so this agent's + // plausible prose over zero diff lines was written into the recovery file + // and its key left `missingKeys` — the resumed run then never relaunches + // it. + const prompt = built('chunk-2'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, 'chunk-2')], + finalText: 'No issues found in chunk 2.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + }); + + it('refuses a NON-chunk agent that opened the diff but never its brief', () => { + // The mirror direction: a reverse-audit brief carries the method and the + // cumulative findings list, so an auditor that never opened it did not + // perform the audit however much of the diff it read. + const prompt = built('reverse-audit'); + transcript('S0', 'a0', prompt, { + opens: [DIFF], + finalText: 'Audit complete; nothing further.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + }); + + it('requires the findings list a verifier was told to read', () => { + // The floor the compose-time gate applies to the same key. Without it, + // recovery certifies a verifier that skipped the read and compose then + // rules the very same key `findings-unread`. + const key = 'verify'; + const findings = findingsFilePath(plan, key); + writeFileSync(findings, '- **[Critical]** x.ts:1 — y'); + // Production shape: the POINTER rides the recorded prompt (post-#8597), + // and the floor keys on that pointer — not on a path derived from the + // record key, which never matches for per-chunk reverse-audit keys. + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${findings}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key)], + finalText: 'Verified.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + + // ...and it recovers once the list was actually opened. + transcript('S0', 'a1', prompt, { + opens: [briefPath(plan, key), findings], + finalText: 'Verified, with the list read.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + key, + ]); + }); + + it('refuses a record whose text is progress, not a return', () => { + // finalText keeps the last non-empty assistant message, including + // narration between tool calls; handing a mid-flight-dead agent's + // narration to the resumed orchestrator as certified final text is the + // exact fabrication recovery exists to prevent. + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Reading the brief now…', + trailingCall: true, + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + }); + + it('applies the findings floor to PER-CHUNK reverse-audit keys', () => { + // Their findings file is keyed WITHOUT the chunk, so a key-derived path + // never matches and the floor silently vanished for exactly these + // auditors — compose-time then ruled the same key `findings-unread`. + // The floor keys on the pointer the recorded prompt names. + const key = 'reverse-audit--chunk-3--round-1--abc123def456'; + const roundList = findingsFilePath( + plan, + 'reverse-audit--round-1--abc123def456', + ); + writeFileSync(roundList, '- **[Critical]** x.ts:1 — y'); + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, 'The brief.'); + // Production shape: NO `chunk N of M` line — the per-chunk audit prompt + // never carries one, and the key alone must supply the assignment. + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${roundList}")\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + + // Skips the round list → refused. + transcript('S0', 'a0', prompt, { + opens: [brief], + finalText: 'No issues found — re-walked chunk 3.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + + // Reads it (and the diff — the chunk branch's territory proof) → + // recovered. + transcript('S0', 'a1', prompt, { + opens: [brief, roundList, DIFF], + finalText: 'No issues found — re-walked chunk 3, list compared.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + key, + ]); + }); + + it('refuses a per-chunk auditor that never opened the diff', () => { + // The fifth divergence: with the key parsed chunk-less and no prose + // identity line, the bar dropped the diff-read requirement for exactly + // the auditors whose territory is a chunk — certifying a died-early + // auditor over territory nobody read, while the live walk flags the same + // record unopened. + const key = 'reverse-audit--chunk-4--round-1--abc123def456'; + const roundList = findingsFilePath( + plan, + 'reverse-audit--round-1--abc123def456', + ); + writeFileSync(roundList, '- **[Critical]** x.ts:1 — y'); + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, 'The brief.'); + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${roundList}")\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + + // Brief and list read, diff never opened — the died-early shape. + transcript('S0', 'a0', prompt, { + opens: [brief, roundList], + finalText: 'No issues found — re-walked chunk 4.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + }); + + it('does not veto an auditor that QUOTES an uncoverable declaration', () => { + // The brief instructs verbatim quoting of the evidence, so a certified + // auditor's text legitimately contains the line. Applied raw, the veto + // matched the quotation, dropped the round from recovery, and regressed + // `latestReverseAuditRound` — restarting the audit loop a round early. + const key = 'reverse-audit--round-2--abc123def456'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key)], + finalText: + 'Reviewed the round-1 declarations. One of them reads:\n' + + 'Uncoverable: chunk 1 — a line exceeds the read limit\n' + + 'That declaration is sound.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + expect(r.latestReverseAuditRound).toBe(2); + }); + + it('a PER-CHUNK auditor quoting its own chunk declaration is still recovered', () => { + // The sixth divergence: the veto keyed on `chunkOfKey(key)` while both + // pipeline authorities key it on `assignedChunk(rec)` — null here, since + // the production per-chunk audit prompt carries no `chunk N of M` line. + // A certified auditor QUOTING its own chunk's declaration (the briefs + // mandate verbatim quoting) was dropped from recovery while coverage + // counted the same record as recovered work, and + // `latestReverseAuditRound` regressed a round. + const key = 'reverse-audit--chunk-3--round-1--abc123def456'; + const roundList = findingsFilePath( + plan, + 'reverse-audit--round-1--abc123def456', + ); + writeFileSync(roundList, '- **[Critical]** x.ts:1 — y'); + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, 'The brief.'); + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${roundList}")\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'a0', prompt, { + opens: [brief, roundList, DIFF], + finalText: + 'Audited the round-1 evidence for chunk 3. It reads:\n' + + 'Uncoverable: chunk 3 — a line exceeds the read limit\n' + + 'The declaration is sound.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + expect(r.latestReverseAuditRound).toBe(1); + }); + + it('a per-chunk VERIFY shard cannot skip the findings floor', () => { + // The seventh entrance: `verify--chunk-N--…` keys fell into the + // chunk-territory branch (`chunk !== null` and not reverse-audit) and + // were certified on a diff read alone — skipping the findings floor + // coverage's `deliveryOf` holds every verify key to. Only the bare + // chunk ROLE takes the territory-only branch now. + const key = 'verify--chunk-2--round-1--abc123def456'; + const list = findingsFilePath(plan, 'verify--round-1--abc123def456'); + writeFileSync(list, '- **[Critical]** x.ts:1 — y'); + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, 'The brief.'); + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + + // Diff and brief opened, findings list skipped → refused. + transcript('S0', 'a0', prompt, { + opens: [brief, DIFF], + finalText: 'All verified.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual( + [], + ); + + // List read too → recovered. + transcript('S0', 'a1', prompt, { + opens: [brief, list, DIFF], + finalText: 'All verified, list compared.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + key, + ]); + }); + + it('certifies a NON-chunk agent that opened its brief AND the findings list only', () => { + // The positive counterpart of the refusals above: every other `opens:` + // fixture in this file opens a brief, so the diff-read side of the bar is + // exercised nowhere on the certifying path. + const key = 'invariant-a'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key), DIFF], + finalText: 'Invariant holds.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + key, + ]); + }); + + it('refuses only the AMBIGUOUS transcript, not the contested key itself', () => { + // The refusal is per-transcript by design: a transcript matching two keys + // proves neither. A competitor that matches exactly one key is innocent + // and still certifies — vetoing the whole key would drop finished work + // and, for a reverse-audit round, regress `latestReverseAuditRound`. + const promptA = built('1a'); + const promptB = built('1b'); + // One transcript launched with BOTH prompts concatenated matches both. + transcript('S0', 'ambig', `${promptA}\n${promptB}`, { + opens: [briefPath(plan, '1a'), briefPath(plan, '1b')], + finalText: 'Ambiguous.', + }); + transcript('S0', 'clean', promptA, { + opens: [briefPath(plan, '1a')], + finalText: 'A clean single-key result.', + }); + + expect(recoverFindings({ plan, out: out() }, ENV).recoveredKeys).toEqual([ + '1a', + ]); + }); + + it('normalizes both paths before refusing to overwrite the plan', () => { + // The guard is `resolve()` on both sides; a mutant comparing raw args, or + // normalizing one side only, lets a RELATIVE out path alias the plan and + // `atomicWriteFileSync` then destroys the run-epoch artifact every fence + // in this feature keys on. + const cwd = process.cwd(); + try { + process.chdir(dir); + expect(() => recoverFindings({ plan, out: basename(plan) }, ENV)).toThrow( + /must not overwrite the plan/, + ); + } finally { + process.chdir(cwd); + } + }); + + it('counts only REVERSE-AUDIT rounds toward latestReverseAuditRound', () => { + // The key grammar also produces `verify--round-N--`. Without the + // prefix gate a certified round-2 VERIFY agent reports the reverse audit + // as having reached round 2, and the resumed run starts at 3 — skipping + // audit rounds the dead run never certified. + const key = 'verify--round-2--abc123def456'; + const prompt = built(key); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, key)], + finalText: 'Verified round 2.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([key]); + expect(r.latestReverseAuditRound).toBeNull(); + }); + + it('reads an ABSENT record dir as empty, not as unreadable', () => { + // The pre-launch shape this command is built for: nothing recorded yet is + // a healthy state, and reporting it as an infrastructure fault prints a + // WARNING an operator cannot act on. + rmSync(promptRecordDir(plan), { recursive: true, force: true }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recordDirUnreadable).toBeNull(); + expect(r.recoveredKeys).toEqual([]); + }); + + it('refuses a findings file no CERTIFIED prompt points at — the plant', () => { + // The record dir is attempt-1-writable: a planted `.findings.md` newer + // than the plan passes the epoch fence and used to be relayed as the + // interrupted attempt's own cumulative state. The corroboration is the + // pointer a certified agent was launched with; a file nothing certified + // names is not enumerated. + const recordDir = promptRecordDir(plan); + const key = 'verify--round-1--deadbeef0000'; + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.findings.md`), + '- forged prior-round rulings', + ); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.findingsFiles).toEqual([]); + }); + + it('refuses a findings file pointed at only by an UNCERTIFIED prompt', () => { + // The agent was built and launched but never certified (it opened + // nothing): its pointer does not corroborate the file. + const key = 'verify'; + const list = findingsFilePath(plan, key); + writeFileSync(list, '- **[Critical]** x.ts:1 — y'); + const recordDir = promptRecordDir(plan); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'a0', prompt, { + opens: [], + finalText: 'Confident prose, no evidence.', + }); + expect(recoverFindings({ plan, out: out() }, ENV).findingsFiles).toEqual( + [], + ); + }); + + it('fences findings files by the run epoch', () => { + // Nothing clears the record dir: a PREVIOUS review of the same PR leaves + // its rounds' lists behind, and restoring one would hand a resumed run a + // foreign attempt's state. + const recordDir = promptRecordDir(plan); + const key = 'reverse-audit--round-1--abc123def456'; + const stale = join(recordDir, `${encodeURIComponent(key)}.findings.md`); + writeFileSync(stale, '- from a previous review'); + const old = new Date(2019, 0, 1); + utimesSync(stale, old, old); + expect(recoverFindings({ plan, out: out() }, ENV).findingsFiles).toEqual( + [], + ); + }); + + it('decodes a key whose file name is percent-encoded', () => { + const recordDir = promptRecordDir(plan); + const key = 'invariant-a--packages/cli/src/x.ts'; + const list = join(recordDir, `${encodeURIComponent(key)}.findings.md`); + writeFileSync(list, '- entry'); + writeFileSync( + `${list}.sha256`, + createHash('sha256').update('- entry').digest('hex'), + ); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'inv', prompt, { + opens: [brief, list], + finalText: 'Invariant holds.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.findingsFiles.map((f) => f.key)).toEqual([key]); + }); + + it('reports the budget stop the interrupted attempt left standing', () => { + writeFileSync( + join(promptRecordDir(plan), 'budget-stop.json'), + JSON.stringify({ + cause: 'round-cap', + cap: 5, + entry: 'the audit stopped at the round cap', + entryZh: '审计在轮数上限停止', + round: 5, + remainingSeconds: 900, + reserveSeconds: 1200, + atMs: Date.now(), + }), + ); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.budgetStop?.cause).toBe('round-cap'); + }); + + it('counts only CERTIFIED rounds toward latestReverseAuditRound', () => { + const k1 = 'reverse-audit--round-1--abc123def456'; + const k2 = 'reverse-audit--round-2--abc123def456'; + const p1 = built(k1); + built(k2); // built, but its agent never opened anything + transcript('S0', 'ra1', p1, { + opens: [briefPath(plan, k1)], + finalText: 'Round 1: no new issues after a full walk.', + }); + transcript('S0', 'ra2', `You are ${k2}.`, { + opens: [], + finalText: 'Round 2: nothing.', + }); + expect( + recoverFindings({ plan, out: out() }, ENV).latestReverseAuditRound, + ).toBe(1); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'discloses an unreadable record dir instead of printing as empty', + () => { + // Guarded like every sibling chmod fixture in this suite: as uid 0 the + // permission bits are bypassed and the directory stays readable, and on + // Windows chmod on a directory only toggles the read-only attribute — + // in both cases `recordDirUnreadable` stays null and this fails on a + // required merge-queue leg for a reason that has nothing to do with the + // code. + const recordDir = promptRecordDir(plan); + chmodSync(recordDir, 0o000); + try { + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recordDirUnreadable).not.toBeNull(); + } finally { + chmodSync(recordDir, 0o755); + } + }, + ); + + it('fences the built prompts by the run epoch — a retry owes only its own keys', () => { + // Nothing clears the record dir, and the CI retry re-runs the review at + // the SAME plan path: an unfenced read enumerated keys earlier attempts + // built, so `missingKeys` named obligations this run does not owe and a + // resumed orchestrator relaunched agents whose prompts it cannot build. + const recordDir = promptRecordDir(plan); + writeFileSync( + join(recordDir, `${encodeURIComponent('chunk-9')}.txt`), + 'You are chunk-9.', + ); + const stale = new Date(2019, 0, 1); + utimesSync( + join(recordDir, `${encodeURIComponent('chunk-9')}.txt`), + stale, + stale, + ); + const prompt = built('1a'); + transcript('S0', 'a0', prompt, { + opens: [briefPath(plan, '1a')], + finalText: 'Covered.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.missingKeys).toEqual([]); + expect(r.recoveredKeys).toEqual(['1a']); + }); + + it('fails closed on an UNSTAMPED transcript — a plant pairs by shape alone', () => { + // The record dir and the transcript tree are attempt-1-writable; a + // planted transcript whose first user record is the recorded prompt + // verbatim passes the shape-based bar unless a session stamp it does + // not have is demanded. Runs new enough to keep the session ledger + // stamp their transcripts, so the requirement re-owes nothing genuine. + const prompt = built('1a'); + const line = (rec: Record) => JSON.stringify(rec); + writeFileSync( + join(dir, 'subagents', 'S0', 'agent-plant.jsonl'), + [ + line({ + agentId: 'plant', + agentName: 'general-purpose', + type: 'user', + message: { role: 'user', parts: [{ text: prompt }] }, + }), + line({ + agentId: 'plant', + agentName: 'general-purpose', + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: briefPath(plan, '1a') }, + }, + }, + ], + }, + }), + line({ + agentId: 'plant', + agentName: 'general-purpose', + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'bytes' }, + }, + }, + ], + }, + }), + line({ + agentId: 'plant', + agentName: 'general-purpose', + type: 'assistant', + message: { + role: 'model', + parts: [{ text: 'Forged final text, certified.' }], + }, + }), + ].join('\n') + '\n', + ); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.recoveredKeys).toEqual([]); + expect(r.missingKeys).toEqual(['1a']); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a SYMLINKED findings file — the read would follow it out', + () => { + // A symlink is not the file it points at: statSync fenced on the + // TARGET's mtime and the resumed orchestrator's read follows the link + // out of the record dir entirely. Only a regular file of the dir can + // be handed on. + const recordDir = promptRecordDir(plan); + const key = 'reverse-audit--round-2--abc123def456'; + const list = join(recordDir, `${encodeURIComponent(key)}.findings.md`); + const outside = join(dir, 'outside-findings.md'); + writeFileSync(outside, '- FORGED: real Criticals erased'); + symlinkSync(outside, list); + const brief = briefPath(plan, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are ${key}.\n` + + `read_file(file_path="${list}")\n` + + `read_file(file_path="${brief}")`; + writeFileSync(join(recordDir, `${encodeURIComponent(key)}.txt`), prompt); + writeFileSync( + join(recordDir, `${encodeURIComponent(key)}.txt.sha256`), + createHash('sha256').update(prompt).digest('hex'), + ); + transcript('S0', 'ra2', prompt, { + opens: [brief, list], + finalText: 'Round 2: no new issues after a full walk.', + }); + const r = recoverFindings({ plan, out: out() }, ENV); + expect(r.findingsFiles).toEqual([]); + }, + ); + + it('exits 1 when the transcript infrastructure is missing entirely', () => { + // The handler takes no env argument, so it reads `process.env` — which is + // what makes this a real probe of the `TranscriptsUnavailableError` exit + // path rather than of the suite's own `ENV` object. Stubbed explicitly + // rather than relied upon: a developer running the suite from inside a + // qwen-code session inherits both variables, and the test would then + // silently exercise the success path instead. + vi.stubEnv('QWEN_CODE_PROJECT_DIR', ''); + vi.stubEnv('QWEN_CODE_SESSION_ID', ''); + const handler = recoverFindingsCommand.handler as (a: unknown) => void; + const saved = process.exitCode; + try { + handler({ plan, out: out() }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = saved; + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/cli/src/commands/review/recover-findings.ts b/packages/cli/src/commands/review/recover-findings.ts new file mode 100644 index 00000000000..9b19c892dbc --- /dev/null +++ b/packages/cli/src/commands/review/recover-findings.ts @@ -0,0 +1,518 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Hand a resumed run the interrupted attempt's agent results, from the +// harness's records — never from the orchestrator's memory of them. +// +// A review's findings normally live only in the orchestrator's context: each +// agent returns inline, and no file carries the returns. A resumed run is a +// NEW session, so that context is gone — but the harness's transcripts are +// not, and each one ends with the agent's own final text. This command pairs +// the CLI's prompt records with those transcripts (the same two-author proof +// `check-coverage` runs on) and writes the certified agents' final texts to a +// file the resumed orchestrator reads back. +// +// It is an assessment, not a gate: exit 0 with whatever could be recovered, +// and `check-coverage` remains the authority on what is still owed. The one +// hard failure is missing transcript infrastructure — a resume with no +// evidence to read should say so rather than print an empty recovery. + +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, +} from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import type { CommandModule } from 'yargs'; +import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + readRunTranscripts, + TranscriptsUnavailableError, + type AgentRecord, +} from './lib/transcripts.js'; +import { + promptRecordDir, + readRecordedPrompts, + deliveredVerbatimLines, + flattenPrompt, + promptLines, + briefPath, + findingsPointerOf, +} from './lib/prompt-record.js'; +import { priorSessionIds } from './lib/run-ledger.js'; +import { assignedChunk, pointedAt } from './lib/coverage.js'; +import { readBudgetStop, type BudgetStop } from './lib/deadline.js'; + +interface RecoverFindingsArgs { + plan: string; + out: string; +} + +/** One findings list a prior round left on disk, named by its record key. */ +interface FindingsFileEntry { + key: string; + path: string; + /** The `--round-` baked into the key, when the key carries one. */ + round: number | null; +} + +export interface RecoverFindingsResult { + schemaVersion: 1; + out: string; + /** Keys whose agent was certified and whose final text was recovered. */ + recoveredKeys: string[]; + /** Keys the CLI built a prompt for with no certifiable transcript. */ + missingKeys: string[]; + /** + * Every `.findings.md` in the record dir whose path a CERTIFIED + * transcript's recorded prompt points at — the model-state snapshots. + * The record dir is attempt-1-writable, and a planted list the mtime + * fence admits would otherwise be relayed as the interrupted attempt's + * own cumulative state; the pointer a certified agent was launched with + * is the authorship corroboration, and a file it names nowhere is not + * enumerated. + */ + findingsFiles: FindingsFileEntry[]; + /** Highest round among certified reverse-audit agents, null if none. */ + latestReverseAuditRound: number | null; + /** The budget-stop marker still standing, if any (round-cap survives). */ + budgetStop: BudgetStop | null; + /** How many earlier sessions the run ledger names. */ + priorSessions: number; + /** + * The errno when the prompt-record directory could not be listed, else + * null. An empty recovery and an unreadable one must not print alike: the + * first says the interrupted attempt achieved nothing, the second says + * this run cannot tell. + */ + recordDirUnreadable: string | null; + /** + * True when the plan file's live mtime — the run epoch every fence below + * keys on — is meaningfully EARLIER than the report's own `fetchedAt`. + * The fences resurrect whichever prior run's records the epoch admits, so + * a rewound plan mtime (one `utimesSync` to a prior run's recorded epoch) + * would hand that run's coverage/findings to the resumed orchestrator as + * this attempt's own. `fetchedAt` is the corroborating clock — the writer + * stamps it moments before writing the file (`windowSound` binds the + * ruling to the same fact) — so a rewind that does not also rewrite it is + * refused here: every recovered field comes back empty. Rewriting BOTH is + * the same on-disk-forgery residual the ledger-trim and transcript-nonce + * follow-ups own, disclosed rather than pretended closed. + */ + epochRewound: boolean; +} + +const ROUND_IN_KEY_RE = /--round-(\d+)(?:--|$)/; + +/** + * Certify one transcript against one built prompt — the same bar coverage + * holds a live launch to: the CLI-built prompt arrived verbatim, and the + * agent demonstrably opened its brief or the diff. Prose proves nothing. + */ +const UNCOVERABLE_RE = /^\s*Uncoverable:\s*chunk\s+(\d+)\b/im; + +/** + * Certify one transcript against one built prompt — the SAME bar the live + * pipeline holds a launch to, branch for branch. + * + * It used to be a re-implementation, and re-implementing a bar means drifting + * from it. `openedBrief || diffToolCalls > 0` certified three things the + * pipeline refuses: a chunk agent that opened its brief and never the diff + * (coverage requires the diff read for a chunk-assigned record), a + * verify/reverse-audit agent that opened the diff and never its brief (the + * brief carries the method and the cumulative findings list), and a verifier + * that skipped the findings-list read the compose-time gate requires of the + * same key. Each handed the resumed orchestrator uncertified prose labelled + * as certified. + */ +/** + * The chunk a KEY assigns: `chunk-13` → 13, and the per-chunk audit shapes — + * `reverse-audit--chunk-13--round-2--` — carry theirs in a `--chunk-N` + * segment. Parsing only the bare form left those keys chunk-less, and with + * the production launch prompt carrying no `chunk N of M` line either, the + * bar's diff-read requirement silently vanished for exactly the auditors + * whose territory is a chunk. + */ +function chunkOfKey(key: string): number | null { + const m = /^chunk-(\d+)$/.exec(key) ?? /--chunk-(\d+)(?:--|$)/.exec(key); + return m ? Number(m[1]) : null; +} + +function meetsBar( + rec: AgentRecord, + planPath: string, + key: string, + builtPrompt: string, + plan: { chunks: Array<{ id: number; startLine: number; endLine: number }> }, +): boolean { + // RETURNED first, like every certification consumer: `finalText` keeps the + // last non-empty assistant text, which includes progress narrated between + // tool calls, and a mid-flight-dead agent's narration must not be handed + // to the resumed orchestrator as certified final text. + if (!rec.returned) return false; + // The DUTY chunk — the territory whose diff read the key demands — comes + // from the key, with the prompt as fallback: recovery is matching a record + // to a KEY the CLI built, which names the chunk directly. + const chunk = chunkOfKey(key) ?? assignedChunk(rec); + // The VETO chunk is the record's OWN assignment — `assignedChunk(rec)`, + // exactly as both pipeline authorities key it (coverage's walk and its + // `certifies()`). A per-chunk audit key names a chunk, but the production + // per-chunk audit prompt carries no `chunk N of M` line, and keying the + // veto on the KEY's chunk dropped a certified auditor whose final text + // QUOTED its own chunk's declaration (the briefs mandate verbatim + // quoting) — while coverage counted the same record as recovered work: + // two authorities of one pipeline answering oppositely about one + // transcript. `latestReverseAuditRound` regressed with the drop and the + // resumed run restarted a round the dead attempt had completed. + const own = assignedChunk(rec); + const declared = UNCOVERABLE_RE.exec(rec.finalText); + if (declared !== null && own !== null && Number(declared[1]) === own) { + return false; + } + // EVERY role opens its brief — the live walk gates `ok` on `unreadBriefs` + // for chunk agents too: the brief carries the severity bar, the finding + // format and the project's own rules, and a chunk agent that skipped it + // reviewed against rules it never saw. + const openedBrief = rec.successfulCallArgs.some((a) => + a.includes(JSON.stringify(briefPath(planPath, key))), + ); + if (!openedBrief) return false; + if (/^chunk-\d+$/.test(key)) { + // The chunk ROLE only — its proof of territory is the diff it opened, + // and its prompt names no findings list. Keyed on the exact bare form: + // `chunk !== null` also matched per-chunk VERIFY shards + // (`verify--chunk-N--…`), certifying them on a diff read alone and + // skipping the findings floor coverage's `deliveryOf` holds every + // verify key to. + return rec.diffToolCalls > 0; + } + // The chunk-less roles (whole-diff auditors) have no chunk floor, but the + // live walk still holds them to the reads their OWN prompt spelled out — + // pointed at diff lines and never opened the diff is `unopenedAgents` + // there. Mirrored here, or a round-level auditor that opened the brief + // and the findings list but never the diff certifies, its round counts + // as reviewed, and the territory is never relaunched. + const chunklessFloor = + pointedAt(builtPrompt, plan).length === 0 || rec.diffToolCalls > 0; + // The findings floor keys on the POINTER the recorded prompt names — not a + // path derived from the record key, which never matches for per-chunk + // reverse-audit keys (their findings file is keyed without the chunk). + // Deriving from the key made the floor silently vanish for exactly those + // auditors, and compose-time then ruled the same key `findings-unread`. + const pointer = findingsPointerOf(builtPrompt); + if (pointer === null) { + return chunk !== null ? rec.diffToolCalls > 0 : chunklessFloor; + } + const readList = rec.successfulReadFileArgs.some((a) => + a.includes(JSON.stringify(pointer)), + ); + if (!readList) return false; + return chunk !== null ? rec.diffToolCalls > 0 : chunklessFloor; +} + +export function recoverFindings( + args: RecoverFindingsArgs, + env: NodeJS.ProcessEnv = process.env, +): RecoverFindingsResult { + const planPath = args.plan; + const outPath = resolve(args.out); + if (outPath === resolve(planPath)) { + throw new Error('--out must not overwrite the plan'); + } + let planRaw: unknown; + try { + planRaw = JSON.parse(readFileSync(planPath, 'utf8')); + } catch (err) { + throw new Error( + `could not read the plan report ${planPath}: ${(err as Error).message}`, + ); + } + const plan = planRaw as { diffPathAbsolute?: unknown; chunks?: unknown }; + const planChunks = { + chunks: (Array.isArray(plan.chunks) ? plan.chunks : []).filter( + (c): c is { id: number; startLine: number; endLine: number } => + typeof c === 'object' && + c !== null && + typeof (c as { id?: unknown }).id === 'number' && + typeof (c as { startLine?: unknown }).startLine === 'number' && + typeof (c as { endLine?: unknown }).endLine === 'number', + ), + }; + const diffPath = + typeof plan.diffPathAbsolute === 'string' && plan.diffPathAbsolute !== '' + ? plan.diffPathAbsolute + : undefined; + const sinceMs = statSync(planPath).mtimeMs; + // The run epoch (plan mtime) corroborated against the report's own + // `fetchedAt`: a rewind to a prior run's epoch resurrects that run's + // fenced records, and the writer stamps `fetchedAt` moments before the + // file exists, so a live mtime meaningfully earlier than `fetchedAt` is a + // rewind. Sixty seconds of slack covers the stamp-then-write gap; a + // backdated mtime only WIDENS the fence toward older records, which is + // the safe direction and not flagged. On a rewind every fenced read is + // skipped and the recovery comes back empty. + const fetchedAtMs = + typeof (plan as { fetchedAt?: unknown }).fetchedAt === 'string' + ? Date.parse((plan as { fetchedAt: string }).fetchedAt) + : NaN; + const epochRewound = + !Number.isNaN(fetchedAtMs) && sinceMs < fetchedAtMs - 60_000; + + // Fenced like the two sibling reads below: nothing clears the record dir, + // and the CI retry re-runs the review at the SAME plan path, so an + // unfenced read enumerates keys earlier attempts built — `missingKeys` + // would name obligations this run does not owe, and a resumed orchestrator + // relaunching them spins up agents whose prompts it cannot even build. + const built = epochRewound + ? new Map() + : readRecordedPrompts(planPath, sinceMs, true); + // The current session has launched nothing yet when this runs — that is + // the point of running it — so its missing transcript dir is the expected + // state, not the infrastructure failure it would be for check-coverage. + const records = epochRewound + ? [] + : readRunTranscripts(planPath, sinceMs, env, diffPath, { + currentDirOptional: true, + }); + + // Pair each transcript with the built prompts it delivered verbatim. The + // injectivity rule is retirement's: a transcript that matches MORE THAN ONE + // built prompt certifies none of them — "one agent taking a stack of + // chunks" must not resurface on the recovery path. + // Flatten each launch once and split each built prompt once: the pairing + // is N×M, and `wasDeliveredVerbatim` would otherwise redo both halves of + // that work on every pair (the helper family exists for exactly this). + const builtLines = new Map(); + for (const [key, prompt] of built) { + if (prompt.trim() === '') continue; + builtLines.set(key, promptLines(prompt)); + } + const matchesOf = new Map(); + for (const rec of records) { + const launch = flattenPrompt(rec.launchPrompt); + const keys: string[] = []; + for (const [key, lines] of builtLines) { + if (deliveredVerbatimLines(launch, lines)) keys.push(key); + } + matchesOf.set(rec, keys); + } + + // Certified transcripts PER KEY. A key with exactly one is recovered; a + // key with two or more is refused to `missingKeys` instead of resolved by + // mtime. The tie-break used to prefer the newest, but transcript mtime is + // attacker-settable — a same-session plant with a newer mtime displaced + // the genuine certified text ("No issues" over a real Critical). A + // legitimate relaunch also produces two certified transcripts, so this + // re-owes that key (the resumed run relaunches it) rather than trusting an + // mtime the planter controls — the injectivity spirit, on the transcript + // side. + const certifiedByKey = new Map(); + for (const [rec, keys] of matchesOf) { + if (keys.length !== 1) continue; // unmatched, or the injectivity refusal + const key = keys[0]; + if (!meetsBar(rec, planPath, key, built.get(key) ?? '', planChunks)) { + continue; + } + if (rec.finalText.trim() === '') continue; + const list = certifiedByKey.get(key); + if (list === undefined) certifiedByKey.set(key, [rec]); + else list.push(rec); + } + const recovered = new Map(); + for (const [key, recs] of certifiedByKey) { + if (recs.length === 1) recovered.set(key, recs[0]); + // recs.length > 1 → ambiguous; left out of `recovered`, so the key falls + // to `missingKeys` below and is re-owed. + } + + const recoveredKeys = [...recovered.keys()].sort(); + const missingKeys = [...built.keys()] + .filter((k) => built.get(k)?.trim() !== '' && !recovered.has(k)) + .sort(); + + // The findings lists a CERTIFIED agent was actually pointed at: the + // pointer each recovered key's recorded prompt carries. The enumeration + // below admits only these — the record dir is attempt-1-writable, and a + // planted `.findings.md` the mtime fence admits is exactly the foreign + // state this corroboration keeps out. + const corroboratedFindings = new Set(); + for (const key of recoveredKeys) { + const pointer = findingsPointerOf(built.get(key) ?? ''); + if (pointer !== null) corroboratedFindings.add(resolve(pointer)); + } + + // The findings lists earlier rounds wrote — the on-disk snapshots of the + // orchestrator's cumulative state. Enumerated from the record dir the CLI + // owns; names decode back to keys exactly (they were percent-encoded). + const recordDir = promptRecordDir(planPath); + const findingsFiles: FindingsFileEntry[] = []; + let names: string[] = []; + let recordDirUnreadable: string | null = null; + try { + names = readdirSync(recordDir).sort(); + } catch (err) { + names = []; + // "Could not look" and "there was nothing" print identically otherwise: + // an empty recovery on a run whose records are unreachable would read as + // an interrupted attempt that had simply achieved nothing. + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + recordDirUnreadable = code ?? (err as Error).message; + } + } + for (const name of names) { + if (!name.endsWith('.findings.md')) continue; + let key: string; + try { + key = decodeURIComponent(name.slice(0, -'.findings.md'.length)); + } catch { + continue; + } + const path = join(recordDir, name); + // lstat, never stat: a symlink is not the file it points at. `statSync` + // would fence on the TARGET's mtime — attacker-chosen — and the read + // the resumed orchestrator makes would follow the link out of the + // record dir entirely, `resolve()` being lexical. Only a regular file + // of this dir can be a findings list this run may hand on. + let st: ReturnType; + try { + st = lstatSync(path); + } catch { + continue; + } + if (!st.isFile()) continue; + // The run-epoch fence every reader here applies: nothing clears the + // record dir, so a PREVIOUS review of the same PR leaves its rounds' + // findings lists behind, and handing one to a resumed run would restore + // a foreign attempt's state as this one's. + if (st.mtimeMs < sinceMs) continue; + if (!corroboratedFindings.has(resolve(path))) continue; + // CONTENT bound, not only the name: the corroborated pointer proves a + // certified agent was POINTED at this path, and the digest the builder + // recorded at write time proves the bytes are still the ones it wrote — + // the demonstrated plant overwrote the file after the certified read, + // with the pointer still corroborating the name. No sidecar or a + // mismatch reads as absent, the direction every unverifiable artifact + // here reads. + let bytes: Buffer; + let recordedDigest: string; + try { + bytes = readFileSync(path); + recordedDigest = readFileSync(`${path}.sha256`, 'utf8').trim(); + } catch { + continue; + } + if (createHash('sha256').update(bytes).digest('hex') !== recordedDigest) { + continue; + } + const m = ROUND_IN_KEY_RE.exec(key); + findingsFiles.push({ + key, + path, + round: m ? Number(m[1]) : null, + }); + } + + let latestReverseAuditRound: number | null = null; + for (const key of recoveredKeys) { + if (!key.startsWith('reverse-audit')) continue; + const m = ROUND_IN_KEY_RE.exec(key); + if (!m) continue; + const round = Number(m[1]); + if (latestReverseAuditRound === null || round > latestReverseAuditRound) { + latestReverseAuditRound = round; + } + } + + const sections: string[] = [ + '# Recovered agent results', + '', + 'Written by `qwen review recover-findings` from the harness transcripts', + "of the interrupted attempt. Each section is one certified agent's own", + 'final text, verbatim. Findings in here still owe Step 4 verification', + 'unless a findings list already carries them as verified.', + '', + ]; + for (const key of recoveredKeys) { + const rec = recovered.get(key) as AgentRecord; + // Keys embed PR file paths (the invariant agents), and git allows + // newlines in filenames — a raw key would let a hostile path forge + // `## ` section boundaries in the one channel this command exists to + // keep clean. Control characters carry no finding text; a space + // cannot forge structure. + const header = + // eslint-disable-next-line no-control-regex -- control chars are exactly what must not reach the markdown + key.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' '); + sections.push(`## ${header}`, '', rec.finalText.trim(), ''); + } + // The sibling with the identical --plan/--out contract does this too. The + // recovered list can be the only surviving copy of an interrupted attempt's + // results, and a missing parent directory turned that into a raw ENOENT + // crash — in exactly the post-mortem context this command exists for. + mkdirSync(dirname(outPath), { recursive: true }); + atomicWriteFileSync(outPath, sections.join('\n'), { noFollow: true }); + + return { + schemaVersion: 1, + out: outPath, + recoveredKeys, + missingKeys, + findingsFiles, + latestReverseAuditRound, + budgetStop: readBudgetStop(planPath), + recordDirUnreadable, + epochRewound, + priorSessions: priorSessionIds(planPath, env).length, + }; +} + +export const recoverFindingsCommand: CommandModule = { + command: 'recover-findings', + describe: + 'Recover the certified agent results of an interrupted review run from the harness transcripts, for a resumed run to read back', + builder: (yargs) => + yargs + .option('plan', { + type: 'string', + demandOption: true, + describe: 'The plan report from Step 1 (fetch-pr output)', + }) + .option('out', { + type: 'string', + demandOption: true, + describe: + 'Where to write the recovered final texts (Markdown, one section per certified agent)', + }) + .version(false), + handler: (argv) => { + try { + const result = recoverFindings(argv as unknown as RecoverFindingsArgs); + writeStdoutLine(JSON.stringify(result)); + writeStderrLine( + `recover-findings: ${result.recoveredKeys.length} agent result(s) recovered, ` + + `${result.missingKeys.length} still owed; wrote ${result.out}`, + ); + if (result.recordDirUnreadable !== null) { + writeStderrLine( + `WARNING: the prompt-record directory could not be read ` + + `(${result.recordDirUnreadable}); this recovery is a floor, not a ` + + `complete account of the interrupted attempt.`, + ); + } + } catch (err) { + if (err instanceof TranscriptsUnavailableError) { + writeStderrLine(`recover-findings: ${err.message}`); + process.exitCode = 1; + return; + } + throw err; + } + }, +}; diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index e9573a25907..fc6f8357708 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -1317,9 +1317,13 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( ); // And no WARNING: the verification compares floats that survived a // filesystem round trip, so an exact-equality check reports failure on - // every enrichment that changed anything. + // every enrichment that changed anything. Captured before the restore + // below — vitest's `mockRestore` clears the recorded calls as well, so + // a capture taken after it is always empty and the assertion always + // passes. const printed = err.mock.calls.map((c) => String(c[0])).join(''); expect(printed).not.toContain("could not restore the plan's timestamp"); + expect(printed).not.toContain('WARNING'); } finally { err.mockRestore(); rmSync(root, { recursive: true, force: true }); diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 54d966a8e1e..561f258d5f3 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -316,7 +316,7 @@ function readPlan(path: string): MutablePlan { return value as MutablePlan; } -function changedPaths(plan: MutablePlan): string[] { +function changedPaths(plan: Pick): string[] { if (!Array.isArray(plan.files)) { throw new Error('repo-context: plan.files must be an array'); } @@ -354,6 +354,28 @@ function contextFromProviders( return null; } +/** + * The repository context the providers derive from THIS worktree and merge + * base — the exact derivation `runRepoContext` enriches the plan with, + * extracted so the resume ruling can compare a recorded `repositoryContext` + * against it instead of trusting a field that sits on attempt-1-writable + * disk. Null when no provider claims the worktree. Throws what the + * providers throw — the caller decides what an underivable context means. + */ +export function deriveRepositoryContext( + worktree: string, + plan: Pick, + mergeBaseSha: string | null, + providers: readonly RepositoryContextProvider[] = REPOSITORY_CONTEXT_PROVIDERS, +): RepositoryContext | null { + return contextFromProviders( + providers, + worktree, + changedPaths(plan), + identityReader(worktree, mergeBaseSha), + ); +} + export function runRepoContext( args: RepoContextArgs, providers: readonly RepositoryContextProvider[] = REPOSITORY_CONTEXT_PROVIDERS, diff --git a/packages/cli/src/commands/review/run.test.ts b/packages/cli/src/commands/review/run.test.ts index 5715402c855..63839e39edc 100644 --- a/packages/cli/src/commands/review/run.test.ts +++ b/packages/cli/src/commands/review/run.test.ts @@ -76,6 +76,30 @@ describe('buildReviewPrompt', () => { ); }); + it('combines --resume with every other flag, in order', () => { + // The CI retry's documented shape is `--comment --resume`; without a + // combination case, an `if` → `else if` slip that binds the resume push + // to the comment branch ships green and silently drops the flag — + // re-running from scratch, the exact waste this series exists to stop. + expect( + buildReviewPrompt({ + target: '7724', + effort: 'low', + comment: true, + resume: true, + }), + ).toBe('/review 7724 --effort low --comment --resume'); + }); + + it('threads --resume through, after the other flags', () => { + expect(buildReviewPrompt({ target: '7724', resume: true })).toBe( + '/review 7724 --resume', + ); + expect(buildReviewPrompt({ target: '7724', resume: false })).toBe( + '/review 7724', + ); + }); + it('rejects a target that would re-tokenize into extra args', () => { // `123 --comment` would split into a target plus a flag the child // honours, silently authorising a post the run never asked for. @@ -646,6 +670,39 @@ describe('review run (handler)', () => { expect(argvUsed[i + 1]).toBe('default'); }); + it('passes --resume through to the child prompt', async () => { + // The argv→runReview mapping, at the handler level: buildReviewPrompt's + // own unit tests cannot see a dropped `resume: Boolean(argv['resume'])` + // line, and a run invoked with --resume that spawns a plain /review + // silently starts the review from scratch. + armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' }); + await runHandler({ target: '7724', resume: true }); + + const [, argvUsed] = spawnMock.mock.calls[0] as [string, string[]]; + const prompt = argvUsed[argvUsed.indexOf('--prompt') + 1]; + expect(prompt).toBe('/review 7724 --resume'); + }); + + it('passes --resume through with no target — the child owns the gating', async () => { + // A guard like `args.resume && args.target` would ship the whole suite + // green while silently dropping the flag before the child can emit the + // documented "ignored because the target is not a PR" warning. + armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' }); + await runHandler({ resume: true }); + + const [, argvUsed] = spawnMock.mock.calls[0] as [string, string[]]; + expect(argvUsed[argvUsed.indexOf('--prompt') + 1]).toBe('/review --resume'); + }); + + it('omits --resume from the child prompt when not asked', async () => { + armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' }); + await runHandler({ target: '7724', resume: false }); + + const [, argvUsed] = spawnMock.mock.calls[0] as [string, string[]]; + const prompt = argvUsed[argvUsed.indexOf('--prompt') + 1]; + expect(prompt).toBe('/review 7724'); + }); + describe('child env: QWEN_CODE_CLI version skew', () => { let saved: string | undefined; diff --git a/packages/cli/src/commands/review/run.ts b/packages/cli/src/commands/review/run.ts index 3835926829e..453aed33e70 100644 --- a/packages/cli/src/commands/review/run.ts +++ b/packages/cli/src/commands/review/run.ts @@ -40,6 +40,7 @@ export interface RunReviewArgs { target?: string; effort?: string; comment: boolean; + resume: boolean; json: boolean; failOn: 'none' | 'request-changes'; timeoutMinutes: number; @@ -215,6 +216,7 @@ export function buildReviewPrompt(args: { target?: string; effort?: string; comment?: boolean; + resume?: boolean; }): string { const parts = ['/review']; // Presence, not truthiness: an EMPTY target is a target the caller named @@ -247,6 +249,7 @@ export function buildReviewPrompt(args: { } if (args.effort) parts.push(`--effort ${args.effort}`); if (args.comment) parts.push('--comment'); + if (args.resume) parts.push('--resume'); return parts.join(' '); } @@ -619,6 +622,12 @@ export const runCommand: CommandModule = { describe: 'Authorise posting the review to GitHub (PR targets only) — same meaning as `/review --comment`', }) + .option('resume', { + type: 'boolean', + default: false, + describe: + 'Continue an interrupted review of this PR when its on-disk state still matches, instead of starting over (PR targets only) — same meaning as `/review --resume`. Falls back to a fresh review when nothing can be resumed.', + }) .option('json', { type: 'boolean', default: false, @@ -655,6 +664,7 @@ export const runCommand: CommandModule = { target: argv['target'] as string | undefined, effort: argv['effort'] as string | undefined, comment: Boolean(argv['comment']), + resume: Boolean(argv['resume']), json: Boolean(argv['json']), failOn: (argv['fail-on'] as 'none' | 'request-changes') ?? 'none', // `|| 120` would treat an explicit `--timeout-minutes 0` as falsy and diff --git a/packages/cli/src/commands/review/worktree-content.test.ts b/packages/cli/src/commands/review/worktree-content.test.ts new file mode 100644 index 00000000000..812549188a7 --- /dev/null +++ b/packages/cli/src/commands/review/worktree-content.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// `worktreeMatchesHead` against the REAL git binary — the mocked fetch-pr +// suite cannot see an invalid invocation (it stubs `gitRawWithInput`), and a +// round-10 defect shipped a `hash-object --stdin-paths -z` command no git +// accepts, silently disabling the whole content cross-check. These run the +// binary so the invocation shape is falsifiable. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + symlinkSync, + realpathSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { worktreeMatchesHead } from './fetch-pr.js'; +import { untrustedLocalConfig, plantedHooks } from './lib/git.js'; + +const hasGit = (() => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasGit)('untrustedLocalConfig against real git', () => { + let wt: string; + const git = (...args: string[]) => + execFileSync('git', ['-C', wt, ...args], { encoding: 'utf8' }); + beforeEach(() => { + wt = realpathSync(mkdtempSync(join(tmpdir(), 'ulc-'))); + git('init', '-q'); + }); + afterEach(() => rmSync(wt, { recursive: true, force: true })); + + it('is clean on a plain repo', () => { + expect(untrustedLocalConfig(wt)).toEqual([]); + }); + + it('flags a command-executing key', () => { + git('config', '--local', 'core.fsmonitor', '/tmp/evil.sh'); + expect(untrustedLocalConfig(wt)).toContain('core.fsmonitor'); + }); + + it('flags a key whose subsection name contains "=" — the last-= / -z parse', () => { + // `[diff "a=b"] command=…` renders in --list as `diff.a=b.command=val`; + // splitting at the FIRST `=` truncated the key to `diff.a`, missing the + // `diff\..+\.command` pattern. The -z parse keeps the whole key. + git('config', '--local', 'diff.a=b.command', '/tmp/evil.sh'); + expect(untrustedLocalConfig(wt)).toContain('diff.a=b.command'); + }); + + it('does not flag ordinary keys', () => { + git('config', '--local', 'user.name', 'someone'); + git('config', '--local', 'core.bare', 'false'); + expect(untrustedLocalConfig(wt)).toEqual([]); + }); +}); + +describe.skipIf(!hasGit)('plantedHooks against real git', () => { + let wt: string; + beforeEach(() => { + wt = realpathSync(mkdtempSync(join(tmpdir(), 'hooks-'))); + execFileSync('git', ['-C', wt, 'init', '-q']); + }); + afterEach(() => rmSync(wt, { recursive: true, force: true })); + + it('ignores the sample hooks git ships', () => { + expect(plantedHooks(join(wt, '.git', 'hooks'))).toEqual([]); + }); + + it('flags an executable non-sample hook', () => { + const h = join(wt, '.git', 'hooks', 'reference-transaction'); + writeFileSync(h, '#!/bin/sh\necho hi\n', { mode: 0o755 }); + expect(plantedHooks(join(wt, '.git', 'hooks'))).toContain( + 'reference-transaction', + ); + }); + + it('ignores a non-executable file', () => { + writeFileSync(join(wt, '.git', 'hooks', 'note.txt'), 'x', { mode: 0o644 }); + expect(plantedHooks(join(wt, '.git', 'hooks'))).toEqual([]); + }); +}); + +describe.skipIf(!hasGit)('worktreeMatchesHead against real git', () => { + let wt: string; + const git = (...args: string[]) => + execFileSync('git', ['-C', wt, ...args], { encoding: 'utf8' }); + + beforeEach(() => { + wt = realpathSync(mkdtempSync(join(tmpdir(), 'wtmh-'))); + git('init', '-q'); + git('config', 'user.email', 't@t'); + git('config', 'user.name', 't'); + git('config', 'commit.gpgsign', 'false'); + writeFileSync(join(wt, 'a.txt'), 'hello\n'); + mkdirSync(join(wt, 'sub')); + writeFileSync(join(wt, 'sub', 'b.txt'), 'world\n'); + symlinkSync('a.txt', join(wt, 'link.txt')); + git('add', '-A'); + git('commit', '-qm', 'init'); + }); + afterEach(() => rmSync(wt, { recursive: true, force: true })); + + it('accepts a pristine checkout — regular files and a symlink', () => { + expect(worktreeMatchesHead(wt)).toBe(true); + }); + + it('rejects a tampered tracked file whose bytes no longer hash to HEAD', () => { + writeFileSync(join(wt, 'sub', 'b.txt'), 'TAMPERED\n'); + expect(worktreeMatchesHead(wt)).toBe(false); + }); + + it('rejects a retargeted tracked symlink — hash-object would follow it', () => { + writeFileSync(join(wt, 'secret.txt'), 'secret\n'); + rmSync(join(wt, 'link.txt')); + symlinkSync('secret.txt', join(wt, 'link.txt')); + expect(worktreeMatchesHead(wt)).toBe(false); + }); + + it('accepts a repo whose only files are symlinks', () => { + rmSync(wt, { recursive: true, force: true }); + wt = realpathSync(mkdtempSync(join(tmpdir(), 'wtmh-'))); + git('init', '-q'); + git('config', 'user.email', 't@t'); + git('config', 'user.name', 't'); + git('config', 'commit.gpgsign', 'false'); + writeFileSync(join(wt, 'target.txt'), 'x\n'); + symlinkSync('target.txt', join(wt, 'l.txt')); + git('add', '-A'); + git('commit', '-qm', 'links'); + expect(worktreeMatchesHead(wt)).toBe(true); + rmSync(join(wt, 'l.txt')); + symlinkSync('/etc/passwd', join(wt, 'l.txt')); + expect(worktreeMatchesHead(wt)).toBe(false); + }); +}); diff --git a/packages/core/src/skills/bundled/review/DESIGN.md b/packages/core/src/skills/bundled/review/DESIGN.md index 5d9d7243e68..accfff2c085 100644 --- a/packages/core/src/skills/bundled/review/DESIGN.md +++ b/packages/core/src/skills/bundled/review/DESIGN.md @@ -498,6 +498,14 @@ So the authoritative ledger now travels in the posted body as an HTML-comment ma Two consequences of those boundaries are worth naming rather than discovering. Ids are **carried, not renumbered**: a still-standing finding is re-reported under the id it already has, that id is written into the comment right after the severity marker, and `buildLedger` reads it back — because a ledger that renumbered by position would key the next round's work list to ids the report riding beside it never used, and `R1-2 names the same claim in every round` is the entire payoff. And own-account recovery means a PR reviewed from **two** accounts — a maintainer locally, a bot in CI — keeps two independent ledgers, each with its own round counter and its own `R2-1`; that is the honest reading of "only this account's reviews can claim what this account stood behind", but it does mean the ids are scoped to the account that wrote them, not to the PR. +## Why `--resume` continues a run from the harness's records, not a checkpoint file + +The CI retry loop used to re-run a dead review from scratch — on a large PR that meant minutes of re-fetching, re-chunking and re-launching before the first finding existed, spent from the same shared time budget that had just killed the first attempt. The fix stores no new state at all, because the persistent checkpoint already existed: the prompt records are the obligations, the harness transcripts are the evidence (each ends with the agent's own final text), the per-round `.findings.md` files are the orchestrator's cumulative state, and the plan's mtime is the run epoch every fence keys on. What was missing was only that a retry destroyed or ignored all of it. + +So `--resume` is three small mechanisms over existing state. `fetch-pr --resume` rules on facts it gathers itself — the worktree still at `fetchedSha` and clean, the diff bytes hashing to the plan's `diffSha256` (content is the checkpoint key: changed input re-runs by construction), the live head unmoved, the resume cap unspent — and on a pass it skips the stale-state sweep and, critically, does not rewrite the plan, so the first attempt's records stay inside every reader's fence. A run-sessions ledger written by `fetch-pr` (never by the model — a path the model can choose is a path it can point somewhere flattering) tells the transcript readers which earlier sessions belong to this run; `coverageFromTranscripts`, `verificationGaps`, retirement and the cost ledger read the union and need no new judgment, because their pairing bar is content-shaped — verbatim-delivered CLI prompt plus an opened brief or diff. The principal that bar defeats is the ORCHESTRATOR MODEL: a fabricated ledger entry can at most point the readers at a directory, and prose alone cannot mint credit there. It does not defeat a co-tenant process with write access to the harness tree — in CI, the reviewed PR's own code running during attempt 1 — which can plant transcript-shaped files that clear a content bar wholesale. For that principal the resume ruling holds the INPUTS to the review outside the blast radius instead: the diff is re-derived from git objects against the forge's base ref and must match the recorded hash, `emptyDiff` is recomputed from that derivation, and `worktreePath` must be the path the CLI derives from the PR number itself. Planted coverage evidence remains possible for an attacker who already ran code on the runner; what it can no longer do is change WHAT is reviewed or WHERE the pipeline operates. `recover-findings` closes the one real gap: the interrupted orchestrator's context is gone, so the certified agents' final texts are read back out of the transcripts and handed to the new session as a CLI-authored file. + +The refusal directions all point the same way: anything unverifiable (missing report, hash mismatch, unreadable ledger, malformed marker) reads as "start fresh" or "evidence invisible, work re-owed" — never as credit. The resume cap reads two counters — the resume marker's count cross-capped by the session ledger's entry count minus one — and the ledger term is read UNGATED (`sessionEntryCount`), which is what makes it a real backstop: the authorization gate protects evidence reads of prior transcripts, and a count is not evidence. Deleting `resume.json` therefore zeroes only the marker term; the ledger still names every attempt and the cap still fires. Deleting the session ledger too is self-defeating — it makes every prior transcript invisible and the resume worthless — and the workflow's MAX_ATTEMPTS is the outer bound in CI either way. The remaining named risks: the workflow's MAX_ATTEMPTS is the outer bound in CI either way; an in-run drift restart is recorded exactly when its Step 1 re-entry goes through `fetch-pr --resume` — any resumed run's does, and the `head-moved` refusal both records and falls through to the fresh fetch the restart wants; a never-resumed run's plain re-entry re-fences the marker and records nothing — so a restart spent by an attempt that never resumed is invisible to a later attempt that does, and the "at most once" bound is guaranteed within an attempt but not across the resume boundary; and budget round stamps are deliberately dropped on resume because a span across the death gap would price a round at hours — the gate falls back to its conservative constant, whose failure direction is an early stop with a disclosure. + ## Why three more mutation operators, and why each is shaped the way it is Statement deletion with a safety-verb filter was the first operator because it has the cleanest survivor semantics. But a live maintainer re-verification produced a survivor list the deletion operator cannot express — and every entry mapped to one of three shapes, each with equally crisp semantics: diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index dcdbfbde534..8f6ba6f9717 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -1,7 +1,7 @@ --- name: review -description: Review changed code for correctness, security, code quality, and performance. Use when the user asks to review code changes, a PR, or specific files. Invoke with `/review`, `/review `, `/review `, `/review --comment` to post inline comments on the PR, or `/review --fix` to apply the findings to your working tree. Add `--effort low|medium|high` to trade depth for speed (defaults to high for PRs, medium for local changes). -argument-hint: '[pr-number|file-path] [--effort low|medium|high] [--severity-floor critical|suggestion] [--comment] [--fix]' +description: Review changed code for correctness, security, code quality, and performance. Use when the user asks to review code changes, a PR, or specific files. Invoke with `/review`, `/review `, `/review `, `/review --comment` to post inline comments on the PR, `/review --fix` to apply the findings to your working tree, or `/review --resume` to continue an interrupted review of that PR instead of starting over. Add `--effort low|medium|high` to trade depth for speed (defaults to high for PRs, medium for local changes). +argument-hint: '[pr-number|file-path] [--effort low|medium|high] [--severity-floor critical|suggestion] [--comment] [--fix] [--resume]' allowedTools: - task - run_shell_command @@ -68,6 +68,8 @@ It prints a JSON verdict; use it **verbatim**: - `comment.requested` / `comment.effective` — `effective` is what gates Step 7 (true also when only the `review.comment` setting is on); `requested && !effective` means the user asked on a non-PR target, and the warning for that is already in `warnings`. - `fix.requested` / `fix.effective` — `--fix` is `--comment` reflected, and gated on the opposite target. `--comment` writes to a **pull request**, so it needs one; `--fix` writes to a **working tree**, so it needs one that outlives the review. A PR review's tree is the ephemeral worktree `fetch-pr` creates and Step 9 deletes, so `--fix` on a PR target is ignored with a warning — edits there are discarded minutes later, and reporting findings as "fixed" into a directory that no longer exists is worse than not fixing them. `effective` is what gates Step 6B. An effective `--fix` also floors the effort at **medium**: it edits the user's files, and low runs no verification, so applying an unverified finding is the same mistake as posting one, aimed at their working tree instead of a pull request. It does not force **high** — medium's findings are verified, and the reverse audit high adds hunts for findings that are _missing_, which is not what deciding whether to apply one turns on. - `severityFloor` + `severityFloorSource` — the posting floor for a PR review: `critical` posts only Criticals (otherwise-postable high-confidence Suggestions are recorded and deferred — Step 6's convergence posture; low-confidence and Nice-to-have findings stay terminal-only as ever), `suggestion` posts Criticals and Suggestions at every round, and `auto` — the default — is the **round-adaptive rule you resolve in Step 6**, where the round is known: `suggestion` through round 5, `critical` from round 6. The parser cannot resolve `auto` itself (the round comes from the previous posted round's ledger, not fetched yet), so carry the verdict's value forward and resolve it there. Explicit flag beats the `review.severityFloor` setting beats `auto`; a non-PR target has no rounds, so the flag warns and is ignored there. The floor governs what the review **posts**, never what it finds, verifies, or reports in the terminal. +- `resume.requested` / `resume.effective` — `--resume` continues an interrupted run of the same PR instead of starting over. `effective` is what gates the resume branch below, and it is a TARGET-SHAPE gate rather than a promise: a cross-repo `pr-url` with no matching remote is `effective: true` but routes to lightweight mode, which never calls `fetch-pr` — item 3 above owns telling the user the flag is inert there. `requested && !effective` means a local or file target, already warned in `warnings`. It never changes the effort: a continuation is pinned to the interrupted run's recorded level, and an explicitly different `--effort` makes `fetch-pr` refuse the resume and run fresh at the requested one. +- `resume.requested` / `resume.effective` — `--resume` continues an interrupted run of the same PR instead of starting over. `effective` is what gates the resume branch below, and it is a TARGET-SHAPE gate rather than a promise: a cross-repo `pr-url` with no matching remote is `effective: true` but routes to lightweight mode, which never calls `fetch-pr` — item 3 below owns telling the user the flag is inert there. `requested && !effective` means a local or file target, already warned in `warnings`. It never changes the effort: a continuation is pinned to the interrupted run's recorded level, and an explicitly different `--effort` makes `fetch-pr` refuse the resume and run fresh at the requested one. - `warnings` — surface every entry to the user, word for word. - `extraTokens` / `unknownFlags` — leftover input the parser refused to guess about; mention them to the user rather than silently dropping them. @@ -94,7 +96,7 @@ The parser already classified the target, so there is nothing to disambiguate by For a `pr-url` whose `host` is not `github.com` (GitHub Enterprise), **pass `--host ` to every review subcommand that talks to the platform — `meta`, `fetch-pr`, `pr-context`, `comment-status`, `issue-context`, `fetch-diff`, `comment-body`, `plan-diff`, `test-plan`, `presubmit`, `compose-review`, `submit`, and `publish-assets`** — which routes all of their API calls at the right host in code; a forgotten host silently retargets them at github.com's same-named `owner/repo`. Every fetch this skill needs rides a subcommand — the one exception is Step 4's render-adjudication carve-out (a direct `gh api` against `QWEN_REVIEW_SCRATCH_REPO`, GitHub-only by nature). That call runs in a **verifier subagent's** shell, so a `--host` note here cannot reach it: it routes at the Enterprise host only when GH_HOST is **exported in the environment** (subagent shells inherit the process env). On an Enterprise run without an exported GH_HOST, render adjudication is unavailable — the verifier rules from the raw markdown and says so. -3. If **no remote matches**, use **lightweight mode**: fetch the diff directly with `"${QWEN_CODE_CLI:-qwen}" review fetch-diff --repo / --out .qwen/tmp/qwen-review-pr--diff.txt` (add `--host ` for Enterprise). If `fetch-diff` fails here (auth, network), inform the user and stop — lightweight mode has no diff to review and no later step refetches it. Skip Step 2 (no local rules) and Step 8 (no local reports or cache). In Step 9, skip worktree removal (none was created) but still clean up temp files (`.qwen/tmp/qwen-review-{target}-*`). Also run `"${QWEN_CODE_CLI:-qwen}" review pr-context / --out .qwen/tmp/qwen-review-pr--context.md` — it is pure platform API and works cross-repo. Agent 0 and Step 6's open-Critical re-check depend on it: a `Refs #123`-style target issue is only discoverable from the PR body, and open Critical threads only from the context file, so skipping it lets a wrong-root fix sail through blocker-free. If `pr-context` fails here (auth, network), warn and continue with the diff alone — but skip Agent 0 (it has nothing to work from) and treat every open-Critical re-check verdict as "cannot tell", which forbids an Approve. Carry this forward as the **context-unavailable** state: Step 7's invariant caps **every** `C=0` outcome of such a run at `COMMENT` with a diff-only body (both the would-be APPROVE and the Suggestion-only "no blockers" sentence), so a run that could not see the PR's existing discussion can post findings but never certify the absence of blockers. In Step 7, use the owner/repo from the URL. Inform the user: "Cross-repo review: running in lightweight mode (no build/test)." +3. If **no remote matches**, use **lightweight mode**: fetch the diff directly with `"${QWEN_CODE_CLI:-qwen}" review fetch-diff --repo / --out .qwen/tmp/qwen-review-pr--diff.txt` (add `--host ` for Enterprise). If `fetch-diff` fails here (auth, network), inform the user and stop — lightweight mode has no diff to review and no later step refetches it. Skip Step 2 (no local rules) and Step 8 (no local reports or cache). In Step 9, skip worktree removal (none was created) but still clean up temp files (`.qwen/tmp/qwen-review-{target}-*`). Also run `"${QWEN_CODE_CLI:-qwen}" review pr-context / --out .qwen/tmp/qwen-review-pr--context.md` — it is pure platform API and works cross-repo. Agent 0 and Step 6's open-Critical re-check depend on it: a `Refs #123`-style target issue is only discoverable from the PR body, and open Critical threads only from the context file, so skipping it lets a wrong-root fix sail through blocker-free. If `pr-context` fails here (auth, network), warn and continue with the diff alone — but skip Agent 0 (it has nothing to work from) and treat every open-Critical re-check verdict as "cannot tell", which forbids an Approve. Carry this forward as the **context-unavailable** state: Step 7's invariant caps **every** `C=0` outcome of such a run at `COMMENT` with a diff-only body (both the would-be APPROVE and the Suggestion-only "no blockers" sentence), so a run that could not see the PR's existing discussion can post findings but never certify the absence of blockers. In Step 7, use the owner/repo from the URL. Inform the user: "Cross-repo review: running in lightweight mode (no build/test)." If `parse-args` reported `resume.requested: true`, also tell the user that `--resume` has no effect in lightweight mode — there is no `fetch-pr`, no worktree and no plan to continue, so the review runs from scratch (the parser cannot see the remote and gates the flag on the target shape only). Based on the parsed `target.type`: @@ -115,7 +117,11 @@ Based on the parsed `target.type`: # every downstream reader — the Step 3A/3B roster, check-coverage, and # compose-review's own coverage recomputation — reads it from there, so they # cannot disagree about which agents a medium review owed. Omit it only if - # the parser resolved the default high; passing it always is harmless. + # the parser resolved the default high. On a FRESH run passing it always + # is harmless; on a RESUME it is not — the ruling cannot tell a passed- + # through default from a user's explicit choice, so follow the resume + # bullet below: pass --effort only when the user chose a level in THIS + # invocation. # High-effort re-review with a cached anchor: append --since # (the incremental check below) — the CLI validates the anchor and scopes # the diff and plan; never run git against an anchor yourself. @@ -154,6 +160,19 @@ Based on the parsed `target.type`: - **When the cache has no anchor, the PR itself carries one** (high effort only, same as the cache). The file being absent is the NORMAL state everywhere except the machine that ran the last review — CI, another clone, a colleague's checkout — and it used to mean the incremental range silently degraded to the full diff every time, which is precisely the cost incremental review exists to avoid. The anchor now rides the posted review: the machine ledger's marker carries `sha`, the head the last clean round reviewed, and `pr-context` writes it into the side file `qwen-review-pr--prev-ledger.json` with the rest of the ledger. So when the cache had no anchor to pass, **or the anchor it passed was refused** (`incremental.effective: false` — a rebase or force-push retires a cached anchor exactly when another environment may have posted a newer round whose marker still holds a valid one): proceed with the setup batch as usual, and when the side file lands with a `sha` — **different from the one already refused, OR the same sha when the refusal was infrastructure** (`base-untrusted`, `capture-failed`: the anchor was never ruled invalid, and the component that failed — a base fetch, a capture — is re-run by the re-run. Every other reason is deterministic for the same sha and must NOT be retried: a validity refusal re-refuses, `partition-failed` re-fails the partitioner on identical bytes — with ONE exception, and `mergeBaseSha` is the field that names it. A `partition-failed` round that came back PLANLESS (`diffPath: null`) **with a null `mergeBaseSha` AND `baseFetchFailed: true`** never ran the full-range rescue at all: there was no base to rescue from, and the component that failed — the base fetch — is one the re-run repeats, so the same bytes can tile as a full review. Retry that one, once. A null `mergeBaseSha` with `baseFetchFailed: false` is the other cause and is NOT retryable: the fetch succeeded and `git merge-base` found no common ancestor at all (a cross-fork PR with unrelated history), which a re-run reproduces exactly. A planless `partition-failed` that DID carry a `mergeBaseSha` means both ranges were in hand and both refused to tile, which the re-run reproduces exactly — do not retry it. The partitioner is deterministic either way; what varies is whether the round ever had a full range to offer it. The containment reasons re-rule identically) —, **re-run the `fetch-pr` command from above with `--since ` — REPLACING any `--since` it already carries, never appending a second one** (a repeated flag is one flag with two values; the CLI takes the last, but a command that reads as two anchors is a command nobody can check) — the PR ref is already fetched so the re-run is cheap, and it rebuilds the worktree, diff and chunk plan scoped to the delta, with the validation the old flow asked you to hand-run (`cat-file`, `merge-base --is-ancestor`) inside the command where it cannot be skipped. Then act on the new report's `incremental` field exactly as the cache path above does (the model comparison uses the ledger's round only for precedence — there is no `lastModelId` in the marker, so an `upToDate` anchor from the side file stops only when `comment.effective` is false). The decision lands AFTER the setup batch but BEFORE any agent launches, which is where the money is (a same-SHA stop still runs `cleanup`; it just fires three cheap commands later than the cache's fast path would have). An anchor that fails validation falls back to the full diff with the reason in the report, exactly as a rebased cache sha does. Two edges, both decided for you: if the side file's `round` is **higher** than the cache's, prefer the side file's sha — the cache is stale by a round some other environment posted; and a side file with no `sha` field means the last posted round was fail-closed (`compose-review` withholds the anchor then — Step 8 names the conditions), had its ledger truncated by the marker's size caps (a partial work list must not certify a range — the dropped entries would fall outside the next round's scope and retire silently), or predates the field — in every case there is no anchor to recover, and the review is full-range. (The side file may also carry `commitId` — the previous review's own `commit_id`. That is Step 6's **age reference** for the convergence posture, present even on fail-closed rounds; it is never an anchor, and scoping the diff to it would skip exactly the range a fail-closed round could not certify.) + - **Resuming an interrupted run (`--resume`)**: when `parse-args` reported `resume.effective: true`, append `--resume` to the `fetch-pr` command above, and pass `--effort` ONLY if the user explicitly asked for a level in THIS invocation — omit it otherwise, even when the parser resolved a default. `fetch-pr` cannot tell a passed-through default from a user's explicit choice: the interrupted run may have recorded a different level, and handing it the resolved default refuses the resume (`effort-mismatch`) whose fresh fall-through discards the very state `--resume` exists to save — blaming an effort nobody asked for. Omitted, the continuation pins to the recorded level; an effort the user DID pass that differs from the recorded one refuses and runs fresh at the requested level, which is right — different effort is different work, never a silent pin. `fetch-pr` rules on the interrupted attempt's on-disk state itself (worktree still at `fetchedSha` and clean, diff bytes unchanged, PR head unmoved, resume cap unspent — every probe is a fact it gathers, none is yours to assert) and prints one JSON line on stdout. Branch on it: + - **`{"resumed": true, ...}`** — this run continues the interrupted one. The report at the `--out` path is the PREVIOUS attempt's, deliberately left untouched (its mtime is the run epoch every downstream fence keys on); read it exactly as above — worktree, plan and diff are all reused. Then rebuild your working state from disk before launching anything: + + ```bash + "${QWEN_CODE_CLI:-qwen}" review recover-findings \ + --plan .qwen/tmp/qwen-review-pr--fetch.json \ + --out .qwen/tmp/qwen-review-pr--recovered.md + ``` + + It certifies the interrupted attempt's agents against the harness transcripts — the same two-author proof `check-coverage` runs on, so nothing here is taken from anyone's say-so — and writes each certified agent's final text to `--out`. Its stdout JSON reports `recoveredKeys`, `missingKeys`, the `findingsFiles` earlier verify/reverse-audit rounds left on disk, and `latestReverseAuditRound`. **Do not run it as its own round-trip: it joins the setup batch below as a fourth member** — it reads only the plan, the prompt records, the run ledger and the harness transcripts, none of which `pr-context`, `comment-status` or the rules load produce or observe, and its one precondition (`fetch-pr` has returned) is the batch's own. Read `--out` and the newest findings file with the batch's other outputs: the newest findings list is the cumulative state; recovered final texts whose findings it does not carry are new entries (they still owe Step 4 verification). Then continue the normal flow — Step 2 as usual, and at Step 3 launch what the roster demands: `check-coverage` reads the previous attempt's evidence itself, so its report and FIX lines name exactly the agents still owed and nothing already covered. If `latestReverseAuditRound` is `k`, Step 5 resumes at round `k+1` — the retirement scheduler reads the earlier rounds' receipts itself. The `resumed: true` line also carries `restartsSpent` and `effort`: announce that the run continues at that effort, and when `restartsSpent >= 1`, Step 7's once-per-review head-movement restart bound is ALREADY SPENT — a later drift or 422 must submit at the reviewed SHA, never restart again. Disclosure is automatic: coverage counts `recoveredAgents` and the composed body carries a continuity line; you do not write it. + + - **`{"resumed": false, "resumeRefused": ""}`** — the same command has already fallen through to a fresh fetch; proceed exactly as a normal run (the report at `--out` is new) and tell the user why the resume was refused. A refusal with reason `head-moved` IS this review's one head-movement restart — `fetch-pr` records it on disk, and Step 7's restart bound reads as already spent. + - **The setup calls that do not feed each other go out in ONE response — as separate tool calls, never joined with `&&`/`;` into one Shell command** (high and medium effort — at low, Step 2's rules load is skipped and nothing consumes the comment index, so the batch is whatever calls remain). A joined chain changes the failure semantics — a `pr-context` failure must warn-and-continue, not skip the other two — and merges the `warning:` size lines the paging decisions below read. Once `fetch-pr` has returned (and the incremental check, which reads its report, is decided — except on the side-file anchor path, where the decision deliberately waits for `pr-context`'s side file), the next three commands are mutually independent — `pr-context` (below), `comment-status` (below), and Step 2's rules load — every one a read with no side effect the others observe. Issue all three tool calls in a single response, exactly as Step 3 already requires for the agent fan-out, then read their outputs (paging where a file exceeds one read, and those reads can share a response too). The rules load takes `/` — the ref `fetch-pr` just updated; no local-existence probe — **except when the fetch report recorded `baseFetchFailed: true`: drop it from the batch and `git fetch ` first** (on an unresolvable ref `load-rules` reports "no rules found", indistinguishable from a repo that has none, and the review silently enforces nothing). Measured on a real small-PR run: the stretch from `parse-args` to the first agent launch took **7 minutes of wall clock**, one round-trip at a time, on calls that never needed an order. The only orderings that matter: `fetch-pr` before all of them (it creates the worktree and the plan), **any side-file `fetch-pr --since` re-run before `repo-context`** (the re-run rewrites the fetch report from scratch, and `repo-context` enriches that same file in place — an enrichment written first is silently discarded, and the roster then builds without the manifest's required agents), `repo-context` before `agent-prompt --roster` (the roster and every brief bake the manifest's required agents and context blocks, so building them first silently drops the context), and `agent-prompt --roster` after the rules load (the roster bakes the rules into every brief). - **Fetch PR context** (metadata + already-discussed issues) in one pass: @@ -410,7 +429,7 @@ Three ranges exist in the report and they are not interchangeable, which is why --out .qwen/tmp/qwen-review-{target}-coverage.json ``` -The gate reads the effort from the plan (`plan.effort`, recorded at Step 1) — the same value `agent-prompt --roster` read — so on a medium plan it requires the balanced set (no 6a/6b/6c) automatically, and a medium review is not flagged for the personas it deliberately did not run. There is no flag to pass: the roster you launched and the gate that checks it read one field, so they cannot disagree. +The gate reads the effort from the plan (`plan.effort`, recorded at Step 1) — the same value `agent-prompt --roster` read — so on a medium plan it requires the balanced set (no 6a/6b/6c) automatically, and a medium review is not flagged for the personas it deliberately did not run. There is no flag to pass: the roster you launched and the gate that checks it read one field, so they cannot disagree. On a resumed run (Step 1's `--resume`) the gate also reads the interrupted attempt's transcripts itself and credits its certified agents — reported as `recoveredAgents`, with a continuity disclosure — so you neither vouch for the previous attempt's work nor relaunch what it demonstrably finished. **This step runs on both topologies.** An earlier 3B-only model of coverage told a fully-covered 3A review that nobody had read it (measured; DESIGN.md — The 3A review told nobody read it). Coverage is now the intersection of two things the harness wrote down: the lines each agent was **pointed at** (its launch prompt) and the fact that it **opened the diff** (a successful tool call naming the diff file). @@ -681,6 +700,8 @@ Redirect and `read_file` it paged, exactly as with `--roster`: one labelled bloc The brief holds what the auditor is for: hunt only the **gaps** no prior agent caught, report only Critical or Suggestion, apply the Exclusion Criteria, and end with a substantive receipt (`No issues found — `) — a bare "No issues found." fails the substantive-return check below and triggers the one relaunch. +On a resumed run (Step 1's `--resume`), the loop re-enters at `latestReverseAuditRound + 1` from the recovery report — never at round 1: the earlier rounds' receipts are on disk, the retirement scheduler reads them itself, and re-running a round that already holds its receipts spends wall clock re-earning evidence the gate already accepts. + **Termination rules:** - **The substantive-return check applies to every round** — the same rule as Step 3's, enforced here, after each round returns: a bare `No issues found.` with no evidence of what the agent re-examined is a whiff, not a clean bill. Relaunch that agent once, within the round. If the relaunch is also bare, do not spin — take it, but its scope counts as **not audited**: track it in an outstanding-whiffed-scopes list, and clear it only when a later round's agent for that scope returns substantively. @@ -1064,7 +1085,7 @@ Read `.qwen/tmp/qwen-review-{target}-presubmit.json`. Schema: - `downgradeApprove` / `downgradeRequestChanges` / `downgradeReasons` → **do not apply these by hand.** Copy them into the `presubmit` field of the `compose-review` input (below); the subcommand owns the semantics its tests pin — a downgrade fires only when the verdict it names is the one on the table (a Suggestion-only review is already Comment, so nothing is downgraded and no "Downgraded" sentence is emitted), the downgrade sentence carries the reasons, and a downgraded Request changes keeps its body Criticals after the sentence so the self-PR downgrade never erases the only copy of a blocker. - `headDrift.drifted=true` → **commits nobody reviewed are on the PR; the verdict can no longer certify the pull request as it stands.** The Approve cap has already fired through the downgrade machinery (the reason names both SHAs — it rides into the body with the other reasons; never hand-apply). What happens to the _submission_ is decided by **`headDrift.anchorsAtRisk`, which presubmit computes — do not re-derive it by hand**: pass `--new-findings` so it has your anchors, and it rules fail-safe on every hole a hand intersection falls into (a truncated `filesTouched` list (measured; DESIGN.md — The 283-file drift cap), the compare API's own 300-file ceiling, a `diverged` force-push, an unavailable compare, or a missing findings list). **`--new-findings` must carry EVERY finding's file, not only the inline-anchored ones** — a body-only Critical (one that could not be mapped to a diff line) still names a file, and if that file is omitted a drift touching it reads as `anchorsAtRisk=false`; include one `{path, line}` per body Critical (any placeholder `line`, e.g. `1`, and NO `id` — the drift intersection keys on `path` only, but the carried-id re-post exemption intersects on `(path, line)` plus id, so a placeholder line carrying an id could alias an inline finding's location and corrupt its exemption; a body-only Critical is never posted inline and can never be a re-post target). **`anchorsAtRisk=true`**: the anchors themselves are at risk and the findings may already be fixed — apply the 422-recovery rule _proactively_: abandon this submission, say so, and restart at the new SHA from Step 1's `fetch-pr`. **`anchorsAtRisk=false`**: submit as planned — the review is of `fetchedSha` (`submit` posts that very SHA as `commit_id`), the body's downgrade sentence says so, and if GitHub still answers 422 the recovery path below takes over. Name the drift in the terminal summary either way. - > **The restart bound is per-review and covers BOTH restart paths — this proactive drift restart AND the reactive 422 recovery below.** Track it as one fact: a review restarts **at most once** for head movement, whichever path triggers it. If a run that already restarted once reaches a drift restart _or_ a 422 again, do NOT restart a second time — submit at that run's reviewed SHA with the drift named (the Approve cap holds either way). A live PR that keeps moving must not be able to starve the review in an unbounded restart loop; one clean re-read is the review, a second is the PR outrunning it. + > **The restart bound is per-review and covers BOTH restart paths — this proactive drift restart AND the reactive 422 recovery below.** Track it as one fact: a review restarts **at most once** for head movement, whichever path triggers it. If a run that already restarted once reaches a drift restart _or_ a 422 again, do NOT restart a second time — submit at that run's reviewed SHA with the drift named (the Approve cap holds either way). A live PR that keeps moving must not be able to starve the review in an unbounded restart loop; one clean re-read is the review, a second is the PR outrunning it. One slice of this fact survives a resume: a `fetch-pr --resume` refused for `head-moved` records the restart beside the prompt records, and a later continuation reads it back as `restartsSpent` in the `resumed: true` line (Step 1) — arriving with `restartsSpent >= 1` means the bound is already spent. On a run that itself resumed, THIS restart's re-entry is such a refusal — Step 1's resume branch appends `--resume` to every Step 1 `fetch-pr`, so the re-entry sees the moved head, records the restart, and falls through to the fresh fetch the restart wants anyway. Only a never-resumed run's re-entry records nothing (a plain fresh `fetch-pr` rewrites the plan, which re-fences the marker) — within such a run the bound stays tracked here, in this transcript, exactly as before. Be aware of the one seam that leaves: a restart spent that way is invisible to a LATER attempt that resumes, which arrives with `restartsSpent: 0`. A fresh resuming process cannot know the earlier attempt restarted, so do not pretend it can — the on-disk bound is per-attempt, the per-REVIEW invariant is carried by the workflow's own MAX_ATTEMPTS ceiling, and the honest reading of `restartsSpent: 0` on a continuation is "no RECORDED restart", not "no restart". - `ciStatus.skippedCheckNames` → **a green CI is not evidence about a check that never ran.** These are checks that reached `completed` with `skipped`, `neutral`, `stale`, or **no conclusion at all** at this commit — GitHub reports them alongside the passing ones, and this classifier used to score them as passes. Most are routing jobs and are noise; a docs-only PR legitimately skips the test matrix. But **presubmit cannot know which of them would have exercised _this_ diff, and you can** — you have `files[]`. So rule on the list: for each skipped check, ask whether it is the one that would have run the code this PR changes (a test job whose suite covers the changed package; the integration/E2E job for a feature whose only new test lives there). If one is, then **CI verified nothing about this change**, and the review must say so rather than resting on the green: - Name the skipped check in the terminal output, always. diff --git a/packages/core/src/skills/bundled/review/SKILL.test.ts b/packages/core/src/skills/bundled/review/SKILL.test.ts index 82ef9ddfb38..27311853d6b 100644 --- a/packages/core/src/skills/bundled/review/SKILL.test.ts +++ b/packages/core/src/skills/bundled/review/SKILL.test.ts @@ -367,6 +367,33 @@ describe('bundled review skill', () => { ); }); + it('pins the resume branch on Step 1', () => { + // The resume flow is prose over three subcommands (`fetch-pr --resume`, + // `recover-findings`, the round re-entry); a later edit dropping any leg + // leaves `--resume` silently starting fresh runs. Pin the load-bearing + // sentences. + const body = skillBody(); + expect(body).toContain('Resuming an interrupted run (`--resume`)'); + expect(body).toContain('review recover-findings'); + expect(body).toContain('`{"resumed": true, ...}`'); + expect(body).toContain('`{"resumed": false, "resumeRefused": ""}`'); + expect(body).toContain('resumes at round `k+1`'); + expect(body).toContain('re-enters at `latestReverseAuditRound + 1`'); + // The restart bound survives a resume only through this reader; the + // effort pin and the lightweight inertness disclosure are the two + // silent-surprise fixes. + expect(body).toContain('`restartsSpent`'); + expect(body).toContain('`effort-mismatch`'); + expect(body).toContain('no effect in lightweight mode'); + // The Step 7 half specifically: `restartsSpent` also appears in Step 1, + // so these anchor the restart-bound blockquote's own survival sentences — + // deleting or inverting them must fail here, not ship silently. + expect(body).toContain('One slice of this fact survives a resume'); + expect(body).toContain( + "Only a never-resumed run's re-entry records nothing", + ); + }); + it('routes both remote-resolution paths through match-remote', () => { // The pr-url path (Step 1) and the bare-PR-number path both resolve the // remote via the deterministic matcher. A later edit reverting either diff --git a/scripts/tests/qwen-pr-review-workflow.test.js b/scripts/tests/qwen-pr-review-workflow.test.js index fb140d09402..84cf4c4b59b 100644 --- a/scripts/tests/qwen-pr-review-workflow.test.js +++ b/scripts/tests/qwen-pr-review-workflow.test.js @@ -72,8 +72,10 @@ function runScenario(scenario, { timeoutMinutes = 180, logPath } = {}) { const bin = join(dir, 'bin'); const attemptFile = join(dir, 'attempts'); const durationFile = join(dir, 'durations'); + const promptFile = join(dir, 'prompts'); writeFileSync(attemptFile, ''); writeFileSync(durationFile, ''); + writeFileSync(promptFile, ''); const write = (name, body) => { const p = join(bin, name); writeFileSync(p, body); @@ -105,6 +107,12 @@ function runScenario(scenario, { timeoutMinutes = 180, logPath } = {}) { [ '#!/bin/bash', 'n=$(( $(cat "$ATT" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$ATT"', + // The full argv, one line per attempt, with the boundaries INTACT: + // `$*` would join on spaces and render `--prompt "/review x --resume"` + // identically to `--prompt "/review x" --resume`, which are different + // wirings — the second reaches the root CLI's own session-resume flag + // and the skill never sees it. + 'printf "%s\\n" "$(printf "<%s>" "$@")" >> "$PRM"', 'r(){ printf \'{"type":"result","subtype":"%s","is_error":%s,"result":"%s"}\\n\' "$1" "$2" "$3"; }', 'case "$SCENARIO" in', ' success) r success false "Reviewed — no blockers." ;;', @@ -154,6 +162,7 @@ function runScenario(scenario, { timeoutMinutes = 180, logPath } = {}) { SCENARIO: scenario, ATT: attemptFile, DUR: durationFile, + PRM: promptFile, }, }); } catch (e) { @@ -176,6 +185,7 @@ function runScenario(scenario, { timeoutMinutes = 180, logPath } = {}) { raw: stdout, attempts: Number(readFileSync(attemptFile, 'utf8').trim()), durations, + prompts: readFileSync(promptFile, 'utf8').split('\n').filter(Boolean), }; } finally { rmSync(dir, { recursive: true, force: true }); @@ -2609,6 +2619,33 @@ describe('review_requested burst coalescing (#8945)', () => { }); }); +describe('qwen pr review retry --resume wiring', () => { + // The retry's one behavioral change: attempt 1 runs the PROMPT verbatim, + // attempt 2 carries `--resume` so fetch-pr can continue the dead attempt. + // Probe-verified failure modes this pins: flipping the guard to `-le 1` + // (resume lands on attempt 1, never the retry) and reverting to + // `--prompt "$PROMPT"` both shipped green before this assertion existed. + it('attempt 1 gets the verbatim prompt; only the retry carries --resume', () => { + const r = runScenario('transient_then_success'); + expect(r.attempts).toBe(2); + expect(r.prompts).toHaveLength(2); + // One argv element per <>, so `--resume` INSIDE the prompt value is + // distinguishable from `--resume` as its own token after it — the second + // would reach the root CLI's session-resume flag and the skill would + // never see it. + expect(r.prompts[0]).toContain('<--prompt>'); + expect(r.prompts[0]).not.toContain('--resume'); + expect(r.prompts[1]).toContain('<--prompt>'); + expect(r.prompts[1]).not.toContain('<--resume>'); + }); + + it('a single successful attempt never carries --resume', () => { + const r = runScenario('success'); + expect(r.prompts).toHaveLength(1); + expect(r.prompts[0]).not.toContain('--resume'); + }); +}); + describe('checkout self-heal', () => { // The reused self-hosted pool fails checkout in two observed shapes: a // transient network drop mid-fetch, and a corrupt persisted workspace