petbox-wire 0.1.0-ci.1206

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.
Files changed (50) hide show
  1. package/README.md +119 -0
  2. package/bin/petbox-wire.js +30 -0
  3. package/package.json +44 -0
  4. package/src/agent-def-fetch.test.ts +317 -0
  5. package/src/agent-def-fetch.ts +409 -0
  6. package/src/agent-definition.ts +210 -0
  7. package/src/append-meta.test.ts +93 -0
  8. package/src/append.ts +172 -0
  9. package/src/apply-artifacts.ts +337 -0
  10. package/src/apply-root.test.ts +148 -0
  11. package/src/apply-root.ts +68 -0
  12. package/src/apply-write.test.ts +169 -0
  13. package/src/apply-write.ts +84 -0
  14. package/src/canon.ts +140 -0
  15. package/src/doctor-definition.test.ts +128 -0
  16. package/src/droid-pull-memory.ts +126 -0
  17. package/src/droid-push-session.ts +100 -0
  18. package/src/droid-transcript.ts +47 -0
  19. package/src/harness-capabilities.ts +126 -0
  20. package/src/harness-models.ts +158 -0
  21. package/src/hook-drain.ts +42 -0
  22. package/src/hook-prune.test.ts +165 -0
  23. package/src/hook-prune.ts +83 -0
  24. package/src/import-sessions.ts +261 -0
  25. package/src/opencode-plugin.ts +127 -0
  26. package/src/origin-marker.ts +37 -0
  27. package/src/posix-env.test.ts +99 -0
  28. package/src/posix-env.ts +61 -0
  29. package/src/protocol.test.ts +236 -0
  30. package/src/protocol.ts +136 -0
  31. package/src/pull-memory.test.ts +168 -0
  32. package/src/pull-memory.ts +119 -0
  33. package/src/push-session.ts +99 -0
  34. package/src/registry.ts +120 -0
  35. package/src/roles.test.ts +255 -0
  36. package/src/roles.ts +241 -0
  37. package/src/self-smoke.test.ts +103 -0
  38. package/src/self-smoke.ts +96 -0
  39. package/src/telemetry-settings.test.ts +65 -0
  40. package/src/telemetry-settings.ts +55 -0
  41. package/src/templates/SKILL.md +45 -0
  42. package/src/templates/agent-factory/SKILL.md +56 -0
  43. package/src/transcript.ts +86 -0
  44. package/src/truthfulness.test.ts +544 -0
  45. package/src/truthfulness.ts +164 -0
  46. package/src/wire-exit.test.ts +43 -0
  47. package/src/wire-exit.ts +25 -0
  48. package/src/wire-identity.test.ts +65 -0
  49. package/src/wire-identity.ts +64 -0
  50. package/src/wire.ts +1384 -0
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # petbox-wire
2
+
3
+ Wire any project to [PetBox](https://petbox.3po.su) for **Claude Code**, **opencode** and
4
+ **Factory Droid** — one command, no repo clone:
5
+
6
+ ```bash
7
+ npx petbox-wire <dir> <project> --key <KEY>
8
+ ```
9
+
10
+ Full documentation: <https://petbox.3po.su/doc/wire>.
11
+
12
+ ## Requirements
13
+
14
+ **Node >= 23.6** — the kit is plain TypeScript run through Node's native type-stripping
15
+ (no build, zero runtime dependencies). Older Node exits with a clear version error.
16
+
17
+ ## Commands
18
+
19
+ | Command | What it does |
20
+ | --- | --- |
21
+ | `petbox-wire <dir> <project>` | Full wire: validate the key → persist it → copy the kit → register the directory → write per-project MCP configs and skills → install the global hooks → self-smoke. Idempotent. |
22
+ | `petbox-wire update` | Refresh only the stable kit under `~/.petbox/wire/` (exact mirror + orphan cleanup, content hash before → after). No keys, no registry, no hook reinstall, no MCP/skills, no sticky-flag reset. Does **not** compile agent files. |
23
+ | `petbox-wire apply [--definition <key>] [--offline]` | Compile the per-harness agent role files from the portable agent definition + your local role→model binding. |
24
+ | `petbox-wire doctor` | Offline gate: check the default agent definition against every known harness; print OK or each violation. |
25
+ | `petbox-wire roles` | Print the active profile and its role→model bindings (`~/.petbox/roles.json`). Offline; an empty store exits 0 — no model is ever invented. |
26
+ | `petbox-wire roles export` | Write a bootstrap copy of `roles.json` to stdout (no secrets). Pipe it to a file on a new machine. |
27
+ | `petbox-wire profile use <name>` | Set `activeProfile` in `~/.petbox/roles.json`. Compiles nothing — re-run `apply`. |
28
+
29
+ `update`, `apply`, `doctor`, `roles` and `profile` take no `<dir> <project>`.
30
+
31
+ ## Flags (full wire)
32
+
33
+ | Flag | Effect |
34
+ | --- | --- |
35
+ | `--key <KEY>` | The project's API key. Mint it on the project's **Connect agent** page in the UI — key minting is out of scope for this tool. Omitted → taken from the env var, then from `~/.petbox/keys.json`. |
36
+ | `--env <VAR>` | Override the env-var name holding the key. |
37
+ | `--workspace <WS>` | Workspace stamped into the generated `SKILL.md`. Omitted → the workspace the server reports for your key; if neither is available the wire stops with a usage error (exit 2). |
38
+ | `--cleanup-legacy` | Remove a project's old per-project hook/plugin copies. |
39
+ | `--telemetry` | Wire Claude Code OTLP export into the project (off by default; Claude Code only). |
40
+ | `--telemetry-log <name>` | Target named log for telemetry (default `cc-telemetry`; created if missing). |
41
+ | `--help`, `-h` | Usage banner, exit 0. |
42
+
43
+ The API key is validated against the server **before** anything is persisted, so a bad key never
44
+ lands in your stores.
45
+
46
+ ## The env-var name
47
+
48
+ The key is held in `PETBOX_<PROJECT>_API_KEY` — the project key upper-cased, runs of
49
+ non-alphanumeric characters collapsed to `_`, leading/trailing `_` trimmed, `PETBOX_` prefixed
50
+ (`kpvotes` → `PETBOX_KPVOTES_API_KEY`). Same name the UI's Connect page shows you. `--env` overrides
51
+ it, and a re-run reuses whatever name is already recorded for that directory — so a machine wired
52
+ before this scheme keeps its older variable. `~/.petbox/keys.json` is the ground truth for the name
53
+ your machine actually has.
54
+
55
+ ## What it installs
56
+
57
+ - **Global hooks** for Claude Code (`~/.claude/settings.json`) and Factory Droid
58
+ (`~/.factory/settings.json`): a `Stop` hook that mirrors each session into PetBox and a
59
+ `SessionStart` hook that injects the memory protocol + canon — the only context the wiring
60
+ injects; there is no per-prompt injection. Merged, never clobbered.
61
+ - **A global opencode plugin** (`~/.config/opencode/plugins/petbox.ts`) with the same two behaviors.
62
+ - **Per-project config** in `<dir>`: `.mcp.json` (Claude Code MCP), `.opencode/opencode.json`
63
+ (opencode MCP), `.factory/mcp.json` (Droid MCP — **merged**, so team servers survive), the rendered
64
+ `SKILL.md` under `.claude/skills/petbox/` and `.factory/skills/petbox/`, and the on-demand
65
+ **agent-factory** skill under `.claude/skills/petbox-agent-factory/` and
66
+ `.factory/skills/petbox-agent-factory/` (`roles` / `profile` / `doctor` / `apply`).
67
+ - **Optional**, per flag: the Claude Code OTLP export env (`--telemetry`).
68
+
69
+ All MCP configs reference the key as `${VAR}` / `{env:VAR}` — the key itself is never written into a
70
+ project file.
71
+
72
+ ## `apply` — generated agent files
73
+
74
+ `apply` resolves the project root by the longest registry prefix covering cwd, fetches the portable
75
+ agent definition (`GET /api/{project}/agent-defs/{key}`, scope **`agents:read`** — a read-only key is
76
+ enough), and compiles one file per role:
77
+
78
+ | Harness | Path |
79
+ | --- | --- |
80
+ | Claude Code | `.claude/agents/<role>.md` |
81
+ | opencode | `.opencode/agent/<role>.md` |
82
+ | Factory Droid | `.factory/droids/<name>.md` |
83
+
84
+ These are **overwritten** — don't hand-edit them. Models come from your local `~/.petbox/roles.json`
85
+ binding only; an unbound role gets no `model:` line (droid: `model: inherit`). A role that requires a
86
+ capability the target harness does not declare is **skipped and reported**, never silently written
87
+ without it.
88
+
89
+ Every successful definition fetch is cached to `~/.petbox/cache/<project>.agent-def.json` (LKG). When
90
+ the server is unreachable — or you pass `--offline` — `apply` uses that cache and marks the result
91
+ stale; only with no cache at all does it fall back to the built-in default definition.
92
+
93
+ ## Exit codes
94
+
95
+ | Code | Meaning |
96
+ | --- | --- |
97
+ | `0` | Success. |
98
+ | `1` | Hard failure — invalid definition, unexpected throw. |
99
+ | `2` | Usage / bad arguments. |
100
+ | `3` | Truthfulness policy block — some roles/harnesses were refused. **A partial write is possible.** |
101
+
102
+ Only `apply` and `doctor` use the full taxonomy. The **full wire** exits `2` for usage errors and `1`
103
+ for *any* other failure — do not script against `3` there.
104
+
105
+ ## Where things live
106
+
107
+ | Path | Contents |
108
+ | --- | --- |
109
+ | `~/.petbox/wire/` | The stable kit copy. Every hook and plugin points here, so wiring survives `npx` cache eviction. Refresh with `update`. |
110
+ | `~/.petbox/projects.json` | Registry: directory prefix → project, env-var name, base URL. |
111
+ | `~/.petbox/keys.json` | `{ "<ENV_VAR>": "<key>" }` (POSIX: `chmod 0600`). The kit hooks read `process.env[<ENV_VAR>]` first, then this file. |
112
+ | `~/.petbox/env.sh` | POSIX only — generated from the key store, sourced from your login profiles. |
113
+ | `~/.petbox/roles.json` | Local role→model bindings + `activeProfile`. Never uploaded. |
114
+ | `~/.petbox/cache/` | LKG agent definition + memory canon per project. |
115
+
116
+ The per-project MCP configs resolve `${<ENV_VAR>}` from the **real** environment, so wire also
117
+ persists it (Windows: user-scope env; POSIX: `env.sh` + profile source). Start a **new terminal**
118
+ before launching agents after the first wiring — the kit's own hooks work immediately, because they
119
+ read `~/.petbox/keys.json` directly.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher for the PetBox agent-wiring kit.
3
+ //
4
+ // Plain JS (no TypeScript) so it still starts on an old Node and can print a clear
5
+ // version error. The kit itself is plain TypeScript executed by Node's native
6
+ // type-stripping, which needs Node >= 23.6.
7
+
8
+ const [maj, min] = process.versions.node.split(".").map((n) => parseInt(n, 10));
9
+ if (maj < 23 || (maj === 23 && min < 6)) {
10
+ console.error(
11
+ `petbox-wire needs Node >= 23.6 (native TypeScript type-stripping); you have ${process.versions.node}`,
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ // Node deliberately refuses to type-strip .ts files under node_modules
17
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), and the npx cache is exactly that — so the
18
+ // kit cannot run in place. Copy it out to a temp dir and import from there; wire.ts then
19
+ // installs the stable copy (~/.petbox/wire/) itself after validating the key.
20
+ const { cpSync, mkdtempSync } = await import("node:fs");
21
+ const { tmpdir } = await import("node:os");
22
+ const { join, dirname } = await import("node:path");
23
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
24
+
25
+ const srcDir = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
26
+ const runDir = mkdtempSync(join(tmpdir(), "petbox-wire-"));
27
+ cpSync(srcDir, runDir, { recursive: true });
28
+
29
+ // wire.ts runs main() at module top level, so importing it executes the CLI.
30
+ await import(pathToFileURL(join(runDir, "wire.ts")).href);
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "petbox-wire",
3
+ "version": "0.1.0-ci.1206",
4
+ "description": "Wire any project to PetBox (Claude Code, opencode, Factory Droid) — global hooks, MCP configs and skills — with a single npx command, no repo clone.",
5
+ "type": "module",
6
+ "bin": {
7
+ "petbox-wire": "bin/petbox-wire.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=23.6.0"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test src/*.test.ts",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "devDependencies": {
22
+ "@opencode-ai/plugin": "^1.17.20",
23
+ "@types/node": "^22.10.2",
24
+ "typescript": "5.7.3"
25
+ },
26
+ "keywords": [
27
+ "petbox",
28
+ "wiring",
29
+ "claude-code",
30
+ "opencode",
31
+ "factory-droid",
32
+ "mcp"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/stdray/petbox.git",
38
+ "directory": "src/clients-ts/petbox-wire"
39
+ },
40
+ "publishConfig": {
41
+ "registry": "https://registry.npmjs.org",
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,317 @@
1
+ // Unit tests for agent-def-fetch (parser + fetch + LKG cache resolution).
2
+ //
3
+ // Run: node --test src/agent-def-fetch.test.ts (Node >= 23.6 native TS)
4
+
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { test } from "node:test";
10
+ import {
11
+ AGENT_DEF_STALE_MARKER,
12
+ agentDefCacheDir,
13
+ agentDefCachePath,
14
+ DEFAULT_DEFINITION_KEY,
15
+ fetchAgentDefinition,
16
+ parseAgentDefinitionResponse,
17
+ readAgentDefCache,
18
+ resolveAgentDefinitionWithLkg,
19
+ writeAgentDefCache,
20
+ } from "./agent-def-fetch.ts";
21
+ import { DEFAULT_AGENT_DEFINITION, validateAgentDefinition } from "./agent-definition.ts";
22
+ import { planOpencodeApply } from "./apply-artifacts.ts";
23
+
24
+ const VALID_BODY = {
25
+ key: "default",
26
+ version: 3,
27
+ created: "2026-01-01T00:00:00Z",
28
+ updated: "2026-01-02T00:00:00Z",
29
+ definition: {
30
+ name: "proj-default",
31
+ roles: [
32
+ {
33
+ slug: "orchestrator",
34
+ tier: "orchestrator",
35
+ requiredCapabilities: ["mcp_main_session"],
36
+ spawn: { allowed: true, allowedRoles: ["worker"] },
37
+ escalation: { available: true, targets: ["reserve"] },
38
+ notes: "main loop",
39
+ },
40
+ {
41
+ slug: "worker",
42
+ tier: "worker",
43
+ requiredCapabilities: [],
44
+ spawn: { allowed: false },
45
+ escalation: { available: false },
46
+ },
47
+ ],
48
+ },
49
+ extraServerField: true,
50
+ };
51
+
52
+ function freshHome(): string {
53
+ return mkdtempSync(join(tmpdir(), "petbox-adef-"));
54
+ }
55
+
56
+ test("parseAgentDefinitionResponse maps camelCase envelope + definition", () => {
57
+ const got = parseAgentDefinitionResponse(VALID_BODY);
58
+ assert.ok(got);
59
+ assert.equal(got.key, "default");
60
+ assert.equal(got.version, 3);
61
+ assert.equal(got.definition.name, "proj-default");
62
+ assert.equal(got.definition.roles.length, 2);
63
+ const [firstRole] = got.definition.roles;
64
+ assert.ok(firstRole);
65
+ assert.equal(firstRole.slug, "orchestrator");
66
+ validateAgentDefinition(got.definition);
67
+ });
68
+
69
+ test("parseAgentDefinitionResponse rejects role.model", () => {
70
+ const bad = structuredClone(VALID_BODY);
71
+ (bad.definition.roles[0] as { model?: string }).model = "claude-opus";
72
+ assert.equal(parseAgentDefinitionResponse(bad), null);
73
+ });
74
+
75
+ test("parseAgentDefinitionResponse rejects missing roles / name / version", () => {
76
+ assert.equal(parseAgentDefinitionResponse(null), null);
77
+ assert.equal(parseAgentDefinitionResponse({}), null);
78
+ assert.equal(
79
+ parseAgentDefinitionResponse({ key: "default", version: 1, definition: { name: "x", roles: [] } }),
80
+ null,
81
+ );
82
+ });
83
+
84
+ test("fetchAgentDefinition returns mapped definition on 200", async () => {
85
+ const calls: Array<{ url: string; headers: RequestInit["headers"] }> = [];
86
+ const fetchImpl: typeof fetch = async (input, init) => {
87
+ calls.push({ url: String(input), headers: init?.headers });
88
+ return new Response(JSON.stringify(VALID_BODY), {
89
+ status: 200,
90
+ headers: { "Content-Type": "application/json" },
91
+ });
92
+ };
93
+
94
+ const got = await fetchAgentDefinition({
95
+ baseUrl: "https://petbox.example/",
96
+ projectKey: "$system",
97
+ apiKey: "k",
98
+ definitionKey: DEFAULT_DEFINITION_KEY,
99
+ fetchImpl,
100
+ });
101
+ assert.ok(got);
102
+ assert.equal(got.key, "default");
103
+ assert.equal(got.version, 3);
104
+ assert.equal(calls.length, 1);
105
+ const [call] = calls;
106
+ assert.ok(call);
107
+ assert.equal(call.url, "https://petbox.example/api/%24system/agent-defs/default");
108
+ });
109
+
110
+ test("fetchAgentDefinition returns null on 404 / network / bad body (never throws)", async () => {
111
+ assert.equal(
112
+ await fetchAgentDefinition({
113
+ baseUrl: "https://petbox.example",
114
+ projectKey: "p",
115
+ apiKey: "k",
116
+ fetchImpl: async () => new Response("nope", { status: 404 }),
117
+ }),
118
+ null,
119
+ );
120
+ assert.equal(
121
+ await fetchAgentDefinition({
122
+ baseUrl: "https://petbox.example",
123
+ projectKey: "p",
124
+ apiKey: "k",
125
+ fetchImpl: async () => {
126
+ throw new Error("ECONNREFUSED");
127
+ },
128
+ }),
129
+ null,
130
+ );
131
+ });
132
+
133
+ test("successful fetch path writeAgentDefCache leaves ~/.petbox/cache/<project>.agent-def.json", () => {
134
+ const home = freshHome();
135
+ try {
136
+ const fetched = parseAgentDefinitionResponse(VALID_BODY)!;
137
+ writeAgentDefCache("$system", fetched, home, () => "2026-07-10T00:00:00.000Z");
138
+ const path = agentDefCachePath("$system", home);
139
+ assert.equal(existsSync(path), true);
140
+ const raw = JSON.parse(readFileSync(path, "utf8"));
141
+ assert.equal(raw.key, "default");
142
+ assert.equal(raw.version, 3);
143
+ assert.equal(raw.fetchedAt, "2026-07-10T00:00:00.000Z");
144
+ assert.equal(raw.definition.name, "proj-default");
145
+ const round = readAgentDefCache("$system", home);
146
+ assert.ok(round);
147
+ assert.equal(round.definition.name, "proj-default");
148
+ } finally {
149
+ rmSync(home, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("resolve: fetch null + LKG present → uses cache with stale marker (not DEFAULT)", async () => {
154
+ const home = freshHome();
155
+ try {
156
+ const fetched = parseAgentDefinitionResponse(VALID_BODY)!;
157
+ writeAgentDefCache("proj", fetched, home);
158
+
159
+ const got = await resolveAgentDefinitionWithLkg({
160
+ offline: false,
161
+ definitionKey: "default",
162
+ projectKey: "proj",
163
+ baseUrl: "https://petbox.example",
164
+ apiKey: "k",
165
+ homeDir: home,
166
+ fetchImpl: async () => new Response("down", { status: 503 }),
167
+ });
168
+
169
+ assert.equal(got.source, "lkg");
170
+ assert.equal(got.stale, true);
171
+ assert.equal(got.staleMarker, AGENT_DEF_STALE_MARKER);
172
+ assert.equal(got.definition.name, "proj-default");
173
+ assert.notEqual(got.definition.name, DEFAULT_AGENT_DEFINITION.name);
174
+ assert.match(got.staleMarker!, /LKG cache|stale/i);
175
+ } finally {
176
+ rmSync(home, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ test("resolve: fetch null + no LKG → DEFAULT", async () => {
181
+ const home = freshHome();
182
+ try {
183
+ const got = await resolveAgentDefinitionWithLkg({
184
+ offline: false,
185
+ definitionKey: "default",
186
+ projectKey: "proj",
187
+ baseUrl: "https://petbox.example",
188
+ apiKey: "k",
189
+ homeDir: home,
190
+ fetchImpl: async () => {
191
+ throw new Error("ECONNREFUSED");
192
+ },
193
+ });
194
+ assert.equal(got.source, "default");
195
+ assert.equal(got.stale, false);
196
+ assert.equal(got.definition, DEFAULT_AGENT_DEFINITION);
197
+ } finally {
198
+ rmSync(home, { recursive: true, force: true });
199
+ }
200
+ });
201
+
202
+ test("resolve: --offline with LKG uses cache (no network)", async () => {
203
+ const home = freshHome();
204
+ try {
205
+ const fetched = parseAgentDefinitionResponse(VALID_BODY)!;
206
+ writeAgentDefCache("proj", fetched, home);
207
+
208
+ let fetchCalls = 0;
209
+ const got = await resolveAgentDefinitionWithLkg({
210
+ offline: true,
211
+ definitionKey: "default",
212
+ projectKey: "proj",
213
+ baseUrl: "https://petbox.example",
214
+ apiKey: "k",
215
+ homeDir: home,
216
+ fetchImpl: async () => {
217
+ fetchCalls++;
218
+ throw new Error("should not be called");
219
+ },
220
+ });
221
+
222
+ assert.equal(fetchCalls, 0);
223
+ assert.equal(got.source, "lkg");
224
+ assert.equal(got.stale, true);
225
+ assert.equal(got.definition.name, "proj-default");
226
+ assert.ok(got.staleMarker);
227
+ } finally {
228
+ rmSync(home, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test("resolve: live fetch writes LKG then returns server source", async () => {
233
+ const home = freshHome();
234
+ try {
235
+ const got = await resolveAgentDefinitionWithLkg({
236
+ offline: false,
237
+ definitionKey: "default",
238
+ projectKey: "proj",
239
+ baseUrl: "https://petbox.example",
240
+ apiKey: "k",
241
+ homeDir: home,
242
+ fetchImpl: async () =>
243
+ new Response(JSON.stringify(VALID_BODY), {
244
+ status: 200,
245
+ headers: { "Content-Type": "application/json" },
246
+ }),
247
+ });
248
+ assert.equal(got.source, "server");
249
+ assert.equal(got.stale, false);
250
+ assert.equal(existsSync(agentDefCachePath("proj", home)), true);
251
+ assert.equal(readAgentDefCache("proj", home)?.version, 3);
252
+ } finally {
253
+ rmSync(home, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ test("offline DEFAULT_AGENT_DEFINITION still compiles (truthfulness + plan green)", () => {
258
+ validateAgentDefinition(DEFAULT_AGENT_DEFINITION);
259
+ const plan = planOpencodeApply(DEFAULT_AGENT_DEFINITION, {});
260
+ assert.equal(plan.violations.length, 0);
261
+ assert.ok(plan.files.length >= 1);
262
+ });
263
+
264
+ test("readAgentDefCache: corrupt JSON → null (treated as no cache)", () => {
265
+ const home = freshHome();
266
+ try {
267
+ mkdirSync(agentDefCacheDir(home), { recursive: true });
268
+ writeFileSync(agentDefCachePath("proj", home), "{ not valid json", "utf8");
269
+ assert.equal(readAgentDefCache("proj", home), null);
270
+ } finally {
271
+ rmSync(home, { recursive: true, force: true });
272
+ }
273
+ });
274
+
275
+ test("resolve: tampered cache (role.model injected) is rejected → DEFAULT, no injection", async () => {
276
+ const home = freshHome();
277
+ try {
278
+ // Well-formed envelope but a poisoned role carrying a model binding — a portable
279
+ // definition must never carry model. The read path must reject it, and resolve must
280
+ // fall through to DEFAULT rather than compile the attacker's roster.
281
+ mkdirSync(agentDefCacheDir(home), { recursive: true });
282
+ const poisoned = {
283
+ key: "default",
284
+ version: 9,
285
+ fetchedAt: "2026-07-10T00:00:00.000Z",
286
+ definition: {
287
+ name: "attacker-roster",
288
+ roles: [
289
+ {
290
+ slug: "orchestrator",
291
+ tier: "orchestrator",
292
+ requiredCapabilities: [],
293
+ model: "attacker/evil-model",
294
+ },
295
+ ],
296
+ },
297
+ };
298
+ writeFileSync(agentDefCachePath("proj", home), JSON.stringify(poisoned), "utf8");
299
+
300
+ // Direct read rejects the tampered record.
301
+ assert.equal(readAgentDefCache("proj", home), null);
302
+
303
+ // And resolve (offline, so cache is the only non-DEFAULT source) falls to DEFAULT,
304
+ // never the attacker roster.
305
+ const got = await resolveAgentDefinitionWithLkg({
306
+ offline: true,
307
+ definitionKey: "default",
308
+ projectKey: "proj",
309
+ homeDir: home,
310
+ });
311
+ assert.equal(got.source, "default");
312
+ assert.equal(got.definition, DEFAULT_AGENT_DEFINITION);
313
+ assert.notEqual(got.definition.name, "attacker-roster");
314
+ } finally {
315
+ rmSync(home, { recursive: true, force: true });
316
+ }
317
+ });