From de230f82829d2197fc3efbfcb908b2043f62b4e4 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Wed, 24 Jun 2026 19:39:29 +0100 Subject: [PATCH 1/8] feat(review): fold local-review spirit into the full review Add --allow-closed (review closed/merged items: fixtures, hypothetical re-review), --body-file (substitute a hypothetical PR body to exercise the proof/mantis decision, and to feed the body to engines that cannot fetch it live), and --additional-policy (layer a repo-specific policy file). All route through additionalPrompt + the one selection gate, so they apply to every engine uniformly. --- src/clawsweeper.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index b517182c52..b1fe246a38 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -5975,18 +5975,21 @@ function selectCandidates(options: { itemNumbers?: number[]; reviewPolicy?: string; hotIntake?: boolean; + // Local-review extension: review closed/merged items too (fixtures, hypothetical + // re-review). Default false preserves the open-only rule for normal operation. + allowClosed?: boolean; }): { candidates: Item[]; scannedPages: number } { if (options.itemNumbers) { const candidates = options.itemNumbers.flatMap((number) => { const { item, state } = fetchItem(number); - return state === "open" ? [item] : []; + return state === "open" || options.allowClosed ? [item] : []; }); return { candidates, scannedPages: 0 }; } if (options.itemNumber) { if (options.shardIndex !== 0) return { candidates: [], scannedPages: 0 }; const { item, state } = fetchItem(options.itemNumber); - if (state !== "open") return { candidates: [], scannedPages: 0 }; + if (state !== "open" && !options.allowClosed) return { candidates: [], scannedPages: 0 }; return { candidates: [item], scannedPages: 0 }; } const due: DueCandidate[] = []; @@ -16356,10 +16359,27 @@ function reviewCommand(args: Args): void { const sandboxMode = stringArg(args.codex_sandbox, "read-only"); const serviceTier = stringArg(args.codex_service_tier, localOnly ? "fast" : DEFAULT_SERVICE_TIER); const timeoutMs = numberArg(args.codex_timeout_ms, DEFAULT_REVIEW_CODEX_TIMEOUT_MS); - const additionalPrompt = stringArg( + let additionalPrompt = stringArg( args.additional_prompt, process.env.CLAWSWEEPER_ADDITIONAL_PROMPT ?? "", ); + // Local-review extensions (spirit of the standalone local-review lane, folded in): + // layer a repo-specific policy file, and/or substitute a hypothetical PR body (e.g. + // to test the real-behavior-proof / mantis decision, or to give engines that cannot + // fetch the live body — the gh-token-scrubbed ones — the body in the prompt). + const additionalPolicyFile = stringArg(args.additional_policy, ""); + if (additionalPolicyFile) { + const policy = readFileSync(additionalPolicyFile, "utf8"); + additionalPrompt = additionalPrompt + ? `${additionalPrompt}\n\n## Additional review policy (layered on the repo's own policy)\n${policy}` + : policy; + } + const allowClosed = boolArg(args.allow_closed); + const bodyFile = stringArg(args.body_file, ""); + if (bodyFile) { + const providedBody = readFileSync(bodyFile, "utf8"); + additionalPrompt = `${additionalPrompt}\n\n## AUTHORITATIVE PR BODY (review THIS exact body)\nTreat the text below as the pull request's current body/description and review it as such — assess its real-behavior proof, telegram-visible-proof, and mantis recommendation against it. Do NOT fetch, prefer, or assume any other version of the body from the GitHub API. The diff, code, and comments are still the live PR.\n\n----- BEGIN PROVIDED PR BODY -----\n${providedBody}\n----- END PROVIDED PR BODY -----`; + } const shardIndex = numberArg(args.shard_index, 0); const shardCount = numberArg(args.shard_count, 1); const hotIntake = boolArg(args.hot_intake); @@ -16383,6 +16403,7 @@ function reviewCommand(args: Args): void { }; if (itemNumber) selectionOptions.itemNumber = itemNumber; if (itemNumbers) selectionOptions.itemNumbers = itemNumbers; + if (allowClosed) selectionOptions.allowClosed = true; if (hotIntake) selectionOptions.hotIntake = true; if (humanLocalReview) { console.error(""); From e8e994adbfae0806aaab3fa9b3ca85a4060f0a0c Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Wed, 24 Jun 2026 23:47:00 +0100 Subject: [PATCH 2/8] feat(review): offline local-range review (--local-range) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesize the Item + ItemContext from the local git range (merge-base(--base, HEAD)..HEAD) so the full review — real-behavior proof and mantis decision — runs BEFORE a PR exists and WITHOUT a GitHub fetch. The diff comes from `git diff`, the body from the commit message (or --body-file), so it works offline and on fork checkouts that the gh-fetch path rejects. --- src/clawsweeper.ts | 88 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index b1fe246a38..ec4db20b4d 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -16321,6 +16321,74 @@ function planCommand(args: Args): void { ); } +// Offline local-range review: synthesize the Item + ItemContext from the local +// git range (merge-base(base, HEAD)..HEAD) so the FULL review (real-behavior +// proof + mantis decision) can run BEFORE a PR exists — the "advisory review +// before submission" #357 describes but gates behind an already-open PR. No +// GitHub fetch: the diff comes from `git diff`, the body from the commit message +// (or --body-file), so it works offline on a fork checkout. +function buildLocalRangeReview( + targetDir: string, + repo: string, + baseRef: string, +): { item: Item; context: ItemContext; baseSha: string } { + const base = baseRef || "origin/main"; + const headSha = run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim(); + const baseSha = run("git", ["merge-base", base, "HEAD"], { cwd: targetDir }).trim(); + if (!baseSha || baseSha === headSha) { + throw new UserFacingCommandError( + `No local-range review: HEAD has no commits beyond ${base} in ${targetDir}.`, + ); + } + const gitField = (format: string): string => + run("git", ["log", "-1", `--format=${format}`, headSha], { cwd: targetDir }).trim(); + const title = gitField("%s") || `local range ${baseSha.slice(0, 8)}..${headSha.slice(0, 8)}`; + const bodyText = gitField("%b"); + const author = gitField("%an") || "local"; + const committedAt = gitField("%cI") || "1970-01-01T00:00:00Z"; + const nameStatus = run("git", ["diff", "--name-status", `${baseSha}..${headSha}`], { + cwd: targetDir, + }).trim(); + const pullFiles = nameStatus + ? nameStatus.split("\n").map((line) => { + const tab = line.indexOf("\t"); + const status = tab >= 0 ? line.slice(0, tab) : line; + const filename = tab >= 0 ? line.slice(tab + 1) : line; + const patch = run("git", ["diff", `${baseSha}..${headSha}`, "--", filename], { + cwd: targetDir, + }); + return { filename, status, patch: truncateText(patch, 8000) }; + }) + : []; + const item: Item = { + repo, + number: 0, + kind: "pull_request", + title, + url: `local:${headSha}`, + createdAt: committedAt, + updatedAt: committedAt, + author, + authorAssociation: "OWNER", + labels: [], + }; + const context: ItemContext = { + issue: { + number: 0, + title, + body: bodyText, + state: "open", + user: { login: author }, + html_url: item.url, + }, + comments: [], + timeline: [], + pullFiles, + counts: { comments: 0, timeline: 0, pullFiles: pullFiles.length }, + }; + return { item, context, baseSha }; +} + function reviewCommand(args: Args): void { const profile = repoFromArgs(args); const localOnly = boolArg(args.local_only); @@ -16380,16 +16448,22 @@ function reviewCommand(args: Args): void { const providedBody = readFileSync(bodyFile, "utf8"); additionalPrompt = `${additionalPrompt}\n\n## AUTHORITATIVE PR BODY (review THIS exact body)\nTreat the text below as the pull request's current body/description and review it as such — assess its real-behavior proof, telegram-visible-proof, and mantis recommendation against it. Do NOT fetch, prefer, or assume any other version of the body from the GitHub API. The diff, code, and comments are still the live PR.\n\n----- BEGIN PROVIDED PR BODY -----\n${providedBody}\n----- END PROVIDED PR BODY -----`; } + const localRange = boolArg(args.local_range); + const localRangeData = localRange + ? buildLocalRangeReview(openclawDir, targetRepo(), stringArg(args.base, "")) + : undefined; const shardIndex = numberArg(args.shard_index, 0); const shardCount = numberArg(args.shard_count, 1); const hotIntake = boolArg(args.hot_intake); const readonlyOpenclaw = boolArg(args.readonly_openclaw); - const skipStartComment = boolArg(args.skip_start_comment) || localOnly; + const skipStartComment = boolArg(args.skip_start_comment) || localOnly || localRange; const forcedLoginMethod = reviewCodexForcedLoginMethod(args); ensureDir(artifactDir); - const git = checkout.gitTargetBranch - ? gitInfo(openclawDir, { targetBranch: checkout.gitTargetBranch }) - : gitInfo(openclawDir); + const git: GitInfo = localRangeData + ? { mainSha: localRangeData.baseSha, latestRelease: null } + : checkout.gitTargetBranch + ? gitInfo(openclawDir, { targetBranch: checkout.gitTargetBranch }) + : gitInfo(openclawDir); const reviewPolicy = reviewPolicyHash({ model, reasoningEffort, sandboxMode, serviceTier }); const readonlyModeSnapshots = readonlyOpenclaw ? makeTreeReadOnly(openclawDir) : []; try { @@ -16409,7 +16483,9 @@ function reviewCommand(args: Args): void { console.error(""); console.error("Loading review item"); } - const { candidates, scannedPages } = selectCandidates(selectionOptions); + const { candidates, scannedPages } = localRangeData + ? { candidates: [localRangeData.item], scannedPages: 0 } + : selectCandidates(selectionOptions); if (humanLocalReview) { if (candidates.length === 0) throw exactLocalReviewNoCandidateError(itemNumber, shardIndex); const item = candidates[0]!; @@ -16438,7 +16514,7 @@ function reviewCommand(args: Args): void { ); } const contextStartedAt = Date.now(); - const context = collectItemContext(item); + const context = localRangeData ? localRangeData.context : collectItemContext(item); const contextElapsedMs = Date.now() - contextStartedAt; const codexWorkDir = join(artifactDir, "codex"); const proofScratchDir = join(codexWorkDir, "proof-scratch", String(item.number)); From 85d6d8457517cd3b2d0f81f4a3ae65f92353baea Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Wed, 24 Jun 2026 23:50:47 +0100 Subject: [PATCH 3/8] test(review): cover offline local-range review Add buildLocalRangeReviewForTest + a temp-git-repo test asserting the synthetic PR item, the commit-message body, and the git-diff pullFiles are built offline, and that an empty range throws. --- src/clawsweeper.ts | 8 ++ test/local-range-review.test.ts | 166 ++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 test/local-range-review.test.ts diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index ec4db20b4d..4f543eb813 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -16389,6 +16389,14 @@ function buildLocalRangeReview( return { item, context, baseSha }; } +export function buildLocalRangeReviewForTest( + targetDir: string, + repo: string, + baseRef: string, +): { item: Item; context: ItemContext; baseSha: string } { + return buildLocalRangeReview(targetDir, repo, baseRef); +} + function reviewCommand(args: Args): void { const profile = repoFromArgs(args); const localOnly = boolArg(args.local_only); diff --git a/test/local-range-review.test.ts b/test/local-range-review.test.ts new file mode 100644 index 0000000000..4e4e9b611a --- /dev/null +++ b/test/local-range-review.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { buildLocalRangeReviewForTest } from "../dist/clawsweeper.js"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function initRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "lrr-")); + git(dir, "init", "-q"); + git(dir, "config", "user.email", "test@example.com"); + git(dir, "config", "user.name", "Range Tester"); + git(dir, "config", "commit.gpgsign", "false"); + return dir; +} + +test("buildLocalRangeReview synthesizes a PR item + offline diff from the local range", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "keep.txt"), "base\n"); + git(dir, "add", "keep.txt"); + git(dir, "commit", "-q", "-m", "init"); + // a ref at the base commit, so HEAD is one commit ahead of it + git(dir, "branch", "base-ref"); + + // a second changed path (modify) alongside the new file (add), so the + // name-status parsing is exercised across multiple lines and both statuses. + writeFileSync(join(dir, "feature.txt"), "hello world\n"); + writeFileSync(join(dir, "keep.txt"), "base\nmore\n"); + git(dir, "add", "feature.txt", "keep.txt"); + git(dir, "commit", "-q", "-m", "feat: add a feature\n\nthis is the body line"); + + const headSha = git(dir, "rev-parse", "HEAD"); + const committedAt = git(dir, "log", "-1", "--format=%cI", "HEAD"); + const result = buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"); + + // synthetic item: a PR #0 titled from the commit subject, no GitHub involved + assert.equal(result.item.number, 0); + assert.equal(result.item.kind, "pull_request"); + assert.equal(result.item.title, "feat: add a feature"); + assert.equal(result.item.repo, "openclaw/clawsweeper"); + assert.equal(result.item.author, "Range Tester"); + assert.equal(result.item.authorAssociation, "OWNER"); + assert.deepEqual(result.item.labels, []); + assert.equal(result.item.url, `local:${headSha}`); + assert.equal(result.item.createdAt, committedAt); + assert.equal(result.item.updatedAt, committedAt); + + // synthetic context: body + issue mirror, diff from `git diff` + const issue = result.context.issue as { + body: string; + title: string; + state: string; + user: { login: string }; + html_url: string; + }; + assert.match(issue.body, /this is the body line/); + assert.equal(issue.title, "feat: add a feature"); + assert.equal(issue.state, "open"); + assert.equal(issue.user.login, "Range Tester"); + assert.equal(issue.html_url, `local:${headSha}`); + assert.deepEqual(result.context.comments, []); + assert.deepEqual(result.context.timeline, []); + + const files = result.context.pullFiles as Array<{ + filename: string; + status: string; + patch: string; + }>; + assert.equal(files.length, 2); + const byName = (name: string) => files.find((f) => f.filename === name); + assert.equal(byName("feature.txt")?.status, "A"); + assert.match(byName("feature.txt")?.patch ?? "", /\+hello world/); + assert.equal(byName("keep.txt")?.status, "M"); + assert.match(byName("keep.txt")?.patch ?? "", /\+more/); + + assert.deepEqual(result.context.counts, { comments: 0, timeline: 0, pullFiles: 2 }); + assert.equal(result.baseSha, git(dir, "rev-parse", "base-ref")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("buildLocalRangeReview falls back to a range title when the commit subject is empty", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "keep.txt"), "base\n"); + git(dir, "add", "keep.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); + + writeFileSync(join(dir, "f.txt"), "x\n"); + git(dir, "add", "f.txt"); + git(dir, "commit", "-q", "--allow-empty-message", "-m", ""); // no subject + + const result = buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"); + const baseSha = git(dir, "rev-parse", "base-ref"); + const headSha = git(dir, "rev-parse", "HEAD"); + // title = `local range ${baseSha.slice(0,8)}..${headSha.slice(0,8)}` + assert.equal(result.item.title, `local range ${baseSha.slice(0, 8)}..${headSha.slice(0, 8)}`); + assert.equal(result.item.title, result.context.issue.title); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("buildLocalRangeReview defaults base to origin/main when baseRef is empty", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "keep.txt"), "base\n"); + git(dir, "add", "keep.txt"); + git(dir, "commit", "-q", "-m", "init"); + const baseSha = git(dir, "rev-parse", "HEAD"); + // stand in for the remote-tracking ref the empty-base default resolves to + git(dir, "update-ref", "refs/remotes/origin/main", baseSha); + + writeFileSync(join(dir, "feature.txt"), "hi\n"); + git(dir, "add", "feature.txt"); + git(dir, "commit", "-q", "-m", "feat: x"); + + // empty baseRef → base falls back to "origin/main" + const result = buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", ""); + assert.equal(result.baseSha, baseSha); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("buildLocalRangeReview yields no pullFiles for a commit that changes nothing", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "keep.txt"), "base\n"); + git(dir, "add", "keep.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); + git(dir, "commit", "-q", "--allow-empty", "-m", "empty: no file changes"); + + const result = buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"); + assert.deepEqual(result.context.pullFiles, []); + assert.equal(result.context.counts.pullFiles, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("buildLocalRangeReview throws when HEAD has no commits beyond base", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "only.txt"), "x\n"); + git(dir, "add", "only.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); // points at HEAD — empty range + + assert.throws(() => buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"), { + message: /no commits beyond/i, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From c974d9550faa085ed2ab1e4d6a7d68ba72c64534 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Thu, 25 Jun 2026 04:00:06 +0100 Subject: [PATCH 4/8] refactor(review): --local-range reuses #298's offline guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback that the offline --local-range full review paralleled the hardened local-review path (#298) instead of reusing it. Now --local-range: - enforces #298's clean-checkout contract (dirtyWorktree guard — rejects a dirty tree; the committed-range review can't see staged/untracked work); - scrubs every GitHub credential from the engine (scrubGitHubCredentialEnv) and disables web search (LOCAL_REVIEW_WEB_SEARCH_CONFIG) — the same offline guarantee; - reuses commitMetadata(offline=true) for author/subject/dates instead of its own git calls. dirtyWorktree + scrubGitHubCredentialEnv are extracted from commit-sweeper.ts's localReviewCommand (behavior unchanged there) and shared. This is the #357 full proof-aware review made offline — distinct from #298's commit-sweeper code-only lane, but built on its offline plumbing. --- src/clawsweeper.ts | 33 +++++++++++++++++++++++++++------ src/commit-sweeper.ts | 21 +++++++++++++++++---- test/local-range-review.test.ts | 20 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 4f543eb813..b03eeab13b 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -51,6 +51,12 @@ import { import { parseGhJson, parseGhJsonLines } from "./github-json.js"; import { stableJson } from "./stable-json.js"; import { isUserFacingCommandError, runText, UserFacingCommandError } from "./command.js"; +import { + commitMetadata, + dirtyWorktree, + scrubGitHubCredentialEnv, + LOCAL_REVIEW_WEB_SEARCH_CONFIG, +} from "./commit-sweeper.js"; import { AUTOMATION_LIMITS } from "./limits.js"; import { buildOpenClawPrSurfaceStats, @@ -7478,6 +7484,7 @@ function runCodex(options: { proofScratchDir?: string; prompt?: string; quietLogs?: boolean; + extraCodexConfig?: string[]; }): Decision { ensureDir(options.workDir); const proofScratchDir = @@ -7519,6 +7526,7 @@ function runCodex(options: { codexConfig.splice(1, 0, codexLoginConfig()); } if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`); + if (options.extraCodexConfig) codexConfig.push(...options.extraCodexConfig); for (let attempt = 1; attempt <= passAttempts; attempt += 1) { if (existsSync(outputPath)) unlinkSync(outputPath); const remainingMs = options.timeoutMs - (Date.now() - startedAt); @@ -16340,12 +16348,20 @@ function buildLocalRangeReview( `No local-range review: HEAD has no commits beyond ${base} in ${targetDir}.`, ); } - const gitField = (format: string): string => - run("git", ["log", "-1", `--format=${format}`, headSha], { cwd: targetDir }).trim(); - const title = gitField("%s") || `local range ${baseSha.slice(0, 8)}..${headSha.slice(0, 8)}`; - const bodyText = gitField("%b"); - const author = gitField("%an") || "local"; - const committedAt = gitField("%cI") || "1970-01-01T00:00:00Z"; + // Reuse #298's committed-range contract: this offline review covers COMMITTED work, + // so a dirty tree (staged/untracked changes the review can't see) is rejected. + const dirtyTree = dirtyWorktree(targetDir); + if (dirtyTree) { + throw new UserFacingCommandError( + `No local-range review: working tree not clean — commit or stash first:\n${dirtyTree}`, + ); + } + // Reuse #298's offline commit metadata (offline=true skips all gh-api hydration). + const meta = commitMetadata(targetDir, repo, headSha, true); + const bodyText = run("git", ["log", "-1", "--format=%b", headSha], { cwd: targetDir }).trim(); + const title = meta.subject || `local range ${baseSha.slice(0, 8)}..${headSha.slice(0, 8)}`; + const author = meta.authorName || "local"; + const committedAt = meta.committedAt || "1970-01-01T00:00:00Z"; const nameStatus = run("git", ["diff", "--name-status", `${baseSha}..${headSha}`], { cwd: targetDir, }).trim(); @@ -16457,6 +16473,10 @@ function reviewCommand(args: Args): void { additionalPrompt = `${additionalPrompt}\n\n## AUTHORITATIVE PR BODY (review THIS exact body)\nTreat the text below as the pull request's current body/description and review it as such — assess its real-behavior proof, telegram-visible-proof, and mantis recommendation against it. Do NOT fetch, prefer, or assume any other version of the body from the GitHub API. The diff, code, and comments are still the live PR.\n\n----- BEGIN PROVIDED PR BODY -----\n${providedBody}\n----- END PROVIDED PR BODY -----`; } const localRange = boolArg(args.local_range); + if (localRange) { + // Reuse #298's offline guarantee: withhold every GitHub credential from the engine. + scrubGitHubCredentialEnv(); + } const localRangeData = localRange ? buildLocalRangeReview(openclawDir, targetRepo(), stringArg(args.base, "")) : undefined; @@ -16595,6 +16615,7 @@ function reviewCommand(args: Args): void { proofScratchDir, prompt: prompt.text, quietLogs: humanLocalReview, + ...(localRange ? { extraCodexConfig: [LOCAL_REVIEW_WEB_SEARCH_CONFIG] } : {}), }); } catch (error) { codexFailures += 1; diff --git a/src/commit-sweeper.ts b/src/commit-sweeper.ts index 495a6b30e8..7c2e881cd4 100644 --- a/src/commit-sweeper.ts +++ b/src/commit-sweeper.ts @@ -407,6 +407,21 @@ export const LOCAL_REVIEW_SCRUBBED_TOKEN_ENV: readonly string[] = [ ]; export const LOCAL_REVIEW_WEB_SEARCH_CONFIG = 'web_search="disabled"'; +// `git status --porcelain` of a checkout (empty string = clean). Shared by the offline +// committed-range review paths (commit-sweeper `local-review` and clawsweeper `--local-range`) +// so both enforce the same "review COMMITTED work on a clean checkout" contract. +export function dirtyWorktree(targetDir: string): string { + return run("git", ["status", "--porcelain"], { cwd: targetDir }).trim(); +} + +// Withhold every GitHub credential from an offline review engine. Shared by the offline +// committed-range review paths so neither can leak a token to the engine it spawns. +export function scrubGitHubCredentialEnv(): void { + for (const tokenVar of LOCAL_REVIEW_SCRUBBED_TOKEN_ENV) { + delete process.env[tokenVar]; + } +} + export function localReviewAdditionalPrompt( baseSha: string, headSha: string, @@ -427,12 +442,10 @@ function localReviewCommand(args: Args): void { ); // Spec: genuinely offline — withhold every GitHub credential from the review engine. - for (const tokenVar of LOCAL_REVIEW_SCRUBBED_TOKEN_ENV) { - delete process.env[tokenVar]; - } + scrubGitHubCredentialEnv(); // Spec: committed-range review requires a clean checkout (no hidden staged/untracked work). - const dirtyTree = run("git", ["status", "--porcelain"], { cwd: targetDir }).trim(); + const dirtyTree = dirtyWorktree(targetDir); if (dirtyTree) { console.error(`[local-review] working tree not clean — commit or stash first:\n${dirtyTree}`); process.exit(1); diff --git a/test/local-range-review.test.ts b/test/local-range-review.test.ts index 4e4e9b611a..274cfb5a77 100644 --- a/test/local-range-review.test.ts +++ b/test/local-range-review.test.ts @@ -149,6 +149,26 @@ test("buildLocalRangeReview yields no pullFiles for a commit that changes nothin } }); +test("buildLocalRangeReview refuses a dirty working tree (committed-range contract)", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "keep.txt"), "base\n"); + git(dir, "add", "keep.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); + writeFileSync(join(dir, "feature.txt"), "x\n"); + git(dir, "add", "feature.txt"); + git(dir, "commit", "-q", "-m", "feat: x"); + writeFileSync(join(dir, "uncommitted.txt"), "dirty\n"); // untracked → dirty tree + + assert.throws(() => buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"), { + message: /not clean|commit or stash/i, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("buildLocalRangeReview throws when HEAD has no commits beyond base", () => { const dir = initRepo(); try { From d12448879b9d7f1e8bf57807de7da1a5aef08cdf Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Thu, 25 Jun 2026 10:32:30 +0100 Subject: [PATCH 5/8] =?UTF-8?q?fix(review):=20address=20@clawsweeper=20fin?= =?UTF-8?q?dings=20=E2=80=94=20full=20offline=20envelope=20+=20rename=20pa?= =?UTF-8?q?rsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of #369 found four issues; all fixed: - [P1 security] --local-range now reuses #298's FULL offline envelope, not just token scrubbing: an empty GH_CONFIG_DIR (isolateGitHubConfigDir) so gh's own cached auth is unreachable, plus the no-network localReviewAdditionalPrompt. - [P1] --local-range implies --local-only, so a standalone run takes the local Codex auth / Windows-launcher path (was gated on --local-only alone). - [P2] name-status parser takes the LAST tab-field as the path, so rename/copy rows (R100oldnew) review the new path instead of an empty patch. - [P2] synthetic item is authorAssociation CONTRIBUTOR, not OWNER, so the proof gate exercises the real pre-submission path. isolateGitHubConfigDir is extracted from #298's localReviewCommand (behavior unchanged there) and shared between the offline paths. --- src/clawsweeper.ts | 47 ++++++++++++++++++++++++--------- src/commit-sweeper.ts | 31 ++++++++++++++++++---- test/local-range-review.test.ts | 31 +++++++++++++++++++++- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index b03eeab13b..8aed31a688 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -54,6 +54,8 @@ import { isUserFacingCommandError, runText, UserFacingCommandError } from "./com import { commitMetadata, dirtyWorktree, + isolateGitHubConfigDir, + localReviewAdditionalPrompt, scrubGitHubCredentialEnv, LOCAL_REVIEW_WEB_SEARCH_CONFIG, } from "./commit-sweeper.js"; @@ -16339,7 +16341,7 @@ function buildLocalRangeReview( targetDir: string, repo: string, baseRef: string, -): { item: Item; context: ItemContext; baseSha: string } { +): { item: Item; context: ItemContext; baseSha: string; headSha: string } { const base = baseRef || "origin/main"; const headSha = run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim(); const baseSha = run("git", ["merge-base", base, "HEAD"], { cwd: targetDir }).trim(); @@ -16367,9 +16369,13 @@ function buildLocalRangeReview( }).trim(); const pullFiles = nameStatus ? nameStatus.split("\n").map((line) => { - const tab = line.indexOf("\t"); - const status = tab >= 0 ? line.slice(0, tab) : line; - const filename = tab >= 0 ? line.slice(tab + 1) : line; + // name-status rows are tab-separated: "A\tfile", "M\tfile", or for rename/copy + // "R100\told\tnew". The reviewable path is always the LAST field (the new path); + // the status is the first. Splitting on the first tab only would feed the literal + // "old\tnew" to `git diff -- ` and yield an empty patch for renames/copies. + const parts = line.split("\t"); + const status = parts[0] ?? line; + const filename = parts[parts.length - 1] ?? line; const patch = run("git", ["diff", `${baseSha}..${headSha}`, "--", filename], { cwd: targetDir, }); @@ -16385,7 +16391,9 @@ function buildLocalRangeReview( createdAt: committedAt, updatedAt: committedAt, author, - authorAssociation: "OWNER", + // A pre-submission self-review is the CONTRIBUTOR case — the proof gate treats OWNER + // (maintainer) PRs more leniently, which would undercut exercising the real proof path. + authorAssociation: "CONTRIBUTOR", labels: [], }; const context: ItemContext = { @@ -16402,20 +16410,22 @@ function buildLocalRangeReview( pullFiles, counts: { comments: 0, timeline: 0, pullFiles: pullFiles.length }, }; - return { item, context, baseSha }; + return { item, context, baseSha, headSha }; } export function buildLocalRangeReviewForTest( targetDir: string, repo: string, baseRef: string, -): { item: Item; context: ItemContext; baseSha: string } { +): { item: Item; context: ItemContext; baseSha: string; headSha: string } { return buildLocalRangeReview(targetDir, repo, baseRef); } function reviewCommand(args: Args): void { const profile = repoFromArgs(args); - const localOnly = boolArg(args.local_only); + // `--local-range` is inherently a local, offline operation, so it implies `--local-only` + // (no GitHub writes, and the local Codex auth / Windows-launcher path in runCodex below). + const localOnly = boolArg(args.local_only) || boolArg(args.local_range); const verbose = boolArg(args.verbose); const itemNumber = numberArg(args.item_number, 0) || undefined; const hasItemNumbersInput = typeof args.item_numbers === "string" && args.item_numbers.trim(); @@ -16473,13 +16483,26 @@ function reviewCommand(args: Args): void { additionalPrompt = `${additionalPrompt}\n\n## AUTHORITATIVE PR BODY (review THIS exact body)\nTreat the text below as the pull request's current body/description and review it as such — assess its real-behavior proof, telegram-visible-proof, and mantis recommendation against it. Do NOT fetch, prefer, or assume any other version of the body from the GitHub API. The diff, code, and comments are still the live PR.\n\n----- BEGIN PROVIDED PR BODY -----\n${providedBody}\n----- END PROVIDED PR BODY -----`; } const localRange = boolArg(args.local_range); - if (localRange) { - // Reuse #298's offline guarantee: withhold every GitHub credential from the engine. - scrubGitHubCredentialEnv(); - } const localRangeData = localRange ? buildLocalRangeReview(openclawDir, targetRepo(), stringArg(args.base, "")) : undefined; + if (localRangeData) { + // Reuse #298's FULL offline envelope (not just token-scrub): withhold every GitHub + // credential AND point gh at an empty config dir — token deletion alone can't stop + // gh's own cached auth — and prepend the no-network local-review prompt. + scrubGitHubCredentialEnv(); + isolateGitHubConfigDir(); + additionalPrompt = [ + localReviewAdditionalPrompt( + localRangeData.baseSha, + localRangeData.headSha, + stringArg(args.base, "") || "origin/main", + ), + additionalPrompt, + ] + .filter(Boolean) + .join("\n\n"); + } const shardIndex = numberArg(args.shard_index, 0); const shardCount = numberArg(args.shard_count, 1); const hotIntake = boolArg(args.hot_intake); diff --git a/src/commit-sweeper.ts b/src/commit-sweeper.ts index 7c2e881cd4..4fb9d7b1f5 100644 --- a/src/commit-sweeper.ts +++ b/src/commit-sweeper.ts @@ -1,8 +1,15 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { changedFilesForCommit, @@ -422,6 +429,22 @@ export function scrubGitHubCredentialEnv(): void { } } +// Point `gh` at an empty config dir so an offline reviewer finds no cached credentials — +// token-env deletion alone can't stop gh's own configured auth. Shared by the offline +// review paths. `parentDir` keeps the empty dir inside a run dir (cleaned with the run); +// omit it for a throwaway temp dir. Returns the dir set on GH_CONFIG_DIR. +export function isolateGitHubConfigDir(parentDir?: string): string { + let ghEmptyConfig: string; + if (parentDir) { + ghEmptyConfig = join(parentDir, ".gh-empty"); + mkdirSync(ghEmptyConfig, { recursive: true }); + } else { + ghEmptyConfig = mkdtempSync(join(tmpdir(), "cs-gh-empty-")); + } + process.env.GH_CONFIG_DIR = ghEmptyConfig; + return ghEmptyConfig; +} + export function localReviewAdditionalPrompt( baseSha: string, headSha: string, @@ -486,9 +509,7 @@ function localReviewCommand(args: Args): void { // refs, and `gh` uses its own configured auth (token-env deletion can't stop it), // so point it at an empty config dir — any `gh` the spawned reviewer runs finds // no cached credentials. Belt-and-suspenders with Codex's read-only sandbox. - const ghEmptyConfig = join(runDir, ".gh-empty"); - ensureDir(ghEmptyConfig); - process.env.GH_CONFIG_DIR = ghEmptyConfig; + isolateGitHubConfigDir(runDir); const additionalPrompt = localReviewAdditionalPrompt(baseSha, headSha, baseBranch); diff --git a/test/local-range-review.test.ts b/test/local-range-review.test.ts index 274cfb5a77..dad91b1401 100644 --- a/test/local-range-review.test.ts +++ b/test/local-range-review.test.ts @@ -46,7 +46,7 @@ test("buildLocalRangeReview synthesizes a PR item + offline diff from the local assert.equal(result.item.title, "feat: add a feature"); assert.equal(result.item.repo, "openclaw/clawsweeper"); assert.equal(result.item.author, "Range Tester"); - assert.equal(result.item.authorAssociation, "OWNER"); + assert.equal(result.item.authorAssociation, "CONTRIBUTOR"); assert.deepEqual(result.item.labels, []); assert.equal(result.item.url, `local:${headSha}`); assert.equal(result.item.createdAt, committedAt); @@ -149,6 +149,35 @@ test("buildLocalRangeReview yields no pullFiles for a commit that changes nothin } }); +test("buildLocalRangeReview handles renamed files (new path, non-empty patch, no tab leak)", () => { + const dir = initRepo(); + try { + writeFileSync(join(dir, "old-name.txt"), "alpha\nbravo\ncharlie\ndelta\necho\n"); + git(dir, "add", "old-name.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); + rmSync(join(dir, "old-name.txt")); + writeFileSync(join(dir, "new-name.txt"), "alpha\nbravo\ncharlie\ndelta\nFOXTROT\n"); + git(dir, "add", "-A"); + git(dir, "commit", "-q", "-m", "rename old-name -> new-name with one edit"); + + const result = buildLocalRangeReviewForTest(dir, "openclaw/clawsweeper", "base-ref"); + const files = result.context.pullFiles as Array<{ + filename: string; + status: string; + patch: string; + }>; + // the new path is what surfaces — NOT the literal "old-name.txt\tnew-name.txt" + assert.ok(!files.some((f) => f.filename.includes("\t")), "filename must not be tab-joined"); + const renamed = files.find((f) => f.filename === "new-name.txt"); + assert.ok(renamed, "renamed file should appear under its new path"); + assert.match(renamed?.status ?? "", /^R/); + assert.match(renamed?.patch ?? "", /FOXTROT/); // patch resolved against the new path + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("buildLocalRangeReview refuses a dirty working tree (committed-range contract)", () => { const dir = initRepo(); try { From a0e5bc7e3afea031d4fe939bb8f4cedc2e3d52d2 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Thu, 25 Jun 2026 11:25:11 +0100 Subject: [PATCH 6/8] fix(review): unique per-run artifact dir for --local-range (item-0 collision) Addresses @clawsweeper P2: every --local-range review is synthesized as item #0, so its item-numbered artifacts (0.md, codex/0.json, proof-scratch/0, logs) under one default dir collide across repeated/concurrent pre-PR runs. The default dir is now per-run (local-range--, mirroring #298's run identity). An explicit --artifact-dir is still honored as-is. --- src/clawsweeper.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 8aed31a688..4d354b149f 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -16425,7 +16425,8 @@ function reviewCommand(args: Args): void { const profile = repoFromArgs(args); // `--local-range` is inherently a local, offline operation, so it implies `--local-only` // (no GitHub writes, and the local Codex auth / Windows-launcher path in runCodex below). - const localOnly = boolArg(args.local_only) || boolArg(args.local_range); + const localRange = boolArg(args.local_range); + const localOnly = boolArg(args.local_only) || localRange; const verbose = boolArg(args.verbose); const itemNumber = numberArg(args.item_number, 0) || undefined; const hasItemNumbersInput = typeof args.item_numbers === "string" && args.item_numbers.trim(); @@ -16434,8 +16435,18 @@ function reviewCommand(args: Args): void { : undefined; const localExactItem = localExactReviewItem(localOnly, itemNumber, itemNumbers); const humanLocalReview = localExactItem && !verbose; + // Every --local-range review is synthesized as item #0, so its item-numbered artifacts + // (0.md, codex/0.json, proof-scratch/0, logs) would collide across repeated/concurrent + // pre-PR runs under one default dir. Give each run a unique per-run dir (mirrors #298's + // run-- identity). An explicit --artifact-dir is still honored as-is. + const defaultArtifactDir = defaultReviewArtifactDir(localOnly, itemNumber, itemNumbers); const artifactDir = resolve( - stringArg(args.artifact_dir, defaultReviewArtifactDir(localOnly, itemNumber, itemNumbers)), + stringArg( + args.artifact_dir, + localRange + ? join(defaultArtifactDir, `local-range-${Date.now()}-${process.pid}`) + : defaultArtifactDir, + ), ); if (humanLocalReview) { console.error(`Local ClawSweeper review for ${targetRepo()}#${itemNumber}`); @@ -16482,7 +16493,6 @@ function reviewCommand(args: Args): void { const providedBody = readFileSync(bodyFile, "utf8"); additionalPrompt = `${additionalPrompt}\n\n## AUTHORITATIVE PR BODY (review THIS exact body)\nTreat the text below as the pull request's current body/description and review it as such — assess its real-behavior proof, telegram-visible-proof, and mantis recommendation against it. Do NOT fetch, prefer, or assume any other version of the body from the GitHub API. The diff, code, and comments are still the live PR.\n\n----- BEGIN PROVIDED PR BODY -----\n${providedBody}\n----- END PROVIDED PR BODY -----`; } - const localRange = boolArg(args.local_range); const localRangeData = localRange ? buildLocalRangeReview(openclawDir, targetRepo(), stringArg(args.base, "")) : undefined; From 8dd03e14e2406930fb67ebc77a2efe92879ba222 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Sat, 27 Jun 2026 14:45:32 +0100 Subject: [PATCH 7/8] feat(review): reject --item-number/--item-numbers with --local-range --local-range synthesizes the review item from the local git range and never fetches a GitHub item, so an item number is meaningless there and could otherwise route into a managed GitHub checkout. Reject the combination with a clear error (+ a CLI test). Addresses the @clawsweeper P2 flag-conflict finding. --- src/clawsweeper.ts | 9 +++++++++ test/local-range-review.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 4d354b149f..3e457c8e61 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -16433,6 +16433,15 @@ function reviewCommand(args: Args): void { const itemNumbers = hasItemNumbersInput ? itemNumbersArg(args.item_numbers, undefined) : undefined; + // --local-range synthesizes the review item from the local git range and never fetches a GitHub + // item, so an item number is meaningless here and could otherwise route into a managed GitHub + // checkout — reject the combination outright rather than silently ignore it. + if (localRange && (itemNumber !== undefined || itemNumbers !== undefined)) { + throw new UserFacingCommandError( + "--item-number / --item-numbers cannot be combined with --local-range (local-range reviews " + + "the local git range and never fetches a GitHub item).", + ); + } const localExactItem = localExactReviewItem(localOnly, itemNumber, itemNumbers); const humanLocalReview = localExactItem && !verbose; // Every --local-range review is synthesized as item #0, so its item-numbered artifacts diff --git a/test/local-range-review.test.ts b/test/local-range-review.test.ts index dad91b1401..2f1d75d63b 100644 --- a/test/local-range-review.test.ts +++ b/test/local-range-review.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { buildLocalRangeReviewForTest } from "../dist/clawsweeper.js"; +const CLI = fileURLToPath(new URL("../dist/clawsweeper.js", import.meta.url)); + function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); } @@ -213,3 +216,30 @@ test("buildLocalRangeReview throws when HEAD has no commits beyond base", () => rmSync(dir, { recursive: true, force: true }); } }); + +test("review rejects --item-number combined with --local-range", () => { + // The guard fires before any checkout/fetch, so a non-git temp dir is enough. + const dir = mkdtempSync(join(tmpdir(), "lrr-guard-")); + try { + const r = spawnSync( + "node", + [ + CLI, + "review", + "--local-only", + "--local-range", + "--item-number", + "5", + "--target-repo", + "openclaw/clawsweeper", + "--target-dir", + dir, + ], + { encoding: "utf8" }, + ); + assert.notEqual(r.status, 0, "should exit non-zero on the flag conflict"); + assert.match((r.stderr ?? "") + (r.stdout ?? ""), /cannot be combined with --local-range/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 8bc4e934256c853a3be330979233f2a64b1ad6c7 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Sun, 28 Jun 2026 00:39:01 +0100 Subject: [PATCH 8/8] fix(review): skip media preprocessing for local-range --- src/clawsweeper.ts | 8 +++- test/local-range-review.test.ts | 79 ++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 3e457c8e61..3b97e131ee 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -16588,7 +16588,13 @@ function reviewCommand(args: Args): void { const contextElapsedMs = Date.now() - contextStartedAt; const codexWorkDir = join(artifactDir, "codex"); const proofScratchDir = join(codexWorkDir, "proof-scratch", String(item.number)); - const preparedMediaProof = prepareMediaProofArtifacts(context, proofScratchDir); + // --local-range is a pre-PR LOCAL code review — it has no telegram-visible-proof to + // capture, and prepareMediaProofArtifacts would host-side `curl` + `ffmpeg` any media URL + // in the synthetic body (commit message / --body-file). Skip it entirely for local-range: + // no host download, no transcode of body-supplied URLs. + const preparedMediaProof: PreparedMediaProof = localRangeData + ? { manifestPath: null, summaryPath: null, artifacts: [] } + : prepareMediaProofArtifacts(context, proofScratchDir); const prompt = buildReviewPrompt( item, context, diff --git a/test/local-range-review.test.ts b/test/local-range-review.test.ts index 2f1d75d63b..56955483c4 100644 --- a/test/local-range-review.test.ts +++ b/test/local-range-review.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -243,3 +244,79 @@ test("review rejects --item-number combined with --local-range", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("--local-range does not host-download proof video URLs from the body", async () => { + const hits: string[] = []; + const server = createServer((req, res) => { + hits.push(req.url ?? ""); + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + const dir = initRepo(); + const codexDir = mkdtempSync(join(tmpdir(), "lrr-codex-")); + const fakeCodex = join(codexDir, "fake-codex.sh"); + const fakeCodexMarker = join(codexDir, "fake-codex-ran.txt"); + writeFileSync(fakeCodex, '#!/bin/sh\nprintf "ran\\n" > "$FAKE_CODEX_MARKER"\nexit 1\n'); + chmodSync(fakeCodex, 0o755); + try { + writeFileSync(join(dir, "a.txt"), "x\n"); + git(dir, "add", "a.txt"); + git(dir, "commit", "-q", "-m", "init"); + git(dir, "branch", "base-ref"); + writeFileSync(join(dir, "b.txt"), "y\n"); + git(dir, "add", "b.txt"); + // a video URL in the commit body that media-proof preprocessing would otherwise curl + git( + dir, + "commit", + "-q", + "-m", + `feat: thing\n\nproof video: http://127.0.0.1:${port}/proof.mp4`, + ); + // codex is stubbed (CODEX_BIN exits 1) so no real engine runs; media-proof would still + // curl the URL BEFORE the engine if it weren't skipped for --local-range. + const result = spawnSync( + "node", + [ + CLI, + "review", + "--local-only", + "--local-range", + "--base", + "base-ref", + "--target-repo", + "openclaw/clawsweeper", + "--target-dir", + dir, + "--artifact-dir", + join(codexDir, "artifacts"), + ], + { + encoding: "utf8", + env: { + ...process.env, + CLAWSWEEPER_CODEX_REVIEW_ATTEMPTS: "1", + CODEX_BIN: fakeCodex, + FAKE_CODEX_MARKER: fakeCodexMarker, + }, + timeout: 60000, + }, + ); + assert.notEqual(result.status, 0, "fake Codex should make the review fail after setup"); + assert.equal(readFileSync(fakeCodexMarker, "utf8"), "ran\n"); + assert.equal( + hits.length, + 0, + `--local-range must not host-download body video URLs (server hits: ${JSON.stringify(hits)})`, + ); + } finally { + if (existsSync(fakeCodexMarker)) rmSync(fakeCodexMarker, { force: true }); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + rmSync(codexDir, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } +});