@sabaiway/agent-workflow-kit 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readFileSync,
5
+ existsSync, readdirSync, symlinkSync,
6
+ } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, dirname, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+ const WRAPPER = join(HERE, 'codex-exec.sh');
14
+
15
+ // A hermetic fake `codex`: answers `login status`, captures argv/env/stdin to the
16
+ // files named by CODEX_FAKE_*, honours -o by writing a final-message file, and
17
+ // emits a minimal --json event stream (thread.started carries the session id).
18
+ // Written with shell double-quotes + a heredoc ONLY (no single-quotes, no
19
+ // backslashes) so it survives transport through a JS single-quoted array.
20
+ const FAKE_CODEX = [
21
+ '#!/usr/bin/env bash',
22
+ 'set -u',
23
+ 'if [[ "${1:-}" == "login" ]]; then echo "${CODEX_FAKE_LOGIN:-Logged in using ChatGPT}"; exit 0; fi',
24
+ ': "${CODEX_FAKE_ARGV:=/dev/null}"',
25
+ ': "${CODEX_FAKE_ENV:=/dev/null}"',
26
+ ': "${CODEX_FAKE_STDIN:=/dev/null}"',
27
+ '{ for a in "$@"; do echo "$a"; done; } >"$CODEX_FAKE_ARGV"',
28
+ '{ echo "HOME=${HOME:-}"; echo "CODEX_HOME=${CODEX_HOME:-}"; echo "XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-}"; echo "OPENAI_API_KEY=${OPENAI_API_KEY:-<unset>}"; echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-<unset>}"; echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "CODEX_REAL_GIT=${CODEX_REAL_GIT:-<unset>}"; } >"$CODEX_FAKE_ENV"',
29
+ 'cat >"$CODEX_FAKE_STDIN"',
30
+ 'if [[ "${CODEX_FAKE_GIT_PROBE:-}" == "1" ]]; then { echo "realgit_env=${CODEX_REAL_GIT:-unset}"; echo "status=$(git status --short >/dev/null 2>&1; echo $?)"; echo "diff=$(git --no-pager diff >/dev/null 2>&1; echo $?)"; echo "dashC_read=$(git -C . status --short >/dev/null 2>&1; echo $?)"; echo "dashc_read=$(git -c core.pager=cat status --short >/dev/null 2>&1; echo $?)"; echo "bare=$(git >/dev/null 2>&1; echo $?)"; echo "commit=$(git commit -m x >/dev/null 2>&1; echo $?)"; echo "add=$(git add -A >/dev/null 2>&1; echo $?)"; echo "checkout=$(git checkout -- . >/dev/null 2>&1; echo $?)"; echo "unknown=$(git frobnicate >/dev/null 2>&1; echo $?)"; echo "config_read=$(git config user.name >/dev/null 2>&1; echo $?)"; echo "config_list=$(git config --list >/dev/null 2>&1; echo $?)"; echo "config_bare=$(git config >/dev/null 2>&1; echo $?)"; echo "config_write=$(git config user.name HACKED >/dev/null 2>&1; echo $?)"; echo "config_bypass=$(git config --get --add a.b v >/dev/null 2>&1; echo $?)"; echo "symref_write=$(git symbolic-ref HEAD refs/heads/x >/dev/null 2>&1; echo $?)"; echo "reflog_write=$(git reflog expire --all >/dev/null 2>&1; echo $?)"; } > "${CODEX_FAKE_GIT_RESULT:-/dev/null}" 2>&1; fi',
31
+ 'if [[ -n "${CODEX_FAKE_SLEEP:-}" ]]; then sleep "${CODEX_FAKE_SLEEP}"; fi',
32
+ 'out=""',
33
+ 'prev=""',
34
+ 'for a in "$@"; do',
35
+ ' if [[ "$prev" == "-o" || "$prev" == "--output-last-message" ]]; then out="$a"; fi',
36
+ ' prev="$a"',
37
+ 'done',
38
+ 'if [[ -n "$out" ]]; then',
39
+ ' if [[ "${CODEX_FAKE_NO_OUT:-}" != "1" ]]; then echo "${CODEX_FAKE_FINAL:-FAKE_FINAL_MESSAGE}" >"$out"; fi',
40
+ ' if [[ "${CODEX_FAKE_NO_THREAD:-}" != "1" ]]; then',
41
+ ' cat <<EOF',
42
+ '{"type":"thread.started","thread_id":"${CODEX_FAKE_THREAD_ID:-fake-thread-123}"}',
43
+ 'EOF',
44
+ ' fi',
45
+ ' cat <<EOF',
46
+ '{"type":"turn.started"}',
47
+ '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"FAKE_FINAL_MESSAGE"}}',
48
+ '{"type":"turn.completed","usage":{}}',
49
+ 'EOF',
50
+ 'else',
51
+ ' echo "${CODEX_FAKE_FINAL:-FAKE_FINAL_MESSAGE}"',
52
+ 'fi',
53
+ 'exit "${CODEX_FAKE_EXIT:-0}"',
54
+ '',
55
+ ].join('\n');
56
+
57
+ const makeSandbox = () => {
58
+ const root = mkdtempSync(join(tmpdir(), 'codex-exec-test-'));
59
+ const bin = join(root, 'bin');
60
+ mkdirSync(bin, { recursive: true });
61
+ const stub = join(bin, 'codex');
62
+ writeFileSync(stub, FAKE_CODEX, { mode: 0o755 });
63
+ chmodSync(stub, 0o755);
64
+ // A git work tree with a root AGENTS.md — the wrapper preflights both.
65
+ const repo = join(root, 'repo');
66
+ mkdirSync(repo);
67
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
68
+ g('init', '-q');
69
+ g('config', 'user.email', 'probe@example.com');
70
+ g('config', 'user.name', 'probe');
71
+ writeFileSync(join(repo, 'AGENTS.md'), '# AGENTS\n\nHard Constraints: none (test fixture).\n');
72
+ g('add', '-A');
73
+ g('commit', '-qm', 'base');
74
+ return { root, bin, repo };
75
+ };
76
+
77
+ // A PATH dir mirroring the real one MINUS the named binaries, to exercise the
78
+ // missing-binary fallbacks (no-cap when timeout is gone; the codex/git preflight
79
+ // 127s) hermetically without a production test backdoor.
80
+ const makePathWithout = (root, exclude = []) => {
81
+ const skip = new Set(exclude);
82
+ const dir = mkdtempSync(join(root, 'nobin-'));
83
+ for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
84
+ let names;
85
+ try { names = readdirSync(d); } catch { continue; }
86
+ for (const name of names) {
87
+ if (skip.has(name)) continue;
88
+ const link = join(dir, name);
89
+ if (existsSync(link)) continue;
90
+ // resolve() so a relative PATH entry still yields an ABSOLUTE symlink target
91
+ // (a relative target would be broken — it resolves against the temp dir, not cwd).
92
+ try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
93
+ }
94
+ }
95
+ return dir;
96
+ };
97
+
98
+ const run = ({ repo, bin }, { args = ['-'], input = 'do the thing', env = {}, path, cwd } = {}) => {
99
+ const argvFile = join(repo, '.cap-argv');
100
+ const envFile = join(repo, '.cap-env');
101
+ const stdinFile = join(repo, '.cap-stdin');
102
+ const r = spawnSync('bash', [WRAPPER, ...args], {
103
+ cwd: cwd || repo,
104
+ input,
105
+ encoding: 'utf8',
106
+ timeout: 30000,
107
+ env: {
108
+ PATH: path || `${bin}:${process.env.PATH}`,
109
+ HOME: repo,
110
+ CODEX_FAKE_ARGV: argvFile,
111
+ CODEX_FAKE_ENV: envFile,
112
+ CODEX_FAKE_STDIN: stdinFile,
113
+ ...env,
114
+ },
115
+ });
116
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
117
+ return { ...r, argv: readIf(argvFile), capEnv: readIf(envFile), capStdin: readIf(stdinFile) };
118
+ };
119
+
120
+ describe('codex-exec.sh — quality-first model/effort guard (1.1)', () => {
121
+ it('refuses a non-default CODEX_MODEL and never spends a run', () => {
122
+ const sb = makeSandbox();
123
+ const r = run(sb, { env: { CODEX_MODEL: 'gpt-5.4-mini' } });
124
+ rmSync(sb.root, { recursive: true, force: true });
125
+ assert.notEqual(r.status, 0);
126
+ assert.match(r.stderr, /not the pinned frontier model/);
127
+ assert.equal(r.capStdin, '', 'codex must not be invoked when the guard fires');
128
+ });
129
+
130
+ it('refuses a non-default CODEX_EFFORT', () => {
131
+ const sb = makeSandbox();
132
+ const r = run(sb, { env: { CODEX_EFFORT: 'high' } });
133
+ rmSync(sb.root, { recursive: true, force: true });
134
+ assert.notEqual(r.status, 0);
135
+ assert.match(r.stderr, /not the pinned max effort/);
136
+ });
137
+
138
+ it('CODEX_PROBE=1 allows a non-default model and warns loudly', () => {
139
+ const sb = makeSandbox();
140
+ const r = run(sb, { env: { CODEX_PROBE: '1', CODEX_MODEL: 'gpt-5.4-mini' } });
141
+ rmSync(sb.root, { recursive: true, force: true });
142
+ assert.equal(r.status, 0, r.stderr);
143
+ assert.match(r.stderr, /THROWAWAY PROBE MODE/);
144
+ assert.match(r.argv, /gpt-5\.4-mini/, 'the probe model must reach codex');
145
+ });
146
+ });
147
+
148
+ // Tier 1 — subscription / sandbox / approval / config / provider / model-pin /
149
+ // capture flags: rejected ALWAYS, even under CODEX_PROBE=1.
150
+ const ALWAYS_BLOCKED = [
151
+ ['-s', 'read-only'], ['--sandbox', 'danger-full-access'], ['-c', 'k=v'], ['--config', 'k=v'],
152
+ ['--full-auto'], ['--dangerously-bypass-approvals-and-sandbox'], ['--oss'], ['--local-provider', 'x'],
153
+ ['-p', 'prof'], ['--profile', 'prof'], ['-m', 'gpt-5.5'], ['--model', 'gpt-5.5'],
154
+ ['-o', '/x'], ['--output-last-message', '/x'], ['--json'], ['--color', 'always'],
155
+ ['--output-schema', '/x'], ['--ephemeral'],
156
+ ];
157
+ // Tier 2 — context/discovery knobs: rejected for real runs, allowed under CODEX_PROBE=1.
158
+ const PROBE_RELAXABLE = [
159
+ ['--add-dir', '/x'], ['-C', '/x'], ['--cd', '/x'], ['--skip-git-repo-check'],
160
+ ['--ignore-rules'], ['--enable', 'foo'], ['--disable', 'foo'],
161
+ ];
162
+
163
+ describe('codex-exec.sh — passthrough guard, two tiers (1.1)', () => {
164
+ for (const flag of ALWAYS_BLOCKED) {
165
+ it(`always rejects ${flag[0]} (no probe)`, () => {
166
+ const sb = makeSandbox();
167
+ const r = run(sb, { args: ['-', '--', ...flag] });
168
+ rmSync(sb.root, { recursive: true, force: true });
169
+ assert.notEqual(r.status, 0);
170
+ assert.match(r.stderr, /is not allowed/);
171
+ });
172
+ it(`still rejects ${flag[0]} even under CODEX_PROBE=1`, () => {
173
+ const sb = makeSandbox();
174
+ const r = run(sb, { args: ['-', '--', ...flag], env: { CODEX_PROBE: '1' } });
175
+ rmSync(sb.root, { recursive: true, force: true });
176
+ assert.notEqual(r.status, 0);
177
+ assert.match(r.stderr, /blocked even under CODEX_PROBE=1/);
178
+ });
179
+ }
180
+
181
+ for (const flag of PROBE_RELAXABLE) {
182
+ it(`rejects ${flag[0]} for a real run (no probe)`, () => {
183
+ const sb = makeSandbox();
184
+ const r = run(sb, { args: ['-', '--', ...flag] });
185
+ rmSync(sb.root, { recursive: true, force: true });
186
+ assert.notEqual(r.status, 0);
187
+ assert.match(r.stderr, /is not allowed/);
188
+ });
189
+ }
190
+
191
+ it('CODEX_PROBE=1 lets a context flag (--add-dir) through and warns', () => {
192
+ const sb = makeSandbox();
193
+ const r = run(sb, { args: ['-', '--', '--add-dir', '/x'], env: { CODEX_PROBE: '1' } });
194
+ rmSync(sb.root, { recursive: true, force: true });
195
+ assert.equal(r.status, 0, r.stderr);
196
+ assert.match(r.argv, /--add-dir/);
197
+ assert.match(r.stderr, /THROWAWAY PROBE MODE/);
198
+ });
199
+ });
200
+
201
+ describe('codex-exec.sh — subscription / config isolation (invariant)', () => {
202
+ it('clears every *_API_KEY + OPENAI_BASE_URL and passes --ignore-user-config', () => {
203
+ const sb = makeSandbox();
204
+ const r = run(sb, { env: {
205
+ OPENAI_API_KEY: 'sk-should-be-cleared', OPENAI_BASE_URL: 'http://evil.example', FOO_API_KEY: 'bar',
206
+ } });
207
+ rmSync(sb.root, { recursive: true, force: true });
208
+ assert.equal(r.status, 0, r.stderr);
209
+ assert.match(r.capEnv, /^OPENAI_API_KEY=<unset>$/m);
210
+ assert.match(r.capEnv, /^OPENAI_BASE_URL=<unset>$/m);
211
+ assert.match(r.capEnv, /^FOO_API_KEY=<unset>$/m);
212
+ assert.match(r.argv, /(^|\n)--ignore-user-config(\n|$)/);
213
+ });
214
+ });
215
+
216
+ describe('codex-exec.sh — clean output + session capture (1.2)', () => {
217
+ it('prints ONLY the final message, not the JSON event stream', () => {
218
+ const sb = makeSandbox();
219
+ const r = run(sb);
220
+ rmSync(sb.root, { recursive: true, force: true });
221
+ assert.equal(r.status, 0, r.stderr);
222
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
223
+ assert.doesNotMatch(r.stdout, /thread\.started/, 'the JSON trace must not leak to stdout');
224
+ });
225
+
226
+ it('passes the clean-capture flags to codex', () => {
227
+ const sb = makeSandbox();
228
+ const r = run(sb);
229
+ rmSync(sb.root, { recursive: true, force: true });
230
+ for (const f of [/(^|\n)-o(\n|$)/, /(^|\n)--json(\n|$)/, /(^|\n)--color(\n|$)/,
231
+ /hide_agent_reasoning=true/, /model_reasoning_summary=none/]) {
232
+ assert.match(r.argv, f, `expected ${f} among codex argv`);
233
+ }
234
+ });
235
+
236
+ it('captures the session id to the default sidecar and stderr', () => {
237
+ const sb = makeSandbox();
238
+ const r = run(sb);
239
+ const sidecar = join(sb.repo, '.codex-last-session');
240
+ const got = existsSync(sidecar) ? readFileSync(sidecar, 'utf8').trim() : '';
241
+ rmSync(sb.root, { recursive: true, force: true });
242
+ assert.equal(got, 'fake-thread-123');
243
+ assert.match(r.stderr, /session: fake-thread-123/);
244
+ });
245
+
246
+ it('honours CODEX_SESSION_FILE and leaves the default sidecar untouched', () => {
247
+ const sb = makeSandbox();
248
+ const custom = join(sb.repo, 'my-session');
249
+ run(sb, { env: { CODEX_SESSION_FILE: custom } });
250
+ const customGot = existsSync(custom) ? readFileSync(custom, 'utf8').trim() : '';
251
+ const defaultWritten = existsSync(join(sb.repo, '.codex-last-session'));
252
+ rmSync(sb.root, { recursive: true, force: true });
253
+ assert.equal(customGot, 'fake-thread-123');
254
+ assert.equal(defaultWritten, false, 'the default sidecar must not be written when CODEX_SESSION_FILE is set');
255
+ });
256
+
257
+ it('falls back to the trace tail when the final-message file is missing', () => {
258
+ const sb = makeSandbox();
259
+ const r = run(sb, { env: { CODEX_FAKE_NO_OUT: '1' } });
260
+ rmSync(sb.root, { recursive: true, force: true });
261
+ assert.equal(r.status, 0, r.stderr);
262
+ assert.match(r.stderr, /no final-message file/);
263
+ assert.match(r.stdout, /turn\.completed/, 'the trace tail should carry the event stream');
264
+ });
265
+
266
+ it('on a codex failure, prints the trace tail to stderr and exits codex code', () => {
267
+ const sb = makeSandbox();
268
+ const r = run(sb, { env: { CODEX_FAKE_EXIT: '7' } });
269
+ rmSync(sb.root, { recursive: true, force: true });
270
+ assert.equal(r.status, 7);
271
+ assert.match(r.stderr, /codex exec failed \(exit 7\)/);
272
+ assert.match(r.stderr, /thread\.started/, 'failure should surface the trace tail');
273
+ });
274
+
275
+ it('warns (never silently) when the session sidecar cannot be written', () => {
276
+ const sb = makeSandbox();
277
+ const blocker = join(sb.repo, 'blocker');
278
+ writeFileSync(blocker, 'x'); // a regular file …
279
+ const bad = join(blocker, 'session'); // … so this path is unwritable (ENOTDIR)
280
+ const r = run(sb, { env: { CODEX_SESSION_FILE: bad } });
281
+ rmSync(sb.root, { recursive: true, force: true });
282
+ assert.equal(r.status, 0, r.stderr);
283
+ assert.match(r.stderr, /could not write the session sidecar/);
284
+ assert.match(r.stderr, /session: fake-thread-123/, 'the id must still reach stderr');
285
+ });
286
+ });
287
+
288
+ describe('codex-exec.sh — leaner prompt (1.4)', () => {
289
+ it('directive obeys AGENTS.md from context without a read-AGENTS action', () => {
290
+ const sb = makeSandbox();
291
+ const r = run(sb);
292
+ rmSync(sb.root, { recursive: true, force: true });
293
+ assert.match(r.capStdin, /Obey EVERY Hard Constraint declared in the project's root AGENTS\.md \(already/);
294
+ assert.doesNotMatch(r.capStdin, /Read the target project's root AGENTS\.md/);
295
+ assert.match(r.capStdin, /do the thing/, 'the task must be appended to the directive');
296
+ });
297
+ });
298
+
299
+ describe('codex-exec.sh — hard timeout (1.3)', () => {
300
+ it('kills a hung codex at CODEX_HARD_TIMEOUT and reports it', () => {
301
+ const sb = makeSandbox();
302
+ const started = Date.now();
303
+ const r = run(sb, { env: { CODEX_FAKE_SLEEP: '30', CODEX_HARD_TIMEOUT: '2' } });
304
+ const elapsed = Date.now() - started;
305
+ rmSync(sb.root, { recursive: true, force: true });
306
+ assert.ok(elapsed < 18000, `must return well under the kill-after window, took ${elapsed}ms`);
307
+ assert.notEqual(r.status, 0);
308
+ assert.match(r.stderr, /exceeded the hard cap/);
309
+ });
310
+
311
+ it('warns and runs uncapped when neither timeout nor gtimeout is on PATH', () => {
312
+ const sb = makeSandbox();
313
+ const path = `${sb.bin}:${makePathWithout(sb.root, ['timeout', 'gtimeout'])}`;
314
+ const r = run(sb, { path });
315
+ rmSync(sb.root, { recursive: true, force: true });
316
+ assert.equal(r.status, 0, r.stderr);
317
+ assert.match(r.stderr, /WITHOUT a hard wall-clock cap/);
318
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
319
+ });
320
+
321
+ it('resume runs uncapped (and warns) when no timeout binary is on PATH', () => {
322
+ const sb = makeSandbox();
323
+ const path = `${sb.bin}:${makePathWithout(sb.root, ['timeout', 'gtimeout'])}`;
324
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go', path });
325
+ rmSync(sb.root, { recursive: true, force: true });
326
+ assert.equal(r.status, 0, r.stderr);
327
+ assert.match(r.stderr, /WITHOUT a hard wall-clock cap/);
328
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
329
+ });
330
+ });
331
+
332
+ describe('codex-exec.sh — preflight (unchanged invariants)', () => {
333
+ it('STOPs when there is no root AGENTS.md', () => {
334
+ const sb = makeSandbox();
335
+ rmSync(join(sb.repo, 'AGENTS.md'));
336
+ const r = run(sb);
337
+ rmSync(sb.root, { recursive: true, force: true });
338
+ assert.equal(r.status, 2);
339
+ assert.match(r.stderr, /no root AGENTS\.md/);
340
+ });
341
+ });
342
+
343
+ describe('codex-exec.sh — resume entrypoint restates every invariant (3.1)', () => {
344
+ const RESUME_INVARIANTS = [
345
+ /(^|\n)resume(\n|$)/, /(^|\n)--ignore-user-config(\n|$)/, /(^|\n)gpt-5\.5(\n|$)/,
346
+ /model_reasoning_effort=xhigh/, /sandbox_mode=workspace-write/,
347
+ /approval_policy=never/, /sandbox_workspace_write\.network_access=false/,
348
+ ];
349
+
350
+ it('--resume <id>: composes `exec resume <id>` with the full restated policy', () => {
351
+ const sb = makeSandbox();
352
+ const r = run(sb, { args: ['--resume', 'sess-xyz', '-'], input: 'continue please' });
353
+ rmSync(sb.root, { recursive: true, force: true });
354
+ assert.equal(r.status, 0, r.stderr);
355
+ assert.match(r.argv, /(^|\n)sess-xyz(\n|$)/, 'the session id is passed positionally');
356
+ for (const inv of RESUME_INVARIANTS) assert.match(r.argv, inv, `resume argv must include ${inv}`);
357
+ assert.doesNotMatch(r.argv, /(^|\n)-o(\n|$)/, 'resume rejects -o');
358
+ assert.doesNotMatch(r.argv, /(^|\n)--json(\n|$)/, 'resume rejects --json');
359
+ assert.doesNotMatch(r.argv, /(^|\n)--color(\n|$)/, 'resume rejects --color');
360
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume prints codex stdout');
361
+ });
362
+
363
+ it('--resume-last reads the session id from the sidecar', () => {
364
+ const sb = makeSandbox();
365
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'sess-from-sidecar\n');
366
+ const r = run(sb, { args: ['--resume-last', '-'], input: 'continue' });
367
+ rmSync(sb.root, { recursive: true, force: true });
368
+ assert.equal(r.status, 0, r.stderr);
369
+ assert.match(r.argv, /(^|\n)sess-from-sidecar(\n|$)/);
370
+ });
371
+
372
+ it('--resume-last honours CODEX_SESSION_FILE', () => {
373
+ const sb = makeSandbox();
374
+ const custom = join(sb.repo, 'mysess');
375
+ writeFileSync(custom, 'sess-custom\n');
376
+ const r = run(sb, { args: ['--resume-last', '-'], input: 'go', env: { CODEX_SESSION_FILE: custom } });
377
+ rmSync(sb.root, { recursive: true, force: true });
378
+ assert.match(r.argv, /(^|\n)sess-custom(\n|$)/);
379
+ });
380
+
381
+ it('--resume-last with no sidecar STOPs (never guesses)', () => {
382
+ const sb = makeSandbox();
383
+ const r = run(sb, { args: ['--resume-last', '-'], input: 'go' });
384
+ rmSync(sb.root, { recursive: true, force: true });
385
+ assert.notEqual(r.status, 0);
386
+ assert.match(r.stderr, /no session sidecar/);
387
+ });
388
+
389
+ it('--resume with no id STOPs', () => {
390
+ const sb = makeSandbox();
391
+ const r = run(sb, { args: ['--resume', '-'], input: 'go' });
392
+ rmSync(sb.root, { recursive: true, force: true });
393
+ assert.notEqual(r.status, 0);
394
+ assert.match(r.stderr, /--resume needs a <session-id>/);
395
+ });
396
+
397
+ it('rejects an empty resumed instruction', () => {
398
+ const sb = makeSandbox();
399
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: ' \n' });
400
+ rmSync(sb.root, { recursive: true, force: true });
401
+ assert.notEqual(r.status, 0);
402
+ assert.match(r.stderr, /empty resumed/);
403
+ });
404
+
405
+ it('resume takes no passthrough flags', () => {
406
+ const sb = makeSandbox();
407
+ const r = run(sb, { args: ['--resume', 'sess-1', '-', '--', '--add-dir', '/x'], input: 'go' });
408
+ rmSync(sb.root, { recursive: true, force: true });
409
+ assert.notEqual(r.status, 0);
410
+ assert.match(r.stderr, /resume modes take no extra flags/);
411
+ });
412
+
413
+ it('resume never sets --ephemeral', () => {
414
+ const sb = makeSandbox();
415
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go' });
416
+ rmSync(sb.root, { recursive: true, force: true });
417
+ assert.doesNotMatch(r.argv, /--ephemeral/);
418
+ });
419
+
420
+ it('--resume-last with an EMPTY sidecar STOPs (no blank id)', () => {
421
+ const sb = makeSandbox();
422
+ writeFileSync(join(sb.repo, '.codex-last-session'), ' \n');
423
+ const r = run(sb, { args: ['--resume-last', '-'], input: 'go' });
424
+ rmSync(sb.root, { recursive: true, force: true });
425
+ assert.notEqual(r.status, 0);
426
+ assert.match(r.stderr, /sidecar.*is empty/);
427
+ });
428
+
429
+ it('resume still clears every *_API_KEY/OPENAI_BASE_URL and keeps --ignore-user-config', () => {
430
+ const sb = makeSandbox();
431
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go', env: {
432
+ OPENAI_API_KEY: 'sk-x', OPENAI_BASE_URL: 'http://evil.example', FOO_API_KEY: 'bar',
433
+ } });
434
+ rmSync(sb.root, { recursive: true, force: true });
435
+ assert.equal(r.status, 0, r.stderr);
436
+ assert.match(r.capEnv, /^OPENAI_API_KEY=<unset>$/m);
437
+ assert.match(r.capEnv, /^OPENAI_BASE_URL=<unset>$/m);
438
+ assert.match(r.capEnv, /^FOO_API_KEY=<unset>$/m);
439
+ assert.match(r.argv, /(^|\n)--ignore-user-config(\n|$)/);
440
+ });
441
+ });
442
+
443
+ describe('codex-exec.sh — enforced git-write boundary shim (3.2)', () => {
444
+ it('passes read-only verbs, blocks writes/unknown/config-writes; no env bypass', () => {
445
+ const sb = makeSandbox();
446
+ const result = join(sb.repo, 'git-probe-result');
447
+ const r = run(sb, { env: { CODEX_FAKE_GIT_PROBE: '1', CODEX_FAKE_GIT_RESULT: result } });
448
+ const probe = existsSync(result) ? readFileSync(result, 'utf8') : '';
449
+ rmSync(sb.root, { recursive: true, force: true });
450
+ assert.equal(r.status, 0, r.stderr);
451
+ // The real git path is baked into the shim, NOT exported — codex cannot read it.
452
+ assert.match(probe, /realgit_env=unset/, 'CODEX_REAL_GIT must not be exposed to codex');
453
+ assert.match(probe, /status=0/, 'git status (read) passes through');
454
+ assert.match(probe, /diff=0/, 'git --no-pager diff (read, global option) passes through');
455
+ assert.match(probe, /dashC_read=0/, 'git -C . status (value-taking global option, 2-token skip) passes through');
456
+ assert.match(probe, /dashc_read=0/, 'git -c core.pager=cat status (value-taking -c) passes through');
457
+ assert.match(probe, /bare=1/, 'bare git (empty verb) passes to real git → its own usage code, not the 13 block');
458
+ assert.match(probe, /commit=13/, 'git commit (write) is blocked');
459
+ assert.match(probe, /add=13/, 'git add (write) is blocked');
460
+ assert.match(probe, /checkout=13/, 'git checkout (write) is blocked');
461
+ assert.match(probe, /unknown=13/, 'an unknown verb is blocked by default');
462
+ assert.match(probe, /config_read=0/, 'git config <name> (read) passes through');
463
+ assert.match(probe, /config_list=0/, 'git config --list (read) passes through');
464
+ assert.match(probe, /config_bare=129/, 'bare git config (empty rest) passes through to real git (usage code 129) — not blocked (13), not a set -u crash (1)');
465
+ assert.match(probe, /config_write=13/, 'git config <name> <value> (write) is blocked');
466
+ assert.match(probe, /config_bypass=13/, 'git config --get --add … (write bypass) is blocked');
467
+ assert.match(probe, /symref_write=13/, 'git symbolic-ref (has write modes) is blocked');
468
+ assert.match(probe, /reflog_write=13/, 'git reflog (has write modes) is blocked');
469
+ });
470
+
471
+ it('the codex env carries no CODEX_REAL_GIT (bypass vector closed)', () => {
472
+ const sb = makeSandbox();
473
+ const r = run(sb);
474
+ rmSync(sb.root, { recursive: true, force: true });
475
+ assert.match(r.capEnv, /^CODEX_REAL_GIT=<unset>$/m);
476
+ });
477
+ });
478
+
479
+ describe('codex-exec.sh — environment preflight (fail fast, before a run)', () => {
480
+ it('STOPs with 127 when codex is not on PATH', () => {
481
+ const sb = makeSandbox();
482
+ // PATH WITHOUT the fake codex bin and without any real codex.
483
+ const path = makePathWithout(sb.root, ['codex']);
484
+ const r = run(sb, { path });
485
+ rmSync(sb.root, { recursive: true, force: true });
486
+ assert.equal(r.status, 127);
487
+ assert.match(r.stderr, /'codex'.*not found on PATH/);
488
+ assert.equal(r.capStdin, '', 'codex must never be invoked');
489
+ });
490
+
491
+ it('STOPs with 127 when git is not on PATH', () => {
492
+ const sb = makeSandbox();
493
+ // codex present (sb.bin) but git stripped — exercises the type -P git guard.
494
+ const path = `${sb.bin}:${makePathWithout(sb.root, ['git'])}`;
495
+ const r = run(sb, { path });
496
+ rmSync(sb.root, { recursive: true, force: true });
497
+ assert.equal(r.status, 127);
498
+ assert.match(r.stderr, /'git' not found on PATH/);
499
+ });
500
+
501
+ it('STOPs (exit 1) when codex is not on a ChatGPT subscription', () => {
502
+ const sb = makeSandbox();
503
+ const r = run(sb, { env: { CODEX_FAKE_LOGIN: 'Logged in using API key' } });
504
+ rmSync(sb.root, { recursive: true, force: true });
505
+ assert.equal(r.status, 1);
506
+ assert.match(r.stderr, /not on a ChatGPT subscription/);
507
+ assert.equal(r.capStdin, '', 'a wrong login must never spend a run');
508
+ });
509
+
510
+ it('STOPs (exit 2) when not inside a git work tree', () => {
511
+ const sb = makeSandbox();
512
+ const nongit = join(sb.root, 'nongit');
513
+ mkdirSync(nongit, { recursive: true });
514
+ writeFileSync(join(nongit, 'AGENTS.md'), '# AGENTS\n'); // present, but the work-tree check fires first
515
+ const r = run(sb, { cwd: nongit });
516
+ rmSync(sb.root, { recursive: true, force: true });
517
+ assert.equal(r.status, 2);
518
+ assert.match(r.stderr, /must run inside a git working tree/);
519
+ });
520
+ });
521
+
522
+ describe('codex-exec.sh — argument & prompt-source dispatch', () => {
523
+ it('prints usage and STOPs (exit 2) with no arguments', () => {
524
+ const sb = makeSandbox();
525
+ const r = run(sb, { args: [] });
526
+ rmSync(sb.root, { recursive: true, force: true });
527
+ assert.equal(r.status, 2);
528
+ assert.match(r.stderr, /usage:/);
529
+ });
530
+
531
+ it('STOPs on a stray extra argument without the -- separator', () => {
532
+ const sb = makeSandbox();
533
+ const r = run(sb, { args: ['-', 'stray'], input: 'go' });
534
+ rmSync(sb.root, { recursive: true, force: true });
535
+ assert.equal(r.status, 2);
536
+ assert.match(r.stderr, /unexpected argument 'stray'/);
537
+ });
538
+
539
+ it('passes an allowed (non-blocked) passthrough flag through to codex', () => {
540
+ const sb = makeSandbox();
541
+ const r = run(sb, { args: ['-', '--', '--foobar', 'val'], input: 'go' });
542
+ rmSync(sb.root, { recursive: true, force: true });
543
+ assert.equal(r.status, 0, r.stderr);
544
+ assert.match(r.argv, /(^|\n)--foobar(\n|$)/, 'an unguarded flag reaches codex argv');
545
+ });
546
+
547
+ it('reads the task from a prompt FILE (not just stdin)', () => {
548
+ const sb = makeSandbox();
549
+ writeFileSync(join(sb.repo, 'task.md'), 'PROMPT_FROM_FILE_MARKER\n');
550
+ const r = run(sb, { args: ['task.md'], input: '' });
551
+ rmSync(sb.root, { recursive: true, force: true });
552
+ assert.equal(r.status, 0, r.stderr);
553
+ assert.match(r.capStdin, /PROMPT_FROM_FILE_MARKER/);
554
+ });
555
+
556
+ it('STOPs (exit 2) when the prompt path is neither - nor a file', () => {
557
+ const sb = makeSandbox();
558
+ const r = run(sb, { args: ['no-such-file.md'], input: '' });
559
+ rmSync(sb.root, { recursive: true, force: true });
560
+ assert.equal(r.status, 2);
561
+ assert.match(r.stderr, /'no-such-file\.md' is not a file/);
562
+ });
563
+
564
+ it('STOPs on an empty task in normal mode (no "resumed" wording)', () => {
565
+ const sb = makeSandbox();
566
+ const r = run(sb, { args: ['-'], input: ' \n' });
567
+ rmSync(sb.root, { recursive: true, force: true });
568
+ assert.equal(r.status, 2);
569
+ assert.match(r.stderr, /empty plan\/instruction/);
570
+ assert.doesNotMatch(r.stderr, /resumed/, 'normal mode must not say "resumed"');
571
+ });
572
+
573
+ it('--resume-last with no prompt argument STOPs (missing <plan-file>)', () => {
574
+ const sb = makeSandbox();
575
+ const r = run(sb, { args: ['--resume-last'], input: '' });
576
+ rmSync(sb.root, { recursive: true, force: true });
577
+ assert.equal(r.status, 2);
578
+ assert.match(r.stderr, /missing <plan-file/);
579
+ });
580
+ });
581
+
582
+ describe('codex-exec.sh — session id absent', () => {
583
+ it('writes no sidecar and no session line when codex emits no thread id', () => {
584
+ const sb = makeSandbox();
585
+ const r = run(sb, { env: { CODEX_FAKE_NO_THREAD: '1' } });
586
+ const wrote = existsSync(join(sb.repo, '.codex-last-session'));
587
+ rmSync(sb.root, { recursive: true, force: true });
588
+ assert.equal(r.status, 0, r.stderr);
589
+ assert.equal(wrote, false, 'no thread id → no sidecar');
590
+ assert.doesNotMatch(r.stderr, /session:/, 'no thread id → no session line');
591
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'the run still succeeds');
592
+ });
593
+ });