@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.
Files changed (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
@@ -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
- });