@sabaiway/agent-workflow-kit 1.20.0 → 1.22.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,441 @@
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, 'agy-review.sh');
14
+
15
+ // Hermetic fake `agy`. PORTING TRAP: agy takes the prompt as the `-p` ARGV value, NOT stdin — so the
16
+ // fake captures the -p value from argv (a stdin capture would make every prompt assertion vacuous).
17
+ // It also records full argv, a couple of env vars, an invocation sentinel, and — for the oversized
18
+ // --add-dir escape — the staging dir's perms + the offloaded artifact's perms/contents WHILE they
19
+ // still exist (agy-review's trap removes the staging dir on exit). Kept inline so the file is
20
+ // standalone (the kit mirror is byte-equality; no shared helper grows that set).
21
+ const FAKE_AGY = [
22
+ '#!/usr/bin/env bash',
23
+ 'set -u',
24
+ ': "${AGY_FAKE_ARGV:=/dev/null}"',
25
+ ': "${AGY_FAKE_ENV:=/dev/null}"',
26
+ ': "${AGY_FAKE_PROMPT:=/dev/null}"',
27
+ ': "${AGY_FAKE_SENTINEL:=/dev/null}"',
28
+ 'printf invoked > "$AGY_FAKE_SENTINEL"',
29
+ '{ for a in "$@"; do printf "%s\\n" "$a"; done; } > "$AGY_FAKE_ARGV"',
30
+ '{ echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY:-<unset>}"; } > "$AGY_FAKE_ENV"',
31
+ 'prev=""; for a in "$@"; do [[ "$prev" == "-p" ]] && printf "%s" "$a" > "$AGY_FAKE_PROMPT"; prev="$a"; done',
32
+ 'prev=""; for a in "$@"; do',
33
+ ' if [[ "$prev" == "--add-dir" ]]; then',
34
+ ' printf "%s" "$a" > "${AGY_FAKE_ADDDIR:-/dev/null}"',
35
+ ' stat -c "%a" "$a" > "${AGY_FAKE_ADDDIR_MODE:-/dev/null}" 2>/dev/null || true',
36
+ ' art="$a/precomputed-change-set"',
37
+ ' if [[ -f "$art" ]]; then stat -c "%a" "$art" > "${AGY_FAKE_ARTIFACT_MODE:-/dev/null}" 2>/dev/null || true; cp "$art" "${AGY_FAKE_ARTIFACT_COPY:-/dev/null}" 2>/dev/null || true; fi',
38
+ ' fi; prev="$a"',
39
+ 'done',
40
+ 'if [[ -n "${AGY_FAKE_SLEEP:-}" ]]; then sleep "$AGY_FAKE_SLEEP"; fi',
41
+ 'echo "FAKE_AGY_REVIEW_OUTPUT"',
42
+ 'exit "${AGY_FAKE_EXIT:-0}"',
43
+ '',
44
+ ].join('\n');
45
+
46
+ // A PATH whose entries are symlinks to the real PATH binaries EXCEPT the excluded names. Excluding
47
+ // `agy-run` forces agy-review onto its `$HERE/agy.sh` fallback (the repo's CURRENT agy.sh, not a
48
+ // possibly-stale installed one), keeping the test hermetic; excluding `agy` ensures the only agy is
49
+ // our fake (prepended via $HOME/.local/bin). Ported from codex-review.test.mjs.
50
+ const makePathWithout = (root, exclude = []) => {
51
+ const skip = new Set(exclude);
52
+ const dir = mkdtempSync(join(root, 'nobin-'));
53
+ for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
54
+ let names;
55
+ try { names = readdirSync(d); } catch { continue; }
56
+ for (const name of names) {
57
+ if (skip.has(name)) continue;
58
+ const link = join(dir, name);
59
+ if (existsSync(link)) continue;
60
+ try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
61
+ }
62
+ }
63
+ return dir;
64
+ };
65
+
66
+ // `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
67
+ // untracked file so `code` mode has a diff to review.
68
+ const makeSandbox = ({ clean = false } = {}) => {
69
+ const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
70
+ const bin = join(home, '.local', 'bin');
71
+ mkdirSync(bin, { recursive: true });
72
+ const stub = join(bin, 'agy');
73
+ writeFileSync(stub, FAKE_AGY, { mode: 0o755 });
74
+ chmodSync(stub, 0o755);
75
+ const repo = join(home, 'repo');
76
+ mkdirSync(repo);
77
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
78
+ g('init', '-q');
79
+ g('config', 'user.email', 'probe@example.com');
80
+ g('config', 'user.name', 'probe');
81
+ writeFileSync(join(repo, 'base.txt'), 'committed base\n');
82
+ g('add', '-A');
83
+ g('commit', '-qm', 'base');
84
+ if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
85
+ return { home, bin, repo, g };
86
+ };
87
+
88
+ const run = (sb, { args, env = {}, cwd } = {}) => {
89
+ const { home, bin, repo } = sb;
90
+ const farm = makePathWithout(home, ['agy', 'agy-run']);
91
+ const cap = {
92
+ argv: join(home, 'cap-argv'), env: join(home, 'cap-env'), prompt: join(home, 'cap-prompt'),
93
+ sentinel: join(home, 'cap-sentinel'), adddir: join(home, 'cap-adddir'),
94
+ adddirMode: join(home, 'cap-adddir-mode'), artifactMode: join(home, 'cap-artifact-mode'),
95
+ artifactCopy: join(home, 'cap-artifact-copy'),
96
+ };
97
+ const r = spawnSync('bash', [WRAPPER, ...args], {
98
+ cwd: cwd || repo,
99
+ encoding: 'utf8',
100
+ timeout: 30000,
101
+ env: {
102
+ HOME: home,
103
+ PATH: `${bin}:${farm}`,
104
+ AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
105
+ AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
106
+ AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
107
+ ...env,
108
+ },
109
+ });
110
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
111
+ return {
112
+ ...r,
113
+ invoked: existsSync(cap.sentinel),
114
+ argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
115
+ adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
116
+ artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
117
+ };
118
+ };
119
+
120
+ describe('agy-review.sh — model policy advisory (1)', () => {
121
+ it('warns for a non-frontier model but still runs', () => {
122
+ const sb = makeSandbox();
123
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_MODEL: 'Gemini 3.5 Flash (Low)' } });
124
+ rmSync(sb.home, { recursive: true, force: true });
125
+ assert.equal(r.status, 0, r.stderr);
126
+ assert.match(r.stderr, /non-frontier model 'Gemini 3.5 Flash \(Low\)'/);
127
+ assert.equal(r.invoked, true, 'a non-frontier model still runs (advisory, not a gate)');
128
+ assert.match(r.argv, /Gemini 3\.5 Flash \(Low\)/, 'the chosen model reaches agy via --model');
129
+ });
130
+
131
+ it('AGY_PROBE=1 silences the advisory', () => {
132
+ const sb = makeSandbox();
133
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_MODEL: 'Gemini 3.5 Flash (Low)', AGY_PROBE: '1' } });
134
+ rmSync(sb.home, { recursive: true, force: true });
135
+ assert.equal(r.status, 0, r.stderr);
136
+ assert.doesNotMatch(r.stderr, /non-frontier model/);
137
+ });
138
+
139
+ it('the frontier default (no AGY_MODEL) earns no advisory', () => {
140
+ const sb = makeSandbox();
141
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'] });
142
+ rmSync(sb.home, { recursive: true, force: true });
143
+ assert.equal(r.status, 0, r.stderr);
144
+ assert.doesNotMatch(r.stderr, /non-frontier model/);
145
+ assert.match(r.argv, /Gemini 3\.1 Pro \(High\)/, 'the frontier default reaches agy');
146
+ });
147
+ });
148
+
149
+ describe('agy-review.sh — guard + grounding (2, 3)', () => {
150
+ it('the model/cutoff GUARD line is in the captured prompt', () => {
151
+ const sb = makeSandbox();
152
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'] });
153
+ rmSync(sb.home, { recursive: true, force: true });
154
+ assert.match(r.prompt, /Do NOT comment on AI model names\/versions or your own knowledge cutoff/);
155
+ });
156
+
157
+ it('--facts / --decided / --focus all reach the prompt', () => {
158
+ const sb = makeSandbox();
159
+ const r = run(sb, { args: [
160
+ 'code', '--facts', 'GROUNDED_FACT_MARKER', '--decided', 'DECIDED_MARKER', '--focus', 'FOCUS_MARKER',
161
+ ] });
162
+ rmSync(sb.home, { recursive: true, force: true });
163
+ assert.equal(r.status, 0, r.stderr);
164
+ assert.match(r.prompt, /Grounded facts — review AGAINST these/);
165
+ assert.match(r.prompt, /GROUNDED_FACT_MARKER/);
166
+ assert.match(r.prompt, /do NOT re-raise these/);
167
+ assert.match(r.prompt, /DECIDED_MARKER/);
168
+ assert.match(r.prompt, /## Focus\nFOCUS_MARKER/);
169
+ });
170
+
171
+ it('--facts @file reads the file; --decided @file too', () => {
172
+ const sb = makeSandbox();
173
+ writeFileSync(join(sb.repo, 'facts.md'), 'FILE_FACT_BODY\n');
174
+ writeFileSync(join(sb.repo, 'decided.md'), 'FILE_DECIDED_BODY\n');
175
+ const r = run(sb, { args: ['code', '--facts', '@facts.md', '--decided', '@decided.md'] });
176
+ rmSync(sb.home, { recursive: true, force: true });
177
+ assert.equal(r.status, 0, r.stderr);
178
+ assert.match(r.prompt, /FILE_FACT_BODY/);
179
+ assert.match(r.prompt, /FILE_DECIDED_BODY/);
180
+ });
181
+
182
+ it('merges --focus and trailing focus words into one Focus block, in parse order', () => {
183
+ const sb = makeSandbox();
184
+ const r = run(sb, { args: ['code', '--facts', 'f', '--focus', 'first', 'second', 'third'] });
185
+ rmSync(sb.home, { recursive: true, force: true });
186
+ assert.equal(r.status, 0, r.stderr);
187
+ assert.match(r.prompt, /## Focus\nfirst second third/);
188
+ });
189
+
190
+ it('warns LOUDLY when --facts is omitted, and proceeds', () => {
191
+ const sb = makeSandbox();
192
+ const r = run(sb, { args: ['code'] });
193
+ rmSync(sb.home, { recursive: true, force: true });
194
+ assert.equal(r.status, 0, r.stderr);
195
+ assert.match(r.stderr, /no --facts supplied/);
196
+ assert.equal(r.invoked, true, 'an ungrounded review still proceeds (warn, not block)');
197
+ assert.match(r.prompt, /none supplied/, 'the prompt notes the missing facts in-band');
198
+ });
199
+ });
200
+
201
+ describe('agy-review.sh — code-mode precomputed diff (4, 5, 8)', () => {
202
+ it('assembles repo map + status + untracked CONTENTS', () => {
203
+ const sb = makeSandbox();
204
+ writeFileSync(join(sb.repo, 'untra.txt'), 'UNIQUE_UNTRACKED_BODY\n');
205
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
206
+ rmSync(sb.home, { recursive: true, force: true });
207
+ assert.equal(r.status, 0, r.stderr);
208
+ for (const sec of [/repo file map/, /git status/, /untracked: untra\.txt/, /UNIQUE_UNTRACKED_BODY/]) {
209
+ assert.match(r.prompt, sec);
210
+ }
211
+ });
212
+
213
+ it('skips a binary untracked file (noted; raw bytes not inlined)', () => {
214
+ const sb = makeSandbox();
215
+ writeFileSync(join(sb.repo, 'blob.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x42]));
216
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
217
+ rmSync(sb.home, { recursive: true, force: true });
218
+ assert.match(r.prompt, /binary, skipped\): blob\.bin/);
219
+ });
220
+
221
+ it('does not follow an untracked symlink (no out-of-tree leak)', () => {
222
+ const sb = makeSandbox();
223
+ const secret = join(sb.home, 'outside-secret.txt'); // OUTSIDE the repo
224
+ writeFileSync(secret, 'TOP_SECRET_LEAK_MARKER\n');
225
+ symlinkSync(secret, join(sb.repo, 'link-to-outside'));
226
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
227
+ rmSync(sb.home, { recursive: true, force: true });
228
+ assert.match(r.prompt, /untracked \(symlink\): link-to-outside -> /);
229
+ assert.doesNotMatch(r.prompt, /TOP_SECRET_LEAK_MARKER/, 'symlink target content must never leak');
230
+ });
231
+
232
+ it('handles untracked paths with spaces (NUL-safe)', () => {
233
+ const sb = makeSandbox();
234
+ writeFileSync(join(sb.repo, 'a b c.txt'), 'SPACED_BODY\n');
235
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
236
+ rmSync(sb.home, { recursive: true, force: true });
237
+ assert.match(r.prompt, /untracked: a b c\.txt/);
238
+ assert.match(r.prompt, /SPACED_BODY/);
239
+ });
240
+
241
+ it('no-diff preflight: a clean tree exits 0 without invoking agy', () => {
242
+ const sb = makeSandbox({ clean: true });
243
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
244
+ rmSync(sb.home, { recursive: true, force: true });
245
+ assert.equal(r.status, 0);
246
+ assert.match(r.stderr, /no uncommitted changes to review/);
247
+ assert.equal(r.invoked, false, 'agy must NOT be invoked on a clean tree');
248
+ });
249
+
250
+ it('the strict output-shape footer is present in a fresh review', () => {
251
+ const sb = makeSandbox();
252
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
253
+ rmSync(sb.home, { recursive: true, force: true });
254
+ for (const sec of [/### Verdict/, /### Blocking/, /### Non-blocking/, /### Questions/]) {
255
+ assert.match(r.prompt, sec);
256
+ }
257
+ });
258
+ });
259
+
260
+ describe('agy-review.sh — size ceiling + gated --add-dir escape (6)', () => {
261
+ it('default: oversized prompt exits 2 with guidance, agy not invoked', () => {
262
+ const sb = makeSandbox();
263
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_MAX_PROMPT_BYTES: '50' } });
264
+ rmSync(sb.home, { recursive: true, force: true });
265
+ assert.equal(r.status, 2, r.stderr);
266
+ assert.match(r.stderr, /over AGY_MAX_PROMPT_BYTES=50/);
267
+ assert.match(r.stderr, /Trim to the relevant hunks/);
268
+ assert.equal(r.invoked, false, 'an oversized prompt must not spend a run by default');
269
+ });
270
+
271
+ // A ceiling ABOVE the grounding-only prompt (~1.3 KB) but BELOW the full prompt (a big artifact),
272
+ // so the escape can actually offload the artifact while the grounding still fits inline.
273
+ it('AGY_REVIEW_ALLOW_ADDDIR=1: offloads the artifact to a 0700/0600 staging dir via --add-dir', () => {
274
+ const sb = makeSandbox();
275
+ writeFileSync(join(sb.repo, 'unique.txt'), `OVERSIZE_UNIQUE_MARKER\n${'x'.repeat(8000)}\n`);
276
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_MAX_PROMPT_BYTES: '2000', AGY_REVIEW_ALLOW_ADDDIR: '1' } });
277
+ rmSync(sb.home, { recursive: true, force: true });
278
+ assert.equal(r.status, 0, r.stderr);
279
+ assert.equal(r.invoked, true, 'the escape hatch lets the run proceed');
280
+ assert.match(r.argv, /--add-dir/, 'agy is given --add-dir');
281
+ assert.ok(r.adddir && !r.adddir.includes('.git'), `--add-dir must NOT point at .git (got ${r.adddir})`);
282
+ assert.ok(r.adddir && r.adddir !== sb.repo, '--add-dir must NOT be the work tree');
283
+ assert.equal(r.adddirMode, '700', 'the staging dir must be mode 0700');
284
+ assert.equal(r.artifactMode, '600', 'the offloaded artifact must be mode 0600');
285
+ assert.match(r.artifactCopy, /OVERSIZE_UNIQUE_MARKER/, 'the artifact file holds the full change set');
286
+ assert.match(r.prompt, /Grounded facts/, 'the -p prompt STILL carries the full grounding inline');
287
+ assert.doesNotMatch(r.prompt, /repo file map/, 'the artifact is offloaded, not inlined into -p');
288
+ assert.match(r.stderr, /RE-ENABLES the Issue-001 stall risk/);
289
+ });
290
+
291
+ it('the staging dir is trap-cleaned on exit (no leftover after the run)', () => {
292
+ const sb = makeSandbox();
293
+ writeFileSync(join(sb.repo, 'unique.txt'), `MARKER\n${'x'.repeat(8000)}\n`);
294
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_MAX_PROMPT_BYTES: '2000', AGY_REVIEW_ALLOW_ADDDIR: '1' } });
295
+ const stagingPath = r.adddir;
296
+ const stillThere = stagingPath ? existsSync(stagingPath) : false;
297
+ rmSync(sb.home, { recursive: true, force: true });
298
+ assert.ok(stagingPath, 'the escape must have fired (a staging path was captured)');
299
+ assert.equal(stillThere, false, 'the private staging dir must be removed by the EXIT trap');
300
+ });
301
+ });
302
+
303
+ describe('agy-review.sh — resume / round-2 delta (7)', () => {
304
+ it('--continue takes NO mode, sends a delta (shape + focus + decided), never re-embeds the artifact', () => {
305
+ const sb = makeSandbox();
306
+ writeFileSync(join(sb.repo, 'decided.md'), 'ALREADY_DECIDED_ITEM\n');
307
+ const r = run(sb, { args: ['--continue', '--decided', '@decided.md', '--focus', 'ROUND2_FOCUS'] });
308
+ rmSync(sb.home, { recursive: true, force: true });
309
+ assert.equal(r.status, 0, r.stderr);
310
+ assert.match(r.argv, /(^|\n)--continue(\n|$)/, 'agy is continued');
311
+ assert.match(r.prompt, /CONTINUE the review you already started/, 'the resume reminder restates posture');
312
+ assert.match(r.prompt, /### Verdict/, 'the delta restates the output shape so round-2 formatting holds');
313
+ assert.match(r.prompt, /ROUND2_FOCUS/);
314
+ assert.match(r.prompt, /ALREADY_DECIDED_ITEM/);
315
+ assert.doesNotMatch(r.prompt, /repo file map/, 'a continuation must NOT re-assemble the artifact');
316
+ });
317
+
318
+ it('--continue rejects a mode token and rejects --facts', () => {
319
+ const sb = makeSandbox();
320
+ const r1 = run(sb, { args: ['--continue', 'code'] });
321
+ const r2 = run(sb, { args: ['--continue', '--facts', 'x'] });
322
+ rmSync(sb.home, { recursive: true, force: true });
323
+ assert.equal(r1.status, 2);
324
+ assert.match(r1.stderr, /takes no positional args/);
325
+ assert.equal(r2.status, 2);
326
+ assert.match(r2.stderr, /--facts is not valid on a continuation/);
327
+ });
328
+
329
+ it('--conversation <id> threads the id through to agy', () => {
330
+ const sb = makeSandbox();
331
+ const r = run(sb, { args: ['--conversation', 'conv-xyz', '--focus', 'f'] });
332
+ rmSync(sb.home, { recursive: true, force: true });
333
+ assert.equal(r.status, 0, r.stderr);
334
+ assert.match(r.argv, /(^|\n)--conversation(\n|$)/);
335
+ assert.match(r.argv, /(^|\n)conv-xyz(\n|$)/);
336
+ });
337
+ });
338
+
339
+ describe('agy-review.sh — delegated guards inherited via agy-run (9, 10)', () => {
340
+ it('hard timeout: a sleeping stub is killed at AGY_HARD_TIMEOUT', () => {
341
+ const sb = makeSandbox();
342
+ const started = Date.now();
343
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '30', AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s' } });
344
+ const elapsed = Date.now() - started;
345
+ rmSync(sb.home, { recursive: true, force: true });
346
+ assert.ok(elapsed < 20000, `must return well under the kill-after window, took ${elapsed}ms`);
347
+ assert.notEqual(r.status, 0);
348
+ assert.match(r.stderr, /exceeded the hard cap/);
349
+ });
350
+
351
+ it('subscription invariant: a stray FOO_API_KEY is unset for the agy subprocess', () => {
352
+ const sb = makeSandbox();
353
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { FOO_API_KEY: 'bar', ANTIGRAVITY_API_KEY: 'baz' } });
354
+ rmSync(sb.home, { recursive: true, force: true });
355
+ assert.equal(r.status, 0, r.stderr);
356
+ assert.match(r.capEnv, /^FOO_API_KEY=<unset>$/m);
357
+ assert.match(r.capEnv, /^ANTIGRAVITY_API_KEY=<unset>$/m);
358
+ });
359
+ });
360
+
361
+ describe('agy-review.sh — mode / arg validation (11)', () => {
362
+ it('unknown mode → usage + exit 2', () => {
363
+ const sb = makeSandbox();
364
+ const r = run(sb, { args: ['bogus'] });
365
+ rmSync(sb.home, { recursive: true, force: true });
366
+ assert.equal(r.status, 2);
367
+ assert.match(r.stderr, /usage:/);
368
+ assert.equal(r.invoked, false);
369
+ });
370
+
371
+ it('plan mode with a missing file → exit 2', () => {
372
+ const sb = makeSandbox();
373
+ const r = run(sb, { args: ['plan', 'nope.md'] });
374
+ rmSync(sb.home, { recursive: true, force: true });
375
+ assert.equal(r.status, 2);
376
+ assert.match(r.stderr, /plan file 'nope\.md' not found/);
377
+ });
378
+
379
+ it('diff mode inlines the supplied file', () => {
380
+ const sb = makeSandbox();
381
+ writeFileSync(join(sb.repo, 'change.diff'), 'DIFF_FILE_BODY_MARKER\n');
382
+ const r = run(sb, { args: ['diff', 'change.diff', '--facts', 'f'] });
383
+ rmSync(sb.home, { recursive: true, force: true });
384
+ assert.equal(r.status, 0, r.stderr);
385
+ assert.match(r.prompt, /The diff under review/);
386
+ assert.match(r.prompt, /DIFF_FILE_BODY_MARKER/);
387
+ });
388
+
389
+ it('rejects a stray -- passthrough (the wrapper owns the posture)', () => {
390
+ const sb = makeSandbox();
391
+ const r = run(sb, { args: ['code', '--', '--add-dir', '.'] });
392
+ rmSync(sb.home, { recursive: true, force: true });
393
+ assert.equal(r.status, 2);
394
+ assert.match(r.stderr, /this wrapper OWNS the review posture/);
395
+ });
396
+
397
+ it('rejects a value-flag that swallows the NEXT flag as its value (--facts --focus x → exit 2)', () => {
398
+ const sb = makeSandbox();
399
+ const r = run(sb, { args: ['code', '--facts', '--focus', 'x'] });
400
+ rmSync(sb.home, { recursive: true, force: true });
401
+ assert.equal(r.status, 2, r.stderr);
402
+ assert.match(r.stderr, /--facts needs a value/);
403
+ assert.equal(r.invoked, false, 'a misplaced flag must not be spent as bogus grounding');
404
+ });
405
+
406
+ it('rejects a value-flag with no value at the end of args (--decided → exit 2)', () => {
407
+ const sb = makeSandbox();
408
+ const r = run(sb, { args: ['code', '--facts', 'f', '--decided'] });
409
+ rmSync(sb.home, { recursive: true, force: true });
410
+ assert.equal(r.status, 2, r.stderr);
411
+ assert.match(r.stderr, /--decided needs a value/);
412
+ });
413
+ });
414
+
415
+ describe('agy-review.sh — no-env run (12)', () => {
416
+ it('a code review with NO AGY_* env vars runs cleanly (no unbound-var abort under set -u)', () => {
417
+ const sb = makeSandbox();
418
+ // run() sets only HOME/PATH + the AGY_FAKE_* capture vars (not AGY_* config) — so this exercises
419
+ // the all-defaults path.
420
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
421
+ rmSync(sb.home, { recursive: true, force: true });
422
+ assert.equal(r.status, 0, r.stderr);
423
+ assert.equal(r.invoked, true);
424
+ });
425
+ });
426
+
427
+ describe('agy-review.sh — subdir invocation is repo-complete (13)', () => {
428
+ it('from a subdir, assembles a repo-complete change set AND reads a relative --facts path', () => {
429
+ const sb = makeSandbox();
430
+ // a change to a ROOT file (sibling of the subdir we invoke from)
431
+ writeFileSync(join(sb.repo, 'root-change.txt'), 'ROOT_SIBLING_CHANGE\n');
432
+ const sub = join(sb.repo, 'deep', 'nested');
433
+ mkdirSync(sub, { recursive: true });
434
+ writeFileSync(join(sub, 'local-facts.md'), 'SUBDIR_RELATIVE_FACT\n');
435
+ const r = run(sb, { args: ['code', '--facts', '@local-facts.md'], cwd: sub });
436
+ rmSync(sb.home, { recursive: true, force: true });
437
+ assert.equal(r.status, 0, r.stderr);
438
+ assert.match(r.prompt, /ROOT_SIBLING_CHANGE/, 'the root/sibling change must appear (repo-complete via cd to toplevel)');
439
+ assert.match(r.prompt, /SUBDIR_RELATIVE_FACT/, 'a relative --facts path resolves against the invocation cwd, before the cd');
440
+ });
441
+ });
@@ -28,6 +28,8 @@
28
28
  # AGY_MODEL="Claude Opus 4.6 (Thinking)" agy-run "..." # pick a model
