From d8f47ee7c1c6da1716b03f14a67002d0c06e1a0d Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 18:34:19 +0800 Subject: [PATCH] fix(runner): withhold prompt-bearing stderr from errors --- src/shared/codex-runners.test.ts | 33 +++++++++++++++++++++++--------- src/shared/codex.ts | 11 +++++++---- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/shared/codex-runners.test.ts b/src/shared/codex-runners.test.ts index a2e3f51..cd6332b 100644 --- a/src/shared/codex-runners.test.ts +++ b/src/shared/codex-runners.test.ts @@ -311,6 +311,7 @@ test("runCodex reports opencode exit errors before parsing stdout", async (t) => const tmp = mkdtempSync(path.join(os.tmpdir(), "needlefish-test-")); const repo = initRepo(tmp); const bin = path.join(tmp, "opencode-bin.js"); + const stderrMarker = "SECRET_REVIEW_PROMPT_MARKER_7f6a"; const previous = { bin: process.env.OPENCODE_BIN, runner: process.env.NEEDLEFISH_RUNNER, @@ -331,7 +332,7 @@ test("runCodex reports opencode exit errors before parsing stdout", async (t) => [ "#!/usr/bin/env node", "process.stdout.write('not json');", - "process.stderr.write('boom');", + `process.stderr.write(${JSON.stringify(stderrMarker)});`, "process.exit(2);", ].join("\n"), ); @@ -340,15 +341,29 @@ test("runCodex reports opencode exit errors before parsing stdout", async (t) => process.env.NEEDLEFISH_RUNNER = "opencode"; process.env.NEEDLEFISH_ALLOW_OPENCODE_RUNNER = "1"; - await assert.rejects( - () => - runCodex("prompt", { - repoPath: repo, - targetHeadSha: headSha(repo), - timeoutMs: 1000, - }), - /opencode runner exited 2: boom/, + let caught: unknown; + try { + await runCodex("prompt", { + repoPath: repo, + targetHeadSha: headSha(repo), + timeoutMs: 1000, + }); + assert.fail("expected runCodex to reject"); + } catch (err) { + caught = err; + } + assert.ok(caught instanceof Error); + const err = caught as Error & { rawOutput?: string }; + assert.match( + err.message, + /opencode runner exited 2; stderr withheld because it may contain the review prompt/, + ); + assert.doesNotMatch(err.message, new RegExp(stderrMarker)); + assert.match( + err.rawOutput ?? "", + new RegExp(stderrMarker), ); + assert.equal(Object.keys(err).includes("rawOutput"), false); }); test("runCodex passes grok plan permission mode when unset or set to 0", async () => { diff --git a/src/shared/codex.ts b/src/shared/codex.ts index 42a9470..0b5eb2b 100644 --- a/src/shared/codex.ts +++ b/src/shared/codex.ts @@ -554,8 +554,8 @@ async function runCodexOnce( // sees it — dying is not an escape hatch from detection. All three // surfaces matter: result.out is the resolved model output (codex writes // it to --output-last-message, not stdout), result.res.stdout the raw - // stream, and stderr rides UNTRUNCATED (the error message clips it at - // 2000 chars — a canary parked past the clip must still reach the scan). + // stream, and stderr rides UNTRUNCATED. Keep it non-enumerable so routine + // error serialization cannot disclose a review prompt. const withRunnerOutput = (err: Error): Error => { const raw = [ ...new Set([result.out, result.res.stdout, result.res.stderr]), @@ -563,7 +563,10 @@ async function runCodexOnce( .filter(Boolean) .join("\n"); if (raw) { - (err as Error & { rawOutput?: string }).rawOutput = raw; + Object.defineProperty(err, "rawOutput", { + value: raw, + configurable: true, + }); } return err; }; @@ -571,7 +574,7 @@ async function runCodexOnce( if (result.res.status !== 0) { throw withRunnerOutput( new Error( - `${runner} runner exited ${result.res.status}: ${(result.res.stderr ?? "").slice(0, 2000)}`, + `${runner} runner exited ${result.res.status}; stderr withheld because it may contain the review prompt`, ), ); }