Files
gstack/test/outside-voice-invocation.test.ts
T
Garry Tanandcapy-ai[bot] 7cb151bff7 Count an outside review as clean only when it ends with a VERDICT line (#4140)
* Add failing tests: outside reviews read clean only from a last-line verdict

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count an outside review as clean only from its last VERDICT line

Every outside-review prompt now asks the reviewer to end with exactly
VERDICT: CLEAN or VERDICT: FINDINGS, and the parser reads a pass only from
that line. Prose no-findings sentences are no longer evidence for clean.
Codex's structured review and /codex review run through codex exec, since
codex review --base takes no prompt and could never give the line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Repin the outside reviewer briefs and fix the tests the verdict line broke (GST-1821)

The outside brief, both adversarial sections and the review-army prompt-file line now carry the last-line VERDICT request; their pins move under the preregistered no-loss replay GST-1821. Test fakes that wrote a clean reply now end it with VERDICT: CLEAN, the review carve marker follows the codex exec sentence, size pins drop where skills shrank, and the plan-review prompt-file line is shortened so /plan-eng-review stays at its pin.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Give the plan-review invocation test's fake reviewer the verdict line it now needs

CI's free-suite shard 5 failed at 1de52b0 with 18 cases in test/model-role-invocation.test.ts: the fake Codex reply had no VERDICT line, so it read unverified (exit 4), and the review-mode template now has one codex exec dispatch block instead of two. The fake reply now ends with VERDICT: FINDINGS and the block count expects one.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Stop the changelog correction from claiming the verdict line closes every false-clean case

Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Split the changelog correction's last sentence for readability

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Close the review fail-opens the fix-check found (GST-1821)

A /codex review whose git diff fails now stops before Codex runs instead of sending an empty diff. The verdict is read from the raw last line, so inline code after VERDICT: CLEAN no longer reads clean, and VERDICT: CLEAN beside numbered items with no severity reads unverified. Codex structured review gets the base-branch diff appended to its prompt, the way the Claude path does, so the scope no longer depends on the prompt the host writes.

Fix round granted by the Captain (a-aec3882c9d48). Review-section pins move under replay GST-1821: the free 681-input replay and the 54 scored probe replies are unchanged through the fixed parser.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog which numbered-list replies now read unverified, and that /codex review stops without its diff

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the skills guide that /codex review now sends the diff through codex exec

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Check that the structured outside review appends the base-branch diff to its prompt

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-07 22:12:57 +00:00

481 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Generated automatic commands, exercised through fake CLIs without API spend. */
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { outsideVoiceCommand, outsideVoiceInvocation, type OutsideCommandOptions } from '../scripts/resolvers/outside-voice';
import { type TemplateContext, HOST_PATHS } from '../scripts/resolvers/types';
import { validateOutsideReview } from '../lib/outside-review-result';
const ROOT = path.resolve(import.meta.dir, '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-invocation-'));
// Shell metacharacters in both the repository and prompt filename must remain data.
const DIR = path.join(TMP, 'repo " with $(touch NEVER)');
const BIN = path.join(TMP, 'bin');
fs.mkdirSync(DIR);
fs.mkdirSync(BIN);
const PROMPT = path.join(DIR, "prompt ' \" $(touch NEVER).txt");
const CAPTURE = path.join(TMP, 'capture.json');
const FAKE_CLAUDE = path.join(TMP, 'fake-claude.ts');
const PROMPT_TEXT = 'review this literal text: $(touch NEVER) `touch NEVER` "\'\nSCORE: 0\n';
fs.writeFileSync(PROMPT, PROMPT_TEXT);
const fakeSource = `
import {existsSync,writeFileSync} from 'node:fs';
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 || (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_NOTICE_ACK) {
const deadline = Date.now() + 1500;
while (!existsSync(process.env.FAKE_NOTICE_ACK)) {
if (Date.now() >= deadline) { console.error('policy notice was not forwarded before provider execution'); process.exit(17); }
await Bun.sleep(10);
}
writeFileSync(process.env.FAKE_PAID_MARKER!, 'provider execution started');
}
if (process.env.FAKE_MODE === 'timeout') {
if (!claude) console.log('Partial finding before timeout');
await new Promise(() => {});
}
if (process.env.FAKE_MODE === 'auth') { console.error('authentication_error: please log in'); process.exit(1); }
const response = process.env.FAKE_RESPONSE || 'Medium: changed.ts loses data on retry.\\nRecommendation: fix the seeded defect because changed.ts loses data.\\nVERDICT: FINDINGS';
if (claude) {
if (process.env.FAKE_MODE === 'malformed') {console.log('{broken');process.exit(0);}
console.log(JSON.stringify({result:response,session_id:'outside-session',modelUsage:{'model-a':{inputTokens:4},'model-b':{inputTokens:8}}}));
} else if (args.includes('-o')) {
// codex exec --json -o <file>: events on stdout, the final message in <file>.
writeFileSync(args[args.indexOf('-o') + 1], response);
if (process.env.FAKE_EVENTS_FILE) process.stdout.write(await Bun.file(process.env.FAKE_EVENTS_FILE).text());
else console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:response}}));
} else console.log(response);
if (process.env.FAKE_STDERR_FILE) process.stderr.write(await Bun.file(process.env.FAKE_STDERR_FILE).text());
process.exit(process.env.FAKE_MODE === 'nonzero' ? 4 : 0);
`;
fs.writeFileSync(FAKE_CLAUDE, fakeSource);
fs.writeFileSync(path.join(BIN, 'codex'), `#!/usr/bin/env bun\n${fakeSource}`, {mode:0o755});
function environment(host: 'codex' | 'claude'): NodeJS.ProcessEnv {
return {...process.env, GSTACK_ROOT:ROOT, GSTACK_BIN:path.join(ROOT,'bin'),
GSTACK_CLAUDE_BIN:process.execPath, GSTACK_CLAUDE_BIN_ARGS:JSON.stringify([FAKE_CLAUDE]),
CAPTURE, FAKE_PROVIDER:host === 'codex' ? 'claude-code' : 'codex',
CODEX_THREAD_ID:host === 'codex' ? 'codex-fixture' : '', CODEX_SANDBOX:'',
CLAUDECODE:host === 'claude' ? '1' : '', GSTACK_ACTIVE_HOST:host, CODEX_HOME:TMP, GSTACK_CODEX_MODEL:'',
GSTACK_HOME:path.join(TMP,'state'), GSTACK_STATE_ROOT:'', CODEX_API_KEY:'', OPENAI_API_KEY:'',
PATH:`${BIN}${path.delimiter}${process.env.PATH}`,
GIT_AUTHOR_NAME:'Test',GIT_AUTHOR_EMAIL:'test@example.invalid',GIT_COMMITTER_NAME:'Test',GIT_COMMITTER_EMAIL:'test@example.invalid'};
}
function git(args: string[]) {
const result = spawnSync('git',args,{cwd:DIR,env:environment('codex'),encoding:'utf8',timeout:5000});
if (result.status !== 0) throw new Error(result.stderr);
}
git(['init','-b','main']);
fs.writeFileSync(path.join(DIR,'changed.txt'),'baseline\n');
git(['add','changed.txt']);
git(['commit','-m','base']);
git(['checkout','-b','work']);
fs.appendFileSync(path.join(DIR,'changed.txt'),'committed change\n');
git(['commit','-am','change']);
fs.appendFileSync(path.join(DIR,'changed.txt'),'working tree change\n');
afterAll(() => fs.rmSync(TMP,{recursive:true,force:true}));
function invoke(host: 'codex' | 'claude', options: Partial<OutsideCommandOptions> = {}, env: NodeJS.ProcessEnv = {}) {
fs.rmSync(CAPTURE,{force:true});
// Env-var roots exercise installed runtime paths as well as the host choice.
const ctx: TemplateContext = {skillName:'review',tmplPath:'review/SKILL.md.tmpl',host,paths:HOST_PATHS.codex};
const command = outsideVoiceCommand(ctx,{promptFile:PROMPT,timeoutMs:3000,...options});
return spawnSync('bash',['-c',command],{cwd:DIR,env:{...environment(host),...env},encoding:'utf8',timeout:10000});
}
function capture() { return JSON.parse(fs.readFileSync(CAPTURE,'utf8')); }
describe('generated outside-review dispatch', () => {
test('Claude policy notice reaches the caller before provider execution, while diagnostics remain available', async () => {
const state = fs.mkdtempSync(path.join(TMP, 'notice-state-'));
const ack = path.join(state, 'caller-saw-notice');
const paid = path.join(state, 'provider-started');
const diagnostics = path.join(state, 'provider-stderr');
fs.writeFileSync(diagnostics, 'provider diagnostic retained\n');
const ctx: TemplateContext = { skillName: 'plan-eng-review', tmplPath: 'plan-eng-review/SKILL.md.tmpl', host: 'codex', paths: HOST_PATHS.codex };
const command = outsideVoiceCommand(ctx, { promptFile: PROMPT, timeoutMs: 3000, role: 'plan-review' });
const child = Bun.spawn(['bash', '-c', command], {
cwd: DIR, env: { ...environment('codex'), HOME: TMP, CLAUDE_CONFIG_DIR: path.join(TMP, '.claude'),
GSTACK_STATE_ROOT: state, GSTACK_CLAUDE_MODEL: '', FAKE_NOTICE_ACK: ack, FAKE_PAID_MARKER: paid, FAKE_STDERR_FILE: diagnostics },
stdout: 'pipe', stderr: 'pipe', timeout: 8000,
});
const stdout = new Response(child.stdout).text();
let stderr = '';
const reader = child.stderr.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
stderr += decoder.decode(chunk.value, { stream: true });
if (stderr.includes('NOTICE: gstack') && !fs.existsSync(ack)) {
expect(fs.existsSync(paid)).toBe(false);
fs.writeFileSync(ack, 'notice observed');
}
}
expect(await child.exited).toBe(0);
expect(await stdout).toContain('OUTSIDE_STATUS: completed provider=claude-code host=codex');
expect(fs.existsSync(paid)).toBe(true);
expect(stderr.match(/NOTICE: gstack/g)).toHaveLength(1);
expect(stderr).toContain('CLAUDE_MODEL: plan-review via anthropic: claude-fable-5-1');
expect(stderr).toContain('provider diagnostic retained');
} finally {
reader.releaseLock();
if (child.exitCode === null) child.kill();
}
});
for (const host of ['codex', 'claude'] as const) {
test(`${host}: creative direction retains the completed recommendation gate`, () => {
const options = { purpose: 'design-direction' as const };
const completed = invoke(host, options, {
FAKE_RESPONSE: 'Recommendation: use a cardless triage table because daily operators need to compare many rows quickly.',
});
expect(completed.status).toBe(0);
expect(completed.stdout).toContain('OUTSIDE_STATUS: completed');
for (const response of ['A blue palette could be nice.', 'I cannot provide a design proposal.']) {
const incomplete = invoke(host, options, { FAKE_RESPONSE: response });
expect(incomplete.status).not.toBe(0);
expect(incomplete.stdout).not.toContain('OUTSIDE_STATUS: completed');
}
});
}
for (const host of ['codex','claude'] as const) {
test(`${host} dispatches the other CLI and keeps hostile prompt/path text literal`, () => {
const result = invoke(host);
expect(result.status).toBe(0);
// 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}`);
if (host === 'codex') {
expect(capture().args).toContain('--tools');
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','-']);
});
test(`${host} rejects its own reviewer markers before any process starts`, () => {
const result = invoke(host,{},host === 'codex' ? {CLAUDECODE:'1'} : {CODEX_THREAD_ID:'stale-codex'});
expect(result.status).toBe(78);
expect(fs.existsSync(CAPTURE)).toBe(false);
expect(result.stderr).toContain('harness mismatch');
expect(result.stderr).toContain('markers conflict');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
test(`${host} reports mixed explicit/native conflicts without guessing a repair host`, () => {
for (const markers of [
{CLAUDECODE:'1', CODEX_THREAD_ID:'', CODEX_SANDBOX:'', GSTACK_ACTIVE_HOST:'codex'},
{CLAUDECODE:'', CODEX_THREAD_ID:'codex-fixture', CODEX_SANDBOX:'', GSTACK_ACTIVE_HOST:'claude'},
]) {
const result = invoke(host, {}, markers);
expect(result.status).toBe(78);
expect(fs.existsSync(CAPTURE)).toBe(false);
expect(result.stderr).toContain('markers conflict');
expect(result.stderr).toContain('setup --host <actual-harness>');
expect(result.stderr).not.toContain('Repair installed skills: run setup --host');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
}
});
test(`${host} detects explicit stale active-host identity without environment marker guesses`, () => {
const result = invoke(host,{},host === 'codex'
? {CLAUDECODE:'',CODEX_THREAD_ID:'',GSTACK_ACTIVE_HOST:'claude'}
: {CLAUDECODE:'',CODEX_THREAD_ID:'',GSTACK_ACTIVE_HOST:'codex'});
expect(result.status).toBe(78);
expect(fs.existsSync(CAPTURE)).toBe(false);
expect(result.stderr).toContain(`setup --host ${host === 'codex' ? 'claude' : 'codex'}`);
});
for (const [label, response] of [
['missing markers','A few scattered observations.'],
['refusal','I cannot review this request. Recommendation: skip because I must refuse.'],
]) {
test(`${host} rejects ${label} after transport success`, () => {
const result = invoke(host,{}, {FAKE_RESPONSE:response});
expect(result.status).toBe(1);
expect(result.stderr).toContain('missing coverage');
expect(result.stdout).toContain(response);
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
}
for (const mode of ['nonzero','auth','timeout']) {
test(`${host} ${mode} is unavailable rather than successful coverage`, () => {
const result = invoke(host,{timeoutMs:300},{FAKE_MODE:mode});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('missing coverage');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
if (mode === 'nonzero' && host === 'claude') expect(result.stdout).toContain('seeded defect');
if (mode === 'timeout' && host === 'claude') expect(result.stdout).toContain('Partial finding before timeout');
});
}
}
test('Claude receives exactly the caller’s committed or working-tree diff scope', () => {
expect(invoke('codex',{diffCommand:'git diff main...HEAD'}).status).toBe(0);
expect(capture().prompt).toContain('+committed change');
expect(capture().prompt).not.toContain('+working tree change');
expect(invoke('codex',{diffCommand:'git diff main'}).status).toBe(0);
expect(capture().prompt).toContain('+committed change');
expect(capture().prompt).toContain('+working tree change');
});
test('Codex structured review runs codex exec on the prepared prompt with --events and accepts concrete severities', () => {
const ctx: TemplateContext = {skillName:'review',tmplPath:'review/SKILL.md.tmpl',host:'claude',paths:HOST_PATHS.codex};
const command = outsideVoiceCommand(ctx,{promptFile:PROMPT,timeoutMs:3000,gate:'structured'});
expect(command).toContain('--events "$_OUTSIDE_TMP/events" structured "$_OUTSIDE_TMP/text"');
expect(command).not.toMatch(/\bcodex review\b/);
const result = invoke('claude',{gate:'structured'},{FAKE_RESPONSE:'[P1] Seeded data-loss bug\nREVIEW_COMPLETE\nVERDICT: FINDINGS'});
expect(result.status).toBe(0);
expect(capture().args.slice(0,2)).toEqual(['exec','-']);
expect(capture().args).not.toContain('--base');
expect(capture().args).toContain('--json');
// exec carries the exec-kind selection, no review_model, and no skill catalog (#2914, #2847).
expect(capture().args.some((arg: string) => arg.startsWith('review_model='))).toBe(false);
expect(capture().args).toContain('model="gpt-6-astra"');
expect(capture().args).toContain('skills.include_instructions=false');
expect(capture().prompt).toBe(PROMPT_TEXT);
// Completion is distinct from approval; the caller retains the P1 fail gate.
expect(validateOutsideReview('[P1] Seeded data-loss bug','structured')).toEqual({completed:true,gate:'fail'});
for (const [severity, gate] of [['P1', 'fail'], ['P2', 'pass']]) {
const response = `**${severity}:** Seeded data-loss bug\nVERDICT: FINDINGS`;
expect(invoke('claude', { gate: 'structured' }, { FAKE_RESPONSE: response }).status).toBe(0);
expect(validateOutsideReview(response, 'structured')).toEqual({ completed: true, gate });
}
});
test('INV-1: the generated caller branches on VERDICT: findings complete, untagged is unverified', () => {
for (const host of ['claude', 'codex'] as const) {
const findings = invoke(host, { gate: 'structured' }, { FAKE_RESPONSE: '[P0] Seeded corruption' });
expect(findings.status).toBe(0);
expect(findings.stdout).toContain('VERDICT: findings\nFINDINGS: P0\n');
expect(findings.stdout).toContain('OUTSIDE_STATUS: completed');
const untagged = invoke(host, { gate: 'structured' }, { FAKE_RESPONSE: 'The change reads fine to me.' });
expect(untagged.status).toBe(4);
expect(untagged.stdout).toContain('VERDICT: unverified');
expect(untagged.stdout).toContain('REASON: missing_verdict');
expect(untagged.stdout).toContain('OUTSIDE_STATUS: unverified');
expect(untagged.stdout).not.toContain('OUTSIDE_STATUS: completed');
expect(untagged.stderr).toContain('ran, verdict unverified');
}
});
describe('B1: Codex sandbox failures are missing coverage, never a pass', () => {
const FIX = path.join(ROOT, 'test', 'fixtures', 'codex-sandbox');
const SANDBOX_LINE = "Codex outside review unavailable: Codex's sandbox could not start here (bwrap: No permissions to create new namespace";
test('the free preflight stops before any paid call', () => {
const result = invoke('claude', {}, { FAKE_SANDBOX_STDERR: fs.readFileSync(path.join(FIX, 'sandbox-userns-denied.stderr'), 'utf8') });
expect(result.status).toBe(1);
expect(fs.existsSync(CAPTURE)).toBe(false);
expect(result.stderr).toContain(SANDBOX_LINE);
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
test('exec: captured events where every command hit the sandbox are unavailable despite exit 0', () => {
const result = invoke('claude', {}, { FAKE_EVENTS_FILE: path.join(FIX, 'exec-json-userns-denied.jsonl'),
FAKE_RESPONSE: 'I could not run commands here. No issues found.\nRecommendation: ship because no issues were found.' });
expect(result.status).toBe(1);
expect(capture().args).toContain('--json');
expect(result.stdout).toContain('REASON: sandbox_unavailable');
expect(result.stderr).toContain(SANDBOX_LINE);
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
test('structured review: captured container transcript on stderr is unavailable, healthy transcript completes', () => {
const failed = invoke('claude', { gate: 'structured' }, {
FAKE_RESPONSE: fs.readFileSync(path.join(FIX, 'review-userns-denied.stdout'), 'utf8'),
FAKE_STDERR_FILE: path.join(FIX, 'review-userns-denied.stderr') });
expect(failed.status).toBe(1);
expect(failed.stderr).toContain(SANDBOX_LINE);
expect(failed.stdout).not.toContain('OUTSIDE_STATUS: completed');
expect(capture().args.slice(capture().args.indexOf('-s'), capture().args.indexOf('-s') + 2)).toEqual(['-s', 'read-only']);
const healthy = invoke('claude', { gate: 'structured' }, {
FAKE_RESPONSE: '[P2] naming nit\nVERDICT: FINDINGS', FAKE_STDERR_FILE: path.join(FIX, 'review-healthy.stderr') });
expect(healthy.status).toBe(0);
expect(healthy.stdout).toContain('OUTSIDE_STATUS: completed');
});
test('GSTACK_CODEX_NO_SANDBOX=1 switches every site to full access, warns, and skips the preflight', () => {
const env = { GSTACK_CODEX_NO_SANDBOX: '1', FAKE_SANDBOX_STDERR: 'bwrap: No permissions to create new namespace' };
const exec = invoke('claude', {}, env);
expect(exec.status).toBe(0);
expect(capture().args.slice(capture().args.indexOf('-s'), capture().args.indexOf('-s') + 2)).toEqual(['-s', 'danger-full-access']);
expect(exec.stderr).toContain('WARNING: GSTACK_CODEX_NO_SANDBOX=1: Codex runs this review without a sandbox');
expect(invoke('claude', { gate: 'structured' }, { ...env, FAKE_RESPONSE: '[P2] nit\nVERDICT: FINDINGS' }).status).toBe(0);
expect(capture().args.slice(capture().args.indexOf('-s'), capture().args.indexOf('-s') + 2)).toEqual(['-s', 'danger-full-access']);
expect(invoke('claude', {}, { GSTACK_CODEX_NO_SANDBOX: 'true' }).status).toBe(0);
expect(capture().args.slice(capture().args.indexOf('-s'), capture().args.indexOf('-s') + 2)).toEqual(['-s', 'read-only']);
});
});
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('Q2: the first automatic Codex review on a machine names the provider and account, once, without blocking', () => {
const state = path.join(TMP, 'notice-state');
const first = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' });
expect(first.status).toBe(0);
expect(first.stderr).toContain('NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: openai) using the API key in OPENAI_API_KEY.');
expect(first.stderr).toContain('To turn them off: gstack-config set codex_reviews disabled');
expect(first.stderr).not.toContain('sk-test-secret-value');
expect(first.stdout).toContain('OUTSIDE_STATUS: completed');
const second = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' });
expect(second.status).toBe(0);
expect(second.stderr).not.toContain('NOTICE:');
});
test('malformed Claude JSON cannot reach completion evaluation', () => {
const result = invoke('codex',{}, {FAKE_MODE:'malformed'});
expect(result.status).toBe(1);
expect(result.stdout).toContain('invalid-json');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
test('a configured but missing Claude binary reports unavailable coverage', () => {
const result = invoke('codex',{}, {GSTACK_CLAUDE_BIN:path.join(TMP,'missing')});
expect(result.status).toBe(1);
expect(result.stdout).toContain('could not start');
expect(result.stderr).toContain('missing coverage');
expect(fs.existsSync(CAPTURE)).toBe(false);
});
for (const response of ['SCORE: 8','SCORE: 8\nAMBIGUITIES:','AMBIGUITIES:\nSCORE: 8','SCORE: 11\nAMBIGUITIES: NONE','SCORE: 8\nSCORE: 2\nAMBIGUITIES: NONE']) {
test(`spec rejects malformed scoring: ${JSON.stringify(response)}`, () => {
const result = invoke('codex',{gate:'spec'},{FAKE_RESPONSE:response});
expect(result.status).toBe(1);
expect(result.stderr).toContain('SCORE/AMBIGUITIES');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
}
test('valid spec scores preserve the pass threshold and failed-quality completion', () => {
for (const score of [0,6,7,10]) {
const response = `SCORE: ${score}\nAMBIGUITIES: NONE`;
expect(invoke('codex',{gate:'spec'},{FAKE_RESPONSE:response}).status).toBe(0);
expect(validateOutsideReview(response,'spec')).toEqual({completed:true,score,gate:score >= 7 ? 'pass':'fail'});
}
});
test('a clean conclusion using “cannot find” is not a refusal', () => {
const response = 'I cannot find any issues.\nRecommendation: approve because the changed behavior is correct.\nVERDICT: CLEAN';
expect(invoke('codex',{}, {FAKE_RESPONSE:response}).status).toBe(0);
});
test('autoplan retains its Codex timeout event and hang record', () => {
const events = path.join(TMP, 'autoplan-events');
const probe = path.join(BIN, 'gstack-codex-probe');
fs.writeFileSync(probe, `#!/usr/bin/env bash
case "$1" in
select-model) printf 'CODEX_SEL: gpt-6-astra\\nCODEX_SEL_KIND: exec\\nCODEX_SANDBOX: read-only\\n' ;;
check-sandbox|show-first-use-notice) ;;
run-with-timeout) echo 'Partial finding'; exit 124 ;;
log-event|log-hang) printf '%s %s\\n' "$2" "$3" >> "$FAKE_EVENTS" ;;
*) exit 64 ;;
esac
`, { mode: 0o755 });
const ctx: TemplateContext = { skillName: 'autoplan', tmplPath: 'autoplan/SKILL.md.tmpl', host: 'claude',
paths: { ...HOST_PATHS.claude, binDir: BIN, skillRoot: ROOT } };
const command = outsideVoiceCommand(ctx, { promptFile: PROMPT, timeoutMs: 600000 });
const result = spawnSync('bash', ['-c', command], { cwd: DIR, env: { ...environment('claude'), FAKE_EVENTS: events }, encoding: 'utf8', timeout: 5000 });
expect(result.status).toBe(124);
expect(result.stdout).toContain('Partial finding');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
expect(fs.readFileSync(events, 'utf8')).toBe('codex_timeout 600\nautoplan 0\n');
});
for (const response of [
'**Recommendation:** fix the guard because it loses data.',
'**Recommendation**: fix the guard because it loses data.',
'`Recommendation: fix the guard because it loses data.`',
'- Recommendation: fix the guard because it loses data.',
'### Recommendation: fix the guard because it loses data.',
]) {
test(`formatted completion remains valid: ${response}`, () => {
expect(invoke('claude', {}, { FAKE_RESPONSE: `[P2] the guard drops one write.\n${response}\nVERDICT: FINDINGS` }).status).toBe(0);
});
}
test('#2776: a provider that ignores TERM is killed at its deadline, keeping partial output', () => {
const stubborn = path.join(TMP, 'stubborn-bin');
const pidFile = path.join(TMP, 'stubborn.pid');
fs.mkdirSync(stubborn, { recursive: true });
fs.writeFileSync(path.join(stubborn, 'codex'), `#!/bin/bash
[ "$1" = sandbox ] && exit 0
trap '' TERM
echo $$ > "$STUBBORN_PID"
echo 'Partial finding before the deadline'
sleep 30
echo 'Recommendation: approve because the late answer arrived.'
`, { mode: 0o755 });
const ctx: TemplateContext = { skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.codex };
const command = outsideVoiceCommand(ctx, { promptFile: PROMPT, timeoutMs: 1000 });
const started = Date.now();
const result = spawnSync('bash', ['-c', command], { cwd: DIR, encoding: 'utf8', timeout: 10000,
env: { ...environment('claude'), PATH: `${stubborn}${path.delimiter}${process.env.PATH}`, STUBBORN_PID: pidFile, _GSTACK_CODEX_KILL_AFTER: '1' } });
expect(result.status).toBe(124);
expect(Date.now() - started).toBeLessThan(8000);
expect(result.stdout).toContain('Partial finding before the deadline');
expect(result.stdout).not.toContain('late answer');
expect(result.stderr).toContain('missing coverage');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
expect(() => process.kill(pid, 0)).toThrow();
});
test('#2914: an invalid model choice is unavailable coverage before any Codex process starts', () => {
const result = invoke('claude', {}, { GSTACK_CODEX_MODEL: 'gpt"; touch NEVER; "' });
expect(result.status).toBe(1);
expect(fs.existsSync(CAPTURE)).toBe(false);
expect(fs.existsSync(path.join(DIR, 'NEVER'))).toBe(false);
expect(result.stderr).toContain('outside review unavailable; missing coverage');
expect(result.stderr).toContain('GSTACK_CODEX_MODEL=<model>');
expect(result.stdout).not.toContain('OUTSIDE_STATUS: completed');
});
test('#2914: the selected model and its source are printed before the call', () => {
fs.writeFileSync(path.join(TMP, 'config.toml'), 'model = "gpt-5.6-terra"\n');
try {
const result = invoke('claude');
expect(result.status).toBe(0);
expect(result.stderr).toContain(`CODEX_MODEL: gpt-5.6-terra (exec; source: ${path.join(TMP, 'config.toml')} model)`);
expect(capture().args).toContain('model="gpt-5.6-terra"');
expect(capture().args).toContain('skills.include_instructions=false');
} finally { fs.rmSync(path.join(TMP, 'config.toml'), { force: true }); }
});
test('#2776: every invocation derives its outer gate from the provider deadline, capped at 600000ms', () => {
for (const host of ['claude', 'codex'] as const) {
const ctx: TemplateContext = { skillName: 'review', tmplPath: 'review/SKILL.md.tmpl', host, paths: HOST_PATHS.claude };
for (const [requested, provider] of [[120000, 120000], [300000, 300000], [540000, 540000], [600000, 540000]]) {
const rendered = outsideVoiceInvocation(ctx, { timeoutMs: requested });
const gates = [...rendered.matchAll(/timeout: (\d+)/g)].map(m => Number(m[1]));
expect(gates).toEqual([provider + 60000]);
if (host === 'claude') expect(rendered).toMatch(new RegExp(`run-with-timeout (?:--effort \\w+ )?${provider / 1000} codex`));
else expect(rendered).toContain(`--timeout-ms ${provider}`);
}
}
});
});