29
29
  # AGY_TIMEOUT=10m agy-run "..." # override print timeout (agy's soft bound)
30
30
  # AGY_HARD_TIMEOUT=8m agy-run "..." # override the hard wall-clock cap (timeout(1))
31
+ # AGY_MAX_PROMPT_BYTES=60000 agy-run @big.md # LOWER the single-argv byte ceiling (default 120000;
32
+ # # the override only tightens it — it can never exceed the OS ~131072 limit)
31
33
  # agy-run "..." -- --add-dir . --dangerously-skip-permissions
32
34
  # # passthrough agy flags (future flows)
33
35
  set -euo pipefail
@@ -96,6 +98,37 @@ if [[ -z "${prompt// }" ]]; then
96
98
  exit 2
97
99
  fi
98
100
 
101
+ # --- Argv byte-ceiling guard --------------------------------------------------
102
+ # agy takes the prompt as a single `-p` argv. On Linux a single argv element past
103
+ # MAX_ARG_STRLEN (~131072 bytes) makes execve fail with a cryptic "Argument list
104
+ # too long". Guard the prompt THIS wrapper holds — the `-` (stdin) / `@file` paths,
105
+ # and pre-measuring callers like agy-review.sh — with a margin under that ceiling.
106
+ # Scope: a huge LITERAL `agy-run "<huge>"` argv fails at THIS script's own exec
107
+ # before any line here runs, so it can't be caught here — route large prompts via
108
+ # `-` (stdin) or `@file`, which land in $prompt where this guard can measure them.
109
+ AGY_MAX_PROMPT_BYTES="${AGY_MAX_PROMPT_BYTES:-120000}"
110
+ if [[ ! "$AGY_MAX_PROMPT_BYTES" =~ ^[0-9]+$ ]]; then
111
+ echo "error: AGY_MAX_PROMPT_BYTES='$AGY_MAX_PROMPT_BYTES' is not a non-negative integer." >&2
112
+ exit 2
113
+ fi
114
+ # The override may only TIGHTEN the ceiling. Raising it past the OS single-argv limit (MAX_ARG_STRLEN
115
+ # ~131072) would let an oversized prompt through the guard only to fail at `exec` with E2BIG — exactly
116
+ # what the guard exists to prevent. Reject anything above a safe hard maximum just under that limit.
117
+ AGY_ARGV_HARD_MAX=131000
118
+ if (( AGY_MAX_PROMPT_BYTES > AGY_ARGV_HARD_MAX )); then
119
+ echo "error: AGY_MAX_PROMPT_BYTES=${AGY_MAX_PROMPT_BYTES} exceeds the OS single-argv ceiling (~${AGY_ARGV_HARD_MAX})." >&2
120
+ echo " The prompt is passed as ONE -p argv; raising the limit past the OS ceiling only restores the cryptic" >&2
121
+ echo " 'Argument list too long' failure. The override may LOWER the ceiling (stricter), never raise it past this." >&2
122
+ exit 2
123
+ fi
124
+ prompt_bytes=$(( $(printf '%s' "$prompt" | wc -c) )) # arithmetic strips any BSD `wc` padding
125
+ if (( prompt_bytes > AGY_MAX_PROMPT_BYTES )); then
126
+ echo "error: prompt is ${prompt_bytes} bytes, over AGY_MAX_PROMPT_BYTES=${AGY_MAX_PROMPT_BYTES}." >&2
127
+ echo " agy takes the prompt as a single argv; past ~131072 bytes it fails with a cryptic" >&2
128
+ echo " 'Argument list too long'. Trim or split the prompt. (Override via AGY_MAX_PROMPT_BYTES.)" >&2
129
+ exit 2
130
+ fi
131
+
99
132
  model_flag=()
