@sabaiway/agent-workflow-kit 1.16.0 → 1.17.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,542 @@
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-review.sh');
14
+
15
+ // Same hermetic fake `codex` as codex-exec.test.mjs (kept inline so each bridge
16
+ // test file is standalone — no shared helper grows the byte-identical mirror set).
17
+ const FAKE_CODEX = [
18
+ '#!/usr/bin/env bash',
19
+ 'set -u',
20
+ 'if [[ "${1:-}" == "login" ]]; then echo "${CODEX_FAKE_LOGIN:-Logged in using ChatGPT}"; exit 0; fi',
21
+ ': "${CODEX_FAKE_ARGV:=/dev/null}"',
22
+ ': "${CODEX_FAKE_ENV:=/dev/null}"',
23
+ ': "${CODEX_FAKE_STDIN:=/dev/null}"',
24
+ '{ for a in "$@"; do echo "$a"; done; } >"$CODEX_FAKE_ARGV"',
25
+ '{ 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>}"; } >"$CODEX_FAKE_ENV"',
26
+ 'cat >"$CODEX_FAKE_STDIN"',
27
+ // If the prompt points at a precomputed-diff temp file, record its perms + a copy
28
+ // WHILE it still exists (the wrapper trap removes it on exit) for the oversized test.
29
+ 'df="$(grep -oE "/[^ ]*codex-review-diff\\.[0-9]+" "$CODEX_FAKE_STDIN" 2>/dev/null | head -1 || true)"',
30
+ 'if [[ -n "$df" && -f "$df" ]]; then stat -c "%a" "$df" > "${CODEX_FAKE_DIFF_PERMS:-/dev/null}" 2>/dev/null || true; cp "$df" "${CODEX_FAKE_DIFF_COPY:-/dev/null}" 2>/dev/null || true; fi',
31
+ 'if [[ "${CODEX_FAKE_FAIL_ON_SCHEMA:-}" == "1" ]]; then for a in "$@"; do [[ "$a" == "--output-schema" ]] && exit 1; done; fi',
32
+ 'if [[ -n "${CODEX_FAKE_SLEEP:-}" ]]; then sleep "${CODEX_FAKE_SLEEP}"; fi',
33
+ 'out=""',
34
+ 'prev=""',
35
+ 'for a in "$@"; do',
36
+ ' if [[ "$prev" == "-o" || "$prev" == "--output-last-message" ]]; then out="$a"; fi',
37
+ ' prev="$a"',
38
+ 'done',
39
+ 'if [[ -n "$out" && "${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
+ 'exit "${CODEX_FAKE_EXIT:-0}"',
51
+ '',
52
+ ].join('\n');
53
+
54
+ // `clean: true` leaves a committed, pristine tree (for the no-diff preflight);
55
+ // the default leaves one uncommitted untracked file so `code` mode has a diff to
56
+ // review (otherwise the new no-diff preflight short-circuits before codex runs).
57
+ const makeSandbox = ({ clean = false } = {}) => {
58
+ const root = mkdtempSync(join(tmpdir(), 'codex-review-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
+ const repo = join(root, 'repo');
65
+ mkdirSync(repo);
66
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
67
+ g('init', '-q');
68
+ g('config', 'user.email', 'probe@example.com');
69
+ g('config', 'user.name', 'probe');
70
+ writeFileSync(join(repo, 'AGENTS.md'), '# AGENTS\n\nHard Constraints: none (test fixture).\n');
71
+ writeFileSync(join(repo, 'plan.md'), '# Plan\n\nDo a thing in two steps.\n');
72
+ g('add', '-A');
73
+ g('commit', '-qm', 'base');
74
+ if (!clean) writeFileSync(join(repo, 'pending.txt'), 'an uncommitted change to review\n');
75
+ return { root, bin, repo };
76
+ };
77
+
78
+ const makePathWithout = (root, exclude = []) => {
79
+ const skip = new Set(exclude);
80
+ const dir = mkdtempSync(join(root, 'nobin-'));
81
+ for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
82
+ let names;
83
+ try { names = readdirSync(d); } catch { continue; }
84
+ for (const name of names) {
85
+ if (skip.has(name)) continue;
86
+ const link = join(dir, name);
87
+ if (existsSync(link)) continue;
88
+ // resolve() so a relative PATH entry still yields an ABSOLUTE symlink target
89
+ // (a relative target would be broken — it resolves against the temp dir, not cwd).
90
+ try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
91
+ }
92
+ }
93
+ return dir;
94
+ };
95
+
96
+ const run = ({ repo, bin }, { args = ['code'], env = {}, path, cwd } = {}) => {
97
+ const argvFile = join(repo, '.cap-argv');
98
+ const envFile = join(repo, '.cap-env');
99
+ const stdinFile = join(repo, '.cap-stdin');
100
+ const codexHome = join(repo, '..', 'codex-home');
101
+ const r = spawnSync('bash', [WRAPPER, ...args], {
102
+ cwd: cwd || repo,
103
+ encoding: 'utf8',
104
+ timeout: 30000,
105
+ env: {
106
+ PATH: path || `${bin}:${process.env.PATH}`,
107
+ HOME: repo,
108
+ CODEX_HOME: codexHome,
109
+ CODEX_FAKE_ARGV: argvFile,
110
+ CODEX_FAKE_ENV: envFile,
111
+ CODEX_FAKE_STDIN: stdinFile,
112
+ ...env,
113
+ },
114
+ });
115
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
116
+ return { ...r, codexHome, argv: readIf(argvFile), capEnv: readIf(envFile), capStdin: readIf(stdinFile) };
117
+ };
118
+
119
+ describe('codex-review.sh — quality-first model/effort guard (1.1)', () => {
120
+ it('refuses a non-default CODEX_MODEL', () => {
121
+ const sb = makeSandbox();
122
+ const r = run(sb, { env: { CODEX_MODEL: 'gpt-5.4-mini' } });
123
+ rmSync(sb.root, { recursive: true, force: true });
124
+ assert.notEqual(r.status, 0);
125
+ assert.match(r.stderr, /not the pinned frontier model/);
126
+ assert.equal(r.capStdin, '', 'codex must not be invoked when the guard fires');
127
+ });
128
+
129
+ it('refuses a non-default CODEX_EFFORT', () => {
130
+ const sb = makeSandbox();
131
+ const r = run(sb, { env: { CODEX_EFFORT: 'high' } });
132
+ rmSync(sb.root, { recursive: true, force: true });
133
+ assert.notEqual(r.status, 0);
134
+ assert.match(r.stderr, /not the pinned max effort/);
135
+ });
136
+
137
+ it('CODEX_PROBE=1 relaxes the guard and warns', () => {
138
+ const sb = makeSandbox();
139
+ const r = run(sb, { env: { CODEX_PROBE: '1', CODEX_EFFORT: 'low' } });
140
+ rmSync(sb.root, { recursive: true, force: true });
141
+ assert.equal(r.status, 0, r.stderr);
142
+ assert.match(r.stderr, /THROWAWAY PROBE MODE/);
143
+ });
144
+ });
145
+
146
+ describe('codex-review.sh — clean output + session capture (1.2)', () => {
147
+ it('prints ONLY the final findings, not the JSON event stream', () => {
148
+ const sb = makeSandbox();
149
+ const r = run(sb);
150
+ rmSync(sb.root, { recursive: true, force: true });
151
+ assert.equal(r.status, 0, r.stderr);
152
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
153
+ assert.doesNotMatch(r.stdout, /thread\.started/);
154
+ });
155
+
156
+ it('passes the clean-capture flags and read-only sandbox to codex', () => {
157
+ const sb = makeSandbox();
158
+ const r = run(sb);
159
+ rmSync(sb.root, { recursive: true, force: true });
160
+ for (const f of [/(^|\n)-o(\n|$)/, /(^|\n)--json(\n|$)/, /hide_agent_reasoning=true/,
161
+ /(^|\n)read-only(\n|$)/]) {
162
+ assert.match(r.argv, f, `expected ${f} among codex argv`);
163
+ }
164
+ });
165
+
166
+ it('surfaces the session id on STDERR only — never the shared resume sidecar', () => {
167
+ const sb = makeSandbox();
168
+ const r = run(sb);
169
+ const sidecar = join(sb.repo, '.codex-last-session');
170
+ const wrote = existsSync(sidecar);
171
+ rmSync(sb.root, { recursive: true, force: true });
172
+ assert.match(r.stderr, /session: fake-thread-123/);
173
+ assert.equal(wrote, false, 'a review must NOT clobber codex-exec --resume-last target');
174
+ });
175
+
176
+ it('on a codex failure, prints the trace tail and exits codex code', () => {
177
+ const sb = makeSandbox();
178
+ const r = run(sb, { env: { CODEX_FAKE_EXIT: '5' } });
179
+ rmSync(sb.root, { recursive: true, force: true });
180
+ assert.equal(r.status, 5);
181
+ assert.match(r.stderr, /codex review failed \(exit 5\)/);
182
+ });
183
+ });
184
+
185
+ describe('codex-review.sh — leaner prompt + read-fence line (1.4 / 1.5)', () => {
186
+ it('code mode: obeys AGENTS.md from context, states the read fence, no read-AGENTS action', () => {
187
+ const sb = makeSandbox();
188
+ const r = run(sb, { args: ['code'] });
189
+ rmSync(sb.root, { recursive: true, force: true });
190
+ assert.match(r.capStdin, /already merged into your context/);
191
+ assert.doesNotMatch(r.capStdin, /Also read the project's root AGENTS\.md/);
192
+ assert.match(r.capStdin, /Do not read files outside this git working tree/);
193
+ });
194
+
195
+ it('code mode: appends extra focus', () => {
196
+ const sb = makeSandbox();
197
+ const r = run(sb, { args: ['code', 'the new reducer'] });
198
+ rmSync(sb.root, { recursive: true, force: true });
199
+ assert.match(r.capStdin, /Extra focus: the new reducer/);
200
+ });
201
+
202
+ it('plan mode: includes the plan body and a PLAN-specific read fence', () => {
203
+ const sb = makeSandbox();
204
+ const r = run(sb, { args: ['plan', 'plan.md'] });
205
+ rmSync(sb.root, { recursive: true, force: true });
206
+ assert.match(r.capStdin, /Do a thing in two steps/);
207
+ assert.match(r.capStdin, /the plan above plus the in-repo code/);
208
+ assert.doesNotMatch(r.capStdin, /assembled change set/i, 'plan mode must not borrow the code fence');
209
+ });
210
+ });
211
+
212
+ describe('codex-review.sh — best-effort env read-fence (1.5)', () => {
213
+ it('repoints HOME/XDG to a throwaway dir while keeping an absolute CODEX_HOME', () => {
214
+ const sb = makeSandbox();
215
+ const r = run(sb);
216
+ const home = (r.capEnv.match(/^HOME=(.*)$/m) || [])[1];
217
+ const codexHome = (r.capEnv.match(/^CODEX_HOME=(.*)$/m) || [])[1];
218
+ const xdg = (r.capEnv.match(/^XDG_CONFIG_HOME=(.*)$/m) || [])[1];
219
+ rmSync(sb.root, { recursive: true, force: true });
220
+ assert.ok(home && home !== sb.repo, `HOME must be repointed away from the caller's, got ${home}`);
221
+ assert.equal(codexHome, r.codexHome, 'CODEX_HOME must stay the absolute real auth root');
222
+ assert.ok(xdg && xdg.startsWith(home), 'XDG_CONFIG_HOME must live under the fenced HOME');
223
+ });
224
+
225
+ it('resolves a literal ~/ in CODEX_HOME against HOME, not $PWD', () => {
226
+ const sb = makeSandbox();
227
+ const r = run(sb, { env: { CODEX_HOME: '~/.codex' } });
228
+ const codexHome = (r.capEnv.match(/^CODEX_HOME=(.*)$/m) || [])[1];
229
+ rmSync(sb.root, { recursive: true, force: true });
230
+ // HOME handed to the wrapper is sb.repo → ~/.codex must expand to <repo>/.codex,
231
+ // never the broken $PWD/~/.codex.
232
+ assert.equal(codexHome, join(sb.repo, '.codex'));
233
+ });
234
+ });
235
+
236
+ describe('codex-review.sh — subscription / config isolation (invariant)', () => {
237
+ it('clears every *_API_KEY + OPENAI_BASE_URL and passes --ignore-user-config', () => {
238
+ const sb = makeSandbox();
239
+ const r = run(sb, { env: {
240
+ OPENAI_API_KEY: 'sk-x', OPENAI_BASE_URL: 'http://evil.example', FOO_API_KEY: 'bar',
241
+ } });
242
+ rmSync(sb.root, { recursive: true, force: true });
243
+ assert.equal(r.status, 0, r.stderr);
244
+ assert.match(r.capEnv, /^OPENAI_API_KEY=<unset>$/m);
245
+ assert.match(r.capEnv, /^OPENAI_BASE_URL=<unset>$/m);
246
+ assert.match(r.capEnv, /^FOO_API_KEY=<unset>$/m);
247
+ assert.match(r.argv, /(^|\n)--ignore-user-config(\n|$)/);
248
+ });
249
+ });
250
+
251
+ describe('codex-review.sh — hard timeout (1.3)', () => {
252
+ it('kills a hung review at CODEX_HARD_TIMEOUT and reports it', () => {
253
+ const sb = makeSandbox();
254
+ const started = Date.now();
255
+ const r = run(sb, { env: { CODEX_FAKE_SLEEP: '30', CODEX_HARD_TIMEOUT: '2' } });
256
+ const elapsed = Date.now() - started;
257
+ rmSync(sb.root, { recursive: true, force: true });
258
+ assert.ok(elapsed < 18000, `must return well under the kill-after window, took ${elapsed}ms`);
259
+ assert.notEqual(r.status, 0);
260
+ assert.match(r.stderr, /exceeded the hard cap/);
261
+ });
262
+
263
+ it('warns and runs uncapped when neither timeout nor gtimeout is on PATH', () => {
264
+ const sb = makeSandbox();
265
+ const path = `${sb.bin}:${makePathWithout(sb.root, ['timeout', 'gtimeout'])}`;
266
+ const r = run(sb, { path });
267
+ rmSync(sb.root, { recursive: true, force: true });
268
+ assert.equal(r.status, 0, r.stderr);
269
+ assert.match(r.stderr, /WITHOUT a hard wall-clock cap/);
270
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
271
+ });
272
+ });
273
+
274
+ describe('codex-review.sh — precomputed diff for code mode (2.1)', () => {
275
+ it('no-diff preflight: a clean tree exits 0 without spending a codex run', () => {
276
+ const sb = makeSandbox({ clean: true });
277
+ const r = run(sb, { args: ['code'] });
278
+ rmSync(sb.root, { recursive: true, force: true });
279
+ assert.equal(r.status, 0);
280
+ assert.match(r.stderr, /no uncommitted changes to review/);
281
+ assert.equal(r.capStdin, '', 'codex must NOT be invoked on a clean tree');
282
+ });
283
+
284
+ it('assembles repo map, status, staged + unstaged diffs; drops the run-git-yourself directive', () => {
285
+ const sb = makeSandbox();
286
+ const g = (...a) => spawnSync('git', a, { cwd: sb.repo, encoding: 'utf8' });
287
+ writeFileSync(join(sb.repo, 'AGENTS.md'), '# AGENTS\n\nHard Constraints: none.\nan unstaged edit\n');
288
+ writeFileSync(join(sb.repo, 'staged.mjs'), 'export const s = 1\n');
289
+ g('add', 'staged.mjs');
290
+ const r = run(sb, { args: ['code'] });
291
+ rmSync(sb.root, { recursive: true, force: true });
292
+ assert.equal(r.status, 0, r.stderr);
293
+ for (const sec of [/repo file map/, /git status/, /staged diff/, /unstaged diff/, /staged\.mjs/]) {
294
+ assert.match(r.capStdin, sec);
295
+ }
296
+ assert.doesNotMatch(r.capStdin, /Run `git status --short`/, 'the old self-discovery directive must be gone');
297
+ });
298
+
299
+ it('inlines untracked file CONTENTS, not just the path', () => {
300
+ const sb = makeSandbox();
301
+ writeFileSync(join(sb.repo, 'untra.txt'), 'UNIQUE_UNTRACKED_BODY\n');
302
+ const r = run(sb, { args: ['code'] });
303
+ rmSync(sb.root, { recursive: true, force: true });
304
+ assert.match(r.capStdin, /untracked: untra\.txt/);
305
+ assert.match(r.capStdin, /UNIQUE_UNTRACKED_BODY/);
306
+ });
307
+
308
+ it('skips binary untracked files (noted; raw bytes not inlined)', () => {
309
+ const sb = makeSandbox();
310
+ writeFileSync(join(sb.repo, 'blob.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x42]));
311
+ const r = run(sb, { args: ['code'] });
312
+ rmSync(sb.root, { recursive: true, force: true });
313
+ assert.match(r.capStdin, /binary, skipped\): blob\.bin/);
314
+ });
315
+
316
+ it('handles untracked paths with spaces (NUL-safe)', () => {
317
+ const sb = makeSandbox();
318
+ writeFileSync(join(sb.repo, 'a b c.txt'), 'SPACED_BODY\n');
319
+ const r = run(sb, { args: ['code'] });
320
+ rmSync(sb.root, { recursive: true, force: true });
321
+ assert.match(r.capStdin, /untracked: a b c\.txt/);
322
+ assert.match(r.capStdin, /SPACED_BODY/);
323
+ });
324
+
325
+ it('does not follow untracked symlinks (no out-of-tree content leak)', () => {
326
+ const sb = makeSandbox();
327
+ const secret = join(sb.root, 'outside-secret.txt'); // OUTSIDE the repo
328
+ writeFileSync(secret, 'TOP_SECRET_LEAK_MARKER\n');
329
+ symlinkSync(secret, join(sb.repo, 'link-to-outside')); // untracked symlink → outside
330
+ const r = run(sb, { args: ['code'] });
331
+ rmSync(sb.root, { recursive: true, force: true });
332
+ assert.match(r.capStdin, /untracked \(symlink\): link-to-outside -> /);
333
+ assert.doesNotMatch(r.capStdin, /TOP_SECRET_LEAK_MARKER/, 'symlink target content must never leak');
334
+ });
335
+
336
+ it('oversized → git-dir temp file: 600 perms, untruncated, carve-out fence, cleaned up', () => {
337
+ const sb = makeSandbox();
338
+ writeFileSync(join(sb.repo, 'unique.txt'), 'OVERSIZE_UNIQUE_MARKER\n');
339
+ writeFileSync(join(sb.repo, 'big.txt'), 'x'.repeat(5000));
340
+ const perms = join(sb.root, 'cap-perms');
341
+ const copy = join(sb.root, 'cap-diffcopy');
342
+ const r = run(sb, { args: ['code'], env: {
343
+ CODEX_REVIEW_MAX_TOTAL_BYTES: '100', CODEX_FAKE_DIFF_PERMS: perms, CODEX_FAKE_DIFF_COPY: copy,
344
+ } });
345
+ const leftover = readdirSync(join(sb.repo, '.git')).filter((f) => f.startsWith('codex-review-diff.'));
346
+ const gotPerms = existsSync(perms) ? readFileSync(perms, 'utf8').trim() : '';
347
+ const gotCopy = existsSync(copy) ? readFileSync(copy, 'utf8') : '';
348
+ rmSync(sb.root, { recursive: true, force: true });
349
+ assert.equal(r.status, 0, r.stderr);
350
+ assert.match(r.capStdin, /codex-review-diff\./);
351
+ assert.match(r.capStdin, /with ONE exception/);
352
+ assert.match(r.capStdin, /read it IN FULL/);
353
+ assert.doesNotMatch(r.capStdin, /ASSEMBLED CHANGE SET:/, 'must not also inline the payload');
354
+ assert.equal(gotPerms, '600', 'the diff temp file must be mode 600');
355
+ assert.match(gotCopy, /OVERSIZE_UNIQUE_MARKER/, 'the temp file must hold the full untracked content');
356
+ assert.ok(gotCopy.length > 4000, 'the temp file must be the full untruncated payload');
357
+ assert.deepEqual(leftover, [], 'the diff temp file must be cleaned up on exit');
358
+ });
359
+ });
360
+
361
+ describe('codex-review.sh — optional structured findings (2.2)', () => {
362
+ it('CODEX_REVIEW_SCHEMA=1 passes --output-schema to codex', () => {
363
+ const sb = makeSandbox();
364
+ const r = run(sb, { args: ['code'], env: { CODEX_REVIEW_SCHEMA: '1' } });
365
+ rmSync(sb.root, { recursive: true, force: true });
366
+ assert.equal(r.status, 0, r.stderr);
367
+ assert.match(r.argv, /(^|\n)--output-schema(\n|$)/);
368
+ });
369
+
370
+ it('is OFF by default — no --output-schema', () => {
371
+ const sb = makeSandbox();
372
+ const r = run(sb, { args: ['code'] });
373
+ rmSync(sb.root, { recursive: true, force: true });
374
+ assert.doesNotMatch(r.argv, /--output-schema/);
375
+ });
376
+
377
+ it('falls back to a raw-text run when the schema run fails (loud; exit 0)', () => {
378
+ const sb = makeSandbox();
379
+ const r = run(sb, { args: ['code'], env: { CODEX_REVIEW_SCHEMA: '1', CODEX_FAKE_FAIL_ON_SCHEMA: '1' } });
380
+ rmSync(sb.root, { recursive: true, force: true });
381
+ assert.equal(r.status, 0, r.stderr);
382
+ assert.match(r.stderr, /without the schema constraint/);
383
+ assert.doesNotMatch(r.argv, /--output-schema/, 'the fallback run must drop the schema');
384
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
385
+ });
386
+
387
+ it('schema ON makes the directive ask for schema JSON, not one-per-line text', () => {
388
+ const sb = makeSandbox();
389
+ const r = run(sb, { args: ['code'], env: { CODEX_REVIEW_SCHEMA: '1' } });
390
+ rmSync(sb.root, { recursive: true, force: true });
391
+ assert.match(r.capStdin, /JSON object matching the provided output schema/);
392
+ assert.doesNotMatch(r.capStdin, /one per line/);
393
+ });
394
+
395
+ it('schema OFF (default) asks for one-finding-per-line text', () => {
396
+ const sb = makeSandbox();
397
+ const r = run(sb, { args: ['code'] });
398
+ rmSync(sb.root, { recursive: true, force: true });
399
+ assert.match(r.capStdin, /one per line/);
400
+ });
401
+ });
402
+
403
+ describe('codex-review.sh — environment preflight (fail fast, before a run)', () => {
404
+ it('STOPs with 127 when codex is not on PATH', () => {
405
+ const sb = makeSandbox();
406
+ const path = makePathWithout(sb.root, ['codex']); // no fake codex, no real codex
407
+ const r = run(sb, { args: ['code'], path });
408
+ rmSync(sb.root, { recursive: true, force: true });
409
+ assert.equal(r.status, 127);
410
+ assert.match(r.stderr, /'codex'.*not found on PATH/);
411
+ assert.equal(r.capStdin, '', 'codex must never be invoked');
412
+ });
413
+
414
+ it('STOPs (exit 1) when codex is not on a ChatGPT subscription', () => {
415
+ const sb = makeSandbox();
416
+ const r = run(sb, { args: ['code'], env: { CODEX_FAKE_LOGIN: 'Logged in using API key' } });
417
+ rmSync(sb.root, { recursive: true, force: true });
418
+ assert.equal(r.status, 1);
419
+ assert.match(r.stderr, /not on a ChatGPT subscription/);
420
+ assert.equal(r.capStdin, '', 'a wrong login must never spend a run');
421
+ });
422
+
423
+ it('STOPs (exit 2) when not inside a git work tree', () => {
424
+ const sb = makeSandbox();
425
+ const nongit = join(sb.root, 'nongit');
426
+ mkdirSync(nongit, { recursive: true });
427
+ writeFileSync(join(nongit, 'AGENTS.md'), '# AGENTS\n');
428
+ const r = run(sb, { args: ['code'], cwd: nongit });
429
+ rmSync(sb.root, { recursive: true, force: true });
430
+ assert.equal(r.status, 2);
431
+ assert.match(r.stderr, /must run inside a git working tree/);
432
+ });
433
+
434
+ it('STOPs (exit 2) when there is no root AGENTS.md', () => {
435
+ const sb = makeSandbox();
436
+ rmSync(join(sb.repo, 'AGENTS.md'));
437
+ const r = run(sb, { args: ['code'] });
438
+ rmSync(sb.root, { recursive: true, force: true });
439
+ assert.equal(r.status, 2);
440
+ assert.match(r.stderr, /no root AGENTS\.md/);
441
+ });
442
+ });
443
+
444
+ describe('codex-review.sh — CODEX_HOME resolution arms (1.5)', () => {
445
+ it('resolves a bare ~ in CODEX_HOME to HOME', () => {
446
+ const sb = makeSandbox();
447
+ const r = run(sb, { args: ['code'], env: { CODEX_HOME: '~' } });
448
+ const codexHome = (r.capEnv.match(/^CODEX_HOME=(.*)$/m) || [])[1];
449
+ rmSync(sb.root, { recursive: true, force: true });
450
+ assert.equal(codexHome, sb.repo, 'bare ~ → the HOME handed to the wrapper');
451
+ });
452
+
453
+ it('anchors a relative CODEX_HOME to $PWD', () => {
454
+ const sb = makeSandbox();
455
+ const r = run(sb, { args: ['code'], env: { CODEX_HOME: 'rel/.codex' } });
456
+ const codexHome = (r.capEnv.match(/^CODEX_HOME=(.*)$/m) || [])[1];
457
+ rmSync(sb.root, { recursive: true, force: true });
458
+ assert.equal(codexHome, join(sb.repo, 'rel/.codex'), 'a relative path anchors to cwd, never left bare');
459
+ });
460
+ });
461
+
462
+ describe('codex-review.sh — mode dispatch & plan validation', () => {
463
+ it('unknown mode prints usage and STOPs (exit 2)', () => {
464
+ const sb = makeSandbox();
465
+ const r = run(sb, { args: ['bogus'] });
466
+ rmSync(sb.root, { recursive: true, force: true });
467
+ assert.equal(r.status, 2);
468
+ assert.match(r.stderr, /usage: .* plan <plan-file> \| code/);
469
+ });
470
+
471
+ it('no mode prints usage and STOPs (exit 2)', () => {
472
+ const sb = makeSandbox();
473
+ const r = run(sb, { args: [] });
474
+ rmSync(sb.root, { recursive: true, force: true });
475
+ assert.equal(r.status, 2);
476
+ assert.match(r.stderr, /usage:/);
477
+ });
478
+
479
+ it('plan mode: STOPs (exit 2) when the plan file is missing', () => {
480
+ const sb = makeSandbox();
481
+ const r = run(sb, { args: ['plan', 'nope.md'] });
482
+ rmSync(sb.root, { recursive: true, force: true });
483
+ assert.equal(r.status, 2);
484
+ assert.match(r.stderr, /plan file 'nope\.md' not found/);
485
+ });
486
+
487
+ it('plan mode: STOPs (exit 2) on unexpected trailing arguments', () => {
488
+ const sb = makeSandbox();
489
+ const r = run(sb, { args: ['plan', 'plan.md', 'extra', 'junk'] });
490
+ rmSync(sb.root, { recursive: true, force: true });
491
+ assert.equal(r.status, 2);
492
+ assert.match(r.stderr, /unexpected arguments after plan file: extra junk/);
493
+ });
494
+ });
495
+
496
+ describe('codex-review.sh — assemble & output edge cases', () => {
497
+ it('skips a non-regular untracked path (an embedded git repo dir) without reading it', () => {
498
+ // git enumerates an untracked path as non-regular only as a DIRECTORY: a FIFO /
499
+ // socket / device is not listed by `git ls-files --others` at all, but an embedded
500
+ // git repo surfaces as `nested/` — a directory, so `[[ ! -f ]]` skips it (and a
501
+ // `cat` is never attempted, which is what the branch guards against for FIFOs).
502
+ const sb = makeSandbox();
503
+ const nested = join(sb.repo, 'nested');
504
+ mkdirSync(nested, { recursive: true });
505
+ const g = (...a) => spawnSync('git', a, { cwd: nested, encoding: 'utf8' });
506
+ g('init', '-q');
507
+ writeFileSync(join(nested, 'inner.txt'), 'INNER_SHOULD_NOT_BE_INLINED\n');
508
+ const r = run(sb, { args: ['code'] });
509
+ rmSync(sb.root, { recursive: true, force: true });
510
+ assert.equal(r.status, 0, r.stderr);
511
+ assert.match(r.capStdin, /non-regular, skipped\): nested\//);
512
+ assert.doesNotMatch(r.capStdin, /INNER_SHOULD_NOT_BE_INLINED/, 'a non-regular path must not be inlined');
513
+ });
514
+
515
+ it('appends extra focus on the oversized (temp-file) path too', () => {
516
+ const sb = makeSandbox();
517
+ writeFileSync(join(sb.repo, 'big.txt'), 'x'.repeat(5000));
518
+ const r = run(sb, { args: ['code', 'watch the parser'], env: { CODEX_REVIEW_MAX_TOTAL_BYTES: '100' } });
519
+ rmSync(sb.root, { recursive: true, force: true });
520
+ assert.equal(r.status, 0, r.stderr);
521
+ assert.match(r.capStdin, /with ONE exception/, 'this is the oversized temp-file path');
522
+ assert.match(r.capStdin, /Extra focus: watch the parser/);
523
+ });
524
+
525
+ it('warns and prints the trace tail when codex writes no final-message file', () => {
526
+ const sb = makeSandbox();
527
+ const r = run(sb, { args: ['code'], env: { CODEX_FAKE_NO_OUT: '1' } });
528
+ rmSync(sb.root, { recursive: true, force: true });
529
+ assert.equal(r.status, 0, r.stderr);
530
+ assert.match(r.stderr, /no final-message file/);
531
+ assert.match(r.stdout, /turn\.completed/, 'the trace tail carries the event stream');
532
+ });
533
+
534
+ it('prints no session line when codex emits no thread id', () => {
535
+ const sb = makeSandbox();
536
+ const r = run(sb, { args: ['code'], env: { CODEX_FAKE_NO_THREAD: '1' } });
537
+ rmSync(sb.root, { recursive: true, force: true });
538
+ assert.equal(r.status, 0, r.stderr);
539
+ assert.doesNotMatch(r.stderr, /session:/);
540
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
541
+ });
542
+ });
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "codex-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "1.0.0",
6
+ "version": "2.0.0",
7
7
  "provides": ["execute", "review"],
8
8
  "roles": {
9
9
  "execute": { "cmd": "codex-exec", "source": "bin/codex-exec.sh", "output": "diff" },