mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
E5: feed every Codex prompt on stdin instead of argv
Prompts went to Codex as one argv string ('codex exec "$(cat file)"'), which
fails over 32 KB on Windows (and over 128 KB on Linux, E2BIG) and mangles
quotes. Every codex exec site (outside-voice resolver, /codex review custom
instructions, challenge, consult new and resume) now runs 'codex exec -'
with the prepared prompt file on stdin; challenge and consult write the
prompt with a quoted heredoc so it is never shell-parsed. The bash-native
timeout watchdog (stock macOS) started the command in the background, which
gives it /dev/null as stdin; it now passes the caller's stdin through.
The 'byte for byte' dispatch test drops its old argv trailing-LF exception,
the consultation and autoplan fixtures follow the stdin form, and the static
site test requires stdin at every rendered codex exec.
This commit is contained in:
1 parent
ebab87bb11
commit
e2f9e144c0
11 files changed
+77
-22
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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("'<prepared-prompt-file>'", 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user