100
133
  if [[ -n "$AGY_MODEL" ]]; then
101
134
  model_flag=(--model "$AGY_MODEL")
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, existsSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -57,3 +57,98 @@ describe('agy.sh — hard wall-clock cap (timeout(1))', () => {
57
57
  assert.doesNotMatch(r.stderr, /exceeded the hard cap/, 'must not mislabel a non-timeout failure');
58
58
  });
59
59
  });
60
+
61
+ // A stub that records (via a SENTINEL file) whether agy was actually invoked, so a
62
+ // "guard fires" test can prove the wrapper exited BEFORE spending a run. When the
63
+ // argv-byte guard trips, the sentinel must be absent.
64
+ const RECORDING_STUB = [
65
+ '#!/usr/bin/env bash',
66
+ 'if [[ -n "${AGY_STUB_SENTINEL:-}" ]]; then printf invoked > "$AGY_STUB_SENTINEL"; fi',
67
+ 'echo "OK reply"',
68
+ 'exit 0',
69
+ '',
70
+ ].join('\n');
71
+
72
+ // Run the wrapper with an explicit argv (so a `@file` / `-` prompt form can be passed)
73
+ // and optional stdin. AGY_MODEL='' drops --model so the stub argv stays clean.
74
+ const runArgs = (home, { args, env = {}, input } = {}) =>
75
+ spawnSync('bash', [WRAPPER, ...args], {
76
+ env: { HOME: home, PATH: `${join(home, '.local', 'bin')}:${process.env.PATH}`, AGY_MODEL: '', ...env },
77
+ encoding: 'utf8',
78
+ timeout: 20000,
79
+ input,
80
+ });
81
+
82
+ describe('agy.sh — argv byte-ceiling guard (AGY_MAX_PROMPT_BYTES)', () => {
83
+ const withSentinel = (home) => join(home, 'sentinel');
84
+
85
+ it('fires for an @file prompt over a lowered cap (exit 2; agy never invoked)', () => {
86
+ const home = makeSandbox(RECORDING_STUB);
87
+ const big = join(home, 'big.md');
88
+ writeFileSync(big, 'x'.repeat(500));
89
+ const sentinel = withSentinel(home);
90
+ const r = runArgs(home, { args: [`@${big}`], env: { AGY_MAX_PROMPT_BYTES: '100', AGY_STUB_SENTINEL: sentinel } });
91
+ const invoked = existsSync(sentinel);
92
+ rmSync(home, { recursive: true, force: true });
93
+ assert.equal(r.status, 2, r.stderr);
94
+ assert.match(r.stderr, /over AGY_MAX_PROMPT_BYTES=100/);
95
+ assert.equal(invoked, false, 'agy must NOT be invoked when the size guard fires');
96
+ });
97
+
98
+ it('fires for a stdin (-) prompt over a lowered cap (exit 2; agy never invoked)', () => {
99
+ const home = makeSandbox(RECORDING_STUB);
100
+ const sentinel = withSentinel(home);
101
+ const r = runArgs(home, { args: ['-'], input: 'y'.repeat(500), env: { AGY_MAX_PROMPT_BYTES: '100', AGY_STUB_SENTINEL: sentinel } });
102
+ const invoked = existsSync(sentinel);
103
+ rmSync(home, { recursive: true, force: true });
104
+ assert.equal(r.status, 2, r.stderr);
105
+ assert.match(r.stderr, /over AGY_MAX_PROMPT_BYTES=100/); // same headline as the @file case
106
+ assert.match(r.stderr, /Argument list too long/); // plus the guidance line
107
+ assert.equal(invoked, false, 'agy must NOT be invoked when the size guard fires');
108
+ });
109
+
110
+ it('passes a normal prompt through unchanged under the default ceiling', () => {
111
+ const home = makeSandbox(RECORDING_STUB);
112
+ const sentinel = withSentinel(home);
113
+ const r = runArgs(home, { args: ['a short prompt'], env: { AGY_STUB_SENTINEL: sentinel } });
114
+ const invoked = existsSync(sentinel);
115
+ rmSync(home, { recursive: true, force: true });
116
+ assert.equal(r.status, 0, r.stderr);
117
+ assert.match(r.stdout, /OK reply/);
118
+ assert.equal(invoked, true, 'a within-ceiling prompt must reach agy');
119
+ });
120
+
121
+ it('an AGY_MAX_PROMPT_BYTES override raises the ceiling so a large prompt passes', () => {
122
+ const home = makeSandbox(RECORDING_STUB);
123
+ const big = join(home, 'big.md');
124
+ writeFileSync(big, 'z'.repeat(500));
125
+ const sentinel = withSentinel(home);
126
+ const r = runArgs(home, { args: [`@${big}`], env: { AGY_MAX_PROMPT_BYTES: '10000', AGY_STUB_SENTINEL: sentinel } });
127
+ const invoked = existsSync(sentinel);
128
+ rmSync(home, { recursive: true, force: true });
129
+ assert.equal(r.status, 0, r.stderr);
130
+ assert.equal(invoked, true, 'the raised ceiling must let the large prompt through');
131
+ });
132
+
133
+ it('rejects a non-integer AGY_MAX_PROMPT_BYTES (exit 2; agy never invoked)', () => {
134
+ const home = makeSandbox(RECORDING_STUB);
135
+ const sentinel = withSentinel(home);
136
+ const r = runArgs(home, { args: ['hi'], env: { AGY_MAX_PROMPT_BYTES: 'abc', AGY_STUB_SENTINEL: sentinel } });
137
+ const invoked = existsSync(sentinel);
138
+ rmSync(home, { recursive: true, force: true });
139
+ assert.equal(r.status, 2, r.stderr);
140
+ assert.match(r.stderr, /not a non-negative integer/);
141
+ assert.equal(invoked, false, 'a malformed ceiling must fail loud before invoking agy');
142
+ });
143
+
144
+ it('rejects an AGY_MAX_PROMPT_BYTES raised above the OS single-argv ceiling (exit 2; agy never invoked)', () => {
145
+ const home = makeSandbox(RECORDING_STUB);
146
+ const sentinel = withSentinel(home);
147
+ const r = runArgs(home, { args: ['hi'], env: { AGY_MAX_PROMPT_BYTES: '200000', AGY_STUB_SENTINEL: sentinel } });
148
+ const invoked = existsSync(sentinel);
149
+ rmSync(home, { recursive: true, force: true });
150
+ assert.equal(r.status, 2, r.stderr);
151
+ assert.match(r.stderr, /exceeds the OS single-argv ceiling/);
152
+ assert.equal(invoked, false, 'raising the ceiling past the OS limit must fail loud, not pass through to E2BIG');
153
+ });
154
+ });
@@ -3,10 +3,10 @@
3
3
  "schema": 1,
4
4
  "name": "antigravity-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "1.0.0",
6
+ "version": "2.0.0",
7
7
  "provides": ["review", "probe"],
8
8
  "roles": {
9
- "review": { "cmd": "agy-run", "source": "bin/agy.sh", "template": "references/review-prompt.md", "output": "advisory" },
9
+ "review": { "cmd": "agy-review", "source": "bin/agy-review.sh", "template": "references/review-prompt.md", "modes": ["code", "plan", "diff"], "output": "advisory" },
10
10
  "probe": { "cmd": "agy-run", "source": "bin/agy.sh", "output": "advisory" }
11
11
  },
12
12
  "detect": {