@openparachute/agent 0.2.2 → 0.2.3-rc.11
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/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +48 -27
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
package/src/agent-defs.test.ts
DELETED
|
@@ -1,1504 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for vault-native agent definitions (design
|
|
3
|
-
* 2026-06-17-vault-native-agents, Phase 4a).
|
|
4
|
-
*
|
|
5
|
-
* Three layers, all deterministic — `fetch` is stubbed (restored in afterEach, NO
|
|
6
|
-
* global mock.module leak) and the registry's side-effects are INJECTED so the
|
|
7
|
-
* lifecycle is exercised without a daemon, a vault, a sandbox, or tmux:
|
|
8
|
-
* - parseAgentDef: note (body + metadata) → AgentSpec, defaults + validation;
|
|
9
|
-
* - DefVaultClient: the def query encoding + the status PATCH;
|
|
10
|
-
* - AgentDefRegistry: instantiate / reload (update + delete) / deregister, with a
|
|
11
|
-
* recorder for ensureChannel / setupAndRegister / deregister / removeChannel.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, test, expect, afterEach } from "bun:test";
|
|
15
|
-
import {
|
|
16
|
-
parseAgentDef,
|
|
17
|
-
resolveDefStatus,
|
|
18
|
-
DefVaultClient,
|
|
19
|
-
AgentDefRegistry,
|
|
20
|
-
AgentDefParseError,
|
|
21
|
-
AgentDefWriteError,
|
|
22
|
-
type DefVaultBinding,
|
|
23
|
-
type InstantiateDeps,
|
|
24
|
-
} from "./agent-defs.ts";
|
|
25
|
-
import { GrantsClient, connectionKey, type ConnectionSpec } from "./grants.ts";
|
|
26
|
-
import type { AgentSpec } from "./sandbox/types.ts";
|
|
27
|
-
|
|
28
|
-
const realFetch = globalThis.fetch;
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
globalThis.fetch = realFetch;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// parseAgentDef — note → AgentSpec
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
describe("parseAgentDef", () => {
|
|
38
|
-
test("maps body → systemPrompt, metadata → spec; defaults backend=programmatic, own-vault binding", () => {
|
|
39
|
-
const def = parseAgentDef(
|
|
40
|
-
{
|
|
41
|
-
id: "Agents/uni-dev",
|
|
42
|
-
content: "You are uni-dev, the development agent for the Parachute project.",
|
|
43
|
-
metadata: { name: "uni-dev" },
|
|
44
|
-
},
|
|
45
|
-
{ vault: "default" },
|
|
46
|
-
);
|
|
47
|
-
expect(def.noteId).toBe("Agents/uni-dev");
|
|
48
|
-
expect(def.name).toBe("uni-dev");
|
|
49
|
-
const spec = def.spec;
|
|
50
|
-
expect(spec.name).toBe("uni-dev");
|
|
51
|
-
// Wake channel = the agent name (agent ≡ channel).
|
|
52
|
-
expect(spec.channels).toEqual(["uni-dev"]);
|
|
53
|
-
expect(spec.backend).toBe("programmatic");
|
|
54
|
-
// Own-vault binding (4a): the def-vault, write-scoped.
|
|
55
|
-
expect(spec.vault).toEqual({ name: "default", access: "write" });
|
|
56
|
-
// The note BODY is the system prompt.
|
|
57
|
-
expect(spec.systemPrompt).toBe(
|
|
58
|
-
"You are uni-dev, the development agent for the Parachute project.",
|
|
59
|
-
);
|
|
60
|
-
// No declared connections → resolves enabled.
|
|
61
|
-
expect(def.declaredConnections).toEqual([]);
|
|
62
|
-
expect(resolveDefStatus(def)).toEqual({ status: "enabled" });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("parses the full config knobs (backend, mode, workspace, filesystem, network, egress)", () => {
|
|
66
|
-
const def = parseAgentDef(
|
|
67
|
-
{
|
|
68
|
-
id: "n1",
|
|
69
|
-
content: "role prose",
|
|
70
|
-
metadata: {
|
|
71
|
-
name: "builder",
|
|
72
|
-
backend: "programmatic",
|
|
73
|
-
systemPromptMode: "replace",
|
|
74
|
-
workspace: "/Users/me/code/proj",
|
|
75
|
-
filesystem: "full",
|
|
76
|
-
network: "restricted",
|
|
77
|
-
egress: "api.github.com, registry.npmjs.org",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
{ vault: "default" },
|
|
81
|
-
);
|
|
82
|
-
const spec = def.spec;
|
|
83
|
-
expect(spec.systemPromptMode).toBe("replace");
|
|
84
|
-
expect(spec.workspace).toBe("/Users/me/code/proj");
|
|
85
|
-
expect(spec.filesystem).toBe("full");
|
|
86
|
-
expect(spec.network).toBe("restricted");
|
|
87
|
-
expect(spec.egress).toEqual(["api.github.com", "registry.npmjs.org"]);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("metadata.model → spec.model (alias or full id); absent → undefined", () => {
|
|
91
|
-
const withModel = parseAgentDef(
|
|
92
|
-
{ id: "n1", content: "x", metadata: { name: "a", model: "opus" } },
|
|
93
|
-
{ vault: "default" },
|
|
94
|
-
);
|
|
95
|
-
expect(withModel.spec.model).toBe("opus");
|
|
96
|
-
|
|
97
|
-
const fullId = parseAgentDef(
|
|
98
|
-
{ id: "n1", content: "x", metadata: { name: "a", model: "claude-opus-4-8" } },
|
|
99
|
-
{ vault: "default" },
|
|
100
|
-
);
|
|
101
|
-
expect(fullId.spec.model).toBe("claude-opus-4-8");
|
|
102
|
-
|
|
103
|
-
const noModel = parseAgentDef(
|
|
104
|
-
{ id: "n1", content: "x", metadata: { name: "a" } },
|
|
105
|
-
{ vault: "default" },
|
|
106
|
-
);
|
|
107
|
-
expect(noModel.spec.model).toBeUndefined();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("a malformed model (spaces/control chars) is a parse error, not a silent passthrough", () => {
|
|
111
|
-
expect(() =>
|
|
112
|
-
parseAgentDef(
|
|
113
|
-
{ id: "n", content: "x", metadata: { name: "a", model: "opus 4.8" } },
|
|
114
|
-
{ vault: "v" },
|
|
115
|
-
),
|
|
116
|
-
).toThrow(/not a valid model name/);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("a blank body → no systemPrompt (CC default untouched), no mode flag", () => {
|
|
120
|
-
const def = parseAgentDef(
|
|
121
|
-
{ id: "n1", content: " \n ", metadata: { name: "a", systemPromptMode: "replace" } },
|
|
122
|
-
{ vault: "default" },
|
|
123
|
-
);
|
|
124
|
-
expect("systemPrompt" in def.spec).toBe(false);
|
|
125
|
-
expect("systemPromptMode" in def.spec).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("parses `uses` connections (NOT granted in 4a) → status pending listing them", () => {
|
|
129
|
-
const def = parseAgentDef(
|
|
130
|
-
{
|
|
131
|
-
id: "n1",
|
|
132
|
-
content: "role",
|
|
133
|
-
metadata: { name: "researcher", uses: "github, vault:research:read" },
|
|
134
|
-
},
|
|
135
|
-
{ vault: "default" },
|
|
136
|
-
);
|
|
137
|
-
expect(def.declaredConnections).toEqual(["github", "vault:research:read"]);
|
|
138
|
-
expect(resolveDefStatus(def)).toEqual({
|
|
139
|
-
status: "pending",
|
|
140
|
-
pending: ["github", "vault:research:read"],
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("parses an array-valued `uses` field too", () => {
|
|
145
|
-
const def = parseAgentDef(
|
|
146
|
-
{ id: "n1", content: "role", metadata: { name: "x", uses: ["github", "cloudflare"] } },
|
|
147
|
-
{ vault: "default" },
|
|
148
|
-
);
|
|
149
|
-
expect(def.declaredConnections).toEqual(["github", "cloudflare"]);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("parses the structured `wants:` field into connection specs (4b)", () => {
|
|
153
|
-
const def = parseAgentDef(
|
|
154
|
-
{
|
|
155
|
-
id: "n1",
|
|
156
|
-
content: "role",
|
|
157
|
-
metadata: {
|
|
158
|
-
name: "researcher",
|
|
159
|
-
wants: "vault:research:read#published, env:github, mcp:github, mcp:https://remote/mcp",
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
{ vault: "default" },
|
|
163
|
-
);
|
|
164
|
-
expect(def.wants).toEqual([
|
|
165
|
-
{ kind: "vault", target: "research", access: "read", tags: ["#published"] },
|
|
166
|
-
{ kind: "service", target: "github", inject: ["env", "mcp"] }, // merged
|
|
167
|
-
{ kind: "mcp", target: "https://remote/mcp" },
|
|
168
|
-
]);
|
|
169
|
-
// No grants client wired (pure resolveDefStatus) → pending listing the conn keys.
|
|
170
|
-
expect(resolveDefStatus(def)).toEqual({
|
|
171
|
-
status: "pending",
|
|
172
|
-
pending: def.wants.map((c) => connectionKey(c)),
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("a def with no `wants:` → wants is [] (own-vault only → enabled)", () => {
|
|
177
|
-
const def = parseAgentDef(
|
|
178
|
-
{ id: "n1", content: "role", metadata: { name: "x" } },
|
|
179
|
-
{ vault: "default" },
|
|
180
|
-
);
|
|
181
|
-
expect(def.wants).toEqual([]);
|
|
182
|
-
expect(resolveDefStatus(def)).toEqual({ status: "enabled" });
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("a MALFORMED `wants:` makes the WHOLE def a parse error (no half-instantiate)", () => {
|
|
186
|
-
expect(() =>
|
|
187
|
-
parseAgentDef(
|
|
188
|
-
{ id: "n1", content: "role", metadata: { name: "x", wants: "vault:research" } },
|
|
189
|
-
{ vault: "default" },
|
|
190
|
-
),
|
|
191
|
-
).toThrow(AgentDefParseError);
|
|
192
|
-
expect(() =>
|
|
193
|
-
parseAgentDef(
|
|
194
|
-
{ id: "n1", content: "role", metadata: { name: "x", wants: "smtp:server" } },
|
|
195
|
-
{ vault: "default" },
|
|
196
|
-
),
|
|
197
|
-
).toThrow(/unknown kind/);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("parses JSON-array mounts; ignores malformed entries", () => {
|
|
201
|
-
const def = parseAgentDef(
|
|
202
|
-
{
|
|
203
|
-
id: "n1",
|
|
204
|
-
content: "role",
|
|
205
|
-
metadata: {
|
|
206
|
-
name: "x",
|
|
207
|
-
mounts: JSON.stringify([
|
|
208
|
-
{ hostPath: "/data", mountPath: "/data", mode: "ro" },
|
|
209
|
-
{ hostPath: "relative", mountPath: "/x", mode: "ro" }, // dropped (not absolute)
|
|
210
|
-
{ hostPath: "/y", mountPath: "/y", mode: "bogus" }, // dropped (bad mode)
|
|
211
|
-
]),
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
{ vault: "default" },
|
|
215
|
-
);
|
|
216
|
-
expect(def.spec.mounts).toEqual([{ hostPath: "/data", mountPath: "/data", mode: "ro" }]);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("rejects a note with no metadata.name", () => {
|
|
220
|
-
expect(() => parseAgentDef({ id: "n1", content: "x", metadata: {} }, { vault: "default" })).toThrow(
|
|
221
|
-
AgentDefParseError,
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("rejects a non-slug name", () => {
|
|
226
|
-
expect(() =>
|
|
227
|
-
parseAgentDef({ id: "n1", content: "x", metadata: { name: "has spaces" } }, { vault: "default" }),
|
|
228
|
-
).toThrow(/slug/);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test("rejects a bad backend / filesystem / network value", () => {
|
|
232
|
-
expect(() =>
|
|
233
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", backend: "weird" } }, { vault: "v" }),
|
|
234
|
-
).toThrow(/backend/);
|
|
235
|
-
expect(() =>
|
|
236
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", filesystem: "weird" } }, { vault: "v" }),
|
|
237
|
-
).toThrow(/filesystem/);
|
|
238
|
-
expect(() =>
|
|
239
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", network: "weird" } }, { vault: "v" }),
|
|
240
|
-
).toThrow(/network/);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("rejects backend:interactive (retired — design 2026-06-18)", () => {
|
|
244
|
-
expect(() =>
|
|
245
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", backend: "interactive" } }, { vault: "v" }),
|
|
246
|
-
).toThrow(/interactive/);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test("accepts backend:attached (design 2026-06-18-channel-backend), threads it onto the spec", () => {
|
|
250
|
-
const def = parseAgentDef(
|
|
251
|
-
{ id: "Agents/laptop", content: "You are the laptop agent.", metadata: { name: "laptop", backend: "attached" } },
|
|
252
|
-
{ vault: "default" },
|
|
253
|
-
);
|
|
254
|
-
expect(def.spec.backend).toBe("attached");
|
|
255
|
-
expect(def.name).toBe("laptop");
|
|
256
|
-
// The body is still the system prompt (the session adopts it on next-message).
|
|
257
|
-
expect(def.spec.systemPrompt).toBe("You are the laptop agent.");
|
|
258
|
-
// Wake channel = the agent name (agent ≡ channel) — same collapse as programmatic.
|
|
259
|
-
expect(def.spec.channels).toEqual(["laptop"]);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
test("DUAL-READ: a persisted def with the legacy backend value \"channel\" normalizes to \"attached\"", () => {
|
|
263
|
-
// The backend VALUE was renamed `channel` → `attached`. An already-authored def note
|
|
264
|
-
// (or spec.json) carrying the legacy `metadata.backend: "channel"` must still LOAD —
|
|
265
|
-
// normalized to the canonical `attached` on read, no operator-facing break, no
|
|
266
|
-
// migration. (The routing key `channel` — metadata.channel / the `/mcp/<channel>`
|
|
267
|
-
// segment — is a SEPARATE concept and is deliberately unchanged.)
|
|
268
|
-
const def = parseAgentDef(
|
|
269
|
-
{ id: "Agents/laptop", content: "You are the laptop agent.", metadata: { name: "laptop", backend: "channel" } },
|
|
270
|
-
{ vault: "default" },
|
|
271
|
-
);
|
|
272
|
-
expect(def.spec.backend).toBe("attached"); // normalized, NOT the legacy "channel".
|
|
273
|
-
expect(def.name).toBe("laptop");
|
|
274
|
-
expect(def.spec.channels).toEqual(["laptop"]); // routing key unchanged.
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// --- execution-lifecycle mode (the Phase-3 prerequisite) ---
|
|
278
|
-
|
|
279
|
-
test("mode defaults to single-threaded when omitted (= today's behavior)", () => {
|
|
280
|
-
const def = parseAgentDef(
|
|
281
|
-
{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } },
|
|
282
|
-
{ vault: "default" },
|
|
283
|
-
);
|
|
284
|
-
expect(def.spec.mode).toBe("single-threaded");
|
|
285
|
-
// The def note id is threaded onto the spec as provenance (for the `#agent/thread` note).
|
|
286
|
-
expect(def.spec.definition).toBe("Agents/uni-dev");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("accepts mode:single-threaded explicitly", () => {
|
|
290
|
-
const def = parseAgentDef(
|
|
291
|
-
{ id: "n1", content: "role", metadata: { name: "a", mode: "single-threaded" } },
|
|
292
|
-
{ vault: "v" },
|
|
293
|
-
);
|
|
294
|
-
expect(def.spec.mode).toBe("single-threaded");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("accepts mode:multi-threaded, threads it onto the spec", () => {
|
|
298
|
-
const def = parseAgentDef(
|
|
299
|
-
{ id: "Agents/digest", content: "Run the daily digest.", metadata: { name: "digest", mode: "multi-threaded" } },
|
|
300
|
-
{ vault: "default" },
|
|
301
|
-
);
|
|
302
|
-
expect(def.spec.mode).toBe("multi-threaded");
|
|
303
|
-
expect(def.spec.definition).toBe("Agents/digest");
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test("rejects an UNKNOWN mode value with AgentDefParseError", () => {
|
|
307
|
-
expect(() =>
|
|
308
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", mode: "weird" } }, { vault: "v" }),
|
|
309
|
-
).toThrow(/mode must be "single-threaded" or "multi-threaded"/);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
test("DUAL-ACCEPTs the legacy aliases (resident→single, one-shot/per-thread→multi)", () => {
|
|
313
|
-
const resident = parseAgentDef(
|
|
314
|
-
{ id: "n1", content: "x", metadata: { name: "a", mode: "resident" } },
|
|
315
|
-
{ vault: "v" },
|
|
316
|
-
);
|
|
317
|
-
expect(resident.spec.mode).toBe("single-threaded");
|
|
318
|
-
|
|
319
|
-
const oneShot = parseAgentDef(
|
|
320
|
-
{ id: "n2", content: "x", metadata: { name: "b", mode: "one-shot" } },
|
|
321
|
-
{ vault: "v" },
|
|
322
|
-
);
|
|
323
|
-
expect(oneShot.spec.mode).toBe("multi-threaded");
|
|
324
|
-
|
|
325
|
-
const perThread = parseAgentDef(
|
|
326
|
-
{ id: "n3", content: "x", metadata: { name: "c", mode: "per-thread" } },
|
|
327
|
-
{ vault: "v" },
|
|
328
|
-
);
|
|
329
|
-
expect(perThread.spec.mode).toBe("multi-threaded");
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("rejects a relative workspace path", () => {
|
|
333
|
-
expect(() =>
|
|
334
|
-
parseAgentDef({ id: "n", content: "x", metadata: { name: "a", workspace: "rel/path" } }, { vault: "v" }),
|
|
335
|
-
).toThrow(/absolute/);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test("does NOT read any secret field off the note (only references)", () => {
|
|
339
|
-
// A note that tries to smuggle a token must NOT end up on the spec.
|
|
340
|
-
const def = parseAgentDef(
|
|
341
|
-
{ id: "n", content: "x", metadata: { name: "a", token: "sekret", CLAUDE_CODE_OAUTH_TOKEN: "sekret2" } },
|
|
342
|
-
{ vault: "v" },
|
|
343
|
-
);
|
|
344
|
-
expect(JSON.stringify(def.spec)).not.toContain("sekret");
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
// DefVaultClient — the def query + the status PATCH
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
|
-
|
|
352
|
-
const binding: DefVaultBinding = {
|
|
353
|
-
vault: "default",
|
|
354
|
-
vaultUrl: "http://127.0.0.1:1940",
|
|
355
|
-
token: "write-token",
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
describe("DefVaultClient", () => {
|
|
359
|
-
test("listDefNotes queries by the EXACT #agent/definition tag (encoded) with Bearer", async () => {
|
|
360
|
-
const urls: string[] = [];
|
|
361
|
-
let auth = "";
|
|
362
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
363
|
-
urls.push(String(url));
|
|
364
|
-
auth = (init?.headers as Record<string, string> | undefined)?.authorization ?? "";
|
|
365
|
-
return new Response(
|
|
366
|
-
JSON.stringify([
|
|
367
|
-
{ id: "Agents/uni-dev", content: "role A", metadata: { name: "uni-dev" } },
|
|
368
|
-
{ id: "Agents/researcher", content: "role B", metadata: { name: "researcher" } },
|
|
369
|
-
{ id: "", content: "no id", metadata: { name: "skip" } }, // dropped (no id)
|
|
370
|
-
]),
|
|
371
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
372
|
-
);
|
|
373
|
-
}) as typeof fetch;
|
|
374
|
-
|
|
375
|
-
const client = new DefVaultClient(binding);
|
|
376
|
-
const notes = await client.listDefNotes();
|
|
377
|
-
expect(urls).toHaveLength(1);
|
|
378
|
-
// `#agent/definition` → `%23agent%2Fdefinition` (both `#` and `/` encoded).
|
|
379
|
-
expect(urls[0]).toContain("tag=%23agent%2Fdefinition");
|
|
380
|
-
expect(urls[0]).toContain("include_content=true");
|
|
381
|
-
expect(auth).toBe("Bearer write-token");
|
|
382
|
-
expect(notes.map((n) => n.id)).toEqual(["Agents/uni-dev", "Agents/researcher"]);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("listDefNotes throws on a non-ok vault response", async () => {
|
|
386
|
-
globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch;
|
|
387
|
-
const client = new DefVaultClient(binding);
|
|
388
|
-
await expect(client.listDefNotes()).rejects.toThrow(/list defs failed \(500\)/);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test("patchStatus PATCHes status + clears pending when enabled", async () => {
|
|
392
|
-
const calls: { url: string; init: RequestInit }[] = [];
|
|
393
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
394
|
-
calls.push({ url: String(url), init: init ?? {} });
|
|
395
|
-
return new Response(null, { status: 200 });
|
|
396
|
-
}) as typeof fetch;
|
|
397
|
-
const client = new DefVaultClient(binding);
|
|
398
|
-
await client.patchStatus("Agents/uni-dev", "enabled");
|
|
399
|
-
expect(calls).toHaveLength(1);
|
|
400
|
-
expect(calls[0]!.init.method).toBe("PATCH");
|
|
401
|
-
expect(calls[0]!.url).toContain("/api/notes/");
|
|
402
|
-
const body = JSON.parse(String(calls[0]!.init.body));
|
|
403
|
-
expect(body.metadata.status).toBe("enabled");
|
|
404
|
-
// Always sets pending (empty here) so a prior list doesn't go stale.
|
|
405
|
-
expect(body.metadata.pending).toBe("");
|
|
406
|
-
// MUST carry the vault mutation precondition or the PATCH 428s (the real-vault
|
|
407
|
-
// bug this guards): `force: true` since status is the module's own derived field.
|
|
408
|
-
expect(body.force).toBe(true);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
test("patchStatus writes the pending list joined when pending", async () => {
|
|
412
|
-
let captured: Record<string, string> = {};
|
|
413
|
-
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
414
|
-
captured = JSON.parse(String(init?.body)).metadata;
|
|
415
|
-
return new Response(null, { status: 200 });
|
|
416
|
-
}) as typeof fetch;
|
|
417
|
-
const client = new DefVaultClient(binding);
|
|
418
|
-
await client.patchStatus("n", "pending", ["github", "vault:research:read"]);
|
|
419
|
-
expect(captured.status).toBe("pending");
|
|
420
|
-
expect(captured.pending).toBe("github, vault:research:read");
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("getNote returns null on 404", async () => {
|
|
424
|
-
globalThis.fetch = (async () => new Response("no", { status: 404 })) as unknown as typeof fetch;
|
|
425
|
-
const client = new DefVaultClient(binding);
|
|
426
|
-
expect(await client.getNote("gone")).toBeNull();
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
test("createNote POSTs body + the def tag + metadata, returns the created note", async () => {
|
|
430
|
-
let captured: { url: string; method: string; body: Record<string, unknown> } | null = null;
|
|
431
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
432
|
-
captured = { url: String(url), method: init?.method ?? "GET", body: JSON.parse(String(init?.body)) };
|
|
433
|
-
return new Response(JSON.stringify({ id: "Agents/newbot", content: "P", metadata: { name: "newbot" } }), {
|
|
434
|
-
status: 200,
|
|
435
|
-
headers: { "content-type": "application/json" },
|
|
436
|
-
});
|
|
437
|
-
}) as typeof fetch;
|
|
438
|
-
const client = new DefVaultClient(binding);
|
|
439
|
-
const created = await client.createNote({
|
|
440
|
-
content: "P",
|
|
441
|
-
metadata: { name: "newbot", backend: "programmatic" },
|
|
442
|
-
path: "Agents/newbot",
|
|
443
|
-
});
|
|
444
|
-
expect(created.id).toBe("Agents/newbot");
|
|
445
|
-
expect(captured!.method).toBe("POST");
|
|
446
|
-
expect(captured!.url).toContain("/vault/default/api/notes");
|
|
447
|
-
expect(captured!.body.tags).toEqual(["#agent/definition"]);
|
|
448
|
-
expect(captured!.body.content).toBe("P");
|
|
449
|
-
expect((captured!.body.metadata as Record<string, string>).name).toBe("newbot");
|
|
450
|
-
expect(captured!.body.path).toBe("Agents/newbot");
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
test("createNote throws on a non-ok vault response", async () => {
|
|
454
|
-
globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch;
|
|
455
|
-
const client = new DefVaultClient(binding);
|
|
456
|
-
await expect(
|
|
457
|
-
client.createNote({ content: "x", metadata: { name: "x" } }),
|
|
458
|
-
).rejects.toThrow(/create def failed \(500\)/);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
test("patchNote sends content/metadata with force:true (the 428 guard)", async () => {
|
|
462
|
-
let body: Record<string, unknown> = {};
|
|
463
|
-
let method = "";
|
|
464
|
-
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
465
|
-
method = init?.method ?? "GET";
|
|
466
|
-
body = JSON.parse(String(init?.body));
|
|
467
|
-
return new Response(null, { status: 200 });
|
|
468
|
-
}) as typeof fetch;
|
|
469
|
-
const client = new DefVaultClient(binding);
|
|
470
|
-
await client.patchNote("Agents/newbot", { content: "new", metadata: { wants: "vault:r:read" } });
|
|
471
|
-
expect(method).toBe("PATCH");
|
|
472
|
-
expect(body.content).toBe("new");
|
|
473
|
-
expect((body.metadata as Record<string, string>).wants).toBe("vault:r:read");
|
|
474
|
-
expect(body.force).toBe(true); // satisfies the vault's mutation precondition.
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("deleteNote DELETEs the note; a 404 is OK (gone is gone)", async () => {
|
|
478
|
-
let method = "";
|
|
479
|
-
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
480
|
-
method = init?.method ?? "GET";
|
|
481
|
-
return new Response("no", { status: 404 });
|
|
482
|
-
}) as typeof fetch;
|
|
483
|
-
const client = new DefVaultClient(binding);
|
|
484
|
-
await client.deleteNote("Agents/gone"); // must NOT throw on 404.
|
|
485
|
-
expect(method).toBe("DELETE");
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
test("deleteNote throws on a non-404 error", async () => {
|
|
489
|
-
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
|
|
490
|
-
const client = new DefVaultClient(binding);
|
|
491
|
-
await expect(client.deleteNote("n")).rejects.toThrow(/delete def n failed \(500\)/);
|
|
492
|
-
});
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
// ---------------------------------------------------------------------------
|
|
496
|
-
// AgentDefRegistry — reactive lifecycle (instantiate / reload / deregister)
|
|
497
|
-
// ---------------------------------------------------------------------------
|
|
498
|
-
|
|
499
|
-
/** A recorder for the injected instantiate side-effects. */
|
|
500
|
-
function recorderDeps() {
|
|
501
|
-
const calls = {
|
|
502
|
-
ensured: [] as string[],
|
|
503
|
-
registered: [] as AgentSpec[],
|
|
504
|
-
deregistered: [] as string[],
|
|
505
|
-
removed: [] as string[],
|
|
506
|
-
};
|
|
507
|
-
const deps: InstantiateDeps = {
|
|
508
|
-
ensureChannel: async (name) => {
|
|
509
|
-
calls.ensured.push(name);
|
|
510
|
-
},
|
|
511
|
-
setupAndRegister: async (spec) => {
|
|
512
|
-
calls.registered.push(spec);
|
|
513
|
-
},
|
|
514
|
-
deregister: async (name) => {
|
|
515
|
-
calls.deregistered.push(name);
|
|
516
|
-
return true;
|
|
517
|
-
},
|
|
518
|
-
removeChannel: async (name) => {
|
|
519
|
-
calls.removed.push(name);
|
|
520
|
-
return true;
|
|
521
|
-
},
|
|
522
|
-
};
|
|
523
|
-
return { deps, calls };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** A fetch that serves a def list + records PATCHes, keyed by query. */
|
|
527
|
-
function vaultFetch(opts: {
|
|
528
|
-
defs?: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>;
|
|
529
|
-
byId?: Record<string, { id: string; content?: string; metadata?: Record<string, unknown> } | null>;
|
|
530
|
-
patches?: Array<{ id: string; status?: string; pending?: string }>;
|
|
531
|
-
}): typeof fetch {
|
|
532
|
-
return (async (url: string | URL | Request, init?: RequestInit) => {
|
|
533
|
-
const u = String(url);
|
|
534
|
-
const method = init?.method ?? "GET";
|
|
535
|
-
if (method === "PATCH") {
|
|
536
|
-
const id = decodeURIComponent(u.split("/api/notes/")[1]!);
|
|
537
|
-
const meta = JSON.parse(String(init?.body)).metadata as Record<string, string>;
|
|
538
|
-
opts.patches?.push({ id, status: meta.status, pending: meta.pending });
|
|
539
|
-
return new Response(null, { status: 200 });
|
|
540
|
-
}
|
|
541
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
542
|
-
return new Response(JSON.stringify(opts.defs ?? []), {
|
|
543
|
-
status: 200,
|
|
544
|
-
headers: { "content-type": "application/json" },
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
// GET one note by id (reload path).
|
|
548
|
-
const m = u.match(/\/api\/notes\/([^?]+)/);
|
|
549
|
-
if (m) {
|
|
550
|
-
const id = decodeURIComponent(m[1]!);
|
|
551
|
-
const note = opts.byId?.[id] ?? null;
|
|
552
|
-
if (!note) return new Response("no", { status: 404 });
|
|
553
|
-
return new Response(JSON.stringify(note), { status: 200 });
|
|
554
|
-
}
|
|
555
|
-
return new Response("[]", { status: 200 });
|
|
556
|
-
}) as typeof fetch;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
describe("AgentDefRegistry — lifecycle", () => {
|
|
560
|
-
test("loadAll instantiates each def: ensureChannel + setupAndRegister + status stamp", async () => {
|
|
561
|
-
const { deps, calls } = recorderDeps();
|
|
562
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
563
|
-
const fetchFn = vaultFetch({
|
|
564
|
-
defs: [
|
|
565
|
-
{ id: "Agents/uni-dev", content: "role A", metadata: { name: "uni-dev" } },
|
|
566
|
-
{ id: "Agents/researcher", content: "role B", metadata: { name: "researcher", uses: "github" } },
|
|
567
|
-
],
|
|
568
|
-
patches,
|
|
569
|
-
});
|
|
570
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
571
|
-
const n = await reg.loadAll();
|
|
572
|
-
expect(n).toBe(2);
|
|
573
|
-
expect(calls.ensured).toEqual(["uni-dev", "researcher"]);
|
|
574
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["uni-dev", "researcher"]);
|
|
575
|
-
// Both agents bind their own vault, write-scoped (own-vault, 4a).
|
|
576
|
-
expect(calls.registered[0]!.vault).toEqual({ name: "default", access: "write" });
|
|
577
|
-
expect(calls.registered[0]!.backend).toBe("programmatic");
|
|
578
|
-
// Status stamped: uni-dev enabled (no connections), researcher pending (declares github).
|
|
579
|
-
const uni = patches.find((p) => p.id === "Agents/uni-dev")!;
|
|
580
|
-
const res = patches.find((p) => p.id === "Agents/researcher")!;
|
|
581
|
-
expect(uni.status).toBe("enabled");
|
|
582
|
-
expect(uni.pending).toBe("");
|
|
583
|
-
expect(res.status).toBe("pending");
|
|
584
|
-
expect(res.pending).toBe("github");
|
|
585
|
-
// The live set reflects both.
|
|
586
|
-
expect(reg.list().map((d) => d.name).sort()).toEqual(["researcher", "uni-dev"]);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
test("a malformed def is skipped (status error) and does NOT abort the others", async () => {
|
|
590
|
-
const { deps, calls } = recorderDeps();
|
|
591
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
592
|
-
const fetchFn = vaultFetch({
|
|
593
|
-
defs: [
|
|
594
|
-
{ id: "bad", content: "x", metadata: {} }, // no name → parse error
|
|
595
|
-
{ id: "Agents/ok", content: "role", metadata: { name: "ok" } },
|
|
596
|
-
],
|
|
597
|
-
patches,
|
|
598
|
-
});
|
|
599
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
600
|
-
const n = await reg.loadAll();
|
|
601
|
-
expect(n).toBe(1); // only the good one
|
|
602
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["ok"]);
|
|
603
|
-
expect(patches.find((p) => p.id === "bad")!.status).toBe("error");
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test("an instantiate failure stamps error and does not record a live def", async () => {
|
|
607
|
-
const { deps } = recorderDeps();
|
|
608
|
-
// Make registration fail (e.g. missing Claude credential at setup).
|
|
609
|
-
deps.setupAndRegister = async () => {
|
|
610
|
-
throw new Error("CredentialNotConfigured: set the Claude credential");
|
|
611
|
-
};
|
|
612
|
-
const patches: Array<{ id: string; status?: string }> = [];
|
|
613
|
-
const fetchFn = vaultFetch({
|
|
614
|
-
defs: [{ id: "Agents/x", content: "role", metadata: { name: "x" } }],
|
|
615
|
-
patches,
|
|
616
|
-
});
|
|
617
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
618
|
-
const n = await reg.loadAll();
|
|
619
|
-
expect(n).toBe(0);
|
|
620
|
-
expect(reg.list()).toHaveLength(0);
|
|
621
|
-
expect(patches.find((p) => p.id === "Agents/x")!.status).toBe("error");
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
test("reload(updated) re-instantiates the changed def (idempotent replace)", async () => {
|
|
625
|
-
const { deps, calls } = recorderDeps();
|
|
626
|
-
const fetchFn = vaultFetch({
|
|
627
|
-
byId: { "Agents/uni-dev": { id: "Agents/uni-dev", content: "NEW role", metadata: { name: "uni-dev" } } },
|
|
628
|
-
});
|
|
629
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
630
|
-
const result = await reg.reload("default", "Agents/uni-dev", "updated");
|
|
631
|
-
expect(result).toBe("instantiated");
|
|
632
|
-
expect(calls.registered).toHaveLength(1);
|
|
633
|
-
expect(calls.registered[0]!.systemPrompt).toBe("NEW role");
|
|
634
|
-
expect(reg.list().map((d) => d.name)).toEqual(["uni-dev"]);
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
test("reload(deleted) deregisters + removes the channel without a fetch", async () => {
|
|
638
|
-
const { deps, calls } = recorderDeps();
|
|
639
|
-
// Seed a live def first via loadAll, then delete it.
|
|
640
|
-
const fetchFn = vaultFetch({
|
|
641
|
-
defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
|
|
642
|
-
});
|
|
643
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
644
|
-
await reg.loadAll();
|
|
645
|
-
expect(reg.list()).toHaveLength(1);
|
|
646
|
-
|
|
647
|
-
const result = await reg.reload("default", "Agents/uni-dev", "deleted");
|
|
648
|
-
expect(result).toBe("deregistered");
|
|
649
|
-
expect(calls.deregistered).toEqual(["uni-dev"]);
|
|
650
|
-
expect(calls.removed).toEqual(["uni-dev"]);
|
|
651
|
-
expect(reg.list()).toHaveLength(0);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("reload of a note that re-reads as gone (no event) deregisters", async () => {
|
|
655
|
-
const { deps, calls } = recorderDeps();
|
|
656
|
-
const fetchFn = vaultFetch({
|
|
657
|
-
defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
|
|
658
|
-
byId: { "Agents/uni-dev": null }, // a later GET says it's gone
|
|
659
|
-
});
|
|
660
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
661
|
-
await reg.loadAll();
|
|
662
|
-
const result = await reg.reload("default", "Agents/uni-dev"); // no event → fetch → 404
|
|
663
|
-
expect(result).toBe("deregistered");
|
|
664
|
-
expect(calls.deregistered).toEqual(["uni-dev"]);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
test("loadAll TEARS DOWN a removed def — deregister + removeChannel (the no-delete-trigger path)", async () => {
|
|
668
|
-
// There is no vault `deleted` trigger (the hub maps only created/updated), so a def
|
|
669
|
-
// deleted out-of-band never fires the reactive teardown — the poll is the ONLY
|
|
670
|
-
// convergence path and must deregister, not just prune grants. Regression for the
|
|
671
|
-
// orphan-agent bug (a deleted agent kept answering until the daemon restarted).
|
|
672
|
-
const { deps, calls } = recorderDeps();
|
|
673
|
-
const present: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
|
|
674
|
-
{ id: "Agents/uni", content: "role", metadata: { name: "uni" } },
|
|
675
|
-
{ id: "Agents/researcher", content: "role", metadata: { name: "researcher" } },
|
|
676
|
-
];
|
|
677
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
678
|
-
const u = String(url);
|
|
679
|
-
const method = init?.method ?? "GET";
|
|
680
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
681
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
682
|
-
return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
|
|
683
|
-
}
|
|
684
|
-
return new Response("[]", { status: 200 });
|
|
685
|
-
}) as typeof fetch;
|
|
686
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
687
|
-
await reg.loadAll(); // both live
|
|
688
|
-
expect(calls.deregistered).toEqual([]); // nothing torn down on a clean load
|
|
689
|
-
present.splice(1, 1); // delete researcher out-of-band (no delete trigger fires)
|
|
690
|
-
await reg.loadAll(); // confident read now sees only uni → researcher is a confirmed removal
|
|
691
|
-
expect(calls.deregistered).toEqual(["researcher"]);
|
|
692
|
-
expect(calls.removed).toEqual(["researcher"]);
|
|
693
|
-
expect(reg.list().map((d) => d.name)).toEqual(["uni"]); // gone from the live set
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
test("loadAll SKIPS removed-def teardown on a truncated (page-cap) read — no spurious deregister", async () => {
|
|
697
|
-
// A list at the page cap may be partial. Since the removed-def diff now does a
|
|
698
|
-
// DESTRUCTIVE teardown, a truncated read that omits the tail must NOT be mistaken for
|
|
699
|
-
// deletions — the guard defers the diff rather than tearing down live agents.
|
|
700
|
-
const { deps, calls } = recorderDeps();
|
|
701
|
-
const initial: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
|
|
702
|
-
{ id: "Agents/uni", content: "role", metadata: { name: "uni" } },
|
|
703
|
-
{ id: "Agents/researcher", content: "role", metadata: { name: "researcher" } },
|
|
704
|
-
];
|
|
705
|
-
// A full page (>= the 500 cap) that omits both originals — a truncated page, NOT a
|
|
706
|
-
// signal that both were deleted.
|
|
707
|
-
const truncated = Array.from({ length: 500 }, (_, i) => ({
|
|
708
|
-
id: `Agents/filler-${i}`,
|
|
709
|
-
content: "role",
|
|
710
|
-
metadata: { name: `filler-${i}` },
|
|
711
|
-
}));
|
|
712
|
-
let current = initial;
|
|
713
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
714
|
-
const u = String(url);
|
|
715
|
-
const method = init?.method ?? "GET";
|
|
716
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
717
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
718
|
-
return new Response(JSON.stringify(current), { status: 200, headers: { "content-type": "application/json" } });
|
|
719
|
-
}
|
|
720
|
-
return new Response("[]", { status: 200 });
|
|
721
|
-
}) as typeof fetch;
|
|
722
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
723
|
-
await reg.loadAll(); // confident: uni + researcher live
|
|
724
|
-
expect(calls.deregistered).toEqual([]);
|
|
725
|
-
current = truncated; // the next poll returns a truncated page
|
|
726
|
-
await reg.loadAll();
|
|
727
|
-
// Guard tripped → NO teardown despite the originals being absent from the page.
|
|
728
|
-
expect(calls.deregistered).toEqual([]);
|
|
729
|
-
expect(calls.removed).toEqual([]);
|
|
730
|
-
|
|
731
|
-
// The guard DEFERS the decision, it doesn't LOSE it: the truncated pass left the
|
|
732
|
-
// seen-set intact (it skipped rebuildSeenDefs), so a later CONFIDENT pass that
|
|
733
|
-
// genuinely drops researcher still catches it as a removal and tears it down.
|
|
734
|
-
current = [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }];
|
|
735
|
-
calls.deregistered.length = 0;
|
|
736
|
-
calls.removed.length = 0;
|
|
737
|
-
await reg.loadAll();
|
|
738
|
-
expect(calls.deregistered).toContain("researcher");
|
|
739
|
-
expect(calls.removed).toContain("researcher");
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
test("reload for an unknown def-vault is a safe skip", async () => {
|
|
743
|
-
const { deps } = recorderDeps();
|
|
744
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn: vaultFetch({}) });
|
|
745
|
-
expect(await reg.reload("ghost-vault", "n")).toBe("skipped");
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test("a def-vault list failure does not sink the others (best-effort per vault)", async () => {
|
|
749
|
-
const { deps, calls } = recorderDeps();
|
|
750
|
-
const b2: DefVaultBinding = { vault: "research", vaultUrl: "http://127.0.0.1:1940", token: "t2" };
|
|
751
|
-
// vault `default` 500s its list; `research` serves one def.
|
|
752
|
-
const fetchFn = (async (url: string | URL | Request) => {
|
|
753
|
-
const u = String(url);
|
|
754
|
-
if (u.includes("/vault/default/")) return new Response("boom", { status: 500 });
|
|
755
|
-
if (u.includes("/vault/research/") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
756
|
-
return new Response(JSON.stringify([{ id: "r1", content: "role", metadata: { name: "r" } }]), {
|
|
757
|
-
status: 200,
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
return new Response(null, { status: 200 }); // PATCHes etc.
|
|
761
|
-
}) as typeof fetch;
|
|
762
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding, b2], fetchFn });
|
|
763
|
-
const n = await reg.loadAll();
|
|
764
|
-
expect(n).toBe(1);
|
|
765
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["r"]);
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
test("findLiveByNote returns the single match (vault + detail)", async () => {
|
|
769
|
-
const { deps } = recorderDeps();
|
|
770
|
-
const fetchFn = vaultFetch({
|
|
771
|
-
defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
|
|
772
|
-
});
|
|
773
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
774
|
-
await reg.loadAll();
|
|
775
|
-
const found = reg.findLiveByNote("Agents/uni-dev");
|
|
776
|
-
expect(found).not.toBeNull();
|
|
777
|
-
expect(found!.vault).toBe("default");
|
|
778
|
-
expect(found!.detail.name).toBe("uni-dev");
|
|
779
|
-
expect(reg.findLiveByNote("Agents/ghost")).toBeNull();
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
test("findLiveByNote throws 409 when the SAME noteId is live in two def-vaults (#106 ambiguity)", async () => {
|
|
783
|
-
const { deps } = recorderDeps();
|
|
784
|
-
const b2: DefVaultBinding = { vault: "research", vaultUrl: "http://127.0.0.1:1940", token: "t2" };
|
|
785
|
-
// The vaultFetch helper serves the same def list for ANY vault → both `default` and
|
|
786
|
-
// `research` vend a def at the SAME note path, so two live entries share the noteId.
|
|
787
|
-
const fetchFn = vaultFetch({
|
|
788
|
-
defs: [{ id: "Agents/shared", content: "role", metadata: { name: "shared" } }],
|
|
789
|
-
});
|
|
790
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding, b2], fetchFn });
|
|
791
|
-
await reg.loadAll();
|
|
792
|
-
// The note id is live in BOTH vaults — picking one is non-deterministic, so it throws
|
|
793
|
-
// a 409-class AgentDefWriteError rather than silently mutating an arbitrary one.
|
|
794
|
-
let caught: unknown;
|
|
795
|
-
try {
|
|
796
|
-
reg.findLiveByNote("Agents/shared");
|
|
797
|
-
} catch (err) {
|
|
798
|
-
caught = err;
|
|
799
|
-
}
|
|
800
|
-
expect(caught).toBeInstanceOf(AgentDefWriteError);
|
|
801
|
-
expect((caught as AgentDefWriteError).status).toBe(409);
|
|
802
|
-
expect((caught as AgentDefWriteError).message).toContain("ambiguous");
|
|
803
|
-
// The PATCH/DELETE write paths surface the same 409 (they resolve via findLiveByNote).
|
|
804
|
-
await expect(reg.editDef("Agents/shared", { systemPrompt: "x" })).rejects.toMatchObject({ status: 409 });
|
|
805
|
-
await expect(reg.deleteDef("Agents/shared")).rejects.toMatchObject({ status: 409 });
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
test("listDetailed carries the def mode (default single-threaded; multi-threaded when declared)", async () => {
|
|
809
|
-
const { deps } = recorderDeps();
|
|
810
|
-
const fetchFn = vaultFetch({
|
|
811
|
-
defs: [
|
|
812
|
-
{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }, // no mode → default
|
|
813
|
-
{ id: "Agents/digest", content: "role", metadata: { name: "digest", mode: "multi-threaded" } },
|
|
814
|
-
],
|
|
815
|
-
});
|
|
816
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
817
|
-
await reg.loadAll();
|
|
818
|
-
const byName = new Map(reg.listDetailed().map((d) => [d.name, d]));
|
|
819
|
-
expect(byName.get("uni-dev")!.mode).toBe("single-threaded");
|
|
820
|
-
expect(byName.get("digest")!.mode).toBe("multi-threaded");
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
test("getFullDef returns the FULL system prompt + mode/backend/wants (not the preview)", async () => {
|
|
824
|
-
const { deps } = recorderDeps();
|
|
825
|
-
const longPrompt = "P".repeat(500); // longer than the 200-char preview cap.
|
|
826
|
-
const fetchFn = vaultFetch({
|
|
827
|
-
defs: [
|
|
828
|
-
{
|
|
829
|
-
id: "Agents/uni-dev",
|
|
830
|
-
content: longPrompt,
|
|
831
|
-
metadata: { name: "uni-dev", backend: "attached", mode: "multi-threaded", wants: "vault:research:read" },
|
|
832
|
-
},
|
|
833
|
-
],
|
|
834
|
-
byId: {
|
|
835
|
-
"Agents/uni-dev": {
|
|
836
|
-
id: "Agents/uni-dev",
|
|
837
|
-
content: longPrompt,
|
|
838
|
-
metadata: { name: "uni-dev", backend: "attached", mode: "multi-threaded", wants: "vault:research:read" },
|
|
839
|
-
},
|
|
840
|
-
},
|
|
841
|
-
});
|
|
842
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
843
|
-
await reg.loadAll();
|
|
844
|
-
const full = await reg.getFullDef("Agents/uni-dev");
|
|
845
|
-
expect(full).not.toBeNull();
|
|
846
|
-
expect(full!.noteId).toBe("Agents/uni-dev");
|
|
847
|
-
expect(full!.name).toBe("uni-dev");
|
|
848
|
-
expect(full!.backend).toBe("attached");
|
|
849
|
-
expect(full!.mode).toBe("multi-threaded");
|
|
850
|
-
expect(full!.vault).toBe("default");
|
|
851
|
-
expect(full!.wants).toEqual(["vault:research:read"]);
|
|
852
|
-
// The FULL body, NOT the truncated preview.
|
|
853
|
-
expect(full!.systemPrompt).toBe(longPrompt);
|
|
854
|
-
expect(full!.systemPrompt.length).toBe(500);
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
test("getFullDef returns null for a note that isn't a live def", async () => {
|
|
858
|
-
const { deps } = recorderDeps();
|
|
859
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn: vaultFetch({}) });
|
|
860
|
-
expect(await reg.getFullDef("Agents/ghost")).toBeNull();
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
test("listDetailed/getFullDef surface per-connection {key,status,grantId} from the hub grants", async () => {
|
|
864
|
-
const { deps } = recorderDeps();
|
|
865
|
-
// The hub grants client's fetch: PUT /admin/grants echoes a grant record with the
|
|
866
|
-
// hub-assigned id + current status (the id we surface — never derived client-side);
|
|
867
|
-
// POST .../reconcile is the grant-GC (no-op here). One mcp connection, one vault.
|
|
868
|
-
const grantFetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
869
|
-
const u = String(url);
|
|
870
|
-
const method = init?.method ?? "GET";
|
|
871
|
-
if (method === "PUT" && u.endsWith("/admin/grants")) {
|
|
872
|
-
const { connection } = JSON.parse(String(init?.body)) as { connection: ConnectionSpec };
|
|
873
|
-
const key = connectionKey(connection);
|
|
874
|
-
// The mcp connection is awaiting consent; the vault one is approved.
|
|
875
|
-
const status = connection.kind === "mcp" ? "needs_consent" : "approved";
|
|
876
|
-
return new Response(
|
|
877
|
-
JSON.stringify({ id: `grant_${key}`, agent: "uni-dev", connection, status }),
|
|
878
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
if (method === "POST" && u.endsWith("/admin/grants/reconcile")) {
|
|
882
|
-
return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
|
|
883
|
-
}
|
|
884
|
-
return new Response("{}", { status: 200 });
|
|
885
|
-
}) as typeof fetch;
|
|
886
|
-
const grants = new GrantsClient({
|
|
887
|
-
hubOrigin: "http://127.0.0.1:1939",
|
|
888
|
-
managerBearer: "host-admin",
|
|
889
|
-
fetchFn: grantFetch,
|
|
890
|
-
});
|
|
891
|
-
const note = {
|
|
892
|
-
id: "Agents/uni-dev",
|
|
893
|
-
content: "role",
|
|
894
|
-
metadata: { name: "uni-dev", wants: "mcp:https://remote/mcp, vault:research:read" },
|
|
895
|
-
};
|
|
896
|
-
const fetchFn = vaultFetch({ defs: [note], byId: { "Agents/uni-dev": note } });
|
|
897
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
898
|
-
await reg.loadAll();
|
|
899
|
-
|
|
900
|
-
const detail = reg.listDetailed().find((d) => d.name === "uni-dev")!;
|
|
901
|
-
const byKey = new Map(detail.connections.map((c) => [c.key, c]));
|
|
902
|
-
const mcp = byKey.get("mcp:https://remote/mcp")!;
|
|
903
|
-
expect(mcp).toMatchObject({
|
|
904
|
-
kind: "mcp",
|
|
905
|
-
target: "https://remote/mcp",
|
|
906
|
-
status: "needs_consent",
|
|
907
|
-
grantId: "grant_mcp:https://remote/mcp",
|
|
908
|
-
});
|
|
909
|
-
const vault = byKey.get("vault:research:read")!;
|
|
910
|
-
expect(vault).toMatchObject({ kind: "vault", status: "approved", grantId: "grant_vault:research:read" });
|
|
911
|
-
// The FULL def carries the same connections (so the edit view needs no second fetch).
|
|
912
|
-
const full = await reg.getFullDef("Agents/uni-dev");
|
|
913
|
-
expect(full!.connections.map((c) => c.key).sort()).toEqual(
|
|
914
|
-
["mcp:https://remote/mcp", "vault:research:read"],
|
|
915
|
-
);
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
test("without a grants client, connections are surfaced display-only (status pending, no grant id)", async () => {
|
|
919
|
-
const { deps } = recorderDeps();
|
|
920
|
-
const note = {
|
|
921
|
-
id: "Agents/uni-dev",
|
|
922
|
-
content: "role",
|
|
923
|
-
metadata: { name: "uni-dev", wants: "mcp:https://remote/mcp" },
|
|
924
|
-
};
|
|
925
|
-
const fetchFn = vaultFetch({ defs: [note], byId: { "Agents/uni-dev": note } });
|
|
926
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn }); // no grants
|
|
927
|
-
await reg.loadAll();
|
|
928
|
-
const detail = reg.listDetailed().find((d) => d.name === "uni-dev")!;
|
|
929
|
-
expect(detail.connections).toEqual([
|
|
930
|
-
{ key: "mcp:https://remote/mcp", kind: "mcp", target: "https://remote/mcp", status: "pending" },
|
|
931
|
-
]);
|
|
932
|
-
// No grant id → the panel can't offer Connect (shows the degraded hint instead).
|
|
933
|
-
expect(detail.connections[0]!.grantId).toBeUndefined();
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
test("soleVaultName resolves the single binding (the reload-webhook default)", () => {
|
|
937
|
-
const { deps } = recorderDeps();
|
|
938
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding] });
|
|
939
|
-
expect(reg.soleVaultName()).toBe("default");
|
|
940
|
-
expect(reg.vaultCount).toBe(1);
|
|
941
|
-
reg.addVault({ vault: "research", token: "t" });
|
|
942
|
-
expect(reg.soleVaultName()).toBeUndefined();
|
|
943
|
-
expect(reg.vaultCount).toBe(2);
|
|
944
|
-
});
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
// ---------------------------------------------------------------------------
|
|
948
|
-
// AgentDefRegistry — 4b grant registration + status (design 2026-06-17-agent-connectors-4b)
|
|
949
|
-
// ---------------------------------------------------------------------------
|
|
950
|
-
|
|
951
|
-
/** A fake GrantsClient that records PUTs + reconcile POSTs and returns a configurable
|
|
952
|
-
* per-connection status. The hub isn't deployed in the test env — this mocks its
|
|
953
|
-
* grants API (register PUT + reconcile POST, #96 grant-GC). */
|
|
954
|
-
function fakeGrantsClient(opts: {
|
|
955
|
-
/** connectionKey → the status the hub returns on register. Default "pending". */
|
|
956
|
-
statusByKey?: Record<string, string>;
|
|
957
|
-
/** Record each registered (agent, connection). */
|
|
958
|
-
registered?: Array<{ agent: string; connection: ConnectionSpec }>;
|
|
959
|
-
/** Record each reconcile (agent, liveConnections) — the #96 grant-GC call. */
|
|
960
|
-
reconciled?: Array<{ agent: string; liveConnections: ConnectionSpec[] }>;
|
|
961
|
-
/** How many grants the hub reports pruned per reconcile (default 0). */
|
|
962
|
-
prunedPerReconcile?: number;
|
|
963
|
-
/** Make reconcile POSTs 500 (to assert the failure is swallowed). */
|
|
964
|
-
reconcileFails?: boolean;
|
|
965
|
-
}): GrantsClient {
|
|
966
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
967
|
-
const u = String(url);
|
|
968
|
-
if (u.endsWith("/admin/grants/reconcile") && (init?.method ?? "GET") === "POST") {
|
|
969
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
|
|
970
|
-
opts.reconciled?.push({ agent: body.agent, liveConnections: body.liveConnections });
|
|
971
|
-
if (opts.reconcileFails) return new Response("boom", { status: 500 });
|
|
972
|
-
return new Response(JSON.stringify({ pruned: opts.prunedPerReconcile ?? 0, prunedIds: [] }), {
|
|
973
|
-
status: 200,
|
|
974
|
-
headers: { "content-type": "application/json" },
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
if (u.endsWith("/admin/grants") && (init?.method ?? "GET") === "PUT") {
|
|
978
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; connection: ConnectionSpec };
|
|
979
|
-
opts.registered?.push({ agent: body.agent, connection: body.connection });
|
|
980
|
-
const key = connectionKey(body.connection);
|
|
981
|
-
const status = opts.statusByKey?.[key] ?? "pending";
|
|
982
|
-
return new Response(
|
|
983
|
-
JSON.stringify({ id: `g-${key}`, agent: body.agent, connection: body.connection, status }),
|
|
984
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
985
|
-
);
|
|
986
|
-
}
|
|
987
|
-
return new Response("{}", { status: 200 });
|
|
988
|
-
}) as typeof fetch;
|
|
989
|
-
return new GrantsClient({ hubOrigin: "https://hub.example.com", managerBearer: "MGR", fetchFn });
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
describe("AgentDefRegistry — grant registration + status (4b)", () => {
|
|
993
|
-
test("registers each `wants:` connection as a pending grant on instantiate", async () => {
|
|
994
|
-
const { deps } = recorderDeps();
|
|
995
|
-
const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
|
|
996
|
-
const grants = fakeGrantsClient({ registered }); // all default "pending"
|
|
997
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
998
|
-
const fetchFn = vaultFetch({
|
|
999
|
-
defs: [
|
|
1000
|
-
{
|
|
1001
|
-
id: "Agents/researcher",
|
|
1002
|
-
content: "role",
|
|
1003
|
-
metadata: { name: "researcher", wants: "vault:research:read, env:github" },
|
|
1004
|
-
},
|
|
1005
|
-
],
|
|
1006
|
-
patches,
|
|
1007
|
-
});
|
|
1008
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1009
|
-
await reg.loadAll();
|
|
1010
|
-
|
|
1011
|
-
// Both connections were registered for the agent.
|
|
1012
|
-
expect(registered.map((r) => r.connection.target).sort()).toEqual(["github", "research"]);
|
|
1013
|
-
expect(registered.every((r) => r.agent === "researcher")).toBe(true);
|
|
1014
|
-
// None approved → status pending listing the connection keys.
|
|
1015
|
-
const p = patches.find((x) => x.id === "Agents/researcher")!;
|
|
1016
|
-
expect(p.status).toBe("pending");
|
|
1017
|
-
expect(p.pending).toContain("vault:research:read");
|
|
1018
|
-
expect(p.pending).toContain("env:github");
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
test("status = enabled only once EVERY connection is approved", async () => {
|
|
1022
|
-
const { deps } = recorderDeps();
|
|
1023
|
-
const vaultConn: ConnectionSpec = { kind: "vault", target: "research", access: "read" };
|
|
1024
|
-
const svcConn: ConnectionSpec = { kind: "service", target: "github", inject: ["env"] };
|
|
1025
|
-
const grants = fakeGrantsClient({
|
|
1026
|
-
statusByKey: {
|
|
1027
|
-
[connectionKey(vaultConn)]: "approved",
|
|
1028
|
-
[connectionKey(svcConn)]: "approved",
|
|
1029
|
-
},
|
|
1030
|
-
});
|
|
1031
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
1032
|
-
const fetchFn = vaultFetch({
|
|
1033
|
-
defs: [
|
|
1034
|
-
{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read, env:github" } },
|
|
1035
|
-
],
|
|
1036
|
-
patches,
|
|
1037
|
-
});
|
|
1038
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1039
|
-
await reg.loadAll();
|
|
1040
|
-
const p = patches.find((x) => x.id === "Agents/r")!;
|
|
1041
|
-
expect(p.status).toBe("enabled");
|
|
1042
|
-
expect(p.pending).toBe("");
|
|
1043
|
-
expect(reg.list().find((d) => d.name === "r")!.status).toBe("enabled");
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
test("partial approval → pending listing only the UNAPPROVED connection keys", async () => {
|
|
1047
|
-
const { deps } = recorderDeps();
|
|
1048
|
-
const vaultConn: ConnectionSpec = { kind: "vault", target: "research", access: "read" };
|
|
1049
|
-
const grants = fakeGrantsClient({
|
|
1050
|
-
statusByKey: { [connectionKey(vaultConn)]: "approved" }, // github stays pending
|
|
1051
|
-
});
|
|
1052
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
1053
|
-
const fetchFn = vaultFetch({
|
|
1054
|
-
defs: [
|
|
1055
|
-
{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read, env:github" } },
|
|
1056
|
-
],
|
|
1057
|
-
patches,
|
|
1058
|
-
});
|
|
1059
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1060
|
-
await reg.loadAll();
|
|
1061
|
-
const p = patches.find((x) => x.id === "Agents/r")!;
|
|
1062
|
-
expect(p.status).toBe("pending");
|
|
1063
|
-
expect(p.pending).toBe("env:github"); // only the unapproved one
|
|
1064
|
-
// The agent STILL instantiated (own-vault runs regardless of grant approval).
|
|
1065
|
-
expect(reg.list().find((d) => d.name === "r")).toBeDefined();
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
test("an mcp-kind want registers + stays pending (parsed, not granted in 4b-1)", async () => {
|
|
1069
|
-
const { deps } = recorderDeps();
|
|
1070
|
-
const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
|
|
1071
|
-
const grants = fakeGrantsClient({ registered }); // mcp stays "pending"
|
|
1072
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
1073
|
-
const fetchFn = vaultFetch({
|
|
1074
|
-
defs: [
|
|
1075
|
-
{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "mcp:https://remote/mcp" } },
|
|
1076
|
-
],
|
|
1077
|
-
patches,
|
|
1078
|
-
});
|
|
1079
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1080
|
-
await reg.loadAll();
|
|
1081
|
-
expect(registered).toHaveLength(1);
|
|
1082
|
-
expect(registered[0]!.connection).toEqual({ kind: "mcp", target: "https://remote/mcp" });
|
|
1083
|
-
const p = patches.find((x) => x.id === "Agents/r")!;
|
|
1084
|
-
expect(p.status).toBe("pending");
|
|
1085
|
-
expect(p.pending).toBe("mcp:https://remote/mcp");
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
test("a malformed `wants:` stamps status error (does not register or instantiate)", async () => {
|
|
1089
|
-
const { deps, calls } = recorderDeps();
|
|
1090
|
-
const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
|
|
1091
|
-
const grants = fakeGrantsClient({ registered });
|
|
1092
|
-
const patches: Array<{ id: string; status?: string }> = [];
|
|
1093
|
-
const fetchFn = vaultFetch({
|
|
1094
|
-
defs: [{ id: "Agents/bad", content: "role", metadata: { name: "bad", wants: "vault:research" } }],
|
|
1095
|
-
patches,
|
|
1096
|
-
});
|
|
1097
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1098
|
-
const n = await reg.loadAll();
|
|
1099
|
-
expect(n).toBe(0);
|
|
1100
|
-
expect(calls.registered).toHaveLength(0); // never instantiated
|
|
1101
|
-
expect(registered).toHaveLength(0); // never registered a grant
|
|
1102
|
-
expect(patches.find((p) => p.id === "Agents/bad")!.status).toBe("error");
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
test("a grant-registration FAILURE is non-fatal → connection counts as pending", async () => {
|
|
1106
|
-
const { deps, calls } = recorderDeps();
|
|
1107
|
-
// A grants client whose PUT 500s.
|
|
1108
|
-
const fetchFn500 = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1109
|
-
if (String(url).endsWith("/admin/grants") && init?.method === "PUT") {
|
|
1110
|
-
return new Response("boom", { status: 500 });
|
|
1111
|
-
}
|
|
1112
|
-
return new Response("{}", { status: 200 });
|
|
1113
|
-
}) as typeof fetch;
|
|
1114
|
-
const grants = new GrantsClient({ hubOrigin: "https://hub.example.com", managerBearer: "MGR", fetchFn: fetchFn500 });
|
|
1115
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
1116
|
-
const fetchFn = vaultFetch({
|
|
1117
|
-
defs: [{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read" } }],
|
|
1118
|
-
patches,
|
|
1119
|
-
});
|
|
1120
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1121
|
-
const count = await reg.loadAll();
|
|
1122
|
-
// The agent STILL instantiated (own-vault) — a hub blip never blocks it.
|
|
1123
|
-
expect(count).toBe(1);
|
|
1124
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["r"]);
|
|
1125
|
-
const p = patches.find((x) => x.id === "Agents/r")!;
|
|
1126
|
-
expect(p.status).toBe("pending");
|
|
1127
|
-
expect(p.pending).toBe("vault:research:read");
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
test("setGrantsClient(null) → falls back to the pure status (no registration)", async () => {
|
|
1131
|
-
const { deps } = recorderDeps();
|
|
1132
|
-
const patches: Array<{ id: string; status?: string; pending?: string }> = [];
|
|
1133
|
-
const fetchFn = vaultFetch({
|
|
1134
|
-
defs: [{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read" } }],
|
|
1135
|
-
patches,
|
|
1136
|
-
});
|
|
1137
|
-
// No grants client at all → resolveDefStatus fallback (pending listing conn keys).
|
|
1138
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
1139
|
-
reg.setGrantsClient(null);
|
|
1140
|
-
await reg.loadAll();
|
|
1141
|
-
const p = patches.find((x) => x.id === "Agents/r")!;
|
|
1142
|
-
expect(p.status).toBe("pending");
|
|
1143
|
-
expect(p.pending).toBe("vault:research:read");
|
|
1144
|
-
});
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
// ---------------------------------------------------------------------------
|
|
1148
|
-
// AgentDefRegistry — grant garbage-collection / reconcile (#96)
|
|
1149
|
-
// ---------------------------------------------------------------------------
|
|
1150
|
-
|
|
1151
|
-
describe("AgentDefRegistry — grant-GC reconcile (#96)", () => {
|
|
1152
|
-
test("a successful load reconciles with the def's CURRENT live connection specs", async () => {
|
|
1153
|
-
const { deps } = recorderDeps();
|
|
1154
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1155
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1156
|
-
const fetchFn = vaultFetch({
|
|
1157
|
-
defs: [
|
|
1158
|
-
{
|
|
1159
|
-
id: "Agents/researcher",
|
|
1160
|
-
content: "role",
|
|
1161
|
-
metadata: { name: "researcher", wants: "vault:research:read, env:github, mcp:github" },
|
|
1162
|
-
},
|
|
1163
|
-
],
|
|
1164
|
-
});
|
|
1165
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1166
|
-
await reg.loadAll();
|
|
1167
|
-
|
|
1168
|
-
expect(reconciled).toHaveLength(1);
|
|
1169
|
-
expect(reconciled[0]!.agent).toBe("researcher");
|
|
1170
|
-
// The SPECS sent MUST equal the parsed wants (env:github + mcp:github MERGE to one
|
|
1171
|
-
// service connection with inject ["env","mcp"]). The hub re-derives the keys.
|
|
1172
|
-
const wants: ConnectionSpec[] = [
|
|
1173
|
-
{ kind: "vault", target: "research", access: "read" },
|
|
1174
|
-
{ kind: "service", target: "github", inject: ["env", "mcp"] },
|
|
1175
|
-
];
|
|
1176
|
-
expect(reconciled[0]!.liveConnections).toEqual(wants);
|
|
1177
|
-
});
|
|
1178
|
-
|
|
1179
|
-
test("a def with NO wants still reconciles with empty liveConnections (prunes any leftover)", async () => {
|
|
1180
|
-
const { deps } = recorderDeps();
|
|
1181
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1182
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1183
|
-
const fetchFn = vaultFetch({
|
|
1184
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1185
|
-
});
|
|
1186
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1187
|
-
await reg.loadAll();
|
|
1188
|
-
expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
test("a REMOVED def (present in a prior load, gone now) → reconcile(agent, []) + teardown", async () => {
|
|
1192
|
-
const { deps, calls } = recorderDeps();
|
|
1193
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1194
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1195
|
-
// Two notes present at first; the second load drops "researcher".
|
|
1196
|
-
const present: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
|
|
1197
|
-
{ id: "Agents/uni", content: "role", metadata: { name: "uni" } },
|
|
1198
|
-
{ id: "Agents/researcher", content: "role", metadata: { name: "researcher", wants: "vault:research:read" } },
|
|
1199
|
-
];
|
|
1200
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1201
|
-
const u = String(url);
|
|
1202
|
-
const method = init?.method ?? "GET";
|
|
1203
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
1204
|
-
if (u.endsWith("/admin/grants/reconcile") || u.endsWith("/admin/grants")) {
|
|
1205
|
-
// delegate to the fake grants client's fetch by re-issuing through it isn't
|
|
1206
|
-
// possible here; instead record reconcile directly.
|
|
1207
|
-
if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
|
|
1208
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
|
|
1209
|
-
reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
|
|
1210
|
-
return new Response(JSON.stringify({ pruned: 0, prunedIds: [] }), { status: 200 });
|
|
1211
|
-
}
|
|
1212
|
-
if (method === "PUT") {
|
|
1213
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; connection: ConnectionSpec };
|
|
1214
|
-
return new Response(
|
|
1215
|
-
JSON.stringify({ id: "g", agent: body.agent, connection: body.connection, status: "pending" }),
|
|
1216
|
-
{ status: 200 },
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
1221
|
-
return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
|
|
1222
|
-
}
|
|
1223
|
-
return new Response("[]", { status: 200 });
|
|
1224
|
-
}) as typeof fetch;
|
|
1225
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1226
|
-
await reg.loadAll(); // first confident read — both present
|
|
1227
|
-
// Drop researcher; second confident read sees only uni.
|
|
1228
|
-
present.splice(1, 1);
|
|
1229
|
-
reconciled.length = 0; // ignore the first-load reconciles; focus on the removal
|
|
1230
|
-
await reg.loadAll();
|
|
1231
|
-
|
|
1232
|
-
// The removed agent gets a prune-ALL reconcile.
|
|
1233
|
-
const removal = reconciled.find((r) => r.agent === "researcher");
|
|
1234
|
-
expect(removal).toEqual({ agent: "researcher", liveConnections: [] });
|
|
1235
|
-
// uni (still present, no wants) reconciles with [] too — that's its clean-load prune,
|
|
1236
|
-
// NOT a removal; distinguished by the agent name.
|
|
1237
|
-
expect(reconciled.find((r) => r.agent === "uni")).toEqual({ agent: "uni", liveConnections: [] });
|
|
1238
|
-
// AND it's torn down (not just grant-pruned): the only auto path for a delete.
|
|
1239
|
-
expect(calls.deregistered).toEqual(["researcher"]);
|
|
1240
|
-
expect(calls.removed).toEqual(["researcher"]);
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
test("a delete reload → reconcile(agent, []) (confirmed removal)", async () => {
|
|
1244
|
-
const { deps } = recorderDeps();
|
|
1245
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1246
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1247
|
-
const fetchFn = vaultFetch({
|
|
1248
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1249
|
-
});
|
|
1250
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1251
|
-
await reg.loadAll();
|
|
1252
|
-
reconciled.length = 0; // drop the clean-load reconcile; focus on the delete
|
|
1253
|
-
const result = await reg.reload("default", "Agents/uni", "deleted");
|
|
1254
|
-
expect(result).toBe("deregistered");
|
|
1255
|
-
expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
|
|
1256
|
-
});
|
|
1257
|
-
|
|
1258
|
-
test("a reload that re-reads as GONE (404) → reconcile(agent, []) (confirmed removal)", async () => {
|
|
1259
|
-
const { deps } = recorderDeps();
|
|
1260
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1261
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1262
|
-
const fetchFn = vaultFetch({
|
|
1263
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1264
|
-
byId: { "Agents/uni": null }, // a later GET says it's gone
|
|
1265
|
-
});
|
|
1266
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1267
|
-
await reg.loadAll();
|
|
1268
|
-
reconciled.length = 0;
|
|
1269
|
-
const result = await reg.reload("default", "Agents/uni"); // no event → GET → 404
|
|
1270
|
-
expect(result).toBe("deregistered");
|
|
1271
|
-
expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
test("SAFETY: a PARSE-FAILING def NEVER reconciles (no prune from an error)", async () => {
|
|
1275
|
-
const { deps } = recorderDeps();
|
|
1276
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1277
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1278
|
-
const fetchFn = vaultFetch({
|
|
1279
|
-
defs: [
|
|
1280
|
-
{ id: "Agents/bad", content: "role", metadata: { name: "bad", wants: "vault:research" } }, // malformed wants
|
|
1281
|
-
{ id: "Agents/noname", content: "role", metadata: {} }, // no name → parse error
|
|
1282
|
-
],
|
|
1283
|
-
});
|
|
1284
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1285
|
-
const n = await reg.loadAll();
|
|
1286
|
-
expect(n).toBe(0); // nothing instantiated
|
|
1287
|
-
// NEITHER parse-failing def reconciled — a transient parse error must not nuke grants.
|
|
1288
|
-
expect(reconciled).toEqual([]);
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
test("SAFETY: a parse-failing def is NOT later flagged removed (its grants survive)", async () => {
|
|
1292
|
-
const { deps } = recorderDeps();
|
|
1293
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1294
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1295
|
-
// First load: a CLEAN def. Second load: the SAME note now parse-fails (a transient
|
|
1296
|
-
// bad edit). It must NOT be treated as a removal (it's still present in the vault).
|
|
1297
|
-
const note: { id: string; content?: string; metadata?: Record<string, unknown> } = {
|
|
1298
|
-
id: "Agents/uni",
|
|
1299
|
-
content: "role",
|
|
1300
|
-
metadata: { name: "uni" },
|
|
1301
|
-
};
|
|
1302
|
-
const present = [note];
|
|
1303
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1304
|
-
const u = String(url);
|
|
1305
|
-
const method = init?.method ?? "GET";
|
|
1306
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
1307
|
-
if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
|
|
1308
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
|
|
1309
|
-
reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
|
|
1310
|
-
return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
|
|
1311
|
-
}
|
|
1312
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
1313
|
-
return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
|
|
1314
|
-
}
|
|
1315
|
-
return new Response("[]", { status: 200 });
|
|
1316
|
-
}) as typeof fetch;
|
|
1317
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1318
|
-
await reg.loadAll(); // clean → reconcile(uni, [])
|
|
1319
|
-
// Now make the same note malformed (remove its name) and reload all.
|
|
1320
|
-
note.metadata = { name: "uni", wants: "vault:research" }; // malformed wants → parse error
|
|
1321
|
-
reconciled.length = 0;
|
|
1322
|
-
await reg.loadAll();
|
|
1323
|
-
// The note is STILL present (just unparseable) → NOT a removal → no reconcile at all.
|
|
1324
|
-
expect(reconciled).toEqual([]);
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
test("SAFETY: a vault LIST failure does NOT prune (no confident read)", async () => {
|
|
1328
|
-
const { deps } = recorderDeps();
|
|
1329
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1330
|
-
const grants = fakeGrantsClient({ reconciled });
|
|
1331
|
-
let listShouldFail = false;
|
|
1332
|
-
const present = [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }];
|
|
1333
|
-
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1334
|
-
const u = String(url);
|
|
1335
|
-
const method = init?.method ?? "GET";
|
|
1336
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
1337
|
-
if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
|
|
1338
|
-
const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
|
|
1339
|
-
reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
|
|
1340
|
-
return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
|
|
1341
|
-
}
|
|
1342
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
1343
|
-
if (listShouldFail) return new Response("boom", { status: 500 });
|
|
1344
|
-
return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
|
|
1345
|
-
}
|
|
1346
|
-
return new Response("[]", { status: 200 });
|
|
1347
|
-
}) as typeof fetch;
|
|
1348
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1349
|
-
await reg.loadAll(); // confident → seen set = {uni}
|
|
1350
|
-
reconciled.length = 0;
|
|
1351
|
-
listShouldFail = true;
|
|
1352
|
-
await reg.loadAll(); // list 500s → NOT a confident read → no removal diff, no prune
|
|
1353
|
-
expect(reconciled).toEqual([]);
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
test("a reconcile HTTP failure is swallowed — the load does NOT throw / still instantiates", async () => {
|
|
1357
|
-
const { deps, calls } = recorderDeps();
|
|
1358
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1359
|
-
const grants = fakeGrantsClient({ reconciled, reconcileFails: true }); // reconcile POST 500s
|
|
1360
|
-
const patches: Array<{ id: string; status?: string }> = [];
|
|
1361
|
-
const fetchFn = vaultFetch({
|
|
1362
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1363
|
-
patches,
|
|
1364
|
-
});
|
|
1365
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1366
|
-
// Must not throw out of loadAll despite the 500.
|
|
1367
|
-
const n = await reg.loadAll();
|
|
1368
|
-
expect(n).toBe(1);
|
|
1369
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["uni"]); // still instantiated
|
|
1370
|
-
expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]); // it was attempted
|
|
1371
|
-
});
|
|
1372
|
-
|
|
1373
|
-
test("no grants client → reconcile is a no-op (the vault-native path still runs)", async () => {
|
|
1374
|
-
const { deps, calls } = recorderDeps();
|
|
1375
|
-
const fetchFn = vaultFetch({
|
|
1376
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni", wants: "vault:research:read" } }],
|
|
1377
|
-
});
|
|
1378
|
-
// No grants client → no reconcile attempted; the agent still instantiates own-vault.
|
|
1379
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
1380
|
-
const n = await reg.loadAll();
|
|
1381
|
-
expect(n).toBe(1);
|
|
1382
|
-
expect(calls.registered.map((s) => s.name)).toEqual(["uni"]);
|
|
1383
|
-
});
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
// ---------------------------------------------------------------------------
|
|
1387
|
-
// FIX 4 (delete ordering: vault-delete first, then deregister) + FIX 5 (grant-GC
|
|
1388
|
-
// failure on delete is surfaced, not swallowed) — PR #3.
|
|
1389
|
-
// ---------------------------------------------------------------------------
|
|
1390
|
-
|
|
1391
|
-
/**
|
|
1392
|
-
* A fetch that serves the def list + by-id GET (so an agent instantiates) and routes
|
|
1393
|
-
* a DELETE to a configurable outcome (`deleteStatus`). Records DELETEs so a test can
|
|
1394
|
-
* assert the note-delete was attempted. Reconcile/PATCH succeed by default.
|
|
1395
|
-
*/
|
|
1396
|
-
function vaultFetchWithDelete(opts: {
|
|
1397
|
-
defs: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>;
|
|
1398
|
-
deleteStatus?: number; // the status the DELETE returns (default 204 = success)
|
|
1399
|
-
deletes?: string[]; // record each DELETEd note id
|
|
1400
|
-
}): typeof fetch {
|
|
1401
|
-
return (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1402
|
-
const u = String(url);
|
|
1403
|
-
const method = init?.method ?? "GET";
|
|
1404
|
-
if (method === "DELETE") {
|
|
1405
|
-
const id = decodeURIComponent(u.split("/api/notes/")[1]!);
|
|
1406
|
-
opts.deletes?.push(id);
|
|
1407
|
-
const status = opts.deleteStatus ?? 204;
|
|
1408
|
-
return new Response(status >= 400 ? "delete failed" : null, { status });
|
|
1409
|
-
}
|
|
1410
|
-
if (method === "PATCH") return new Response(null, { status: 200 });
|
|
1411
|
-
if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
|
|
1412
|
-
return new Response(JSON.stringify(opts.defs), {
|
|
1413
|
-
status: 200,
|
|
1414
|
-
headers: { "content-type": "application/json" },
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1417
|
-
return new Response("[]", { status: 200 });
|
|
1418
|
-
}) as typeof fetch;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
describe("AgentDefRegistry — deleteDef ordering + grant-GC surfacing (FIX 4/5, PR #3)", () => {
|
|
1422
|
-
test("FIX 4: a vault-delete failure leaves the def REGISTERED (not orphaned)", async () => {
|
|
1423
|
-
const { deps, calls } = recorderDeps();
|
|
1424
|
-
const deletes: string[] = [];
|
|
1425
|
-
const fetchFn = vaultFetchWithDelete({
|
|
1426
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1427
|
-
deleteStatus: 502, // the vault note delete 502s
|
|
1428
|
-
deletes,
|
|
1429
|
-
});
|
|
1430
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
1431
|
-
await reg.loadAll();
|
|
1432
|
-
expect(reg.findLiveByNote("Agents/uni")).not.toBeNull(); // live before delete.
|
|
1433
|
-
|
|
1434
|
-
// The delete throws (the vault note delete failed) BEFORE any deregister.
|
|
1435
|
-
await expect(reg.deleteDef("Agents/uni")).rejects.toThrow(/delete def Agents\/uni failed \(502\)/);
|
|
1436
|
-
|
|
1437
|
-
// FIX 4 invariant: the agent is STILL registered (the in-memory def was NOT torn down
|
|
1438
|
-
// on a failed vault delete) — it re-converges on the next poll rather than orphaning.
|
|
1439
|
-
expect(reg.findLiveByNote("Agents/uni")).not.toBeNull();
|
|
1440
|
-
expect(calls.deregistered).toEqual([]); // nothing was deregistered.
|
|
1441
|
-
expect(deletes).toEqual(["Agents/uni"]); // the delete WAS attempted (and failed).
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
test("FIX 4: a successful vault-delete deregisters cleanly", async () => {
|
|
1445
|
-
const { deps, calls } = recorderDeps();
|
|
1446
|
-
const deletes: string[] = [];
|
|
1447
|
-
const fetchFn = vaultFetchWithDelete({
|
|
1448
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1449
|
-
deleteStatus: 204,
|
|
1450
|
-
deletes,
|
|
1451
|
-
});
|
|
1452
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
|
|
1453
|
-
await reg.loadAll();
|
|
1454
|
-
|
|
1455
|
-
const removed = await reg.deleteDef("Agents/uni");
|
|
1456
|
-
expect(removed.name).toBe("uni");
|
|
1457
|
-
expect(removed.grantsReconciled).toBe(true); // no grants client → nothing to reconcile = ok.
|
|
1458
|
-
// Now deregistered + removed from the live set.
|
|
1459
|
-
expect(reg.findLiveByNote("Agents/uni")).toBeNull();
|
|
1460
|
-
expect(calls.deregistered).toEqual(["uni"]);
|
|
1461
|
-
expect(deletes).toEqual(["Agents/uni"]);
|
|
1462
|
-
});
|
|
1463
|
-
|
|
1464
|
-
test("FIX 5: a grant-reconcile failure on delete is SURFACED (grantsReconciled:false) — the note-delete still completes", async () => {
|
|
1465
|
-
const { deps, calls } = recorderDeps();
|
|
1466
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1467
|
-
const grants = fakeGrantsClient({ reconciled, reconcileFails: true }); // reconcile POST 500s
|
|
1468
|
-
const deletes: string[] = [];
|
|
1469
|
-
const fetchFn = vaultFetchWithDelete({
|
|
1470
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1471
|
-
deleteStatus: 204,
|
|
1472
|
-
deletes,
|
|
1473
|
-
});
|
|
1474
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1475
|
-
await reg.loadAll();
|
|
1476
|
-
reconciled.length = 0;
|
|
1477
|
-
|
|
1478
|
-
// The delete must NOT throw (grant GC is best-effort) but MUST report the partial
|
|
1479
|
-
// success so the caller doesn't claim a clean full success (orphaned grants).
|
|
1480
|
-
const removed = await reg.deleteDef("Agents/uni");
|
|
1481
|
-
expect(removed.name).toBe("uni");
|
|
1482
|
-
expect(removed.grantsReconciled).toBe(false); // FIX 5: the failure is surfaced, not swallowed.
|
|
1483
|
-
// The note-delete + deregister STILL completed (the def IS gone).
|
|
1484
|
-
expect(reg.findLiveByNote("Agents/uni")).toBeNull();
|
|
1485
|
-
expect(calls.deregistered).toEqual(["uni"]);
|
|
1486
|
-
expect(deletes).toEqual(["Agents/uni"]);
|
|
1487
|
-
// The reconcile WAS attempted (prune-all on the removed agent) — it just failed on the hub.
|
|
1488
|
-
expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
|
|
1489
|
-
});
|
|
1490
|
-
|
|
1491
|
-
test("FIX 5: a SUCCESSFUL grant-reconcile on delete reports grantsReconciled:true", async () => {
|
|
1492
|
-
const { deps } = recorderDeps();
|
|
1493
|
-
const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
|
|
1494
|
-
const grants = fakeGrantsClient({ reconciled }); // reconcile succeeds
|
|
1495
|
-
const fetchFn = vaultFetchWithDelete({
|
|
1496
|
-
defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
|
|
1497
|
-
deleteStatus: 204,
|
|
1498
|
-
});
|
|
1499
|
-
const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
|
|
1500
|
-
await reg.loadAll();
|
|
1501
|
-
const removed = await reg.deleteDef("Agents/uni");
|
|
1502
|
-
expect(removed.grantsReconciled).toBe(true);
|
|
1503
|
-
});
|
|
1504
|
-
});
|