diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 49bc4ad74..c054813af 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -335,7 +335,9 @@ _gstack_codex_timeout_wrapper() { # mirror timeout(1)'s exit-124 contract. The watchdog's stdout is # detached so an early finish never blocks a caller's $(...) capture on # the orphaned sleep. - "$@" & + # Without job control a background command's stdin is /dev/null; keep the + # caller's stdin so prompts fed with `codex exec -` arrive (#1674). + "$@" <&0 & local _cmd_pid=$! ( sleep "$_duration" && trap '' TERM && kill -TERM "$_cmd_pid" 2>/dev/null || exit 0 # best-effort: the command already exited, nothing to stop diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index f132338b9..83d5312f8 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -184,8 +184,8 @@ Every prompt sent to Codex MUST be prefixed with this boundary instruction: This applies to Challenge mode (prompt) and Consult mode (persona prompt), and to the custom-instructions path of Review mode — all three use `codex exec`, which still takes -a free-form prompt argument. It does **not** apply to the default scoped `codex review` -call in Step 2A: that command is invoked with **no prompt argument at all** (see "Scope +a free-form prompt (fed on stdin with `codex exec -`, so size and quoting never break it). It does **not** apply to the default scoped `codex review` +call in Step 2A: that command is invoked with **no prompt at all** (see "Scope flags exclude the prompt argument" in the Review mode section), so there is nowhere to put the preamble. That is acceptable — `codex review --base` hands the model a pre-computed diff rather than turning it loose on the filesystem, so the rabbit-hole risk the boundary guards against diff --git a/codex/sections/challenge-mode.md.tmpl b/codex/sections/challenge-mode.md.tmpl index 5de4e26ad..2e3993e01 100644 --- a/codex/sections/challenge-mode.md.tmpl +++ b/codex/sections/challenge-mode.md.tmpl @@ -18,6 +18,7 @@ With focus (e.g., "security"): Review the changes on this branch against the base branch. Run `git diff origin/` to see the diff. Focus specifically on SECURITY. Your job is to find every way an attacker could exploit this code. Think about injection vectors, auth bypasses, privilege escalation, data exposure, and timing attacks. Be adversarial." 2. Run codex exec with **JSONL output** to capture reasoning traces and tool calls. +Replace `` with the full prompt from step 1, verbatim and unescaped; Codex reads it on stdin. Use `timeout: 600000` on the Bash call (the tool's maximum) — the gate sits ABOVE the 540s wrapper so the wrapper fires first, ends Codex, and prints its explicit stall message: @@ -36,7 +37,12 @@ fi TMPRESP=$(mktemp "$TMP_ROOT/codex-resp-XXXXXX") || { echo "ERROR: mktemp failed in TMP_ROOT=$TMP_ROOT; not running codex without its temp file" >&2; exit 1; } source ~/.claude/skills/gstack/bin/gstack-codex-probe || exit 1 _gstack_codex_select_model exec || exit 1 -_gstack_codex_timeout_wrapper 540 codex exec "" -C "$_REPO_ROOT" -s "${_GSTACK_CODEX_SANDBOX:?}" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="high"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < /dev/null 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX") || { echo "ERROR: mktemp failed in TMP_ROOT=$TMP_ROOT; not running codex without its temp file" >&2; exit 1; } +# The prompt goes to Codex on stdin (codex exec -), verbatim: no shell quoting, no argv size limit. +cat > "$_PROMPT_FILE" <<'CODEX_PROMPT_END' + +CODEX_PROMPT_END +_gstack_codex_timeout_wrapper 540 codex exec - -C "$_REPO_ROOT" -s "${_GSTACK_CODEX_SANDBOX:?}" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="high"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < "$_PROMPT_FILE" 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json turn_completed_count = 0 turn_failed = False @@ -94,7 +100,7 @@ if grep -qiE "auth|login|unauthorized" "$TMPERR" 2>/dev/null; then _gstack_codex_log_event "codex_auth_failed" fi bun ~/.claude/skills/gstack/lib/outside-review-result.ts --label 'Codex challenge' --exit "$_CODEX_EXIT" --stderr "$TMPERR" --events "$TMPRESP.events" execution "$TMPRESP" -rm -f "$TMPRESP" "$TMPRESP.events" +rm -f "$TMPRESP" "$TMPRESP.events" "$_PROMPT_FILE" ``` `VERDICT: unavailable` means the challenge did not run (its line names why, such as a diff --git a/codex/sections/consult-mode.md.tmpl b/codex/sections/consult-mode.md.tmpl index 2ff341209..d055871da 100644 --- a/codex/sections/consult-mode.md.tmpl +++ b/codex/sections/consult-mode.md.tmpl @@ -60,7 +60,8 @@ For non-plan consult prompts (user typed `/codex `), still prepend the " -4. Run codex exec with **JSONL output** to capture reasoning traces. Use +4. Run codex exec with **JSONL output** to capture reasoning traces. Replace `` +with the full prompt, verbatim and unescaped; Codex reads it on stdin. Use `timeout: 600000` on the Bash call (the tool's maximum; for both new and resumed sessions) — the gate sits ABOVE the 540s wrapper so the wrapper fires first, ends Codex, and prints its explicit stall message: @@ -78,7 +79,12 @@ fi source ~/.claude/skills/gstack/bin/gstack-codex-probe || exit 1 _gstack_codex_select_model exec || exit 1 # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 540 codex exec "" -C "$_REPO_ROOT" -s "${_GSTACK_CODEX_SANDBOX:?}" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="medium"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < /dev/null 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX") || { echo "ERROR: mktemp failed in TMP_ROOT=$TMP_ROOT; not running codex without its temp file" >&2; exit 1; } +# The prompt goes to Codex on stdin (codex exec -), verbatim: no shell quoting, no argv size limit. +cat > "$_PROMPT_FILE" <<'CODEX_PROMPT_END' + +CODEX_PROMPT_END +_gstack_codex_timeout_wrapper 540 codex exec - -C "$_REPO_ROOT" -s "${_GSTACK_CODEX_SANDBOX:?}" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="medium"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < "$_PROMPT_FILE" 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json turn_completed_count = 0 turn_failed = False @@ -134,6 +140,7 @@ elif [ "$_CODEX_EXIT" != "0" ]; then _gstack_codex_log_event "codex_nonzero_exit" "consult:$_CODEX_EXIT" fi bun ~/.claude/skills/gstack/lib/outside-review-result.ts --label 'Codex consult' --exit "$_CODEX_EXIT" --stderr "$TMPERR" --events "$TMPRESP.events" execution "$TMPRESP" +rm -f "$_PROMPT_FILE" ``` **Session-cost reality (#2387, measured):** every `codex exec` call — resumed @@ -156,7 +163,12 @@ cd "$_REPO_ROOT" || exit 1 source ~/.claude/skills/gstack/bin/gstack-codex-probe || exit 1 _gstack_codex_select_model exec || exit 1 # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 540 codex exec resume "" -c "sandbox_mode=\"${_GSTACK_CODEX_SANDBOX:?}\"" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="medium"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < /dev/null 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX") || { echo "ERROR: mktemp failed in TMP_ROOT=$TMP_ROOT; not running codex without its temp file" >&2; exit 1; } +# The prompt goes to Codex on stdin (codex exec -), verbatim: no shell quoting, no argv size limit. +cat > "$_PROMPT_FILE" <<'CODEX_PROMPT_END' + +CODEX_PROMPT_END +_gstack_codex_timeout_wrapper 540 codex exec resume - -c "sandbox_mode=\"${_GSTACK_CODEX_SANDBOX:?}\"" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="medium"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPRESP" < "$_PROMPT_FILE" 2>"$TMPERR" | tee "$TMPRESP.events" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " " # Fix 1: same hang detection pattern as new-session block @@ -173,6 +185,7 @@ elif [ "$_CODEX_EXIT" != "0" ]; then _gstack_codex_log_event "codex_nonzero_exit" "consult-resume:$_CODEX_EXIT" fi bun ~/.claude/skills/gstack/lib/outside-review-result.ts --label 'Codex consult' --exit "$_CODEX_EXIT" --stderr "$TMPERR" --events "$TMPRESP.events" execution "$TMPRESP" +rm -f "$_PROMPT_FILE" ``` `VERDICT: unavailable` from either block means Codex did not answer from a working diff --git a/codex/sections/review-mode.md.tmpl b/codex/sections/review-mode.md.tmpl index 607383399..b957eeded 100644 --- a/codex/sections/review-mode.md.tmpl +++ b/codex/sections/review-mode.md.tmpl @@ -96,7 +96,7 @@ _PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX") || { echo "ERROR: mktemp git diff "...HEAD" 2>/dev/null printf '\nDIFF_END\n' } > "$_PROMPT_FILE" -_gstack_codex_timeout_wrapper 330 codex exec -s "${_GSTACK_CODEX_SANDBOX:?}" "$(cat "$_PROMPT_FILE")" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="high"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPOUT" < /dev/null >"$TMPOUT.events" 2>"$TMPERR" +_gstack_codex_timeout_wrapper 330 codex exec - -s "${_GSTACK_CODEX_SANDBOX:?}" {{CODEX_MODEL_CONFIG_FLAG}} -c 'model_reasoning_effort="high"' {{CODEX_WEB_SEARCH_FLAG}} --json -o "$TMPOUT" < "$_PROMPT_FILE" >"$TMPOUT.events" 2>"$TMPERR" _CODEX_EXIT=$? rm -f "$_PROMPT_FILE" cat "$TMPOUT" diff --git a/scripts/resolvers/outside-voice.ts b/scripts/resolvers/outside-voice.ts index 751bcd5f9..94dae34b2 100644 --- a/scripts/resolvers/outside-voice.ts +++ b/scripts/resolvers/outside-voice.ts @@ -129,11 +129,11 @@ export function outsideVoiceCommand(ctx: TemplateContext, opts: OutsideCommandOp const prompt = sh(opts.promptFile ?? ''); const codex = opts.structuredBase ? `codex review --base ${sh(opts.structuredBase)} -c "sandbox_mode=\\"\${_GSTACK_CODEX_SANDBOX:?}\\"" ${CODEX_REVIEW_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="${opts.reasoningEffort ?? 'high'}"' ${CODEX_WEB_SEARCH_FLAG} < /dev/null` - : `codex exec "$_OUTSIDE_PROMPT" -C "$_REPO_ROOT" -s "\${_GSTACK_CODEX_SANDBOX:?}" ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="${opts.reasoningEffort ?? 'high'}"' ${CODEX_WEB_SEARCH_FLAG} --json -o "$_OUTSIDE_TMP/text" < /dev/null`; + : `codex exec - -C "$_REPO_ROOT" -s "\${_GSTACK_CODEX_SANDBOX:?}" ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="${opts.reasoningEffort ?? 'high'}"' ${CODEX_WEB_SEARCH_FLAG} --json -o "$_OUTSIDE_TMP/text" <"$_OUTSIDE_INPUT"`; const invocation = v.id === 'codex' ? `source "${bin}/gstack-codex-probe" && _gstack_codex_select_model ${opts.structuredBase ? 'review' : 'exec'} || exit 1 _gstack_codex_sandbox_preflight >/dev/null || exit 1 -${opts.structuredBase ? '' : '_OUTSIDE_PROMPT=$(cat "$_OUTSIDE_INPUT") || exit 1\n'}_OUTSIDE_EXIT=0 +_OUTSIDE_EXIT=0 _gstack_codex_timeout_wrapper ${Math.ceil(opts.timeoutMs / 1000)} ${codex} >"$_OUTSIDE_TMP/${opts.structuredBase ? 'text' : 'events'}" 2>"$_OUTSIDE_TMP/stderr" || _OUTSIDE_EXIT=$? ${opts.structuredBase ? 'cat "$_OUTSIDE_TMP/text"' : 'cat "$_OUTSIDE_TMP/text" 2>/dev/null || tail -n 20 "$_OUTSIDE_TMP/events"'}` : `_OUTSIDE_EXIT=0 diff --git a/test/autoplan-dual-voice-evidence.test.ts b/test/autoplan-dual-voice-evidence.test.ts index ed22b72e6..85c88ed26 100644 --- a/test/autoplan-dual-voice-evidence.test.ts +++ b/test/autoplan-dual-voice-evidence.test.ts @@ -265,7 +265,7 @@ test.each(['assignment-only','set-config','other-config','foreign-reader','or-co if(kind==='changed-harness')input.command=input.command.replace('exit 78','exit 0'); if(kind==='changed-timeout')input.command=input.command.replace('_gstack_codex_timeout_wrapper 540','_gstack_codex_timeout_wrapper 1'); if(kind==='changed-sandbox')input.command=input.command.replace('-s "${_GSTACK_CODEX_SANDBOX:?}"','-s danger-full-access'); - if(kind==='changed-prompt')input.command=input.command.replace('codex exec "$_OUTSIDE_PROMPT"','codex exec "Different plan"'); + if(kind==='changed-prompt')input.command=input.command.replace('codex exec - ','codex exec "Different plan" '); if(kind==='skipped-validator')input.command=input.command.replace(/^bun .*outside-review-result.*\n/m,''); if(kind==='suffix')input.command+='\ntrue'; expect(f.read().codexVoiceFired,kind).toBe(false); diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index 36b36fee7..52d252b1f 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -375,6 +375,21 @@ describe('gstack-codex-probe: timeout wrapper + namespace hygiene', () => { } }); + for (const native of [false, true]) test(`E5: ${native ? 'bash-native watchdog' : 'timeout(1)'} passes the caller's stdin to the command (#1674)`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-watchdog-stdin-')); + try { + for (const tool of native ? ['bash', 'sleep', 'cat', 'printf'] : []) { + const resolved = spawnSync('bash', ['-c', `command -v ${tool}`], { timeout: 5000 }); + const target = resolved.stdout.toString().trim(); + if (target.startsWith('/')) fs.symlinkSync(target, path.join(dir, tool)); + } + const r = runProbe({ snippet: `printf 'prompt on stdin' | _gstack_codex_timeout_wrapper 5 cat; echo " rc=$?"`, env: native ? { PATH: dir } : {} }); + expect(r.stdout).toBe('prompt on stdin rc=0\n'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + test('bash-native watchdog reports its timeout while still retiring after TERM', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-watchdog-race-')); try { diff --git a/test/codex-invocation-sites.test.ts b/test/codex-invocation-sites.test.ts index edcd1d067..7faab3322 100644 --- a/test/codex-invocation-sites.test.ts +++ b/test/codex-invocation-sites.test.ts @@ -2,7 +2,8 @@ * B1 static coverage: every rendered fence that runs `codex exec` or * `codex review` as a command routes the result through the shared validator * (lib/outside-review-result.ts) later in the same fence, and takes its - * sandbox from `_GSTACK_CODEX_SANDBOX` (read-only unless GSTACK_CODEX_NO_SANDBOX=1). + * sandbox from `_GSTACK_CODEX_SANDBOX` (read-only unless GSTACK_CODEX_NO_SANDBOX=1); + * every `codex exec` reads its prompt on stdin (E5). * Prose mentions of the commands are ignored: only command positions count. */ import { afterAll, describe, expect, test } from 'bun:test'; @@ -66,6 +67,13 @@ describe('every rendered Codex invocation is classified by the shared validator' expect(missing).toEqual([]); }); + test(`${host}: E5 every codex exec reads its prompt on stdin, never argv`, () => { + const argv = rendered[host]!.filter(site => /codex\s+exec\b/.test(site.line)) + .filter(site => !/codex\s+exec\s+(?:resume\s+\S+\s+)?-\s/.test(site.line) || /<\s*\/dev\/null/.test(site.line)) + .map(s => `${s.file}: ${s.line}`); + expect(argv).toEqual([]); + }); + test(`${host}: each codex exec/review command takes its sandbox from _GSTACK_CODEX_SANDBOX`, () => { const literal = rendered[host]!.filter(site => !site.line.includes('${_GSTACK_CODEX_SANDBOX:?}')).map(s => `${s.file}: ${s.line}`); expect(literal).toEqual([]); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 59d9ef195..e45641d74 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2194,16 +2194,17 @@ describe('DESIGN_OUTSIDE_VOICES resolver', () => { const product = `A product with $(touch ${marker}), \`touch ${marker}\`, and "quotes".\nUsers: builders.\n`; fs.writeFileSync(brief, product); // Fake codex: the free sandbox preflight succeeds; exec writes its final message to -o. - fs.writeFileSync(path.join(dir, 'codex'), `#!${process.execPath}\nconst fs = require('fs'); const a = process.argv.slice(2); if (a[0] === 'sandbox') process.exit(0);\nfs.writeFileSync(process.env.CAPTURE, JSON.stringify(a));\nconst msg = 'Recommendation: choose a clear hierarchy because builders need to find their work.';\nif (a.includes('-o')) fs.writeFileSync(a[a.indexOf('-o') + 1], msg);\nconsole.log(msg);\n`, { mode: 0o700 }); + fs.writeFileSync(path.join(dir, 'codex'), `#!${process.execPath}\nconst fs = require('fs'); const a = process.argv.slice(2); if (a[0] === 'sandbox') process.exit(0);\nfs.writeFileSync(process.env.CAPTURE, JSON.stringify({ args: a, stdin: a[1] === '-' ? fs.readFileSync(0, 'utf8') : '' }));\nconst msg = 'Recommendation: choose a clear hierarchy because builders need to find their work.';\nif (a.includes('-o')) fs.writeFileSync(a[a.indexOf('-o') + 1], msg);\nconsole.log(msg);\n`, { mode: 0o700 }); const prepare = (file: string) => command.replace("''", quote(file)) .replaceAll('$HOME/.claude/skills/gstack', ROOT); const env = { ...process.env, PATH: dir + path.delimiter + process.env.PATH, CAPTURE: capture, CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude' }; const result = spawnSync('bash', ['-c', prepare(brief)], { cwd: ROOT, env, encoding: 'utf8', timeout: 5_000 }); expect(result.status, result.stderr).toBe(0); - const args = JSON.parse(fs.readFileSync(capture, 'utf8')); + const { args, stdin } = JSON.parse(fs.readFileSync(capture, 'utf8')); expect(args[0]).toBe('exec'); - expect(args[1]).toBe(product.trimEnd()); + expect(args[1]).toBe('-'); + expect(stdin).toBe(product); expect(args).toContain('read-only'); expect(fs.readFileSync(brief, 'utf8')).toBe(product); expect(fs.existsSync(marker)).toBe(false); diff --git a/test/outside-voice-invocation.test.ts b/test/outside-voice-invocation.test.ts index fcac18851..578c291ab 100644 --- a/test/outside-voice-invocation.test.ts +++ b/test/outside-voice-invocation.test.ts @@ -27,7 +27,7 @@ const args = process.argv.slice(2); // The free sandbox preflight (\`codex sandbox ... true\`) succeeds unless a test plants its failure. if (args[0] === 'sandbox') { if (process.env.FAKE_SANDBOX_STDERR) { console.error(process.env.FAKE_SANDBOX_STDERR); process.exit(1); } process.exit(0); } const claude = process.env.FAKE_PROVIDER === 'claude-code'; -const prompt = claude ? await Bun.stdin.text() : args[0] === 'exec' ? args[1] : ''; +const prompt = claude || (args[0] === 'exec' && args[1] === '-') ? await Bun.stdin.text() : args[0] === 'exec' ? args[1] : ''; writeFileSync(process.env.CAPTURE!, JSON.stringify({args,prompt,cwd:process.cwd()})); if (process.env.FAKE_MODE === 'timeout') { if (!claude) console.log('Partial finding before timeout'); @@ -104,10 +104,9 @@ describe('generated outside-review dispatch', () => { test(`${host} dispatches the other CLI and keeps hostile prompt/path text literal`, () => { const result = invoke(host); expect(result.status).toBe(0); - // Codex keeps its existing command-substitution argv contract, which - // removes trailing LF; shell metacharacters within that value stay data. - const expectedPrompt = host === 'codex' ? PROMPT_TEXT : PROMPT_TEXT.replace(/\n+$/,''); - expect(capture().prompt).toBe(expectedPrompt); + // Both CLIs read the prepared prompt on stdin, byte for byte (E5: no argv + // size limit, no quoting); shell metacharacters stay data. + expect(capture().prompt).toBe(PROMPT_TEXT); expect(capture().cwd).toBe(DIR); expect(fs.existsSync(path.join(DIR,'NEVER'))).toBe(false); expect(result.stdout).toContain(`OUTSIDE_STATUS: completed provider=${host === 'codex' ? 'claude-code' : 'codex'} host=${host}`); @@ -116,7 +115,7 @@ describe('generated outside-review dispatch', () => { expect(capture().args).not.toContain('--resume'); expect(result.stdout).toContain('model-a'); expect(result.stdout).toContain('model-b'); - } else expect(capture().args.slice(0,2)).toEqual(['exec',expectedPrompt]); + } else expect(capture().args.slice(0,2)).toEqual(['exec','-']); }); test(`${host} rejects its own reviewer markers before any process starts`, () => { @@ -268,6 +267,17 @@ describe('generated outside-review dispatch', () => { }); }); + test('E5: a prompt larger than one argv string reaches Codex intact on stdin', () => { + const big = path.join(TMP, 'big-prompt.txt'); + const text = `${'x'.repeat(200_000)}\n"quotes" 'single' $(touch NEVER) \\ end\n`; + fs.writeFileSync(big, text); + const ctx: TemplateContext = { skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.codex }; + const result = spawnSync('bash', ['-c', outsideVoiceCommand(ctx, { promptFile: big, timeoutMs: 5000 })], { cwd: DIR, env: environment('claude'), encoding: 'utf8', timeout: 15000 }); + expect(result.status).toBe(0); + expect(capture().prompt).toBe(text); + expect(fs.existsSync(path.join(DIR, 'NEVER'))).toBe(false); + }); + test('malformed Claude JSON cannot reach completion evaluation', () => { const result = invoke('codex',{}, {FAKE_MODE:'malformed'}); expect(result.status).toBe(1);