mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
Merge oct7-probe: executed Codex probe, severity-word classifier, gstack-doctor (B3, B5)
# Conflicts: # test/helpers/carve-guards.ts
This commit is contained in:
commit
6001665c64
65 files changed
+1348
-439
No files matched your search
@@ -263,8 +263,8 @@ test.each(['assignment-only','set-config','other-config','foreign-reader','or-co
|
||||
if(kind==='inside-body')input.command=input.command.replace(guards+'\n','').replace('_OUTSIDE_EXIT=0','_OUTSIDE_EXIT=0\n'+guards);
|
||||
if(kind==='before-cd')input.command=guards+'\ncd '+f.dir+'\n'+f.options.commands.outside.replace("'<prepared-prompt-file>'","'"+f.file+"'");
|
||||
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-timeout')input.command=input.command.replace('run-with-timeout 540','run-with-timeout 1');
|
||||
if(kind==='changed-sandbox')input.command=input.command.replace('-s "${_CODEX_SANDBOX_MODE:?}"','-s danger-full-access');
|
||||
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';
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('content-binding template drift', () => {
|
||||
const codex = rendered('codex/sections/review-mode.md');
|
||||
const starts = [...codex.matchAll(/gstack-review-log --start codex-review/g)];
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(starts[0].index).toBeLessThan(codex.indexOf('_gstack_codex_timeout_wrapper 330 codex review'));
|
||||
expect(starts[0].index).toBeLessThan(codex.indexOf('run-with-timeout 330 codex review'));
|
||||
expect(starts[1].index).toBeLessThan(codex.indexOf('git diff "<base>...HEAD"'));
|
||||
expect(codex).toContain('--finish CODEX_REVIEW_START');
|
||||
expect(codex).toContain('"completed":COMPLETED,"converged":CONVERGED');
|
||||
|
||||
@@ -645,20 +645,20 @@ describe('codex timeout wrapper: /review + /ship diff passes', () => {
|
||||
|
||||
test(`${relPath}: both diff-review Codex calls run under the wrapper`, () => {
|
||||
const wrapped =
|
||||
read().match(/_gstack_codex_timeout_wrapper\s+\d+\s+codex\s+(exec|review)\b/g) ?? [];
|
||||
read().match(/run-with-timeout\s+\d+\s+codex\s+(exec|review)\b/g) ?? [];
|
||||
// Adversarial pass + structured review pass.
|
||||
expect(wrapped.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test(`${relPath}: does not claim \`timeout\` is unavailable on macOS`, () => {
|
||||
// _gstack_codex_timeout_wrapper resolves gtimeout -> timeout -> unwrapped,
|
||||
// run-with-timeout resolves gtimeout -> timeout -> a bash watchdog,
|
||||
// so the coreutils-less case is already handled. The old claim is what
|
||||
// steered these call sites away from the wrapper in the first place.
|
||||
expect(read()).not.toMatch(/doesn't exist on macOS/);
|
||||
});
|
||||
|
||||
test(`${relPath}: wrapper budget stays under the outer Bash gate`, () => {
|
||||
const budgets = [...read().matchAll(/_gstack_codex_timeout_wrapper\s+(\d+)\s+codex\b/g)].map(
|
||||
const budgets = [...read().matchAll(/run-with-timeout\s+(\d+)\s+codex\b/g)].map(
|
||||
(m) => Number(m[1]) * 1000,
|
||||
);
|
||||
expect(budgets.length).toBeGreaterThan(0);
|
||||
@@ -710,13 +710,13 @@ describe('codex skeleton+sections union: review sandbox + fail-closed gate + tim
|
||||
test(`${relPath}: (a) every scoped codex review invocation pins sandbox_mode from the selected sandbox`, () => {
|
||||
const invocations = read()
|
||||
.split('\n')
|
||||
.filter((l) => /_gstack_codex_timeout_wrapper\s+\d+\s+codex\s+review\b/.test(l));
|
||||
.filter((l) => /run-with-timeout\s+\d+\s+codex\s+review\b/.test(l));
|
||||
expect(invocations.length).toBeGreaterThanOrEqual(1);
|
||||
for (const line of invocations) {
|
||||
// _gstack_codex_select_model sets read-only (full access only for
|
||||
// select-model reports read-only (full access only for
|
||||
// GSTACK_CODEX_NO_SANDBOX=1, test/codex-model-probe.test.ts); :? stops
|
||||
// an unselected command instead of inheriting config.toml's default.
|
||||
expect(line).toContain('sandbox_mode=\\"${_GSTACK_CODEX_SANDBOX:?}\\"');
|
||||
expect(line).toContain('sandbox_mode=\\"${_CODEX_SANDBOX_MODE:?}\\"');
|
||||
// `codex review` has no -s/--sandbox flag (verified 0.147.0) — the
|
||||
// config override is the only lever. `-s read-only` here would fail
|
||||
// at argv parsing, which check (b) would then read as a gate FAIL.
|
||||
@@ -766,13 +766,13 @@ describe('codex skeleton+sections union: review sandbox + fail-closed gate + tim
|
||||
test(`${relPath}: (c) every Bash gate sits strictly above its section's wrapper budgets`, () => {
|
||||
// Split on `## ` headings; within any section that declares BOTH a Bash
|
||||
// tool gate (`timeout: N` in ms) and a wrapper budget
|
||||
// (`_gstack_codex_timeout_wrapper S codex`), every gate must be strictly
|
||||
// (`run-with-timeout S codex`), every gate must be strictly
|
||||
// greater than every wrapper budget so the wrapper fires first.
|
||||
const sections = read().split(/\n## /);
|
||||
const inspected: string[] = [];
|
||||
for (const section of sections) {
|
||||
const gates = [...section.matchAll(/timeout:\s*(\d{4,})/g)].map((m) => Number(m[1]));
|
||||
const wrappers = [...section.matchAll(/_gstack_codex_timeout_wrapper\s+(\d+)\s+codex\b/g)].map(
|
||||
const wrappers = [...section.matchAll(/run-with-timeout\s+(\d+)\s+codex\b/g)].map(
|
||||
(m) => Number(m[1]) * 1000,
|
||||
);
|
||||
if (gates.length === 0 || wrappers.length === 0) continue;
|
||||
@@ -941,17 +941,17 @@ describe('codex broken-install detection (#2742)', () => {
|
||||
for (const rel of ['autoplan/SKILL.md.tmpl', 'autoplan/SKILL.md']) {
|
||||
const raw = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
const src = rel.endsWith('.tmpl') ? raw.replace('{{OUTSIDE_PREFLIGHT:autoplan}}', RESOLVERS.OUTSIDE_PREFLIGHT({ host: 'claude', paths: HOST_PATHS.claude, skillName: 'autoplan', tmplPath: rel }, ['autoplan'])) : raw;
|
||||
expect(src).toContain('_gstack_codex_model_probe; _CODEX_MP=$?');
|
||||
expect(src).toContain('_CODEX_PO=$("$_CODEX_PROBE" probe-model $_CODEX_KIND); _CODEX_MP=$?');
|
||||
expect(src).toMatch(/_CODEX_MP" -eq 2/);
|
||||
expect(src).toContain('binary cannot run');
|
||||
expect(src).not.toContain('elif ! _gstack_codex_model_probe');
|
||||
expect(src).not.toContain('elif ! "$_CODEX_PROBE" probe-model');
|
||||
}
|
||||
});
|
||||
|
||||
test('the preflight resolver routes exit 2 to broken_install', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'scripts/resolvers/constants.ts'), 'utf8');
|
||||
expect(src).toContain('broken_install');
|
||||
// The chain must capture the probe's code; `elif ! _gstack_codex_model_probe`
|
||||
// The chain must capture the probe's code; `elif ! gstack-codex-probe probe-model`
|
||||
// collapses 1 and 2 into one branch and loses the distinction.
|
||||
expect(src).toContain('_CODEX_MP=$?');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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 `_CODEX_SANDBOX_MODE` (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.
|
||||
*/
|
||||
@@ -16,8 +16,8 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-sites-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
/** `codex exec|review` at the start of a shell command, optionally behind the timeout wrapper. */
|
||||
const COMMAND = /(?:^|[;&|]\s*|\$\(\s*)(?:_gstack_codex_timeout_wrapper\s+\d+\s+)?codex\s+(?:exec|review)\b/;
|
||||
/** `codex exec|review` at the start of a shell command, optionally behind the probe's run-with-timeout. */
|
||||
const COMMAND = /(?:^|[;&|]\s*|\$\(\s*)(?:"\$_CODEX_PROBE"\s+run-with-timeout\s+\d+\s+)?codex\s+(?:exec|review)\b/;
|
||||
const VALIDATOR = /outside-review-result\.ts\b/;
|
||||
|
||||
interface Site { file: string; line: string; fence: string; after: string }
|
||||
@@ -74,8 +74,8 @@ describe('every rendered Codex invocation is classified by the shared validator'
|
||||
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}`);
|
||||
test(`${host}: each codex exec/review command takes its sandbox from _CODEX_SANDBOX_MODE`, () => {
|
||||
const literal = rendered[host]!.filter(site => !site.line.includes('${_CODEX_SANDBOX_MODE:?}')).map(s => `${s.file}: ${s.line}`);
|
||||
expect(literal).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -86,13 +86,13 @@ describe('every rendered Codex invocation is classified by the shared validator'
|
||||
'```bash',
|
||||
'# codex exec in a comment',
|
||||
'echo "use codex exec later"',
|
||||
'_gstack_codex_timeout_wrapper 30 codex exec "x" -s read-only',
|
||||
'"$_CODEX_PROBE" run-with-timeout 30 codex exec "x" -s read-only',
|
||||
'```',
|
||||
].join('\n');
|
||||
const sites = codexSites(doc, 'planted.md');
|
||||
expect(sites.map(s => s.line)).toEqual(['_gstack_codex_timeout_wrapper 30 codex exec "x" -s read-only']);
|
||||
expect(sites.map(s => s.line)).toEqual(['"$_CODEX_PROBE" run-with-timeout 30 codex exec "x" -s read-only']);
|
||||
expect(VALIDATOR.test(sites[0]!.after)).toBe(false);
|
||||
expect(sites[0]!.line.includes('${_GSTACK_CODEX_SANDBOX:?}')).toBe(false);
|
||||
expect(sites[0]!.line.includes('${_CODEX_SANDBOX_MODE:?}')).toBe(false);
|
||||
expect(codexSites('```bash\nX=$(codex exec "y")\n```', 'p.md')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { codexPreflight } from '../scripts/resolvers/constants';
|
||||
import { withoutDeprecation } from './helpers/codex-probe-sourcing';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const PROBE = path.join(ROOT, 'bin', 'gstack-codex-probe');
|
||||
@@ -129,7 +130,7 @@ function runProbe(f: Fixture, stubMode: string, extraEnv: Record<string, string>
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return { stdout: (result.stdout ?? '').toString(), stderr: (result.stderr ?? '').toString(), status: result.status ?? -1 };
|
||||
return { stdout: (result.stdout ?? '').toString(), stderr: withoutDeprecation((result.stderr ?? '').toString()), status: result.status ?? -1 };
|
||||
}
|
||||
|
||||
function lastArgs(f: Fixture): string {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* bin/gstack-codex-probe is an executed command: one subcommand per probe,
|
||||
* KEY: value lines on stdout, human lines where they went before, nothing set
|
||||
* in the caller's shell. Generated skills run it as a command, so the calling
|
||||
* shell (bash, zsh, sh) no longer matters. Sourcing still works during the
|
||||
* compatibility window and says it is deprecated; executing never does.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { PROBE_SOURCING_DEPRECATION } from './helpers/codex-probe-sourcing';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const PROBE = path.join(ROOT, 'bin', 'gstack-codex-probe');
|
||||
const HAS_ZSH = Boolean(Bun.which('zsh'));
|
||||
const ZSH_REQUIRED = Boolean(process.env.CI) && process.platform !== 'win32';
|
||||
const SHELLS = ['bash', 'zsh'] as const;
|
||||
const SUBCOMMANDS = ['select-model', 'check-auth', 'show-sandbox', 'check-sandbox', 'probe-model', 'check-version',
|
||||
'show-first-use-notice', 'run-with-timeout', 'log-event', 'log-hang', 'help'];
|
||||
|
||||
const STUB = `#!/usr/bin/env bash
|
||||
if [ "$1" = sandbox ]; then
|
||||
[ "\${STUB_SANDBOX:-ok}" = ok ] && exit 0
|
||||
echo 'bwrap: No permissions to create new namespace' >&2; exit 1
|
||||
fi
|
||||
case "$1" in --version) echo "codex-cli \${STUB_VERSION:-0.160.1}"; exit 0 ;; esac
|
||||
printf '%s\\n' "$*" >> "$STUB_ARGS_LOG"
|
||||
case "\${STUB_MODE:-ok}" in
|
||||
ok) echo OK; exit 0 ;;
|
||||
model400) echo 'ERROR: {"type":"error","status":400,"error":{"message":"The model is not supported when using Codex with a ChatGPT account."}}' >&2; exit 1 ;;
|
||||
broken) echo 'Error: spawn /usr/lib/codex/vendor/codex ENOENT' >&2; exit 1 ;;
|
||||
quota) echo "ERROR: You've hit your usage limit. Try again at Oct 10th, 2026 2:55 AM." >&2; exit 1 ;;
|
||||
ratelimit) echo 'ERROR: {"type":"error","status":429,"error":{"type":"rate_limit_exceeded"}}' >&2; exit 1 ;;
|
||||
transient) echo 'stream error: network unreachable' >&2; exit 7 ;;
|
||||
esac
|
||||
`;
|
||||
|
||||
interface Fixture { home: string; env: Record<string, string>; argsLog: string; state: string }
|
||||
|
||||
function fixture(config = 'model = "gpt-test-model"\n', auth = true): Fixture {
|
||||
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-probe-sub-')));
|
||||
const stubDir = path.join(home, 'stub-bin');
|
||||
const codexHome = path.join(home, '.codex');
|
||||
const state = path.join(home, '.gstack');
|
||||
fs.mkdirSync(stubDir);
|
||||
fs.mkdirSync(codexHome);
|
||||
fs.mkdirSync(state);
|
||||
fs.writeFileSync(path.join(stubDir, 'codex'), STUB, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), config);
|
||||
if (auth) fs.writeFileSync(path.join(codexHome, 'auth.json'), '{}');
|
||||
const argsLog = path.join(home, 'args.log');
|
||||
const env = {
|
||||
PATH: [stubDir, path.dirname(process.execPath), process.env.PATH ?? ''].join(path.delimiter),
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GSTACK_HOME: state,
|
||||
STUB_ARGS_LOG: argsLog,
|
||||
};
|
||||
return { home, env, argsLog, state };
|
||||
}
|
||||
|
||||
function sh(shell: string, script: string, env: Record<string, string>, input?: string) {
|
||||
const args = shell === 'zsh' ? ['-f', '-c', script] : ['--noprofile', '--norc', '-c', script];
|
||||
const r = spawnSync(shell, args, { env, input, encoding: 'utf8', timeout: 20_000 });
|
||||
return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
function probe(f: Fixture, args: string, extra: Record<string, string> = {}, shell = 'bash', input?: string) {
|
||||
return sh(shell, `'${PROBE}' ${args}`, { ...f.env, ...extra }, input);
|
||||
}
|
||||
|
||||
const calls = (f: Fixture) => (fs.existsSync(f.argsLog) ? fs.readFileSync(f.argsLog, 'utf8').trim().split('\n') : []);
|
||||
const skipZsh = !HAS_ZSH && !ZSH_REQUIRED;
|
||||
|
||||
describe('gstack-codex-probe subcommands', () => {
|
||||
test('help lists every subcommand; unknown or missing subcommands print usage on stderr and exit 64', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const help = probe(f, 'help');
|
||||
expect(help.code).toBe(0);
|
||||
for (const name of SUBCOMMANDS) expect(help.stdout).toContain(` ${name}`);
|
||||
for (const bad of ['', 'select_model', 'auth', 'select-model bogus', 'select-model exec --model', 'probe-model exec extra',
|
||||
'run-with-timeout', 'run-with-timeout 5', 'run-with-timeout soon echo hi', 'show-sandbox extra']) {
|
||||
const r = probe(f, bad);
|
||||
expect({ bad, code: r.code, stdout: r.stdout, usage: r.stderr.startsWith('Usage: gstack-codex-probe') })
|
||||
.toEqual({ bad, code: 64, stdout: '', usage: true });
|
||||
}
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const shell of SHELLS) {
|
||||
test.skipIf(shell === 'zsh' && skipZsh)(`each subcommand runs once as a command from ${shell}, with its KEY: value contract and no deprecation line`, () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const results: Record<string, ReturnType<typeof probe>> = {
|
||||
'select-model': probe(f, 'select-model exec', {}, shell),
|
||||
'check-auth': probe(f, 'check-auth', {}, shell),
|
||||
'show-sandbox': probe(f, 'show-sandbox', {}, shell),
|
||||
'check-sandbox': probe(f, 'check-sandbox', {}, shell),
|
||||
'probe-model': probe(f, 'probe-model exec', {}, shell),
|
||||
'check-version': probe(f, 'check-version', {}, shell),
|
||||
'show-first-use-notice': probe(f, 'show-first-use-notice', {}, shell),
|
||||
'run-with-timeout': probe(f, 'run-with-timeout 5 echo ran', {}, shell),
|
||||
'log-event': probe(f, 'log-event codex_timeout 540', {}, shell),
|
||||
'log-hang': probe(f, 'log-hang review 0', {}, shell),
|
||||
help: probe(f, 'help', {}, shell),
|
||||
};
|
||||
expect(Object.keys(results).sort()).toEqual([...SUBCOMMANDS].sort());
|
||||
for (const [name, r] of Object.entries(results)) {
|
||||
expect({ name, code: r.code, deprecated: r.stderr.includes('deprecated') }).toEqual({ name, code: 0, deprecated: false });
|
||||
}
|
||||
expect(results['select-model']!.stdout).toBe('CODEX_SEL: gpt-test-model\nCODEX_SEL_KIND: exec\nCODEX_SANDBOX: read-only\n');
|
||||
expect(results['select-model']!.stderr).toContain('CODEX_MODEL: gpt-test-model (exec; source: ');
|
||||
expect(results['check-auth']!.stdout).toBe('AUTH_OK\n');
|
||||
expect(results['show-sandbox']!.stdout).toBe('CODEX_SANDBOX: read-only\n');
|
||||
expect(results['check-sandbox']!.stdout).toBe('');
|
||||
expect(results['probe-model']!.stdout).toBe('MODEL_OK\nCODEX_PROBE_STATE: ok\n');
|
||||
expect(results['check-version']!.stdout).toBe('');
|
||||
expect(results['show-first-use-notice']!.stdout).toBe('');
|
||||
expect(results['show-first-use-notice']!.stderr).toContain('NOTICE: gstack outside reviews send the review prompt and code to Codex');
|
||||
expect(results['run-with-timeout']!.stdout).toBe('ran\n');
|
||||
expect(results['log-event']!.stdout + results['log-event']!.stderr).toBe('');
|
||||
expect(results['log-hang']!.stdout + results['log-hang']!.stderr).toBe('');
|
||||
expect(calls(f)).toHaveLength(1);
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
test('nothing crosses into the caller: the selection lives only in the printed lines', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const r = sh('bash', `set -u; '${PROBE}' select-model exec >/dev/null 2>&1; echo "sel=\${_GSTACK_CODEX_SEL-unset} sandbox=\${_GSTACK_CODEX_SANDBOX-unset} bin=\${_GSTACK_CODEX_BIN-unset}"`, f.env);
|
||||
expect(r.stdout).toBe('sel=unset sandbox=unset bin=unset\n');
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('select-model keeps the precedence: --model, then GSTACK_CODEX_MODEL, then config.toml (review_model for review)', () => {
|
||||
const f = fixture('model = "gpt-5.6-terra"\nreview_model = "gpt-5.6-luna"\n');
|
||||
try {
|
||||
const sel = (args: string, extra: Record<string, string> = {}) => probe(f, `select-model ${args}`, extra).stdout.split('\n')[0];
|
||||
expect(sel('exec')).toBe('CODEX_SEL: gpt-5.6-terra');
|
||||
expect(sel('review')).toBe('CODEX_SEL: gpt-5.6-luna');
|
||||
expect(sel('review', { GSTACK_CODEX_MODEL: 'gpt-6-sol' })).toBe('CODEX_SEL: gpt-6-sol');
|
||||
const explicit = probe(f, "select-model exec --model 'gpt-6-luna'", { GSTACK_CODEX_MODEL: 'gpt-6-sol' });
|
||||
expect(explicit.stdout.split('\n')[0]).toBe('CODEX_SEL: gpt-6-luna');
|
||||
expect(explicit.stderr).toContain('CODEX_MODEL: gpt-6-luna (exec; source: explicit request)');
|
||||
const invalid = probe(f, "select-model exec --model 'not a model'");
|
||||
expect([invalid.code, invalid.stdout]).toEqual([1, '']);
|
||||
expect(invalid.stderr).toContain('CODEX_MODEL: invalid');
|
||||
expect(calls(f)).toEqual([]);
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('probe-model reports CODEX_PROBE_STATE with the old exit codes, probing exactly the requested model', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const cases: Array<[string, number, string]> = [
|
||||
['model400', 1, 'unusable'], ['broken', 2, 'broken_install'], ['quota', 4, 'quota_exhausted'],
|
||||
['ratelimit', 0, 'rate_limited'], ['transient', 0, 'inconclusive'], ['ok', 0, 'ok'],
|
||||
];
|
||||
for (const [mode, code, state] of cases) {
|
||||
fs.rmSync(path.join(f.state, '.codex-model-probe'), { force: true });
|
||||
const r = probe(f, 'probe-model exec', { STUB_MODE: mode });
|
||||
expect({ mode, code: r.code, state: r.stdout.match(/^CODEX_PROBE_STATE: (.*)$/m)?.[1] }).toEqual({ mode, code, state });
|
||||
}
|
||||
fs.rmSync(path.join(f.state, '.codex-model-probe'), { force: true });
|
||||
fs.rmSync(f.argsLog, { force: true });
|
||||
const named = probe(f, "probe-model review --model 'gpt-6-luna'");
|
||||
expect(named.code).toBe(0);
|
||||
expect(calls(f)).toHaveLength(1);
|
||||
expect(calls(f)[0]).toContain('model="gpt-6-luna"');
|
||||
const invalid = probe(f, "probe-model exec --model 'not a model'");
|
||||
expect(invalid.code).toBe(1);
|
||||
expect(invalid.stdout).toContain('MODEL_UNUSABLE\n');
|
||||
expect(invalid.stdout).toContain('CODEX_PROBE_STATE: unusable\n');
|
||||
expect(calls(f)).toHaveLength(1);
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('check-sandbox and check-auth report by exit code, reasons on stderr', () => {
|
||||
const f = fixture('model = "gpt-test-model"\n', false);
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const sandbox = probe(f, 'check-sandbox', { STUB_SANDBOX: 'userns' });
|
||||
expect([sandbox.code, sandbox.stdout]).toEqual([3, '']);
|
||||
expect(sandbox.stderr).toContain("Codex outside review unavailable: Codex's sandbox could not start here (bwrap: No permissions");
|
||||
}
|
||||
const auth = probe(f, 'check-auth');
|
||||
expect([auth.code, auth.stdout]).toEqual([1, 'AUTH_FAILED\n']);
|
||||
expect(probe(f, 'check-auth', { CODEX_API_KEY: 'present' }).code).toBe(0);
|
||||
const version = probe(f, 'check-version', { STUB_VERSION: '0.120.1' });
|
||||
expect([version.code, version.stdout]).toEqual([0, '']);
|
||||
expect(version.stderr).toContain('WARN: Codex CLI codex-cli 0.120.1 has known stdin deadlock bugs');
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const native of [false, true]) {
|
||||
test(`run-with-timeout supervises with ${native ? 'the bash watchdog' : 'timeout(1)'}: stdin passes through, a deadline exits 124`, () => {
|
||||
const f = fixture();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-probe-sub-path-'));
|
||||
try {
|
||||
for (const tool of native ? ['bash', 'sleep', 'cat'] : []) {
|
||||
const target = spawnSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
|
||||
if (target.startsWith('/')) fs.symlinkSync(target, path.join(dir, tool));
|
||||
}
|
||||
const bash = spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
|
||||
const env = native ? { ...f.env, PATH: dir } : f.env;
|
||||
const run = (args: string[], input?: string) => {
|
||||
const r = spawnSync(bash, [PROBE, 'run-with-timeout', ...args], { env, input, encoding: 'utf8', timeout: 20_000 });
|
||||
return { code: r.status, stdout: r.stdout ?? '' };
|
||||
};
|
||||
expect(run(['5', 'cat'], 'prompt on stdin')).toEqual({ code: 0, stdout: 'prompt on stdin' });
|
||||
const started = Date.now();
|
||||
expect(run(['1', 'sleep', '30']).code).toBe(124);
|
||||
expect(Date.now() - started).toBeLessThan(15_000);
|
||||
expect(run(['5', 'bash', '-c', 'exit 7']).code).toBe(7);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(f.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('log-event follows telemetry: the exported _TEL first, else gstack-config; log-hang never prints', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const usage = path.join(f.state, 'analytics', 'skill-usage.jsonl');
|
||||
const lines = () => (fs.existsSync(usage) ? fs.readFileSync(usage, 'utf8').trim().split('\n') : []);
|
||||
expect(probe(f, 'log-event codex_timeout 540').code).toBe(0);
|
||||
expect(lines()).toEqual([]);
|
||||
fs.writeFileSync(path.join(f.state, 'config.yaml'), 'telemetry: community\n');
|
||||
expect(probe(f, 'log-event codex_timeout 540').code).toBe(0);
|
||||
expect(lines()).toHaveLength(1);
|
||||
expect(JSON.parse(lines()[0]!)).toMatchObject({ skill: 'codex', event: 'codex_timeout', duration_s: '540' });
|
||||
expect(probe(f, 'log-event codex_auth_failed', { _TEL: 'off' }).code).toBe(0);
|
||||
expect(lines()).toHaveLength(1);
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const shell of SHELLS) {
|
||||
test.skipIf(shell === 'zsh' && skipZsh)(`sourcing from ${shell} still defines the functions and prints the deprecation line once`, () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const r = sh(shell, `. '${PROBE}' && _gstack_codex_select_model exec && echo "sel=$_GSTACK_CODEX_SEL"`, f.env);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toBe('sel=gpt-test-model\n');
|
||||
expect(r.stderr.startsWith(PROBE_SOURCING_DEPRECATION)).toBe(true);
|
||||
expect(r.stderr.split('deprecated').length - 1).toBe(1);
|
||||
expect(PROBE_SOURCING_DEPRECATION).toContain('/gstack-upgrade');
|
||||
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('generated skills run the probe as a command', () => {
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name === 'test') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/(?:SKILL\.md|\.md\.tmpl|\.md)$/.test(entry.name) || (dir.endsWith(path.join('scripts', 'resolvers')) && entry.name.endsWith('.ts'))) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('no template, resolver or generated skill sources the probe or uses its sourced-only names', () => {
|
||||
const offenders: string[] = [];
|
||||
const files = [...walk(path.join(ROOT, 'scripts', 'resolvers')), ...fs.readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !['node_modules', 'test', 'docs', 'scripts'].includes(e.name))
|
||||
.flatMap(e => walk(path.join(ROOT, e.name)))];
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
if (/(?:^|[;&|({]|then\s)[\t ]*(?:source|\.)\s+"?[^"\s;)]*gstack-codex-probe|_gstack_codex_|_GSTACK_CODEX_/m.test(text)) offenders.push(path.relative(ROOT, file));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('Codex skill isolation (#2847)', () => {
|
||||
const calls: string[] = [];
|
||||
for (const file of renderedSkillFiles()) {
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
if (/_gstack_codex_timeout_wrapper\s+\d+\s+codex\s+(exec|review)\b/.test(line)) calls.push(`${path.relative(ROOT, file)}: ${line}`);
|
||||
if (/run-with-timeout\s+\d+\s+codex\s+(exec|review)\b/.test(line)) calls.push(`${path.relative(ROOT, file)}: ${line}`);
|
||||
}
|
||||
}
|
||||
expect(calls.length).toBeGreaterThan(10);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'node:os';
|
||||
import { CODEX_MODEL_CONFIG_FLAG, CODEX_REVIEW_MODEL_CONFIG_FLAG, CODEX_WEB_SEARCH_FLAG } from '../scripts/resolvers/constants';
|
||||
import { CODEX_MODEL_CONFIG_FLAG, CODEX_REVIEW_MODEL_CONFIG_FLAG, CODEX_WEB_SEARCH_FLAG, codexSelect } from '../scripts/resolvers/constants';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const DEPRECATED = '--enable web_search_cached';
|
||||
@@ -92,11 +92,12 @@ describe('deprecated codex web-search flag is gone (#2525)', () => {
|
||||
|
||||
describe('codex frontier model flag is present', () => {
|
||||
// #2914: the flag carries the runtime selection, never a hard-coded default.
|
||||
const flagArgv = (flag: string, select: string, env: Record<string, string>, config?: string) => {
|
||||
// The generated selection block, run exactly as skills run it: the executed probe, no sourcing.
|
||||
const flagArgv = (flag: string, kind: 'exec' | 'review', env: Record<string, string>, config?: string) => {
|
||||
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-flag-model-'));
|
||||
try {
|
||||
if (config !== undefined) fs.writeFileSync(path.join(codexHome, 'config.toml'), config);
|
||||
return execFileSync('bash', ['-c', `source "${path.join(ROOT, 'bin', 'gstack-codex-probe')}" && ${select} 2>/dev/null && printf '%s\\n' ${flag}`], {
|
||||
return execFileSync('bash', ['-c', `exec 2>/dev/null\n${codexSelect(kind, `'${path.join(ROOT, 'bin', 'gstack-codex-probe')}'`)}\nprintf '%s\\n' ${flag}`], {
|
||||
env: { ...process.env, GSTACK_CODEX_MODEL: '', CODEX_HOME: codexHome, ...env }, encoding: 'utf8', timeout: 10000,
|
||||
}).trim().split('\n');
|
||||
} finally {
|
||||
@@ -105,7 +106,7 @@ describe('codex frontier model flag is present', () => {
|
||||
};
|
||||
|
||||
test('the model flag passes the selected model: GSTACK_CODEX_MODEL, then config.toml, then gpt-6-astra', () => {
|
||||
const select = '_gstack_codex_select_model exec';
|
||||
const select = 'exec' as const;
|
||||
const isolation = ['-c', 'skills.include_instructions=false'];
|
||||
expect(flagArgv(CODEX_MODEL_CONFIG_FLAG, select, {})).toEqual(['-c', 'model="gpt-6-astra"', ...isolation]);
|
||||
expect(flagArgv(CODEX_MODEL_CONFIG_FLAG, select, {}, 'model = "gpt-5.6-terra"\n')).toEqual(['-c', 'model="gpt-5.6-terra"', ...isolation]);
|
||||
@@ -120,7 +121,7 @@ describe('codex frontier model flag is present', () => {
|
||||
[{ GSTACK_CODEX_MODEL: 'custom-codex' }, 'review_model = "gpt-5.6-luna"\n', 'custom-codex'],
|
||||
[{}, 'model = "gpt-5.6-terra"\nreview_model = "gpt-5.6-luna"\n', 'gpt-5.6-luna'],
|
||||
] as const) {
|
||||
const argv = flagArgv(CODEX_REVIEW_MODEL_CONFIG_FLAG, '_gstack_codex_select_model review', { ...env }, config);
|
||||
const argv = flagArgv(CODEX_REVIEW_MODEL_CONFIG_FLAG, 'review', { ...env }, config);
|
||||
expect(argv).toEqual(['-c', `review_model="${expected}"`, '-c', `model="${expected}"`, '-c', 'skills.include_instructions=false']);
|
||||
}
|
||||
for (const file of ['codex/sections/review-mode.md', 'review/sections/adversarial.md', 'ship/sections/adversarial.md']) {
|
||||
|
||||
@@ -16,7 +16,7 @@ for (const { name: host } of ALL_HOST_CONFIGS) {
|
||||
const prompt = text.match(/"(Given this product context, propose a complete design direction:[\s\S]*?)"\n/)!;
|
||||
expect(prompt).not.toBeNull();
|
||||
expect(prompt[1]).toContain('Recommendation: <direction> because <product-specific reason>');
|
||||
expect(validateOutsideReview('Recommendation: use a compact triage table because operators compare many incident rows.', 'review').completed).toBe(true);
|
||||
expect(validateOutsideReview('Recommendation: use a compact triage table because operators compare many incident rows.', 'proposal').completed).toBe(true);
|
||||
expect(validateOutsideReview('A compact table sounds nice.', 'review').completed).toBe(false);
|
||||
const preparation = outsideVoiceInvocation(context(host), { timeoutMs: 300000, purpose: 'design-direction' });
|
||||
expect(preparation).toContain('missing Recommendation marker');
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Reviewed the CEO implementation snapshot end to end, including the release promises, Tier 1 items and the validation plan.
|
||||
|
||||
1. **High** — P1 promises more than its acceptance proves. The plan declares /autoplan recovered on current Claude Code, but the only live proof is a single foreground phase entry. Require live phase transitions in the foreground and through `claude attach`, or narrow P1 and leave the issue open.
|
||||
|
||||
2. **High** — The plan deepens the private-journal dependency. The payload path, flush rules and allowlist all read Claude Code's private journal. Time-box a supported-hook-event alternative, prove the canary catches a deliberate incompatibility, and test a working fallback.
|
||||
|
||||
3. **High** — A4 can publish inconsistent artifact groups. Holding one flagged file while pushing its siblings can leave a decision log without its active snapshot on the remote. Hold coupled groups and restore onto a second machine in acceptance.
|
||||
|
||||
4. **High** — Optional work can hold urgent recovery. Tier 2 rides in the same PR as the P0 fix. Keep one PR, but name the mandatory set and an admission cutoff, and produce the eval schedule and its cost before Tier 2 is admitted.
|
||||
|
||||
5. **Medium** — B2 may not deliver faster review. Pushing early only helps when Greptile finishes before /ship's other passes. Measure it and keep it optional.
|
||||
|
||||
6. **Medium** — There is no denominator to show the guards stopped crying wolf. Log every guarded attempt and its outcome, not only denials.
|
||||
|
||||
7. **Medium** — The blanket contributor rewrite loses tested edge cases. Reuse contributors' regression tests when they pass review.
|
||||
|
||||
Recommendation: Revise before implementation because the plan can declare recovery without proving its release promises and can publish partially usable state while optional work delays the fixes.
|
||||
@@ -0,0 +1,27 @@
|
||||
Developer experience challenge for the follow-up wave plan.
|
||||
|
||||
### Finding 1: Post-after-approval has no contract
|
||||
Severity: High
|
||||
The posting helper refuses a MEDIUM finding but says nothing about how the caller resumes after the user approves. Define the confirmation and its exit codes.
|
||||
|
||||
### Finding 2: select-model drops the per-request model precedence
|
||||
Severity: High
|
||||
The subcommand list names `select-model <exec|review>` with no `--model`, so a model named for one request silently falls back to the environment or config.
|
||||
|
||||
### Finding 3: One-release sourcing window is unsafe
|
||||
Severity: High
|
||||
Skills rendered before the upgrade keep sourcing the probe; a single release can be skipped by users who upgrade monthly. Keep sourcing for at least one release and 14 days, with a deprecation line.
|
||||
|
||||
### Finding 4: The early PR is still automatic
|
||||
Severity: High
|
||||
B2 pushes and opens a draft whenever Greptile is detected, while the challenge to make it opt-in is still open.
|
||||
|
||||
Medium: Doctor cannot tell an intentionally absent Codex from a broken one; give each row ok, warn, not configured or fail.
|
||||
|
||||
Medium: Recovery docs are not an acceptance requirement, and docs/troubleshooting.md still teaches sourcing the probe.
|
||||
|
||||
Medium: --skip-file is permanent with no undo; add an unskip command.
|
||||
|
||||
Medium: The novice hello-world journey under five minutes is unproven.
|
||||
|
||||
Recommendation: revise before implementation because approval resumption, model-selection compatibility, upgrade continuity, and early-publication defaults lack complete developer-facing contracts.
|
||||
@@ -0,0 +1,16 @@
|
||||
Architecture challenge.
|
||||
|
||||
- [P1] Same-phase batches are still timing-dependent on the journal path — autoplan/bin/phase-publication-hook.ts:476
|
||||
A batch whose second call is journaled before the first is evaluated differently depending on flush order.
|
||||
- [P1] A 10-minute age does not prove an index lock is abandoned — bin/gstack-skill-start:326
|
||||
The skill-start merge and artifacts init run git outside the drain lock.
|
||||
- [P1] Rule-id confirmation cannot bind exact bytes — scripts/resolvers/redact-doc.ts:79
|
||||
Replacing one flagged email with another keeps the same rule ids and lines.
|
||||
- [P1] Holding coupled files does not make publication atomic — bin/gstack-decision-log:84
|
||||
Only the log is enqueued; the snapshot follows later.
|
||||
- [P1] A failed commit still finalizes the queue as idle — bin/gstack-brain-sync:763
|
||||
- [P2] A2 bounds record size, not total memory or evaluation cost — autoplan/bin/phase-publication-hook.ts:441
|
||||
- [P1] The long-session PTY case would be observed by the 32 MiB counting reader — lib/claude-public-transcript.ts:349
|
||||
- [P2] A3 line numbers are slice-relative — bin/gstack-redact-prepush:310
|
||||
|
||||
Recommendation: Revise before implementation because batch handling, sync transactions, posting confirmations, and long-session validation still contain concrete correctness gaps.
|
||||
@@ -0,0 +1,5 @@
|
||||
This is a high-level plan with low-risk changes and a medium-term follow-up. The design is highly consistent, and there are no critical findings. A high number of the steps are already covered by tests, and the low cost of the rollback keeps the change safe.
|
||||
|
||||
No issues found in the changed helpers.
|
||||
|
||||
Recommendation: proceed because the high-quality tests cover every changed path.
|
||||
@@ -0,0 +1,3 @@
|
||||
The plan's high-level structure is sound, but the rollback path writes the queue before the lock is held, and the docs still describe the old flag. The medium-term cleanup can wait.
|
||||
|
||||
Recommendation: fix the rollback ordering because a concurrent drain can lose records.
|
||||
@@ -0,0 +1,6 @@
|
||||
| # | Severity | Finding | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | high | The retry loop ignores the configured cap. | Check the cap before each attempt. |
|
||||
| 2 | medium | The backoff constant is duplicated. | Import it from config. |
|
||||
|
||||
Recommendation: fix the retry cap because callers can loop forever.
|
||||
@@ -14,7 +14,9 @@ import * as path from 'node:path';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const QUERY_SCRIPTS = ['bin/gstack-learnings-search', 'bin/gstack-timeline-read'];
|
||||
const GATE_CALLS = ['outside-review-result.ts', 'gstack-learnings-search', 'gstack-timeline-read',
|
||||
'_gstack_codex_auth_probe', '_gstack_codex_model_probe', '_gstack_codex_sandbox_preflight'];
|
||||
'_gstack_codex_auth_probe', '_gstack_codex_model_probe', '_gstack_codex_sandbox_preflight',
|
||||
'gstack-codex-probe check-auth', 'gstack-codex-probe probe-model', 'gstack-codex-probe check-sandbox',
|
||||
'"$_CODEX_PROBE" check-auth', '"$_CODEX_PROBE" probe-model', '"$_CODEX_PROBE" check-sandbox'];
|
||||
const SILENT_EXIT = /2>\/dev\/null \|\| exit 0\b/;
|
||||
|
||||
function walk(dir: string, keep: (file: string) => boolean, out: string[] = []): string[] {
|
||||
|
||||
@@ -34,7 +34,7 @@ function block(source: string, needle: string): string {
|
||||
}
|
||||
export function loadAutoplanDualCommandContract(root: string) {
|
||||
return { probe: block(readFileSync(join(root, 'autoplan/SKILL.md'), 'utf8'), 'echo "CODEX_MODE: $_CODEX_MODE"'),
|
||||
outside: block(readFileSync(join(root, 'autoplan/sections/ceo-phase.md'), 'utf8'), '_gstack_codex_timeout_wrapper 540 codex exec') };
|
||||
outside: block(readFileSync(join(root, 'autoplan/sections/ceo-phase.md'), 'utf8'), 'run-with-timeout 540 codex exec') };
|
||||
}
|
||||
export interface AutoplanDualEvidenceOptions {
|
||||
ownedRoots: string[];
|
||||
|
||||
@@ -166,7 +166,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.4975, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06).
|
||||
maxSizeRatio: 1.5001, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07).
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
@@ -221,7 +221,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
|
||||
// hardening: host-anchored mode signal, precedence, passing-mention
|
||||
// guards) and the plan-mode preamble reword land the union at 1.092.
|
||||
maxSizeRatio: 1.185, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 146,905 / 124,597 = 1.1790 (2026-10-06). Combined Oct 6 wave head (A and B): measured 147,524 / 124,597 = 1.1840 (2026-10-06).
|
||||
maxSizeRatio: 1.1859, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 146,905 / 124,597 = 1.1790 (2026-10-06). Combined Oct 6 wave head (A and B): measured 147,524 / 124,597 = 1.1840 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 147,693 / 124,597 = 1.1854 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 147,752 / 124,597 = 1.1858 (2026-10-07).
|
||||
},
|
||||
'plan-design-review': {
|
||||
skill: 'plan-design-review',
|
||||
@@ -243,7 +243,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
|
||||
// (telemetry --error-message/--failed-step preamble prose, PR #769).
|
||||
// Budget covers the sum of both waves.
|
||||
maxSkeletonBytes: 84_700, // Harness-aware outside voice: validated dispatch and provenance. + v1.91.19.0 wave: design --version probe (B4) and verdict-form outside voice (INV-1) on top of v1.91.17.0's round accounting (#3022); measured 80,443 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); skeleton measured 84,669 (2026-10-06).
|
||||
maxSkeletonBytes: 84_900, // Harness-aware outside voice: validated dispatch and provenance. + v1.91.19.0 wave: design --version probe (B4) and verdict-form outside voice (INV-1) on top of v1.91.17.0's round accounting (#3022); measured 80,443 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); skeleton measured 84,669 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; skeleton measured 84,804 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; skeleton measured 84,863 (2026-10-07).
|
||||
minUnionBytes: 99_200, // token-reduction Phases 1-2 (v1.69.x branch); measured union 110,293
|
||||
mustContain: ['design', 'visual'],
|
||||
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
|
||||
@@ -348,10 +348,10 @@ do not launch the downstream skill or open a browser.`,
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 92_600, // Office-hours + sketch outside voices include host guards and completion checks. + v1.91.19.0 wave: Q<N> open-question prose form (#2729, approved Q3), DESIGN_READY --version probe (B4), Aside ~/.local/bin fallback (E7), verdict-form outside voice (INV-1), plus v1.91.17.0's round accounting (#3022) merged in; measured 89,684 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); skeleton measured 92,590 (2026-10-06).
|
||||
maxSkeletonBytes: 92_950, // Office-hours + sketch outside voices include host guards and completion checks. + v1.91.19.0 wave: Q<N> open-question prose form (#2729, approved Q3), DESIGN_READY --version probe (B4), Aside ~/.local/bin fallback (E7), verdict-form outside voice (INV-1), plus v1.91.17.0's round accounting (#3022) merged in; measured 89,684 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); skeleton measured 92,590 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; skeleton measured 92,869 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; skeleton measured 92,930 (2026-10-07).
|
||||
minUnionBytes: 115_800, // Phase 4 wave 4; measured union 118,175
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.143, // + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 149,758 / 131,110 = 1.1422 (2026-10-06).
|
||||
maxSizeRatio: 1.1449, // + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 149,758 / 131,110 = 1.1422 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 150,037 / 131,110 = 1.1444 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 150,098 / 131,110 = 1.1448 (2026-10-07).
|
||||
},
|
||||
'document-release': {
|
||||
skill: 'document-release',
|
||||
@@ -411,7 +411,7 @@ do not launch the downstream skill or open a browser.`,
|
||||
// eureka log, the office-hours lookup and the taste-profile read; measured
|
||||
// 1.0834 (2026-09-30). + named design-direction defaults to avoid in the native
|
||||
// subagent prompt (s02 H4a); measured 1.0911 (2026-10-02).
|
||||
maxSizeRatio: 1.147, // + v1.91.19.0 wave: design --version probe (B4), taste-profile load (F3), verdict-form outside voice (INV-1), plus v1.91.17.0's round accounting and board/approval blocks (#3022) merged in; measured 100,446 / 90,375 = 1.1114 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 103,384 / 90,375 = 1.1439 (2026-10-06). + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 103,576 / 90,375 = 1.1461 (2026-10-07).
|
||||
maxSizeRatio: 1.15, // + v1.91.19.0 wave: design --version probe (B4), taste-profile load (F3), verdict-form outside voice (INV-1), plus v1.91.17.0's round accounting and board/approval blocks (#3022) merged in; measured 100,446 / 90,375 = 1.1114 (2026-10-03). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 103,384 / 90,375 = 1.1439 (2026-10-06). + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 103,576 / 90,375 = 1.1461 (2026-10-07). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 103,522 / 90,375 = 1.1455 (2026-10-07). Both merged: measured 103,726 / 90,375 = 1.1477 (2026-10-07).
|
||||
},
|
||||
cso: {
|
||||
skill: 'cso',
|
||||
@@ -485,7 +485,7 @@ do not launch the downstream skill or open a browser.`,
|
||||
maxSkeletonBytes: 74_881, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01).
|
||||
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
|
||||
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
|
||||
maxSizeRatio: 1.217, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06).
|
||||
maxSizeRatio: 1.2188, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07).
|
||||
},
|
||||
codex: {
|
||||
skill: 'codex',
|
||||
@@ -575,7 +575,7 @@ do not launch the downstream skill or open a browser.`,
|
||||
maxSkeletonBytes: 70_000, // Phase-specific outside coverage, native fallback, and harness guard.
|
||||
minUnionBytes: 85_000, // measured union 86,926
|
||||
mustContain: ['6 Decision Principles', 'TASTE DECISION', 'USER CHALLENGE', 'consensus', 'Restore Point'],
|
||||
maxSizeRatio: 1.12, // Four validated outside invocations replace raw CLI calls; phases keep independent coverage.
|
||||
maxSizeRatio: 1.1232, // Four validated outside invocations replace raw CLI calls; phases keep independent coverage. + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 114,297 / 101,979 = 1.1208 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 114,533 / 101,979 = 1.1231 (2026-10-07).
|
||||
},
|
||||
spec: {
|
||||
skill: 'spec',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sourcing bin/gstack-codex-probe is the compatibility form for skills rendered
|
||||
* before the executed subcommands: it still works and prints exactly this line
|
||||
* on stderr, first. Function-level tests that source the probe strip it with
|
||||
* withoutDeprecation(), which fails when the line is missing or moved.
|
||||
*/
|
||||
export const PROBE_SOURCING_DEPRECATION =
|
||||
'gstack: sourcing gstack-codex-probe is deprecated and stops working in a release on or after 2026-10-21. Run /gstack-upgrade so your skills run it as a command (gstack-codex-probe help).\n';
|
||||
|
||||
export function withoutDeprecation(stderr: string): string {
|
||||
if (!stderr.startsWith(PROBE_SOURCING_DEPRECATION)) {
|
||||
throw new Error(`sourcing gstack-codex-probe did not print its deprecation line first; stderr was:\n${stderr}`);
|
||||
}
|
||||
return stderr.slice(PROBE_SOURCING_DEPRECATION.length);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/claude-native-journal-roots-2.1.284.json': ['test/autoplan-publication-hook.test.ts', 'test/plan-count-transcript.test.ts'],
|
||||
'test/fixtures/codex-boundary-commands.json': ['test/codex-boundary-evidence.test.ts'],
|
||||
'test/fixtures/codex-review-sandbox-unavailable-37158847998.json': ['test/outside-voice-evidence.test.ts'],
|
||||
'test/fixtures/codex-review/**': ['test/codex-hardening.test.ts', 'test/outside-voice-evidence.test.ts', 'test/skill-validation.test.ts'],
|
||||
'test/fixtures/codex-review/**': ['test/codex-hardening.test.ts', 'test/outside-review-severity.test.ts', 'test/outside-voice-evidence.test.ts', 'test/skill-validation.test.ts'],
|
||||
'test/fixtures/codex-sandbox/**': ['test/codex-eval-recording.test.ts', 'test/codex-model-probe.test.ts', 'test/outside-review-result.test.ts', 'test/outside-voice-invocation.test.ts'],
|
||||
'test/fixtures/context-bill/**': ['test/context-bill.test.ts', 'test/context-budget-ratchet.test.ts'],
|
||||
'test/fixtures/context-budget.json': ['test/helpers/capture-context-budget.ts', 'test/test-value-bar.test.ts'],
|
||||
@@ -128,6 +128,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/native-auq-devex-clipped-elided-sep21.json': ['test/auq-native-capture.test.ts'],
|
||||
'test/fixtures/native-auq-margin-elided-sep21.json': ['test/auq-native-capture.test.ts'],
|
||||
'test/fixtures/native-auq-packet-clipped-elided-sep21.json': ['test/auq-native-capture.test.ts'],
|
||||
'test/fixtures/outside-review-severity/**': ['test/outside-review-severity.test.ts'],
|
||||
'test/fixtures/outside-voice-failure-prose-allowlist.json': ['test/outside-voice-failure-policy.test.ts'],
|
||||
'test/fixtures/overlay-v4-replay-37198445662.json': ['test/overlay-measurement.test.ts'],
|
||||
'test/fixtures/paid-report-census/**': ['test/paid-report-census.test.ts', 'test/paid-shards.test.ts'],
|
||||
|
||||
@@ -27,11 +27,14 @@ function cli(args: string[]) {
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
const RECOMMEND = 'Recommendation: fix the guard because changed.ts loses data.';
|
||||
// A clean review states that it found nothing; an untagged finding is unverified (DX-11).
|
||||
const RECOMMEND = 'No issues found in changed.ts.\nRecommendation: approve because changed.ts keeps its data.';
|
||||
const UNTAGGED = 'Recommendation: fix the guard because changed.ts loses data.';
|
||||
|
||||
describe('classifyOutsideReview: separate execution, findings and verdict', () => {
|
||||
const cases: Array<[string, { text: string; gate: OutsideGate; stderr?: string; exit?: number }, string, string | null, string | undefined]> = [
|
||||
['clean review', { text: RECOMMEND, gate: 'review' }, 'clean', null, undefined],
|
||||
['review with an untagged finding', { text: UNTAGGED, gate: 'review' }, 'unverified', null, 'untagged_review'],
|
||||
['review with P1', { text: `[P1] data loss\n${RECOMMEND}`, gate: 'review' }, 'findings', 'P1', undefined],
|
||||
['review with only P2', { text: `[P2] naming\n${RECOMMEND}`, gate: 'review' }, 'clean', 'P2', undefined],
|
||||
['review missing recommendation', { text: 'A few observations.', gate: 'review' }, 'unavailable', null, 'missing_markers'],
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* CEO-28 / DX-11 / ENG-10: a review that lists high-severity findings is never
|
||||
* `VERDICT: clean`. The Oct 7 /autoplan run's Codex CEO and DX outputs labeled
|
||||
* seven and eight findings "High"/"Medium" without [Pn] tags, and the installed
|
||||
* classifier printed `VERDICT: clean` / `FINDINGS: none` for both. The fixtures
|
||||
* in test/fixtures/outside-review-severity/ reproduce those shapes (the
|
||||
* findings come from the run's review record; the raw outputs were not kept),
|
||||
* plus the eng output's [P1] tags, a table, and prose negative controls.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyOutsideReview, type OutsideGate } from '../lib/outside-review-result';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
import { outsideVoiceCommand } from '../scripts/resolvers/outside-voice';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const LIB = path.join(ROOT, 'lib', 'outside-review-result.ts');
|
||||
const FIX = path.join(ROOT, 'test', 'fixtures', 'outside-review-severity');
|
||||
const fixture = (name: string) => path.join(FIX, name);
|
||||
const read = (name: string) => fs.readFileSync(fixture(name), 'utf8');
|
||||
const REC = 'Recommendation: fix it because the change loses data.';
|
||||
|
||||
describe('severity words count as findings in label position', () => {
|
||||
for (const [name, highest] of [
|
||||
['ceo-codex-shape.txt', 'P1'],
|
||||
['dx-codex-shape.txt', 'P1'],
|
||||
['eng-codex-tagged.txt', 'P1'],
|
||||
['table-shape.txt', 'P1'],
|
||||
] as const) {
|
||||
test(`${name}: the CLI prints VERDICT: findings, FINDINGS: ${highest}, exit 3`, () => {
|
||||
const r = spawnSync(process.execPath, [LIB, '--verdict', 'review', fixture(name)], { encoding: 'utf8', timeout: 10000 });
|
||||
expect([r.status, r.stdout]).toEqual([3, `VERDICT: findings\nFINDINGS: ${highest}\n`]);
|
||||
});
|
||||
}
|
||||
|
||||
test('every label form ENG-10 names is read, with critical/high blocking and medium/low advisory', () => {
|
||||
const cases: Array<[string, string | null, string]> = [
|
||||
['Severity: High\nThe cap is ignored.', 'P1', 'findings'],
|
||||
['Priority: critical — data loss', 'P0', 'findings'],
|
||||
['Severity: **medium**', 'P2', 'clean'],
|
||||
['High: the retry loop never stops.', 'P1', 'findings'],
|
||||
['1. **High** — the cap is ignored.', 'P1', 'findings'],
|
||||
['- [High] the cap is ignored.', 'P1', 'findings'],
|
||||
['### Critical - secrets in logs', 'P0', 'findings'],
|
||||
['High — the cap is ignored.', 'P1', 'findings'],
|
||||
['The cap is ignored (**High**).', 'P1', 'findings'],
|
||||
['| 1 | high | cap ignored |', 'P1', 'findings'],
|
||||
['Low: rename the helper.', 'P3', 'clean'],
|
||||
['Medium: the docs lag.\nLow: a typo.', 'P2', 'clean'],
|
||||
];
|
||||
for (const [text, highest, verdict] of cases) {
|
||||
const r = classifyOutsideReview({ text: `${text}\n${REC}`, gate: 'review' });
|
||||
expect({ text, highest: r.findings.highest as string | null, verdict: r.verdict as string }).toEqual({ text, highest, verdict });
|
||||
}
|
||||
});
|
||||
|
||||
test('negative controls: severity words inside prose never count', () => {
|
||||
const r = classifyOutsideReview({ text: read('prose-negative.txt'), gate: 'review' });
|
||||
expect([r.verdict, r.findings.highest]).toEqual(['clean', null]);
|
||||
for (const text of ['A high-level plan.', 'Highly consistent.', 'This is low-risk.', 'A medium-term follow-up.',
|
||||
'There are no critical findings.', 'High availability: kept.', 'The high number of retries is fine.', 'Lower the limit.']) {
|
||||
expect({ text, highest: classifyOutsideReview({ text: `${text}\nNo issues found.\n${REC}`, gate: 'review' }).findings.highest })
|
||||
.toEqual({ text, highest: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('a review with neither tags, severity labels nor a no-findings statement is unverified, never clean', () => {
|
||||
const r = classifyOutsideReview({ text: read('prose-unlabeled.txt'), gate: 'review' });
|
||||
expect([r.verdict, r.reason]).toEqual(['unverified', 'untagged_review']);
|
||||
const cli = spawnSync(process.execPath, [LIB, '--verdict', 'review', fixture('prose-unlabeled.txt')], { encoding: 'utf8', timeout: 10000 });
|
||||
expect(cli.status).toBe(4);
|
||||
expect(cli.stdout).toContain('VERDICT: unverified\n');
|
||||
expect(classifyOutsideReview({ text: `No issues found.\n${REC}`, gate: 'review' }).verdict).toBe('clean');
|
||||
expect(classifyOutsideReview({ text: `I did not find any issues.\n${REC}`, gate: 'review' }).verdict).toBe('clean');
|
||||
});
|
||||
|
||||
test('the structured gate reads severity labels too', () => {
|
||||
expect(classifyOutsideReview({ text: 'High: race in cleanup.', gate: 'structured' }).verdict).toBe('findings');
|
||||
expect(classifyOutsideReview({ text: 'Medium: stale comment.', gate: 'structured' }).verdict).toBe('clean');
|
||||
});
|
||||
|
||||
test('a stored panel of real Codex outputs keeps its verdicts', () => {
|
||||
const panel = path.join(ROOT, 'test', 'fixtures', 'codex-review');
|
||||
const verdict = (name: string, gate: OutsideGate) => {
|
||||
const r = classifyOutsideReview({ text: fs.readFileSync(path.join(panel, name), 'utf8'), gate });
|
||||
return [r.verdict, r.findings.highest, r.reason ?? null];
|
||||
};
|
||||
expect(verdict('clean-review.stdout.txt', 'structured')).toEqual(['unverified', null, 'untagged_review']);
|
||||
expect(verdict('p2-review.stdout.txt', 'structured')).toEqual(['clean', 'P2', null]);
|
||||
expect(verdict('clean-review.stdout.txt', 'review')).toEqual(['unavailable', null, 'missing_markers']);
|
||||
expect(verdict('p2-review.stdout.txt', 'review')).toEqual(['unavailable', 'P2', 'missing_markers']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design proposals use the proposal gate', () => {
|
||||
test('a completed proposal needs only its Recommendation line; severity rules do not apply', () => {
|
||||
const proposal = 'Use a dense triage table with sticky filters.\nRecommendation: table because operators compare many rows.';
|
||||
expect(classifyOutsideReview({ text: proposal, gate: 'proposal' }).verdict).toBe('clean');
|
||||
expect(classifyOutsideReview({ text: 'A table.', gate: 'proposal' }).reason).toBe('missing_markers');
|
||||
expect(classifyOutsideReview({ text: proposal, gate: 'review' }).verdict).toBe('unverified');
|
||||
});
|
||||
|
||||
test('design-direction invocations render the proposal gate; reviews keep the review gate', () => {
|
||||
const ctx = { skillName: 'design-consultation', tmplPath: 'design-consultation/SKILL.md.tmpl', host: 'claude' as const, paths: HOST_PATHS.claude };
|
||||
expect(outsideVoiceCommand(ctx, { timeoutMs: 300000, purpose: 'design-direction' })).toMatch(/ proposal "\$_OUTSIDE_TMP\/text"/);
|
||||
expect(outsideVoiceCommand({ ...ctx, skillName: 'plan-ceo-review' }, { timeoutMs: 300000 })).toMatch(/ review "\$_OUTSIDE_TMP\/text"/);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ if (process.env.FAKE_MODE === '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 || 'Recommendation: fix the seeded defect because changed.ts loses data.';
|
||||
const response = process.env.FAKE_RESPONSE || 'Medium: changed.ts loses data on retry.\\nRecommendation: fix the seeded defect because changed.ts loses data.';
|
||||
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}}}));
|
||||
@@ -332,13 +332,15 @@ describe('generated outside-review dispatch', () => {
|
||||
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, `_gstack_codex_select_model() { _GSTACK_CODEX_SEL=gpt-6-astra; _GSTACK_CODEX_SANDBOX=read-only; }
|
||||
_gstack_codex_sandbox_preflight() { return 0; }
|
||||
_gstack_codex_first_use_notice() { :; }
|
||||
_gstack_codex_timeout_wrapper() { echo 'Partial finding'; return 124; }
|
||||
_gstack_codex_log_event() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }
|
||||
_gstack_codex_log_hang() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }
|
||||
`);
|
||||
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 });
|
||||
@@ -357,7 +359,7 @@ _gstack_codex_log_hang() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }
|
||||
'### Recommendation: fix the guard because it loses data.',
|
||||
]) {
|
||||
test(`formatted completion remains valid: ${response}`, () => {
|
||||
expect(invoke('claude', {}, { FAKE_RESPONSE: response }).status).toBe(0);
|
||||
expect(invoke('claude', {}, { FAKE_RESPONSE: `[P2] the guard drops one write.\n${response}` }).status).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,7 +418,7 @@ echo 'Recommendation: approve because the late answer arrived.'
|
||||
const rendered = outsideVoiceInvocation(ctx, { timeoutMs: requested });
|
||||
const gates = [...rendered.matchAll(/timeout: (\d+)/g)].map(m => Number(m[1]));
|
||||
expect(gates).toEqual([provider + 60000]);
|
||||
expect(rendered).toContain(host === 'claude' ? `_gstack_codex_timeout_wrapper ${provider / 1000} codex` : `--timeout-ms ${provider}`);
|
||||
expect(rendered).toContain(host === 'claude' ? `run-with-timeout ${provider / 1000} codex` : `--timeout-ms ${provider}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ function fixture() {
|
||||
if (git.status !== 0) throw new Error(git.stderr);
|
||||
const capture = path.join(dir, 'capture.json');
|
||||
const fake = path.join(dir, 'fake-claude.ts');
|
||||
fs.writeFileSync(fake, `const prompt=await Bun.stdin.text(); await Bun.write(process.env.FIXTURE_CAPTURE!,JSON.stringify({prompt,args:process.argv.slice(2)})); console.log(JSON.stringify({result:'Recommendation: ship because the isolated fixture completed its review.'}));`);
|
||||
fs.writeFileSync(fake, `const prompt=await Bun.stdin.text(); await Bun.write(process.env.FIXTURE_CAPTURE!,JSON.stringify({prompt,args:process.argv.slice(2)})); console.log(JSON.stringify({result:'No issues found.\\nRecommendation: ship because the isolated fixture completed its review.'}));`);
|
||||
const env = { ...process.env, HOME: home, CODEX_HOME: '', GSTACK_HOME: path.join(home, '.gstack'),
|
||||
GSTACK_ROOT: '', GSTACK_BIN: '', GSTACK_ACTIVE_HOST: 'codex', CODEX_THREAD_ID: 'fixture', CODEX_SANDBOX: '', CLAUDECODE: '',
|
||||
GSTACK_CLAUDE_BIN: process.execPath, GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([fake]), FIXTURE_CAPTURE: capture };
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as path from 'node:path';
|
||||
import { createOutsideReceiptRuntime } from './helpers/outside-voice-receipt';
|
||||
import { codexExecutionTranscript, foundInvoiceAuthorizationDefect } from './helpers/outside-voice-evidence';
|
||||
|
||||
const finding = 'invoice.ts removes the owner check and exposes another user’s bankAccount.\nRecommendation: Restore authorization because invoice financial data crosses ownership boundaries.';
|
||||
const finding = 'High: invoice.ts removes the owner check and exposes another user’s bankAccount.\nRecommendation: Restore authorization because invoice financial data crosses ownership boundaries.';
|
||||
function fixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-receipt-free-'));
|
||||
const real = path.join(dir, 'real runtime'), repo = path.join(dir, 'repo'), observed = path.join(dir, 'observed.json');
|
||||
|
||||
@@ -22,6 +22,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { codexPreflight } from '../scripts/resolvers/constants';
|
||||
import { PROBE_SOURCING_DEPRECATION, withoutDeprecation } from './helpers/codex-probe-sourcing';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
@@ -52,6 +53,9 @@ const code = (text: string) => text.split('\n').filter(line => !/^\s*#/.test(lin
|
||||
* Every bin/ helper a generated skill or resolver loads with `source` or `.`,
|
||||
* plus every bin/ file those helpers source in turn (for example
|
||||
* gstack-state-root.sh, sourced by the codex probe and the egress lib).
|
||||
* Current skills run gstack-codex-probe as a command, but skills rendered
|
||||
* before that change still source it until the compatibility window closes,
|
||||
* so it stays on the list until sourcing is removed.
|
||||
*/
|
||||
function sourcedHelpers(): string[] {
|
||||
const files = [
|
||||
@@ -60,7 +64,7 @@ function sourcedHelpers(): string[] {
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules' && e.name !== 'test')
|
||||
.flatMap(e => walk(path.join(ROOT, e.name), f => /(?:SKILL\.md|\.md\.tmpl)$/.test(f))),
|
||||
];
|
||||
const names = new Set<string>();
|
||||
const names = new Set<string>(['gstack-codex-probe']);
|
||||
const direct = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?[^"\s;)<]*\bbin\/([A-Za-z0-9._-]+)/g;
|
||||
const viaVar = /\/(gstack-[A-Za-z0-9._-]+\.sh)"; \[ -r "\$[A-Z_]+" \] && \. "\$[A-Z_]+"/g;
|
||||
const nested = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?\/([A-Za-z0-9._-]+)/g;
|
||||
@@ -167,7 +171,8 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
|
||||
for (const name of helpers) {
|
||||
const file = path.join(BIN, name);
|
||||
const r = run(shell, `${shell} -n '${file}' && . '${file}' && echo SOURCED`);
|
||||
expect({ name, out: r.out.trim().split('\n').pop() }).toEqual({ name, out: 'SOURCED' });
|
||||
const deprecation = name === 'gstack-codex-probe' ? PROBE_SOURCING_DEPRECATION : '';
|
||||
expect({ name, stdout: r.stdout, stderr: r.stderr }).toEqual({ name, stdout: 'SOURCED\n', stderr: deprecation });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,7 +224,8 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
|
||||
LOCAL_PROVIDER_KEY: 'present',
|
||||
});
|
||||
fs.rmSync(codexHome, { recursive: true, force: true });
|
||||
expect(r.out.trim()).toBe('AUTH_OK');
|
||||
expect(r.stdout.trim()).toBe('AUTH_OK');
|
||||
expect(withoutDeprecation(r.stderr)).toBe('');
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
@@ -259,17 +265,20 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
|
||||
expect(r.out).toContain('rc=1 bin=unset');
|
||||
});
|
||||
|
||||
for (const [label, shell, prefix, expected] of [
|
||||
['dash', 'dash', '', `gstack: cannot locate gstack-codex-probe (shell: dash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`],
|
||||
['an unknown shell', 'bash', 'unset BASH_VERSION\n', `gstack: cannot locate gstack-codex-probe (shell: bash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`],
|
||||
// The preflight runs the probe as a command, so the calling shell no longer
|
||||
// matters: dash and a shell without BASH_VERSION reach the same verdict.
|
||||
for (const [label, shell, prefix] of [
|
||||
['dash', 'dash', ''],
|
||||
['an unknown shell', 'bash', 'unset BASH_VERSION\n'],
|
||||
] as const) {
|
||||
test.skipIf(shell === 'dash' && skipDash)(`the full generated preflight under ${label} reports helper_unavailable with the helper's line`, () => {
|
||||
test.skipIf(shell === 'dash' && skipDash)(`the full generated preflight under ${label} runs the probe as a command and reaches CODEX_MODE: ready`, () => {
|
||||
const { home, env } = preflightHome();
|
||||
try {
|
||||
const r = run(shell, `${prefix}${PREFLIGHT}`, env, home);
|
||||
expect(r.out).toContain(`${expected}\nCODEX_MODE: helper_unavailable\n`);
|
||||
expect(r.out).not.toContain('not_authed');
|
||||
expect(r.out).not.toMatch(/Syntax error|not found/);
|
||||
expect(r.out).toContain('CODEX_MODEL: gpt-test-model (exec;');
|
||||
expect(r.out).toContain('CODEX_MODE: ready\n');
|
||||
expect(r.out).not.toContain('cannot locate');
|
||||
expect(r.out).not.toMatch(/Syntax error|not found|deprecated/);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ printf '%s\\n' "$p"
|
||||
printf '%s\\n' "$FAKE_REVIEW_ID" >> "$FAKE_CALLS"
|
||||
out=; prev=; for a in "$@"; do [ "$prev" = -o ] && out=$a; prev=$a; done
|
||||
cat > /dev/null
|
||||
printf '%s\\n' "$FAKE_REVIEW_ID: current findings" "Recommendation: fix $FAKE_REVIEW_ID because this is the current finding." > "$out"
|
||||
printf '%s\\n' "$FAKE_REVIEW_ID: current findings" "No issues found." "Recommendation: fix $FAKE_REVIEW_ID because this is the current finding." > "$out"
|
||||
printf '%s\\n' '{"type":"turn.completed"}'
|
||||
printf '%s\\n' "$FAKE_REVIEW_ID: current stderr" >&2
|
||||
exit "$FAKE_CODEX_STATUS"
|
||||
@@ -101,7 +101,7 @@ exec ${quote(Bun.which('cat')!)} "$@"
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
const findings = (id: string) => `${id}: current findings\nRecommendation: fix ${id} because this is the current finding.\n`;
|
||||
const findings = (id: string) => `${id}: current findings\nNo issues found.\nRecommendation: fix ${id} because this is the current finding.\n`;
|
||||
// INV-1: the verdict-form validator prints its verdict before the completed status.
|
||||
const unavailable = (id: string) => `${findings(id)}VERDICT: unavailable\nFINDINGS: none\nREASON: execution_failed\n`;
|
||||
const completed = (id: string) => `${findings(id)}VERDICT: clean\nFINDINGS: none\nOUTSIDE_STATUS: completed provider=codex host=claude\n`;
|
||||
@@ -258,8 +258,8 @@ describe('outside-voice dispatch contract', () => {
|
||||
const apply = rendered.indexOf('**4. Apply the answered row.**');
|
||||
expect(answer).toBeGreaterThan(0);
|
||||
expect(apply).toBeGreaterThan(answer);
|
||||
// B1: the sandbox comes from _gstack_codex_select_model (read-only unless GSTACK_CODEX_NO_SANDBOX=1).
|
||||
expect(rendered).toContain(`-s "\${_GSTACK_CODEX_SANDBOX:?}" ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="high"'`);
|
||||
// B1: the sandbox comes from the probe's select-model (read-only unless GSTACK_CODEX_NO_SANDBOX=1).
|
||||
expect(rendered).toContain(`-s "\${_CODEX_SANDBOX_MODE:?}" ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="high"'`);
|
||||
});
|
||||
|
||||
test('the generated-carrier exception rejects missing wait, cancellation or result guards', () => {
|
||||
|
||||
@@ -1517,8 +1517,8 @@ describe('Codex skill', () => {
|
||||
const resumeCommand = match![0];
|
||||
expect(resumeCommand).not.toContain(' -C ');
|
||||
expect(resumeCommand).not.toMatch(/ -s /);
|
||||
// resume takes the sandbox only as config; _gstack_codex_select_model sets it (read-only by default).
|
||||
expect(resumeCommand).toContain('-c "sandbox_mode=\\"${_GSTACK_CODEX_SANDBOX:?}\\""');
|
||||
// resume takes the sandbox only as config; the probe's select-model reports it (read-only by default).
|
||||
expect(resumeCommand).toContain('-c "sandbox_mode=\\"${_CODEX_SANDBOX_MODE:?}\\""');
|
||||
});
|
||||
|
||||
test('codex union contains cost tracking', () => {
|
||||
|
||||
Reference in new issue
Block a user