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.
- package/README.md +119 -0
- package/bin/petbox-wire.js +30 -0
- package/package.json +44 -0
- package/src/agent-def-fetch.test.ts +317 -0
- package/src/agent-def-fetch.ts +409 -0
- package/src/agent-definition.ts +210 -0
- package/src/append-meta.test.ts +93 -0
- package/src/append.ts +172 -0
- package/src/apply-artifacts.ts +337 -0
- package/src/apply-root.test.ts +148 -0
- package/src/apply-root.ts +68 -0
- package/src/apply-write.test.ts +169 -0
- package/src/apply-write.ts +84 -0
- package/src/canon.ts +140 -0
- package/src/doctor-definition.test.ts +128 -0
- package/src/droid-pull-memory.ts +126 -0
- package/src/droid-push-session.ts +100 -0
- package/src/droid-transcript.ts +47 -0
- package/src/harness-capabilities.ts +126 -0
- package/src/harness-models.ts +158 -0
- package/src/hook-drain.ts +42 -0
- package/src/hook-prune.test.ts +165 -0
- package/src/hook-prune.ts +83 -0
- package/src/import-sessions.ts +261 -0
- package/src/opencode-plugin.ts +127 -0
- package/src/origin-marker.ts +37 -0
- package/src/posix-env.test.ts +99 -0
- package/src/posix-env.ts +61 -0
- package/src/protocol.test.ts +236 -0
- package/src/protocol.ts +136 -0
- package/src/pull-memory.test.ts +168 -0
- package/src/pull-memory.ts +119 -0
- package/src/push-session.ts +99 -0
- package/src/registry.ts +120 -0
- package/src/roles.test.ts +255 -0
- package/src/roles.ts +241 -0
- package/src/self-smoke.test.ts +103 -0
- package/src/self-smoke.ts +96 -0
- package/src/telemetry-settings.test.ts +65 -0
- package/src/telemetry-settings.ts +55 -0
- package/src/templates/SKILL.md +45 -0
- package/src/templates/agent-factory/SKILL.md +56 -0
- package/src/transcript.ts +86 -0
- package/src/truthfulness.test.ts +544 -0
- package/src/truthfulness.ts +164 -0
- package/src/wire-exit.test.ts +43 -0
- package/src/wire-exit.ts +25 -0
- package/src/wire-identity.test.ts +65 -0
- package/src/wire-identity.ts +64 -0
- 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
|
+
});
|