@sabaiway/agent-workflow-kit 3.0.0 → 3.1.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.
- package/CHANGELOG.md +49 -1
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bin/install.mjs +10 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +76 -18
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +65 -17
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +2 -2
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/changed-surface.mjs +8 -8
- package/tools/commands.mjs +9 -0
- package/tools/grounding.mjs +13 -13
- package/tools/inject-methodology.mjs +71 -40
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/procedures.mjs +1 -1
- package/tools/recipes.mjs +2 -2
- package/tools/recommendations.mjs +34 -7
- package/tools/release-scan.mjs +9 -2
- package/tools/review-state.mjs +3 -3
- package/tools/sandbox-masks.mjs +13 -13
- package/tools/set-recipe.mjs +1 -1
- package/tools/velocity-profile.mjs +7 -7
- package/tools/worktrees.mjs +2292 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 3.1.0 — parallel feature worktrees v1: provision · list · land --prepare · cleanup (AD-060)
|
|
8
|
+
|
|
9
|
+
New `worktrees` mode + `tools/worktrees.mjs` — several features implemented simultaneously in
|
|
10
|
+
DIFFERENT agent sessions on one machine/repo, zero interference on working-tree files (the ONE
|
|
11
|
+
exception: the default `node_modules` symlink is a shared MUTABLE dependency cache — the printed
|
|
12
|
+
isolated install is the isolation lane), unambiguous ownership. One thin dependency-free tool over git: every verification datum is recomputed live
|
|
13
|
+
from git, never read from stored metadata (the ONE exception: the PREPARED OID in the handoff,
|
|
14
|
+
read back only for recovery). Git >= 2.36 floor (NUL-terminated worktree porcelain); typed STOPs,
|
|
15
|
+
never a guess.
|
|
16
|
+
|
|
17
|
+
- **`provision <slug> --plan <path>`** — visible sibling worktree + `aw/<slug>` branch: the
|
|
18
|
+
registry-derived footprint copy-if-missing (a tracked file is NEVER overwritten), exactly one
|
|
19
|
+
seeded feature plan, the `handoff-<slug>.md` stub, shared-`node_modules` symlink advice
|
|
20
|
+
(`--install` only PRINTS the isolated install — zero spawn, zero write). `--resume` completes a
|
|
21
|
+
half-done provision behind fail-closed identity: at most one handoff, slug AND branch must
|
|
22
|
+
match, the section-required `## Provision record` (a duplicated field or section is a STOP,
|
|
23
|
+
never last-wins), user content preserved byte-exact.
|
|
24
|
+
- **`list`** — read-only and honest: any read failure renders `handoff: (unreadable)` — a silent
|
|
25
|
+
"no" never appears.
|
|
26
|
+
- **`land <slug> --prepare`** — the transient common-git-dir lock (shared with cleanup),
|
|
27
|
+
dirty-main / graph-divergence / docs-ai-drift / red-review-state refusals, the satellite
|
|
28
|
+
working-tree diff versus its base — staged AND unstaged inspected, every unstaged or
|
|
29
|
+
untracked-not-ignored leftover listed and refused (ignored content is outside observation by
|
|
30
|
+
design) — binary-safe transfer excluding exactly `docs/ai` + `docs/plans`, the optional
|
|
31
|
+
porcelain-visible sync adapter, the main gate matrix, and an OID report (main HEAD · TRANSFER ·
|
|
32
|
+
PREPARED · sync delta). The commit is NEVER run by the tool — it stays a dialogue ask. A second
|
|
33
|
+
prepare is reset-only against the recorded PREPARED OID; transfer-apply and post-launch sync
|
|
34
|
+
failures attempt a byte-clean rollback (rollback failures are composed without losing the
|
|
35
|
+
primary error), while a red gate matrix that leaves the snapshot unchanged intentionally KEEPS
|
|
36
|
+
the prepared tree and names both recovery lanes.
|
|
37
|
+
- **`cleanup <slug>`** — live landed-verification against main HEAD (exact land-exclusion
|
|
38
|
+
parity), typed-EXACT ownership of ignored content (file/glob roots own only files), literal
|
|
39
|
+
pathspecs, branch `-d`, prune; foreign content stops it. `--abandon` is the ONE destructive arm
|
|
40
|
+
(`-D`; requires the handoff identity).
|
|
41
|
+
- **Hardening:** every content read and regular-file copy goes through two no-follow descriptor
|
|
42
|
+
doors (identity-bound source · exclusive destination · descriptor-mode update), pinned as the
|
|
43
|
+
only paths by tripwire tests; strict parsers everywhere (NUL porcelain fields,
|
|
44
|
+
scan-before-parse JSON with any-depth duplicate-key refusal, atomic section surgery).
|
|
45
|
+
- **release-scan:** the reviewer-round-identity rung — an `agy` or `codex` name followed by an
|
|
46
|
+
R-number reference is refused; shippable finding IDs use neutral
|
|
47
|
+
`review-<scope>-rNN-<severity>-NN` IDs; the clean line now reads "no AI attribution or
|
|
48
|
+
reviewer-round identity found".
|
|
49
|
+
- **recommendations:** the worktrees parent-dir item — write access stays "not confirmed" without
|
|
50
|
+
a trusted host-capability signal; HAND-APPLY lines are host-qualified.
|
|
51
|
+
- **Docs:** the `references/modes/worktrees.md` contract — the MAIN/SATELLITE ownership matrix
|
|
52
|
+
(shared git state included), satellite forbidden verbs (the v1 docs-only bar), the
|
|
53
|
+
host-specific consent lane, and the other-harnesses PROVEN/ASSUMED split.
|
|
54
|
+
|
|
7
55
|
## 3.0.0 — strip-the-kit: the hardened computed core replaces the ledger machinery (AD-059)
|
|
8
56
|
|
|
9
57
|
> ### ⚠ BREAKING — the review loop is now computed, not recorded
|
|
@@ -502,7 +550,7 @@ was blocked on an honest degrade.
|
|
|
502
550
|
|
|
503
551
|
Contract surfaces moved in lockstep (the tool header, `--help`, the human render, the `--await`
|
|
504
552
|
comment, `references/modes/review-state.md`). Council-converged both segments (S1 codex + agy SHIP; S2
|
|
505
|
-
|
|
553
|
+
review round 1 revise → allPresent fold → review round 2 both SHIP), the two-gate agreement dogfooded live + pinned by a
|
|
506
554
|
detector-independent `two-gate-agreement.test.mjs` + a 17-case matrix; full suite 2969 green.
|
|
507
555
|
|
|
508
556
|
## 1.40.0 — Universal verification profile + session-loop economics (a)–(h) (BUGFREE-3, AD-049)
|
package/README.md
CHANGED
|
@@ -243,6 +243,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
243
243
|
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
244
244
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (sandbox-lane only) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
245
245
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
246
|
+
| `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
|
|
246
247
|
| `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
|
|
247
248
|
| `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
|
|
248
249
|
| `/agent-workflow-kit agents` | Claude Code · opt-in | **cheap-lane subagents** — places bundled subagent definitions (`.claude/agents/`) pinned to a **cheap model** (haiku, low effort, read-only tools) for mechanical work: extraction sweeps, changelog fact-skeletons, gate-failure triage. Judgment, review, and real code stay on your main lane — these vehicles only extract and draft, and their output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '3.
|
|
6
|
+
version: '3.1.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -187,6 +187,10 @@ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/commit-guard.md` before
|
|
|
187
187
|
|
|
188
188
|
read-only — read `${CLAUDE_SKILL_DIR}/references/modes/doc-parity.md` before acting.
|
|
189
189
|
|
|
190
|
+
### Mode: worktrees
|
|
191
|
+
|
|
192
|
+
writer — read `${CLAUDE_SKILL_DIR}/references/modes/worktrees.md` before acting.
|
|
193
|
+
|
|
190
194
|
---
|
|
191
195
|
|
|
192
196
|
## References
|
package/bin/install.mjs
CHANGED
|
@@ -211,7 +211,16 @@ const spawnMember = ({ command, args, options }) => spawnSync(command, args, opt
|
|
|
211
211
|
// npm/network blip (rate-limit, registry hiccup, momentary DNS) — an immediate retry tends to hit the
|
|
212
212
|
// same blip, so wait briefly first. Atomics.wait is a dependency-free sync sleep (the install flow is
|
|
213
213
|
// already synchronous here). Injected in tests as a 0ms no-op so the suite never actually sleeps.
|
|
214
|
-
|
|
214
|
+
// AW_INSTALL_RETRY_DELAY_MS is a test seam (the subprocess cascade suite zeroes the wait; the
|
|
215
|
+
// retry POLICY itself stays exercised). Unset, empty, malformed and negative all keep the shipped
|
|
216
|
+
// default — `Number('')` is 0, so the raw value is screened BEFORE coercion (the R1 council bug).
|
|
217
|
+
export const resolveRetryDelayMs = (env) => {
|
|
218
|
+
const raw = env.AW_INSTALL_RETRY_DELAY_MS;
|
|
219
|
+
if (typeof raw !== 'string' || raw.trim() === '') return 1500;
|
|
220
|
+
const parsed = Number(raw);
|
|
221
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1500;
|
|
222
|
+
};
|
|
223
|
+
const RETRY_DELAY_MS = resolveRetryDelayMs(process.env);
|
|
215
224
|
const sleepSync = (ms) => {
|
|
216
225
|
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
217
226
|
};
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// posture.model null). Colocated separately from agy-review.test.mjs — that file is
|
|
6
6
|
// red-proof-frozen; this one carries its own minimal standalone harness (the family idiom).
|
|
7
7
|
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
8
|
+
import { describe, it, after } from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, symlinkSync, cpSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join, dirname, resolve } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
@@ -41,12 +41,17 @@ const makePathWithout = (root, exclude = []) => {
|
|
|
41
41
|
return dir;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// Farm + sandbox base are READ-ONLY per invocation — built ONCE, shared (a per-run farm rebuild
|
|
45
|
+
// plus a per-test `git init`+commit dominate the wall otherwise).
|
|
46
|
+
const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-honesty-shared-'));
|
|
47
|
+
after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
|
|
48
|
+
const FARM = makePathWithout(SHARED_ROOT, ['agy', 'agy-run']);
|
|
49
|
+
|
|
50
|
+
const TEMPLATE_HOME = (() => {
|
|
51
|
+
const home = join(SHARED_ROOT, 'template-home');
|
|
46
52
|
const bin = join(home, '.local', 'bin');
|
|
47
53
|
mkdirSync(bin, { recursive: true });
|
|
48
54
|
writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
|
|
49
|
-
chmodSync(join(bin, 'agy'), 0o755);
|
|
50
55
|
const repo = join(home, 'repo');
|
|
51
56
|
mkdirSync(repo);
|
|
52
57
|
const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
@@ -58,7 +63,15 @@ const makeSandbox = () => {
|
|
|
58
63
|
g('commit', '-qm', 'base');
|
|
59
64
|
writeFileSync(join(repo, 'pending.txt'), 'PENDING\n');
|
|
60
65
|
writeFileSync(join(repo, 'plan.md'), '# a plan artifact\n');
|
|
61
|
-
return
|
|
66
|
+
return home;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
const makeSandbox = () => {
|
|
70
|
+
const home = mkdtempSync(join(tmpdir(), 'agy-honesty-'));
|
|
71
|
+
cpSync(TEMPLATE_HOME, home, { recursive: true });
|
|
72
|
+
const bin = join(home, '.local', 'bin');
|
|
73
|
+
chmodSync(join(bin, 'agy'), 0o755);
|
|
74
|
+
return { home, bin, repo: join(home, 'repo') };
|
|
62
75
|
};
|
|
63
76
|
|
|
64
77
|
const run = (sb, { args, env = {} } = {}) => {
|
|
@@ -69,7 +82,7 @@ const run = (sb, { args, env = {} } = {}) => {
|
|
|
69
82
|
timeout: 30000,
|
|
70
83
|
env: {
|
|
71
84
|
HOME: sb.home,
|
|
72
|
-
PATH: `${sb.bin}:${
|
|
85
|
+
PATH: `${sb.bin}:${FARM}`,
|
|
73
86
|
TMPDIR: process.env.TMPDIR ?? '/tmp',
|
|
74
87
|
AGY_FAKE_SENTINEL: sentinel,
|
|
75
88
|
...env,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import {
|
|
4
4
|
mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readFileSync,
|
|
5
|
-
existsSync, readdirSync, symlinkSync,
|
|
5
|
+
existsSync, readdirSync, symlinkSync, cpSync,
|
|
6
6
|
} from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join, dirname, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { spawnSync, execFile } from 'node:child_process';
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const WRAPPER = join(HERE, 'agy-review.sh');
|
|
@@ -65,15 +65,23 @@ const makePathWithout = (root, exclude = []) => {
|
|
|
65
65
|
return dir;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// The PATH farms and the sandbox base are READ-ONLY per invocation, so both are built ONCE and
|
|
69
|
+
// shared: a per-run farm rebuild (thousands of symlinks) plus a per-test `git init`+commit were
|
|
70
|
+
// the suite's dominant wall cost, not the wrapper under test.
|
|
71
|
+
const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-review-shared-'));
|
|
72
|
+
after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
|
|
73
|
+
const farms = new Map();
|
|
74
|
+
const farmFor = (exclude) => {
|
|
75
|
+
const key = exclude.join('|');
|
|
76
|
+
if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
|
|
77
|
+
return farms.get(key);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const TEMPLATE_HOME = (() => {
|
|
81
|
+
const home = join(SHARED_ROOT, 'template-home');
|
|
72
82
|
const bin = join(home, '.local', 'bin');
|
|
73
83
|
mkdirSync(bin, { recursive: true });
|
|
74
|
-
|
|
75
|
-
writeFileSync(stub, FAKE_AGY, { mode: 0o755 });
|
|
76
|
-
chmodSync(stub, 0o755);
|
|
84
|
+
writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
|
|
77
85
|
const repo = join(home, 'repo');
|
|
78
86
|
mkdirSync(repo);
|
|
79
87
|
const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
@@ -83,13 +91,25 @@ const makeSandbox = ({ clean = false } = {}) => {
|
|
|
83
91
|
writeFileSync(join(repo, 'base.txt'), 'committed base\n');
|
|
84
92
|
g('add', '-A');
|
|
85
93
|
g('commit', '-qm', 'base');
|
|
94
|
+
return home;
|
|
95
|
+
})();
|
|
96
|
+
|
|
97
|
+
// `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
|
|
98
|
+
// untracked file so `code` mode has a diff to review.
|
|
99
|
+
const makeSandbox = ({ clean = false } = {}) => {
|
|
100
|
+
const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
|
|
101
|
+
cpSync(TEMPLATE_HOME, home, { recursive: true });
|
|
102
|
+
const bin = join(home, '.local', 'bin');
|
|
103
|
+
chmodSync(join(bin, 'agy'), 0o755);
|
|
104
|
+
const repo = join(home, 'repo');
|
|
105
|
+
const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
86
106
|
if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
|
|
87
107
|
return { home, bin, repo, g };
|
|
88
108
|
};
|
|
89
109
|
|
|
90
110
|
const run = (sb, { args, env = {}, cwd } = {}) => {
|
|
91
111
|
const { home, bin, repo } = sb;
|
|
92
|
-
const farm =
|
|
112
|
+
const farm = farmFor(['agy', 'agy-run']);
|
|
93
113
|
const cap = {
|
|
94
114
|
argv: join(home, 'cap-argv'), env: join(home, 'cap-env'), prompt: join(home, 'cap-prompt'),
|
|
95
115
|
sentinel: join(home, 'cap-sentinel'), adddir: join(home, 'cap-adddir'),
|
|
@@ -122,6 +142,44 @@ const run = (sb, { args, env = {}, cwd } = {}) => {
|
|
|
122
142
|
};
|
|
123
143
|
};
|
|
124
144
|
|
|
145
|
+
// Async twin of run() for the two sleep-bound timeout tests: spawnSync blocks the event loop
|
|
146
|
+
// for the whole deliberate wait, so a concurrent describe could not overlap them. Same spawn
|
|
147
|
+
// contract and captures.
|
|
148
|
+
const runAsync = (sb, { args, env = {}, cwd } = {}) =>
|
|
149
|
+
new Promise((done) => {
|
|
150
|
+
const { home, bin, repo } = sb;
|
|
151
|
+
const cap = {
|
|
152
|
+
argv: join(home, 'cap-argv'), env: join(home, 'cap-env'), prompt: join(home, 'cap-prompt'),
|
|
153
|
+
sentinel: join(home, 'cap-sentinel'), adddir: join(home, 'cap-adddir'),
|
|
154
|
+
adddirMode: join(home, 'cap-adddir-mode'), artifactMode: join(home, 'cap-artifact-mode'),
|
|
155
|
+
artifactCopy: join(home, 'cap-artifact-copy'),
|
|
156
|
+
};
|
|
157
|
+
const child = execFile('bash', [WRAPPER, ...args], {
|
|
158
|
+
cwd: cwd || repo,
|
|
159
|
+
encoding: 'utf8',
|
|
160
|
+
timeout: 30000,
|
|
161
|
+
env: {
|
|
162
|
+
HOME: home,
|
|
163
|
+
PATH: `${bin}:${farmFor(['agy', 'agy-run'])}`,
|
|
164
|
+
TMPDIR: process.env.TMPDIR ?? '/tmp',
|
|
165
|
+
AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
|
|
166
|
+
AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
|
|
167
|
+
AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
|
|
168
|
+
...env,
|
|
169
|
+
},
|
|
170
|
+
}, (error, stdout, stderr) => {
|
|
171
|
+
const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
|
|
172
|
+
done({
|
|
173
|
+
status: error ? (error.code ?? 1) : 0, stdout, stderr,
|
|
174
|
+
invoked: existsSync(cap.sentinel),
|
|
175
|
+
argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
|
|
176
|
+
adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
|
|
177
|
+
artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
child.stdin.end();
|
|
181
|
+
});
|
|
182
|
+
|
|
125
183
|
describe('agy-review.sh — model policy advisory (1)', () => {
|
|
126
184
|
it('warns for a non-frontier model but still runs', () => {
|
|
127
185
|
const sb = makeSandbox();
|
|
@@ -424,11 +482,11 @@ describe('agy-review.sh — resume / round-2 delta (7)', () => {
|
|
|
424
482
|
});
|
|
425
483
|
});
|
|
426
484
|
|
|
427
|
-
describe('agy-review.sh — delegated guards inherited via agy-run (9, 10)', () => {
|
|
428
|
-
it('hard timeout: a sleeping stub is killed at AGY_HARD_TIMEOUT', () => {
|
|
485
|
+
describe('agy-review.sh — delegated guards inherited via agy-run (9, 10)', { concurrency: true }, () => {
|
|
486
|
+
it('hard timeout: a sleeping stub is killed at AGY_HARD_TIMEOUT', async () => {
|
|
429
487
|
const sb = makeSandbox();
|
|
430
488
|
const started = Date.now();
|
|
431
|
-
const r =
|
|
489
|
+
const r = await runAsync(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '30', AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s' } });
|
|
432
490
|
const elapsed = Date.now() - started;
|
|
433
491
|
rmSync(sb.home, { recursive: true, force: true });
|
|
434
492
|
assert.ok(elapsed < 20000, `must return well under the kill-after window, took ${elapsed}ms`);
|
|
@@ -552,7 +610,7 @@ const runHelp = (arg) => {
|
|
|
552
610
|
const root = mkdtempSync(join(tmpdir(), 'agy-review-help-'));
|
|
553
611
|
const nongit = join(root, 'nongit');
|
|
554
612
|
mkdirSync(nongit, { recursive: true });
|
|
555
|
-
const path =
|
|
613
|
+
const path = farmFor(['codex', 'agy', 'git']);
|
|
556
614
|
const r = spawnSync('bash', [WRAPPER, arg], {
|
|
557
615
|
cwd: nongit, encoding: 'utf8', timeout: 15000, env: { HOME: root, PATH: path },
|
|
558
616
|
});
|
|
@@ -1078,7 +1136,7 @@ const writeSettings = (sb, text) => {
|
|
|
1078
1136
|
};
|
|
1079
1137
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
1080
1138
|
|
|
1081
|
-
describe('agy-review.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
1139
|
+
describe('agy-review.sh — bridge settings file (bridges 2.3.0)', { concurrency: true }, () => {
|
|
1082
1140
|
it('a file-set AGY_REVIEW_ALLOW_ADDDIR=1 arms the oversized --add-dir escape', () => {
|
|
1083
1141
|
const sb = makeSandbox();
|
|
1084
1142
|
writeFileSync(join(sb.repo, 'unique.txt'), `OVERSIZE_UNIQUE_MARKER\n${'x'.repeat(8000)}\n`);
|
|
@@ -1123,10 +1181,10 @@ describe('agy-review.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
1123
1181
|
assert.equal(r.invoked, false);
|
|
1124
1182
|
});
|
|
1125
1183
|
|
|
1126
|
-
it('a file-set AGY_HARD_TIMEOUT flows through the agy-run delegation (killed at the file cap)', () => {
|
|
1184
|
+
it('a file-set AGY_HARD_TIMEOUT flows through the agy-run delegation (killed at the file cap)', async () => {
|
|
1127
1185
|
const sb = makeSandbox();
|
|
1128
1186
|
writeSettings(sb, 'AGY_HARD_TIMEOUT=2s\n');
|
|
1129
|
-
const r =
|
|
1187
|
+
const r = await runAsync(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '5' } });
|
|
1130
1188
|
rmSync(sb.home, { recursive: true, force: true });
|
|
1131
1189
|
assert.notEqual(r.status, 0, 'the file cap must apply end-to-end (reader → agy-run → timeout)');
|
|
1132
1190
|
assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, existsSync, readdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, dirname, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { spawnSync, execFile } from 'node:child_process';
|
|
8
8
|
|
|
9
9
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const WRAPPER = join(HERE, 'agy.sh');
|
|
@@ -31,11 +31,23 @@ const runWrapper = (home, env, prompt = 'hello') =>
|
|
|
31
31
|
timeout: 20000,
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Async twin of runWrapper for the sleep-bound suites below: spawnSync BLOCKS the event loop
|
|
35
|
+
// and would serialize the deliberate timeout waits; the async form lets a concurrent describe
|
|
36
|
+
// overlap them (the waiting children are idle, not CPU-bound). Same spawn contract.
|
|
37
|
+
const runWrapperAsync = (home, env, prompt = 'hello') =>
|
|
38
|
+
new Promise((done) => {
|
|
39
|
+
execFile('bash', [WRAPPER, prompt], {
|
|
40
|
+
env: { HOME: home, PATH: `${join(home, '.local', 'bin')}:${process.env.PATH}`, TMPDIR: process.env.TMPDIR ?? '/tmp', ...env },
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
timeout: 20000,
|
|
43
|
+
}, (error, stdout, stderr) => done({ status: error ? (error.code ?? 1) : 0, stdout, stderr }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('agy.sh — hard wall-clock cap (timeout(1))', { concurrency: true }, () => {
|
|
47
|
+
it('kills a hung agy at AGY_HARD_TIMEOUT and reports it (non-zero + actionable guidance)', async () => {
|
|
36
48
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 30\n');
|
|
37
49
|
const started = Date.now();
|
|
38
|
-
const r =
|
|
50
|
+
const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
|
|
39
51
|
const elapsed = Date.now() - started;
|
|
40
52
|
rmSync(home, { recursive: true, force: true });
|
|
41
53
|
assert.ok(elapsed < 13000, `wrapper must return well under the kill-after window, took ${elapsed}ms`);
|
|
@@ -43,17 +55,17 @@ describe('agy.sh — hard wall-clock cap (timeout(1))', () => {
|
|
|
43
55
|
assert.match(r.stderr, /exceeded the hard cap/, 'must explain the hard-cap kill');
|
|
44
56
|
});
|
|
45
57
|
|
|
46
|
-
it('passes a fast agy run through unchanged (exit 0, stdout preserved)', () => {
|
|
58
|
+
it('passes a fast agy run through unchanged (exit 0, stdout preserved)', async () => {
|
|
47
59
|
const home = makeSandbox('#!/usr/bin/env bash\necho "OK reply"\nexit 0\n');
|
|
48
|
-
const r =
|
|
60
|
+
const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
|
|
49
61
|
rmSync(home, { recursive: true, force: true });
|
|
50
62
|
assert.equal(r.status, 0, `expected clean exit, got ${r.status}; stderr=${r.stderr}`);
|
|
51
63
|
assert.match(r.stdout, /OK reply/);
|
|
52
64
|
});
|
|
53
65
|
|
|
54
|
-
it('propagates a non-timeout agy failure code verbatim (no false hard-cap message)', () => {
|
|
66
|
+
it('propagates a non-timeout agy failure code verbatim (no false hard-cap message)', async () => {
|
|
55
67
|
const home = makeSandbox('#!/usr/bin/env bash\necho "boom" >&2\nexit 3\n');
|
|
56
|
-
const r =
|
|
68
|
+
const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
|
|
57
69
|
rmSync(home, { recursive: true, force: true });
|
|
58
70
|
assert.equal(r.status, 3, 'a genuine agy failure code must pass through');
|
|
59
71
|
assert.doesNotMatch(r.stderr, /exceeded the hard cap/, 'must not mislabel a non-timeout failure');
|
|
@@ -178,6 +190,12 @@ const makePathWithout = (root, exclude = []) => {
|
|
|
178
190
|
return dir;
|
|
179
191
|
};
|
|
180
192
|
|
|
193
|
+
// The farm is READ-ONLY per invocation — built ONCE and shared (a per-call rebuild is thousands
|
|
194
|
+
// of symlinks).
|
|
195
|
+
const FARM_ROOT = mkdtempSync(join(tmpdir(), 'agy-farm-'));
|
|
196
|
+
after(() => rmSync(FARM_ROOT, { recursive: true, force: true }));
|
|
197
|
+
const FARM = makePathWithout(FARM_ROOT, ['agy', 'git']);
|
|
198
|
+
|
|
181
199
|
describe('agy.sh — --help (pre-preflight, candidate C)', () => {
|
|
182
200
|
it('--help and -h exit 0 with NO agy on PATH and name the documented usage', () => {
|
|
183
201
|
for (const arg of ['--help', '-h']) {
|
|
@@ -185,7 +203,7 @@ describe('agy.sh — --help (pre-preflight, candidate C)', () => {
|
|
|
185
203
|
// the help must not need the CLI even when the host has a real agy installed.
|
|
186
204
|
const home = mkdtempSync(join(tmpdir(), 'agy-help-'));
|
|
187
205
|
const r = spawnSync('bash', [WRAPPER, arg], {
|
|
188
|
-
env: { HOME: home, PATH:
|
|
206
|
+
env: { HOME: home, PATH: FARM },
|
|
189
207
|
encoding: 'utf8',
|
|
190
208
|
timeout: 15000,
|
|
191
209
|
});
|
|
@@ -226,77 +244,77 @@ const writeSettings = (home, text) => {
|
|
|
226
244
|
};
|
|
227
245
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
228
246
|
|
|
229
|
-
describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
230
|
-
it('a file-set AGY_HARD_TIMEOUT is effective (killed at the file cap)', () => {
|
|
247
|
+
describe('agy.sh — bridge settings file (bridges 2.3.0)', { concurrency: true }, () => {
|
|
248
|
+
it('a file-set AGY_HARD_TIMEOUT is effective (killed at the file cap)', async () => {
|
|
231
249
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
|
|
232
250
|
writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
|
|
233
|
-
const r =
|
|
251
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
234
252
|
rmSync(home, { recursive: true, force: true });
|
|
235
253
|
assert.notEqual(r.status, 0, 'the file cap must apply when the env is unset');
|
|
236
254
|
assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
|
|
237
255
|
});
|
|
238
256
|
|
|
239
|
-
it('env overrides file: env=2s file=10m → killed at the env cap', () => {
|
|
257
|
+
it('env overrides file: env=2s file=10m → killed at the env cap', async () => {
|
|
240
258
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
|
|
241
259
|
writeSettings(home, 'AGY_HARD_TIMEOUT=10m\n');
|
|
242
|
-
const r =
|
|
260
|
+
const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
|
|
243
261
|
rmSync(home, { recursive: true, force: true });
|
|
244
262
|
assert.notEqual(r.status, 0, 'the env cap (2s) must win over the file cap (10m)');
|
|
245
263
|
assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
|
|
246
264
|
});
|
|
247
265
|
|
|
248
|
-
it('an EXPLICITLY EMPTY env (AGY_HARD_TIMEOUT=) disables the file knob for one run', () => {
|
|
266
|
+
it('an EXPLICITLY EMPTY env (AGY_HARD_TIMEOUT=) disables the file knob for one run', async () => {
|
|
249
267
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
|
|
250
268
|
writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
|
|
251
|
-
const r =
|
|
269
|
+
const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '', AGY_MODEL: '' });
|
|
252
270
|
rmSync(home, { recursive: true, force: true });
|
|
253
271
|
assert.equal(r.status, 0, `built-in default must apply (not the 2s file cap): ${r.stderr}`);
|
|
254
272
|
assert.match(r.stdout, /OK reply/);
|
|
255
273
|
});
|
|
256
274
|
|
|
257
|
-
it('duplicate key → the LAST occurrence wins (10m then 2s → killed at 2s)', () => {
|
|
275
|
+
it('duplicate key → the LAST occurrence wins (10m then 2s → killed at 2s)', async () => {
|
|
258
276
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
|
|
259
277
|
writeSettings(home, 'AGY_HARD_TIMEOUT=10m\nAGY_HARD_TIMEOUT=2s\n');
|
|
260
|
-
const r =
|
|
278
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
261
279
|
rmSync(home, { recursive: true, force: true });
|
|
262
280
|
assert.notEqual(r.status, 0);
|
|
263
281
|
assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
|
|
264
282
|
});
|
|
265
283
|
|
|
266
|
-
it('an invalid duration warns and falls back to the built-in default', () => {
|
|
284
|
+
it('an invalid duration warns and falls back to the built-in default', async () => {
|
|
267
285
|
const home = makeSandbox(RECORDING_STUB);
|
|
268
286
|
writeSettings(home, 'AGY_HARD_TIMEOUT=abc\n');
|
|
269
|
-
const r =
|
|
287
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
270
288
|
rmSync(home, { recursive: true, force: true });
|
|
271
289
|
assert.equal(r.status, 0, r.stderr);
|
|
272
290
|
assert.match(r.stderr, /invalid value 'abc'/);
|
|
273
291
|
assert.match(r.stdout, /OK reply/);
|
|
274
292
|
});
|
|
275
293
|
|
|
276
|
-
it('a bare-integer duration (no unit suffix) is invalid → warn + built-in default', () => {
|
|
294
|
+
it('a bare-integer duration (no unit suffix) is invalid → warn + built-in default', async () => {
|
|
277
295
|
const home = makeSandbox(RECORDING_STUB);
|
|
278
296
|
writeSettings(home, 'AGY_HARD_TIMEOUT=90\n');
|
|
279
|
-
const r =
|
|
297
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
280
298
|
rmSync(home, { recursive: true, force: true });
|
|
281
299
|
assert.equal(r.status, 0, r.stderr);
|
|
282
300
|
assert.match(r.stderr, /invalid value '90'/, 'duration values require a unit suffix (5m/30m/90s)');
|
|
283
301
|
assert.match(r.stdout, /OK reply/);
|
|
284
302
|
});
|
|
285
303
|
|
|
286
|
-
it('a ZERO duration is invalid — timeout 0 would silently DISABLE the hard cap', () => {
|
|
304
|
+
it('a ZERO duration is invalid — timeout 0 would silently DISABLE the hard cap', async () => {
|
|
287
305
|
const home = makeSandbox(RECORDING_STUB);
|
|
288
306
|
writeSettings(home, 'AGY_HARD_TIMEOUT=0s\n');
|
|
289
|
-
const r =
|
|
307
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
290
308
|
rmSync(home, { recursive: true, force: true });
|
|
291
309
|
assert.equal(r.status, 0, r.stderr);
|
|
292
310
|
assert.match(r.stderr, /invalid value '0s'/, 'a persistent settings line must never remove the stall guard');
|
|
293
311
|
assert.match(r.stdout, /OK reply/);
|
|
294
312
|
});
|
|
295
313
|
|
|
296
|
-
it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', () => {
|
|
314
|
+
it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', async () => {
|
|
297
315
|
const home = makeSandbox(RECORDING_STUB);
|
|
298
316
|
mkdirSync(join(home, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
|
|
299
|
-
const r =
|
|
317
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
300
318
|
rmSync(home, { recursive: true, force: true });
|
|
301
319
|
assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
|
|
302
320
|
assert.match(r.stderr, /unreadable or not a regular file/);
|
|
@@ -304,20 +322,20 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
304
322
|
assert.match(r.stdout, /OK reply/);
|
|
305
323
|
});
|
|
306
324
|
|
|
307
|
-
it("another bridge's valid key is skipped silently (and never applied)", () => {
|
|
325
|
+
it("another bridge's valid key is skipped silently (and never applied)", async () => {
|
|
308
326
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
|
|
309
327
|
writeSettings(home, 'CODEX_SERVICE_TIER=priority\nCODEX_HARD_TIMEOUT=2\n');
|
|
310
|
-
const r =
|
|
328
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
311
329
|
rmSync(home, { recursive: true, force: true });
|
|
312
330
|
assert.equal(r.status, 0, `a codex key must not cap an agy run: ${r.stderr}`);
|
|
313
331
|
assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
|
|
314
332
|
assert.match(r.stdout, /OK reply/);
|
|
315
333
|
});
|
|
316
334
|
|
|
317
|
-
it('a truly unknown key warns ONCE naming the file; the run is unaffected', () => {
|
|
335
|
+
it('a truly unknown key warns ONCE naming the file; the run is unaffected', async () => {
|
|
318
336
|
const home = makeSandbox(RECORDING_STUB);
|
|
319
337
|
writeSettings(home, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
|
|
320
|
-
const r =
|
|
338
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
321
339
|
rmSync(home, { recursive: true, force: true });
|
|
322
340
|
assert.equal(r.status, 0, r.stderr);
|
|
323
341
|
const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
|
|
@@ -326,10 +344,10 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
326
344
|
assert.match(r.stdout, /OK reply/);
|
|
327
345
|
});
|
|
328
346
|
|
|
329
|
-
it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
|
|
347
|
+
it('malformed lines warn and are ignored; comments and blank lines are silent', async () => {
|
|
330
348
|
const home = makeSandbox(RECORDING_STUB);
|
|
331
349
|
writeSettings(home, '# a comment\n\nNOT A KEY VALUE LINE\n');
|
|
332
|
-
const r =
|
|
350
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
333
351
|
rmSync(home, { recursive: true, force: true });
|
|
334
352
|
assert.equal(r.status, 0, r.stderr);
|
|
335
353
|
const malformed = r.stderr.match(/malformed line/g) ?? [];
|
|
@@ -337,22 +355,22 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
337
355
|
assert.match(r.stdout, /OK reply/);
|
|
338
356
|
});
|
|
339
357
|
|
|
340
|
-
it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
|
|
358
|
+
it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, async () => {
|
|
341
359
|
const home = makeSandbox(RECORDING_STUB);
|
|
342
360
|
const file = writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
|
|
343
361
|
chmodSync(file, 0o000);
|
|
344
|
-
const r =
|
|
362
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
345
363
|
rmSync(home, { recursive: true, force: true });
|
|
346
364
|
assert.equal(r.status, 0, r.stderr);
|
|
347
365
|
assert.match(r.stderr, /unreadable/);
|
|
348
366
|
assert.match(r.stdout, /OK reply/);
|
|
349
367
|
});
|
|
350
368
|
|
|
351
|
-
it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
|
|
369
|
+
it('a settings line can NEVER execute code (command-substitution payload inert)', async () => {
|
|
352
370
|
const home = makeSandbox(RECORDING_STUB);
|
|
353
371
|
const pwned = join(home, 'pwned');
|
|
354
372
|
writeSettings(home, `AGY_HARD_TIMEOUT=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned}2\`\n`);
|
|
355
|
-
const r =
|
|
373
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
356
374
|
const executed = existsSync(pwned) || existsSync(`${pwned}2`);
|
|
357
375
|
rmSync(home, { recursive: true, force: true });
|
|
358
376
|
assert.equal(r.status, 0, r.stderr);
|
|
@@ -360,9 +378,9 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
360
378
|
assert.match(r.stdout, /OK reply/);
|
|
361
379
|
});
|
|
362
380
|
|
|
363
|
-
it('no file → byte-identical behaviour to today (no settings chatter)', () => {
|
|
381
|
+
it('no file → byte-identical behaviour to today (no settings chatter)', async () => {
|
|
364
382
|
const home = makeSandbox(RECORDING_STUB);
|
|
365
|
-
const r =
|
|
383
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
366
384
|
rmSync(home, { recursive: true, force: true });
|
|
367
385
|
assert.equal(r.status, 0, r.stderr);
|
|
368
386
|
assert.doesNotMatch(r.stderr, /bridge settings/);
|
|
@@ -398,7 +416,7 @@ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () =>
|
|
|
398
416
|
const runHelpText = () => {
|
|
399
417
|
const home = mkdtempSync(join(tmpdir(), 'agy-settings-help-'));
|
|
400
418
|
const r = spawnSync('bash', [WRAPPER, '--help'], {
|
|
401
|
-
env: { HOME: home, PATH:
|
|
419
|
+
env: { HOME: home, PATH: FARM },
|
|
402
420
|
encoding: 'utf8',
|
|
403
421
|
timeout: 15000,
|
|
404
422
|
});
|
|
@@ -475,7 +493,7 @@ describe('agy.sh — mode catalog ⟷ wrapper reality (the contract-free raw mod
|
|
|
475
493
|
const helpText = () => {
|
|
476
494
|
const home = mkdtempSync(join(tmpdir(), 'agy-catalog-help-'));
|
|
477
495
|
const r = spawnSync('bash', [WRAPPER, '--help'], {
|
|
478
|
-
env: { HOME: home, PATH:
|
|
496
|
+
env: { HOME: home, PATH: FARM },
|
|
479
497
|
encoding: 'utf8',
|
|
480
498
|
timeout: 15000,
|
|
481
499
|
});
|