@openparachute/hub 0.7.1 → 0.7.2-rc.1

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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -3,11 +3,11 @@
3
3
  * P5) — `GET /api/connections/catalog`, `GET/POST /admin/connections`,
4
4
  * `DELETE /admin/connections/:id`.
5
5
  *
6
- * The catalog is built from injected module manifests. The vault + channel HTTP
6
+ * The catalog is built from injected module manifests. The vault + agent HTTP
7
7
  * calls are mocked via an injectable `fetchImpl` that records every request
8
8
  * (method, URL, decoded bearer, parsed body) and returns scripted responses.
9
9
  * Tokens are real (minted by the actual `signAccessToken`), so we can decode the
10
- * JWT claims (scope/aud) the way channel/vault would.
10
+ * JWT claims (scope/aud) the way agent/vault would.
11
11
  *
12
12
  * The whole point of the engine is GENERALITY: the webhook + scope come from the
13
13
  * SINK ACTION's declaration, not hardcoded per module. The tests assert that —
@@ -39,7 +39,7 @@ import { rotateSigningKey } from "../signing-keys.ts";
39
39
  import { createUser } from "../users.ts";
40
40
 
41
41
  const HUB_ORIGIN = "https://hub.test";
42
- const CHANNEL_ORIGIN = "http://127.0.0.1:1941";
42
+ const AGENT_ORIGIN = "http://127.0.0.1:1941";
43
43
  const VAULT_ORIGIN = "http://127.0.0.1:1940";
44
44
 
