From deebd7a81531580173ff12abb8cf7b72c7336e01 Mon Sep 17 00:00:00 2001 From: ALV0612 Date: Wed, 2 Sep 2026 10:13:22 +0700 Subject: [PATCH 1/2] feat(task): verify prompt file digests --- plugins/codex/scripts/codex-companion.mjs | 25 ++--- plugins/codex/scripts/lib/task-prompt.mjs | 58 ++++++++++ .../codex/skills/codex-cli-runtime/SKILL.md | 1 + tests/commands.test.mjs | 2 + tests/runtime.test.mjs | 104 ++++++++++++++++++ tests/task-prompt.test.mjs | 102 +++++++++++++++++ tsconfig.app-server.json | 1 + 7 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 plugins/codex/scripts/lib/task-prompt.mjs create mode 100644 tests/task-prompt.test.mjs diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..99b832800 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -23,6 +23,7 @@ import { } from "./lib/codex.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; +import { readTaskPromptInput } from "./lib/task-prompt.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; @@ -79,7 +80,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--prompt-file [--prompt-file-sha256 ]] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -513,7 +514,8 @@ async function executeTaskRun(request) { threadId: result.threadId, rawOutput, touchedFiles: result.touchedFiles, - reasoningSummary: result.reasoningSummary + reasoningSummary: result.reasoningSummary, + ...(request.promptSha256 ? { promptSha256: request.promptSha256 } : {}) }; return { @@ -601,12 +603,13 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, promptSha256 = null, write, resumeLast, jobId }) { return { cwd, model, effort, prompt, + ...(promptSha256 ? { promptSha256 } : {}), write, resumeLast, jobId @@ -640,15 +643,6 @@ async function executeTransfer(cwd, options = {}) { }; } -function readTaskPrompt(cwd, options, positionals) { - if (options["prompt-file"]) { - return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8"); - } - - const positionalPrompt = positionals.join(" "); - return positionalPrompt || readStdinIfPiped(); -} - function requireTaskRequest(prompt, resumeLast) { if (!prompt && !resumeLast) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); @@ -761,7 +755,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "prompt-file-sha256"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" @@ -772,7 +766,8 @@ async function handleTask(argv) { const workspaceRoot = resolveCommandWorkspace(options); const model = normalizeRequestedModel(options.model); const effort = normalizeReasoningEffort(options.effort); - const prompt = readTaskPrompt(cwd, options, positionals); + const promptInput = readTaskPromptInput(cwd, options, positionals, readStdinIfPiped); + const prompt = promptInput.text; const resumeLast = Boolean(options["resume-last"] || options.resume); const fresh = Boolean(options.fresh); @@ -795,6 +790,7 @@ async function handleTask(argv) { model, effort, prompt, + promptSha256: promptInput.sha256, write, resumeLast, jobId: job.id @@ -813,6 +809,7 @@ async function handleTask(argv) { model, effort, prompt, + promptSha256: promptInput.sha256, write, resumeLast, jobId: job.id, diff --git a/plugins/codex/scripts/lib/task-prompt.mjs b/plugins/codex/scripts/lib/task-prompt.mjs new file mode 100644 index 000000000..957fceb3c --- /dev/null +++ b/plugins/codex/scripts/lib/task-prompt.mjs @@ -0,0 +1,58 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/i; + +function normalizeExpectedSha256(value) { + if (value == null) { + return null; + } + const normalized = String(value).trim().toLowerCase(); + if (!SHA256_PATTERN.test(normalized)) { + throw new Error("`--prompt-file-sha256` must be exactly 64 hexadecimal characters."); + } + return normalized; +} + +function digestsEqual(leftHex, rightHex) { + const left = Buffer.from(leftHex, "hex"); + const right = Buffer.from(rightHex, "hex"); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +export function readTaskPromptInput(cwd, options, positionals, readStdin) { + const expectedSha256 = normalizeExpectedSha256(options["prompt-file-sha256"]); + const promptFile = options["prompt-file"]; + if (expectedSha256 && !promptFile) { + throw new Error("`--prompt-file-sha256` requires `--prompt-file `."); + } + + if (promptFile) { + const resolvedPath = path.resolve(cwd, promptFile); + const bytes = fs.readFileSync(resolvedPath); + const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + if (expectedSha256 && !digestsEqual(expectedSha256, sha256)) { + throw new Error( + `Prompt file SHA-256 mismatch for ${resolvedPath}: expected ${expectedSha256}, received ${sha256}.` + ); + } + return { + text: bytes.toString("utf8"), + source: "file", + sha256, + filePath: resolvedPath + }; + } + + const positionalPrompt = positionals.join(" "); + if (positionalPrompt) { + return { text: positionalPrompt, source: "positional", sha256: null, filePath: null }; + } + return { + text: readStdin(), + source: "stdin", + sha256: null, + filePath: null + }; +} diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..a763e7bb8 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -33,6 +33,7 @@ Command selection: - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- `--prompt-file-sha256 `: when using `--prompt-file`, pass the caller-supplied SHA-256 to bind the approved bytes to the prompt that Codex receives. Never invent or recompute an expected digest on the caller's behalf after handoff. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..2c9291118 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -151,6 +151,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); + assert.match(runtimeSkill, /`--prompt-file-sha256 `/i); + assert.match(runtimeSkill, /bind the approved bytes to the prompt that Codex receives/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..1914e2f37 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import test from "node:test"; @@ -2257,3 +2258,106 @@ test("setup and status honor --cwd when reading shared session runtime", () => { assert.equal(payload.sessionRuntime.mode, "shared"); assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); }); + + +test("task verifies prompt-file bytes and returns their SHA-256 receipt", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const prompt = "Inspect $HOME, `backticks`, and the exact newline.\n"; + const promptFile = path.join(repo, "prompt.txt"); + fs.writeFileSync(promptFile, prompt, "utf8"); + const digest = crypto.createHash("sha256").update(Buffer.from(prompt, "utf8")).digest("hex"); + + const result = run( + "node", + [SCRIPT, "task", "--json", "--prompt-file", promptFile, "--prompt-file-sha256", digest], + { cwd: repo, env: buildEnv(binDir) } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.promptSha256, digest); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, prompt.trim()); + const stateDir = resolveStateDir(repo); + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const stored = JSON.parse(fs.readFileSync(path.join(stateDir, "jobs", `${state.jobs[0].id}.json`), "utf8")); + assert.equal(stored.result.promptSha256, digest); +}); + +test("task rejects a prompt-file digest mismatch before creating a job", () => { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const approved = Buffer.from("approved prompt", "utf8"); + const promptFile = path.join(repo, "prompt.txt"); + fs.writeFileSync(promptFile, "substituted prompt", "utf8"); + const digest = crypto.createHash("sha256").update(approved).digest("hex"); + const stateDir = resolveStateDir(repo); + + const result = run( + "node", + [SCRIPT, "task", "--json", "--prompt-file", promptFile, "--prompt-file-sha256", digest], + { cwd: repo } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Prompt file SHA-256 mismatch/); + assert.equal(fs.existsSync(path.join(stateDir, "state.json")), false); + assert.equal(fs.existsSync(path.join(stateDir, "jobs")), false); +}); + +test("background prompt-file task persists the actual SHA-256 and exact prompt", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const prompt = "Background prompt with $ and `literal` bytes.\n"; + const promptFile = path.join(repo, "prompt.txt"); + fs.writeFileSync(promptFile, prompt, "utf8"); + const digest = crypto.createHash("sha256").update(Buffer.from(prompt, "utf8")).digest("hex"); + const env = buildEnv(binDir); + + const launched = run( + "node", + [SCRIPT, "task", "--background", "--json", "--prompt-file", promptFile], + { cwd: repo, env } + ); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const stateDir = resolveStateDir(repo); + + const stored = await waitFor(() => { + const jobFile = path.join(stateDir, "jobs", `${jobId}.json`); + if (!fs.existsSync(jobFile)) return null; + const value = JSON.parse(fs.readFileSync(jobFile, "utf8")); + return value.request?.promptSha256 ? value : null; + }); + assert.equal(stored.request.promptSha256, digest); + assert.equal(stored.request.prompt, prompt); + + const waited = run( + "node", + [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: repo, env } + ); + assert.equal(waited.status, 0, waited.stderr); + assert.equal(JSON.parse(waited.stdout).job.status, "completed"); + + const result = run("node", [SCRIPT, "result", jobId, "--json"], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).storedJob.result.promptSha256, digest); +}); diff --git a/tests/task-prompt.test.mjs b/tests/task-prompt.test.mjs new file mode 100644 index 000000000..d460387c9 --- /dev/null +++ b/tests/task-prompt.test.mjs @@ -0,0 +1,102 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readTaskPromptInput } from "../plugins/codex/scripts/lib/task-prompt.mjs"; +import { makeTempDir } from "./helpers.mjs"; + +function sha256(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + +test("readTaskPromptInput verifies and decodes the same prompt-file bytes", () => { + const cwd = makeTempDir(); + const bytes = Buffer.from("review $HOME and `ticks`\n", "utf8"); + fs.writeFileSync(path.join(cwd, "prompt.txt"), bytes); + + const input = readTaskPromptInput( + cwd, + { "prompt-file": "prompt.txt", "prompt-file-sha256": sha256(bytes).toUpperCase() }, + [], + () => "unused" + ); + + assert.equal(input.text, bytes.toString("utf8")); + assert.equal(input.source, "file"); + assert.equal(input.sha256, sha256(bytes)); + assert.equal(input.filePath, path.join(cwd, "prompt.txt")); +}); + +test("readTaskPromptInput rejects changed prompt-file bytes", () => { + const cwd = makeTempDir(); + const approved = Buffer.from("approved prompt", "utf8"); + fs.writeFileSync(path.join(cwd, "prompt.txt"), "substituted prompt", "utf8"); + + assert.throws( + () => + readTaskPromptInput( + cwd, + { "prompt-file": "prompt.txt", "prompt-file-sha256": sha256(approved) }, + [], + () => "unused" + ), + /Prompt file SHA-256 mismatch.*expected.*received/ + ); +}); + +for (const digest of ["abc", "g".repeat(64), "a".repeat(63), "a".repeat(65)]) { + test(`readTaskPromptInput rejects malformed digest ${digest.slice(0, 8)}`, () => { + const cwd = makeTempDir(); + fs.writeFileSync(path.join(cwd, "prompt.txt"), "prompt", "utf8"); + assert.throws( + () => + readTaskPromptInput( + cwd, + { "prompt-file": "prompt.txt", "prompt-file-sha256": digest }, + [], + () => "unused" + ), + /exactly 64 hexadecimal characters/ + ); + }); +} + +test("readTaskPromptInput rejects digest without prompt-file", () => { + assert.throws( + () => + readTaskPromptInput( + makeTempDir(), + { "prompt-file-sha256": "a".repeat(64) }, + ["do", "not", "leak"], + () => "unused" + ), + /requires `--prompt-file/ + ); +}); + +test("readTaskPromptInput records a receipt without enforcing a digest", () => { + const cwd = makeTempDir(); + const bytes = Buffer.from("compatible prompt", "utf8"); + fs.writeFileSync(path.join(cwd, "prompt.txt"), bytes); + + const input = readTaskPromptInput(cwd, { "prompt-file": "prompt.txt" }, [], () => "unused"); + assert.equal(input.text, "compatible prompt"); + assert.equal(input.sha256, sha256(bytes)); +}); + +test("readTaskPromptInput preserves positional and stdin transports", () => { + assert.deepEqual(readTaskPromptInput(makeTempDir(), {}, ["hello", "world"], () => "unused"), { + text: "hello world", + source: "positional", + sha256: null, + filePath: null + }); + assert.deepEqual(readTaskPromptInput(makeTempDir(), {}, [], () => "stdin prompt"), { + text: "stdin prompt", + source: "stdin", + sha256: null, + filePath: null + }); +}); diff --git a/tsconfig.app-server.json b/tsconfig.app-server.json index 3f8c11f4a..90470078b 100644 --- a/tsconfig.app-server.json +++ b/tsconfig.app-server.json @@ -17,6 +17,7 @@ "plugins/codex/scripts/lib/codex.mjs", "plugins/codex/scripts/lib/fs.mjs", "plugins/codex/scripts/lib/process.mjs", + "plugins/codex/scripts/lib/task-prompt.mjs", "plugins/codex/scripts/lib/app-server-protocol.d.ts", "plugins/codex/.generated/app-server-types/**/*.ts" ] From a8f21dd608f9e41b9199fa2fbd09bb196e2983df Mon Sep 17 00:00:00 2001 From: ALV0612 Date: Wed, 2 Sep 2026 10:39:55 +0700 Subject: [PATCH 2/2] fix(task): preserve verified prompt file text --- plugins/codex/scripts/codex-companion.mjs | 14 ++++--- plugins/codex/scripts/lib/codex.mjs | 9 ++++- .../codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 3 +- tests/runtime.test.mjs | 38 ++++++++++++++----- tests/task-prompt.test.mjs | 2 +- 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 99b832800..72615450b 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -486,6 +486,7 @@ async function executeTaskRun(request) { const result = await runAppServerTurn(workspaceRoot, { resumeThreadId, prompt: request.prompt, + preservePromptWhitespace: request.promptSource === "file", defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, @@ -515,7 +516,7 @@ async function executeTaskRun(request) { rawOutput, touchedFiles: result.touchedFiles, reasoningSummary: result.reasoningSummary, - ...(request.promptSha256 ? { promptSha256: request.promptSha256 } : {}) + ...(request.promptFileSha256 ? { promptFileSha256: request.promptFileSha256 } : {}) }; return { @@ -603,13 +604,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, promptSha256 = null, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, promptSource, promptFileSha256 = null, write, resumeLast, jobId }) { return { cwd, model, effort, prompt, - ...(promptSha256 ? { promptSha256 } : {}), + promptSource, + ...(promptFileSha256 ? { promptFileSha256 } : {}), write, resumeLast, jobId @@ -790,7 +792,8 @@ async function handleTask(argv) { model, effort, prompt, - promptSha256: promptInput.sha256, + promptSource: promptInput.source, + promptFileSha256: promptInput.sha256, write, resumeLast, jobId: job.id @@ -809,7 +812,8 @@ async function handleTask(argv) { model, effort, prompt, - promptSha256: promptInput.sha256, + promptSource: promptInput.source, + promptFileSha256: promptInput.sha256, write, resumeLast, jobId: job.id, diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..706dbc574 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -1124,7 +1124,14 @@ export async function runAppServerTurn(cwd, options = {}) { threadId }); - const prompt = options.prompt?.trim() || options.defaultPrompt || ""; + const suppliedPrompt = typeof options.prompt === "string" ? options.prompt : ""; + const normalizedPrompt = suppliedPrompt.trim(); + if (options.preservePromptWhitespace && !normalizedPrompt) { + throw new Error("A prompt is required for this Codex run."); + } + const prompt = options.preservePromptWhitespace + ? suppliedPrompt + : normalizedPrompt || options.defaultPrompt || ""; if (!prompt) { throw new Error("A prompt is required for this Codex run."); } diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index a763e7bb8..fd70ff26b 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -33,7 +33,7 @@ Command selection: - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. -- `--prompt-file-sha256 `: when using `--prompt-file`, pass the caller-supplied SHA-256 to bind the approved bytes to the prompt that Codex receives. Never invent or recompute an expected digest on the caller's behalf after handoff. +- `--prompt-file-sha256 `: when using `--prompt-file`, pass the caller-supplied SHA-256 to bind the approved file bytes to the verbatim decoded text Codex receives. The JSON receipt is `promptFileSha256`. Never invent or recompute an expected digest on the caller's behalf after handoff. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 2c9291118..a2d3fec94 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -152,7 +152,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /`--prompt-file-sha256 `/i); - assert.match(runtimeSkill, /bind the approved bytes to the prompt that Codex receives/i); + assert.match(runtimeSkill, /bind the approved file bytes to the verbatim decoded text Codex receives/i); + assert.match(runtimeSkill, /JSON receipt is `promptFileSha256`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 1914e2f37..5c00fa00b 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2260,7 +2260,7 @@ test("setup and status honor --cwd when reading shared session runtime", () => { }); -test("task verifies prompt-file bytes and returns their SHA-256 receipt", () => { +test("task verifies prompt-file bytes and sends their decoded text verbatim", () => { const repo = makeTempDir(); const binDir = makeTempDir(); installFakeCodex(binDir); @@ -2269,7 +2269,7 @@ test("task verifies prompt-file bytes and returns their SHA-256 receipt", () => run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const prompt = "Inspect $HOME, `backticks`, and the exact newline.\n"; + const prompt = " Inspect $HOME, `backticks`, and the exact newline. \n"; const promptFile = path.join(repo, "prompt.txt"); fs.writeFileSync(promptFile, prompt, "utf8"); const digest = crypto.createHash("sha256").update(Buffer.from(prompt, "utf8")).digest("hex"); @@ -2282,13 +2282,30 @@ test("task verifies prompt-file bytes and returns their SHA-256 receipt", () => assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); - assert.equal(payload.promptSha256, digest); + assert.equal(payload.promptFileSha256, digest); const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, prompt.trim()); + assert.equal(fakeState.lastTurnStart.prompt, prompt); const stateDir = resolveStateDir(repo); const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); const stored = JSON.parse(fs.readFileSync(path.join(stateDir, "jobs", `${state.jobs[0].id}.json`), "utf8")); - assert.equal(stored.result.promptSha256, digest); + assert.equal(stored.result.promptFileSha256, digest); +}); + +test("task keeps stdin prompt normalization separate from verbatim prompt files", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--json"], { + cwd: repo, + env: buildEnv(binDir), + input: " stdin prompt with boundary whitespace \n" + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, "stdin prompt with boundary whitespace"); }); test("task rejects a prompt-file digest mismatch before creating a job", () => { @@ -2325,7 +2342,7 @@ test("background prompt-file task persists the actual SHA-256 and exact prompt", run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const prompt = "Background prompt with $ and `literal` bytes.\n"; + const prompt = " Background prompt with $ and `literal` bytes. \n"; const promptFile = path.join(repo, "prompt.txt"); fs.writeFileSync(promptFile, prompt, "utf8"); const digest = crypto.createHash("sha256").update(Buffer.from(prompt, "utf8")).digest("hex"); @@ -2344,10 +2361,11 @@ test("background prompt-file task persists the actual SHA-256 and exact prompt", const jobFile = path.join(stateDir, "jobs", `${jobId}.json`); if (!fs.existsSync(jobFile)) return null; const value = JSON.parse(fs.readFileSync(jobFile, "utf8")); - return value.request?.promptSha256 ? value : null; + return value.request?.promptFileSha256 ? value : null; }); - assert.equal(stored.request.promptSha256, digest); + assert.equal(stored.request.promptFileSha256, digest); assert.equal(stored.request.prompt, prompt); + assert.equal(stored.request.promptSource, "file"); const waited = run( "node", @@ -2356,8 +2374,10 @@ test("background prompt-file task persists the actual SHA-256 and exact prompt", ); assert.equal(waited.status, 0, waited.stderr); assert.equal(JSON.parse(waited.stdout).job.status, "completed"); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, prompt); const result = run("node", [SCRIPT, "result", jobId, "--json"], { cwd: repo, env }); assert.equal(result.status, 0, result.stderr); - assert.equal(JSON.parse(result.stdout).storedJob.result.promptSha256, digest); + assert.equal(JSON.parse(result.stdout).storedJob.result.promptFileSha256, digest); }); diff --git a/tests/task-prompt.test.mjs b/tests/task-prompt.test.mjs index d460387c9..b6ee3c3c5 100644 --- a/tests/task-prompt.test.mjs +++ b/tests/task-prompt.test.mjs @@ -13,7 +13,7 @@ function sha256(bytes) { test("readTaskPromptInput verifies and decodes the same prompt-file bytes", () => { const cwd = makeTempDir(); - const bytes = Buffer.from("review $HOME and `ticks`\n", "utf8"); + const bytes = Buffer.from(" review $HOME and `ticks` \n", "utf8"); fs.writeFileSync(path.join(cwd, "prompt.txt"), bytes); const input = readTaskPromptInput(