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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions src/shared/codex-runners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
);
Expand All @@ -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 () => {
Expand Down
11 changes: 7 additions & 4 deletions src/shared/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,24 +554,27 @@ 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]),
]
.filter(Boolean)
.join("\n");
if (raw) {
(err as Error & { rawOutput?: string }).rawOutput = raw;
Object.defineProperty(err, "rawOutput", {
value: raw,
configurable: true,
});
}
return err;
};
if (result.res.error) throw withRunnerOutput(result.res.error);
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`,
),
);
}
Expand Down
Loading