45
45
  interface Harness {
@@ -162,10 +162,10 @@ const VAULT_MANIFEST: ModuleManifest = {
162
162
  };
163
163
 
164
164
  const CHANNEL_MANIFEST: ModuleManifest = {
165
- name: "channel",
166
- manifestName: "parachute-channel",
165
+ name: "agent",
166
+ manifestName: "parachute-agent",
167
167
  port: 1941,
168
- paths: ["/channel"],
168
+ paths: ["/agent"],
169
169
  health: "/health",
170
170
  events: [{ key: "message.received", title: "A message arrived" }],
171
171
  actions: [
@@ -173,24 +173,34 @@ const CHANNEL_MANIFEST: ModuleManifest = {
173
173
  key: "message.deliver",
174
174
  title: "Deliver an inbound message",
175
175
  endpoint: "/api/vault/inbound",
176
- scope: "channel:send",
176
+ scope: "agent:send",
177
+ provision: { type: "vault-trigger" },
178
+ },
179
+ {
180
+ // A second agent action that needs NO session reply path — a pure inbound
181
+ // webhook. Drives the regression test that the channel-reply prerequisite
182
+ // is gated on the ACTION (message.deliver), not the module (agent#117).
183
+ key: "definition.reload",
184
+ title: "Reload a changed agent definition",
185
+ endpoint: "/api/vault/agent-def",
186
+ scope: "agent:send",
177
187
  provision: { type: "vault-trigger" },
178
188
  },
179
189
  ],
180
- // Mirrors the declaration channel ships in its real module.json (boundary
181
- // D2) — drives the catalog `templates` round-trip pin below.
190
+ // Mirrors the declaration the agent module ships in its real module.json
191
+ // (boundary D2) — drives the catalog `templates` round-trip pin below.
182
192
  connectionTemplates: [
183
193
  {
184
194
  key: "link-to-vault",
185
195
  title: "Link a channel to a vault",
186
196
  description: "Back a channel with a Parachute vault.",
187
- requestedBy: "channel",
197
+ requestedBy: "agent",
188
198
  source: {
189
199
  module: "vault",
190
200
  event: "note.created",
191
- filter: { tags: ["#channel-message/inbound"] },
201
+ filter: { tags: ["#agent-message/inbound"] },
192
202
  },
193
- sink: { module: "channel", action: "message.deliver" },
203
+ sink: { module: "agent", action: "message.deliver" },
194
204
  parameters: [
195
205
  { key: "vault", target: "source.vault", title: "Vault" },
196
206
  { key: "channel", target: "sink.params.channel", title: "Channel name" },
@@ -231,7 +241,7 @@ function baseDeps(fetchImpl: typeof fetch, modules: InstalledModuleInfo[]): Conn
231
241
  hubOrigin: HUB_ORIGIN,
232
242
  modules,
233
243
  resolveVaultOrigin: (v) => (v === "default" ? VAULT_ORIGIN : null),
234
- channelOrigin: CHANNEL_ORIGIN,
244
+ agentOrigin: AGENT_ORIGIN,
235
245
  storePath: harness.storePath,
236
246
  fetchImpl,
237
247
  };
@@ -256,14 +266,14 @@ describe("derivation units", () => {
256
266
  test("whenFromFilter maps filter keys 1:1 to the trigger predicate", () => {
257
267
  expect(
258
268
  whenFromFilter({
259
- tags: ["#channel-message/inbound"],
269
+ tags: ["#agent-message/inbound"],
260
270
  has_metadata: ["channel"],
261
271
  missing_metadata: ["channel_inbound_rendered_at"],
262
272
  has_content: true,
263
273
  ignored: "x",
264
274
  }),
265
275
  ).toEqual({
266
- tags: ["#channel-message/inbound"],
276
+ tags: ["#agent-message/inbound"],
267
277
  has_metadata: ["channel"],
268
278
  missing_metadata: ["channel_inbound_rendered_at"],
269
279
  has_content: true,
@@ -272,8 +282,8 @@ describe("derivation units", () => {
272
282
  });
273
283
 
274
284
  test("buildWebhook joins origin + mount + endpoint, trimming slashes", () => {
275
- expect(buildWebhook(`${HUB_ORIGIN}/`, "/channel", "/api/vault/inbound")).toBe(
276
- `${HUB_ORIGIN}/channel/api/vault/inbound`,
285
+ expect(buildWebhook(`${HUB_ORIGIN}/`, "/agent", "/api/vault/inbound")).toBe(
286
+ `${HUB_ORIGIN}/agent/api/vault/inbound`,
277
287
  );
278
288
  expect(buildWebhook(HUB_ORIGIN, "widget", "hooks/incoming")).toBe(
279
289
  `${HUB_ORIGIN}/widget/hooks/incoming`,
@@ -295,7 +305,7 @@ describe("GET /api/connections/catalog", () => {
295
305
  filterSchema: { type: "object", properties: { tags: { type: "array" } } },
296
306
  });
297
307
  expect(cat.actions).toContainEqual({
298
- module: "channel",
308
+ module: "agent",
299
309
  key: "message.deliver",
300
310
  title: "Deliver an inbound message",
301
311
  inputSchema: null,
@@ -307,17 +317,17 @@ describe("GET /api/connections/catalog", () => {
307
317
  const cat = buildCatalog(modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
308
318
  expect(cat.templates).toEqual([
309
319
  {
310
- module: "channel",
320
+ module: "agent",
311
321
  key: "link-to-vault",
312
322
  title: "Link a channel to a vault",
313
323
  description: "Back a channel with a Parachute vault.",
314
- requestedBy: "channel",
324
+ requestedBy: "agent",
315
325
  source: {
316
326
  module: "vault",
317
327
  event: "note.created",
318
- filter: { tags: ["#channel-message/inbound"] },
328
+ filter: { tags: ["#agent-message/inbound"] },
319
329
  },
320
- sink: { module: "channel", action: "message.deliver" },
330
+ sink: { module: "agent", action: "message.deliver" },
321
331
  parameters: [
322
332
  {
323
333
  key: "vault",
@@ -371,9 +381,10 @@ describe("GET /api/connections/catalog", () => {
371
381
  );
372
382
  expect(res.status).toBe(200);
373
383
  const out = (await res.json()) as { events: unknown[]; actions: unknown[] };
374
- // vault: 3 events + 1 action; channel: 1 event + 1 action.
384
+ // vault: 3 events + 1 action; agent: 1 event + 2 actions (message.deliver +
385
+ // definition.reload).
375
386
  expect(out.events.length).toBe(4);
376
- expect(out.actions.length).toBe(2);
387
+ expect(out.actions.length).toBe(3);
377
388
  });
378
389
  });
379
390
 
@@ -418,7 +429,7 @@ describe("POST /admin/connections — validation", () => {
418
429
  headers: { cookie },
419
430
  body: JSON.stringify({
420
431
  source: { module: "vault", vault: "default", event: "note.imaginary" },
421
- sink: { module: "channel", action: "message.deliver" },
432
+ sink: { module: "agent", action: "message.deliver" },
422
433
  }),
423
434
  });
424
435
  const res = await handleConnections(
@@ -462,7 +473,7 @@ describe("POST /admin/connections — validation", () => {
462
473
  headers: { cookie },
463
474
  body: JSON.stringify({
464
475
  source: { module: "vault", vault: "default", event: "note.created" },
465
- sink: { module: "channel", action: "message.imaginary" },
476
+ sink: { module: "agent", action: "message.imaginary" },
466
477
  }),
467
478
  });
468
479
  const res = await handleConnections(
@@ -648,7 +659,7 @@ describe("POST /admin/connections — provenance (R2)", () => {
648
659
  headers: { cookie },
649
660
  body: JSON.stringify({
650
661
  id: "w1",
651
- requestedBy: "channel",
662
+ requestedBy: "agent",
652
663
  source: { module: "vault", vault: "default", event: "note.created" },
653
664
  sink: { module: "widget", action: "thing.do" },
654
665
  }),
@@ -659,7 +670,7 @@ describe("POST /admin/connections — provenance (R2)", () => {
659
670
  expect(res.status).toBe(200);
660
671
  // Persisted on the record.
661
672
  const stored = readConnections(harness.storePath);
662
- expect(stored[0]!.requestedBy).toBe("channel");
673
+ expect(stored[0]!.requestedBy).toBe("agent");
663
674
  // Returned (snake_case) on the GET wire shape.
664
675
  const list = await handleConnections(
665
676
  new Request(`${HUB_ORIGIN}/admin/connections`, { method: "GET", headers: { cookie } }),
@@ -667,7 +678,7 @@ describe("POST /admin/connections — provenance (R2)", () => {
667
678
  deps,
668
679
  );
669
680
  const out = (await list.json()) as { connections: Array<{ requested_by?: string }> };
670
- expect(out.connections[0]!.requested_by).toBe("channel");
681
+ expect(out.connections[0]!.requested_by).toBe("agent");
671
682
  });
672
683
 
673
684
  test("defaults requestedBy to custom when the body omits it", async () => {
@@ -740,12 +751,12 @@ describe("POST /admin/connections — channel-backed (the #624 flow as a connect
740
751
  vault: "default",
741
752
  event: "note.created",
742
753
  filter: {
743
- tags: ["#channel-message/inbound"],
754
+ tags: ["#agent-message/inbound"],
744
755
  has_metadata: ["channel"],
745
756
  missing_metadata: ["channel_inbound_rendered_at"],
746
757
  },
747
758
  },
748
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
759
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
749
760
  }),
750
761
  });
751
762
  const res = await handleConnections(
@@ -761,12 +772,12 @@ describe("POST /admin/connections — channel-backed (the #624 flow as a connect
761
772
  };
762
773
  expect(out.ok).toBe(true);
763
774
  // Derived channel id.
764
- expect(out.connection.id).toBe("channel-eng");
775
+ expect(out.connection.id).toBe("agent-eng");
765
776
  // Connect lines (parity with #624).
766
777
  expect(out.connect?.mcpAdd).toBe(
767
- `claude mcp add --transport http --scope user channel-eng ${HUB_ORIGIN}/channel/mcp/eng`,
778
+ `claude mcp add --transport http --scope user agent-eng ${HUB_ORIGIN}/agent/mcp/eng`,
768
779
  );
769
- expect(out.connect?.launch).toContain("server:channel-eng");
780
+ expect(out.connect?.launch).toContain("server:agent-eng");
770
781
 
771
782
  // Channel config POST: vault transport, loopback vaultUrl, real
772
783
  // vault:default:write token, NO webhookSecret.
@@ -780,26 +791,26 @@ describe("POST /admin/connections — channel-backed (the #624 flow as a connect
780
791
  expect(cfgBody.name).toBe("eng");
781
792
  expect(cfgBody.config.vaultUrl).toBe(VAULT_ORIGIN);
782
793
  expect(cfgBody.config).not.toHaveProperty("webhookSecret");
783
- expect(scopeOf(cfgCall!.bearer!)).toEqual(["channel:admin"]);
794
+ expect(scopeOf(cfgCall!.bearer!)).toEqual(["agent:admin"]);
784
795
  expect(scopeOf(cfgBody.config.token)).toEqual(["vault:default:write"]);
785
796
 
786
- // Trigger: webhook from channel mount + message.deliver endpoint; bearer
787
- // carries channel:send; predicate from the filter.
797
+ // Trigger: webhook from agent mount + message.deliver endpoint; bearer
798
+ // carries agent:send; predicate from the filter.
788
799
  const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
789
800
  const trig = trigCall!.body as {
790
801
  name: string;
791
802
  when: Record<string, unknown>;
792
803
  action: { webhook: string; auth: { bearer: string } };
793
804
  };
794
- expect(trig.action.webhook).toBe(`${HUB_ORIGIN}/channel/api/vault/inbound`);
795
- expect(scopeOf(trig.action.auth.bearer)).toEqual(["channel:send"]);
796
- expect((decodeJwt(trig.action.auth.bearer) as { aud?: string }).aud).toBe("channel");
805
+ expect(trig.action.webhook).toBe(`${HUB_ORIGIN}/agent/api/vault/inbound`);
806
+ expect(scopeOf(trig.action.auth.bearer)).toEqual(["agent:send"]);
807
+ expect((decodeJwt(trig.action.auth.bearer) as { aud?: string }).aud).toBe("agent");
797
808
  expect(trig.when).toEqual({
798
- tags: ["#channel-message/inbound"],
809
+ tags: ["#agent-message/inbound"],
799
810
  has_metadata: ["channel"],
800
811
  missing_metadata: ["channel_inbound_rendered_at"],
801
812
  });
802
- expect(trig.name).toBe("conn_channel-eng");
813
+ expect(trig.name).toBe("conn_agent-eng");
803
814
 
804
815
  // sub = the operator on every minted token.
805
816
  expect((decodeJwt(cfgBody.config.token) as { sub?: string }).sub).toBe(userId);
@@ -809,24 +820,114 @@ describe("POST /admin/connections — channel-backed (the #624 flow as a connect
809
820
  expect(serialized).not.toContain(trig.action.auth.bearer);
810
821
  });
811
822
 
812
- test("503 when the channel module is not installed (channelOrigin null)", async () => {
823
+ test("503 when the agent module is not installed (agentOrigin null)", async () => {
813
824
  const { cookie } = await adminCookie();
814
825
  const { fetchImpl } = mockFetch({});
815
826
  const deps = {
816
827
  ...baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
817
- channelOrigin: null,
828
+ agentOrigin: null,
818
829
  };
819
830
  const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
820
831
  method: "POST",
821
832
  headers: { cookie },
822
833
  body: JSON.stringify({
823
834
  source: { module: "vault", vault: "default", event: "note.created" },
824
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
835
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
825
836
  }),
826
837
  });
827
838
  const res = await handleConnections(req, "", deps);
828
839
  expect(res.status).toBe(503);
829
- expect(((await res.json()) as { error: string }).error).toBe("channel_unavailable");
840
+ expect(((await res.json()) as { error: string }).error).toBe("agent_unavailable");
841
+ });
842
+
843
+ test("definition.reload agent sink provisions WITH NO channel param (agent#117)", async () => {
844
+ // Regression: the channel-reply prerequisite was gated on `sinkModule ===
845
+ // "agent"`, so a non-message.deliver agent sink (no channel param) 400'd
846
+ // with `agent sink requires sink.params.channel`. The def-reload connectors
847
+ // could never provision. Now the gate is action-specific — a pure inbound
848
+ // webhook action provisions the vault trigger and SKIPS the channel config.
849
+ const { cookie } = await adminCookie();
850
+ const { fetchImpl, calls } = mockFetch({
851
+ "POST /vault/default/api/triggers": () => ok({ ok: true }),
852
+ // If the prerequisite wrongly ran, it would POST here — left mocked so a
853
+ // regression surfaces as an unexpected call, not a 599.
854
+ "POST /api/channels": () => ok({ ok: true }),
855
+ });
856
+ const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
857
+ method: "POST",
858
+ headers: { cookie },
859
+ body: JSON.stringify({
860
+ id: "agentdefs-create-default",
861
+ requestedBy: "agent",
862
+ source: {
863
+ module: "vault",
864
+ vault: "default",
865
+ event: "note.created",
866
+ filter: { tags: ["#agent/definition"] },
867
+ },
868
+ sink: { module: "agent", action: "definition.reload" }, // NO params.channel
869
+ }),
870
+ });
871
+ const res = await handleConnections(
872
+ req,
873
+ "",
874
+ baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
875
+ );
876
+ expect(res.status).toBe(200);
877
+ const out = (await res.json()) as { ok: boolean; connection: { id: string }; connect?: unknown };
878
+ expect(out.ok).toBe(true);
879
+ expect(out.connection.id).toBe("agentdefs-create-default");
880
+ // No channel → no connect lines (those are message-delivery-specific).
881
+ expect(out.connect).toBeUndefined();
882
+
883
+ // The channel-reply prerequisite was SKIPPED — no /api/channels POST.
884
+ expect(calls.some((c) => c.url.endsWith("/api/channels"))).toBe(false);
885
+
886
+ // The vault trigger WAS registered, from the def-reload action's declaration.
887
+ const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
888
+ expect(trigCall).toBeDefined();
889
+ const trig = trigCall!.body as {
890
+ events: string[];
891
+ when: Record<string, unknown>;
892
+ action: { webhook: string; auth: { bearer: string } };
893
+ };
894
+ expect(trig.events).toEqual(["created"]);
895
+ expect(trig.action.webhook).toBe(`${HUB_ORIGIN}/agent/api/vault/agent-def`);
896
+ expect(scopeOf(trig.action.auth.bearer)).toEqual(["agent:send"]);
897
+ expect(trig.when).toEqual({ tags: ["#agent/definition"] });
898
+ });
899
+
900
+ test("definition.reload provisions even when agentOrigin is null (no reply path)", async () => {
901
+ // Unlike message.deliver (503 agent_unavailable above), a def-reload sink
902
+ // never touches the agent daemon at provision time — it only registers a
903
+ // vault trigger. So a null agentOrigin must NOT block it.
904
+ const { cookie } = await adminCookie();
905
+ const { fetchImpl } = mockFetch({
906
+ "POST /vault/default/api/triggers": () => ok({ ok: true }),
907
+ });
908
+ const deps = {
909
+ ...baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
910
+ agentOrigin: null,
911
+ };
912
+ const res = await handleConnections(
913
+ new Request(`${HUB_ORIGIN}/admin/connections`, {
914
+ method: "POST",
915
+ headers: { cookie },
916
+ body: JSON.stringify({
917
+ id: "agentdefs-edit-default",
918
+ source: {
919
+ module: "vault",
920
+ vault: "default",
921
+ event: "note.updated",
922
+ filter: { tags: ["#agent/definition"] },
923
+ },
924
+ sink: { module: "agent", action: "definition.reload" },
925
+ }),
926
+ }),
927
+ "",
928
+ deps,
929
+ );
930
+ expect(res.status).toBe(200);
830
931
  });
831
932
  });
832
933
 
@@ -922,7 +1023,7 @@ describe("DELETE /admin/connections/:id — teardown", () => {
922
1023
  "POST /api/channels": () => ok({ ok: true }),
923
1024
  "POST /vault/default/api/triggers": () => ok({ ok: true }),
924
1025
  "DELETE /api/channels/eng": () => ok({ ok: true }),
925
- "DELETE /vault/default/api/triggers/conn_channel-eng": () => ok({ ok: true }),
1026
+ "DELETE /vault/default/api/triggers/conn_agent-eng": () => ok({ ok: true }),
926
1027
  });
927
1028
  const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
928
1029
  await handleConnections(
@@ -931,18 +1032,18 @@ describe("DELETE /admin/connections/:id — teardown", () => {
931
1032
  headers: { cookie },
932
1033
  body: JSON.stringify({
933
1034
  source: { module: "vault", vault: "default", event: "note.created" },
934
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
1035
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
935
1036
  }),
936
1037
  }),
937
1038
  "",
938
1039
  deps,
939
1040
  );
940
1041
  const res = await handleConnections(
941
- new Request(`${HUB_ORIGIN}/admin/connections/channel-eng`, {
1042
+ new Request(`${HUB_ORIGIN}/admin/connections/agent-eng`, {
942
1043
  method: "DELETE",
943
1044
  headers: { cookie },
944
1045
  }),
945
- "/channel-eng",
1046
+ "/agent-eng",
946
1047
  deps,
947
1048
  );
948
1049
  expect(res.status).toBe(200);
@@ -952,9 +1053,62 @@ describe("DELETE /admin/connections/:id — teardown", () => {
952
1053
  expect(
953
1054
  calls.some(
954
1055
  (c) =>
955
- c.method === "DELETE" && c.url.endsWith("/vault/default/api/triggers/conn_channel-eng"),
1056
+ c.method === "DELETE" && c.url.endsWith("/vault/default/api/triggers/conn_agent-eng"),
1057
+ ),
1058
+ ).toBe(true);
1059
+ });
1060
+
1061
+ test("definition.reload teardown removes the trigger but NOT a channel (agent#117)", async () => {
1062
+ // Symmetric to the create-side fix: a channel-less agent action created no
1063
+ // channel config entry, so teardown must not issue a spurious
1064
+ // DELETE /api/channels/<id> (the old module-level gate fell back to record.id
1065
+ // as the channel name → a delete against a never-created channel).
1066
+ const { cookie } = await adminCookie();
1067
+ const { fetchImpl, calls } = mockFetch({
1068
+ "POST /vault/default/api/triggers": () => ok({ ok: true }),
1069
+ "DELETE /vault/default/api/triggers/conn_agentdefs-create-default": () => ok({ ok: true }),
1070
+ // Present so a regression surfaces as an unexpected matched call, not a 599.
1071
+ "DELETE /api/channels/agentdefs-create-default": () => ok({ ok: true }),
1072
+ });
1073
+ const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
1074
+ await handleConnections(
1075
+ new Request(`${HUB_ORIGIN}/admin/connections`, {
1076
+ method: "POST",
1077
+ headers: { cookie },
1078
+ body: JSON.stringify({
1079
+ id: "agentdefs-create-default",
1080
+ source: {
1081
+ module: "vault",
1082
+ vault: "default",
1083
+ event: "note.created",
1084
+ filter: { tags: ["#agent/definition"] },
1085
+ },
1086
+ sink: { module: "agent", action: "definition.reload" },
1087
+ }),
1088
+ }),
1089
+ "",
1090
+ deps,
1091
+ );
1092
+ const res = await handleConnections(
1093
+ new Request(`${HUB_ORIGIN}/admin/connections/agentdefs-create-default`, {
1094
+ method: "DELETE",
1095
+ headers: { cookie },
1096
+ }),
1097
+ "/agentdefs-create-default",
1098
+ deps,
1099
+ );
1100
+ expect(res.status).toBe(200);
1101
+ // NO channel delete (the channel-reply path was never created).
1102
+ expect(calls.some((c) => c.method === "DELETE" && c.url.includes("/api/channels/"))).toBe(false);
1103
+ // The vault trigger WAS torn down.
1104
+ expect(
1105
+ calls.some(
1106
+ (c) =>
1107
+ c.method === "DELETE" &&
1108
+ c.url.endsWith("/vault/default/api/triggers/conn_agentdefs-create-default"),
956
1109
  ),
957
1110
  ).toBe(true);
1111
+ expect(readConnections(harness.storePath)).toHaveLength(0);
958
1112
  });
959
1113
 
960
1114
  test("404 deleting an unknown connection", async () => {
@@ -989,7 +1143,7 @@ describe("B0 — registered connection mints", () => {
989
1143
  headers: { cookie },
990
1144
  body: JSON.stringify({
991
1145
  source: { module: "vault", vault: "default", event: "note.created" },
992
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
1146
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
993
1147
  }),
994
1148
  }),
995
1149
  "",
@@ -1025,10 +1179,10 @@ describe("B0 — registered connection mints", () => {
1025
1179
  const webhookRow = findTokenRowByJti(harness.db, webhookJti);
1026
1180
  expect(webhookRow).not.toBeNull();
1027
1181
  expect(webhookRow!.createdVia).toBe("connection_provision");
1028
- expect(webhookRow!.scopes).toEqual(["channel:send"]);
1182
+ expect(webhookRow!.scopes).toEqual(["agent:send"]);
1029
1183
 
1030
1184
  // The short-lived (60s) provisioning bearers — vault:<v>:admin on the
1031
- // trigger POST, channel:admin on the channel-config POST — ride to expiry
1185
+ // trigger POST, agent:admin on the channel-config POST — ride to expiry
1032
1186
  // by design (the documented ≤10-min unregistered bound). NOT registered.
1033
1187
  const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
1034
1188
  const cfgCall = calls.find((c) => c.url.endsWith("/api/channels"));
@@ -1051,17 +1205,17 @@ describe("B0 — registered connection mints", () => {
1051
1205
  "POST /api/channels": () => ok({ ok: true }),
1052
1206
  "POST /vault/default/api/triggers": () => ok({ ok: true }),
1053
1207
  "DELETE /api/channels/eng": () => ok({ ok: true }),
1054
- "DELETE /vault/default/api/triggers/conn_channel-eng": () => ok({ ok: true }),
1208
+ "DELETE /vault/default/api/triggers/conn_agent-eng": () => ok({ ok: true }),
1055
1209
  });
1056
1210
  const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
1057
1211
  const { replyJti, webhookJti } = await createChannelConnection(cookie, deps, calls);
1058
1212
 
1059
1213
  const res = await handleConnections(
1060
- new Request(`${HUB_ORIGIN}/admin/connections/channel-eng`, {
1214
+ new Request(`${HUB_ORIGIN}/admin/connections/agent-eng`, {
1061
1215
  method: "DELETE",
1062
1216
  headers: { cookie },
1063
1217
  }),
1064
- "/channel-eng",
1218
+ "/agent-eng",
1065
1219
  deps,
1066
1220
  );
1067
1221
  expect(res.status).toBe(200);
@@ -15,9 +15,9 @@
15
15
  * endpoint's own gate. (The legacy `/admin/channels` wiring was belted
16
16
  * here too until boundary D1 retired the endpoint.)
17
17
  *
18
- * The canonical seam consumer is pinned here: channel's admin page POSTs
18
+ * The canonical seam consumer is pinned here: the agent module's admin page POSTs
19
19
  * `/admin/connections` as a same-origin `fetch()` with
20
- * `credentials: "include"` (parachute-channel src/admin-ui.ts) — i.e.
20
+ * `credentials: "include"` (parachute-agent src/admin-ui.ts) — i.e.
21
21
  * session cookie + browser-sent matching Origin. That shape must keep
22
22
  * passing the belt without any token dance.
23
23
  */
@@ -248,8 +248,8 @@ describe("CSRF belt wiring — /admin/connections", () => {
248
248
  expect(await errorCode(res)).toBe("invalid_request");
249
249
  });
250
250
 
251
- test("seam pin — the channel link-vault shape (cookie + correct Origin, requestedBy: channel) passes the belt", async () => {
252
- // parachute-channel/src/admin-ui.ts: fetch(window.location.origin +
251
+ test("seam pin — the agent link-vault shape (cookie + correct Origin, requestedBy: agent) passes the belt", async () => {
252
+ // parachute-agent/src/admin-ui.ts: fetch(window.location.origin +
253
253
  // "/admin/connections", { method: "POST", credentials: "include" }) —
254
254
  // same-origin fetch(), so the browser sends Origin = hub origin on the
255
255
  // POST. With no modules installed in this harness the engine answers
@@ -265,10 +265,10 @@ describe("CSRF belt wiring — /admin/connections", () => {
265
265
  module: "vault",
266
266
  vault: "main",
267
267
  event: "note.created",
268
- filter: { tags: ["channel-message/inbound"] },
268
+ filter: { tags: ["agent-message/inbound"] },
269
269
  },
270
- sink: { module: "channel", action: "message.deliver", params: { channel: "tg" } },
271
- requestedBy: "channel",
270
+ sink: { module: "agent", action: "message.deliver", params: { channel: "tg" } },
271
+ requestedBy: "agent",
272
272
  },
273
273
  }),
274
274
  );