@openparachute/hub 0.7.0 → 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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -735,7 +735,7 @@ import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jw
735
735
  import { createUser, setUserVaults } from "../users.ts";
736
736
 
737
737
  const VAULT_ORIGIN = "http://127.0.0.1:19400";
738
- const CHANNEL_ORIGIN = "http://127.0.0.1:19410";
738
+ const AGENT_ORIGIN = "http://127.0.0.1:19410";
739
739
 
740
740
  /** Successful no-op runner — records the commands it was asked to run. */
741
741
  function stubRun(
@@ -788,7 +788,7 @@ interface DeleteCallOpts {
788
788
  connectionsStorePath: string;
789
789
  runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
790
790
  restartVaultModule?: () => Promise<void>;
791
- channelOrigin?: string | null;
791
+ agentOrigin?: string | null;
792
792
  fetchImpl?: typeof fetch;
793
793
  resolveVaultOrigin?: (v: string) => string | null;
794
794
  }
@@ -810,7 +810,7 @@ async function callDelete(opts: DeleteCallOpts): Promise<Response> {
810
810
  issuer: ISSUER,
811
811
  manifestPath: opts.manifestPath,
812
812
  connectionsStorePath: opts.connectionsStorePath,
813
- channelOrigin: opts.channelOrigin ?? null,
813
+ agentOrigin: opts.agentOrigin ?? null,
814
814
  resolveVaultOrigin: opts.resolveVaultOrigin ?? (() => VAULT_ORIGIN),
815
815
  runCommand: opts.runCommand ?? stubRun().run,
816
816
  ...(opts.restartVaultModule ? { restartVaultModule: opts.restartVaultModule } : {}),
@@ -1002,7 +1002,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1002
1002
  // 1. Registry rows: two naming "work" (one standalone + the
1003
1003
  // connection's registered mint below), one naming "default".
1004
1004
  registryRow(db, "jti-work-1", ["vault:work:write"]);
1005
- registryRow(db, "jti-conn-1", ["channel:send"]); // connection webhook bearer (non-vault scope)
1005
+ registryRow(db, "jti-conn-1", ["agent:send"]); // connection webhook bearer (non-vault scope)
1006
1006
  registryRow(db, "jti-default-1", ["vault:default:read"]);
1007
1007
 
1008
1008
  // 2. Grants: one spanning both vaults (rewrite), one work-only (drop).
@@ -1036,7 +1036,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1036
1036
  putConnection(store, {
1037
1037
  id: "conn-work",
1038
1038
  source: { module: "vault", vault: "work", event: "note.created" },
1039
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
1039
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
1040
1040
  provisioned: {
1041
1041
  type: "vault-trigger",
1042
1042
  vault: "work",
@@ -1048,7 +1048,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1048
1048
  putConnection(store, {
1049
1049
  id: "conn-default",
1050
1050
  source: { module: "vault", vault: "default", event: "note.created" },
1051
- sink: { module: "channel", action: "message.deliver", params: { channel: "ops" } },
1051
+ sink: { module: "agent", action: "message.deliver", params: { channel: "ops" } },
1052
1052
  provisioned: { type: "vault-trigger", vault: "default", triggerName: "conn_d" },
1053
1053
  createdAt: new Date().toISOString(),
1054
1054
  });
@@ -1078,7 +1078,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1078
1078
  restartVaultModule: async () => {
1079
1079
  restarted = true;
1080
1080
  },
1081
- channelOrigin: CHANNEL_ORIGIN,
1081
+ agentOrigin: AGENT_ORIGIN,
1082
1082
  fetchImpl,
1083
1083
  resolveVaultOrigin: (v) => (v === "work" ? VAULT_ORIGIN : null),
1084
1084
  });
@@ -1102,7 +1102,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1102
1102
  expect(out.name).toBe("work");
1103
1103
 
1104
1104
  // 1. Registry sweep: the work-scoped row revoked; default untouched.
1105
- // The connection's webhook-bearer row (channel:send — names no
1105
+ // The connection's webhook-bearer row (agent:send — names no
1106
1106
  // vault) is revoked by the CONNECTION teardown, not the sweep.
1107
1107
  expect(out.cascade.tokens_revoked).toBe(1);
1108
1108
  expect(findTokenRowByJti(db, "jti-work-1")?.revokedAt).not.toBeNull();
@@ -68,6 +68,24 @@ function deps() {
68
68
  return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
69
69
  }
70
70
 
71
+ /** Register an existing vault in the harness manifest (for shared-vault invites). */
72
+ function seedVaultInManifest(name: string) {
73
+ writeFileSync(
74
+ harness.manifestPath,
75
+ JSON.stringify({
76
+ services: [
77
+ {
78
+ name: "parachute-vault",
79
+ port: 4101,
80
+ paths: [`/vault/${name}`],
81
+ health: "/health",
82
+ version: "0.0.0-test",
83
+ },
84
+ ],
85
+ }),
86
+ );
87
+ }
88
+
71
89
  function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
72
90
  const headers = new Headers(init.headers ?? {});
73
91
  headers.set("authorization", `Bearer ${bearer}`);
@@ -121,23 +139,64 @@ describe("POST /api/invites", () => {
121
139
  expect(res.status).toBe(400);
122
140
  });
123
141
 
124
- test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
125
- // Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
126
- // as owner-admin is a cross-tenant breach; shared-vault invites aren't
127
- // supported yet, so the create handler refuses this combination outright.
142
+ test("shared-vault invite (provision_vault=false + EXISTING vault_name) → 201 at the given role", async () => {
143
+ // The Adam/Jonathan shape: read-only access to a vault the admin already
144
+ // built. Issuing is host:admin-gated the same authority that can assign
145
+ // any user to any vault via POST /api/users.
146
+ seedVaultInManifest("jonathan-vault");
128
147
  const bearer = await makeAdminBearer();
129
148
  const res = await handleCreateInvite(
130
149
  withBearer("/api/invites", bearer, {
131
150
  method: "POST",
132
151
  headers: { "content-type": "application/json" },
133
- body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
152
+ body: JSON.stringify({
153
+ provision_vault: false,
154
+ vault_name: "jonathan-vault",
155
+ role: "read",
156
+ }),
157
+ }),
158
+ deps(),
159
+ );
160
+ expect(res.status).toBe(201);
161
+ const body = (await res.json()) as {
162
+ invite: { vault_name: string | null; role: string; provision_vault: boolean };
163
+ };
164
+ expect(body.invite.vault_name).toBe("jonathan-vault");
165
+ expect(body.invite.role).toBe("read");
166
+ expect(body.invite.provision_vault).toBe(false);
167
+ });
168
+
169
+ test("400 vault_not_found when the shared vault doesn't exist on this hub", async () => {
170
+ // A pinned-but-nonexistent name is a typo, not a future reservation —
171
+ // fail at mint, not at the invitee's redeem.
172
+ const bearer = await makeAdminBearer();
173
+ const res = await handleCreateInvite(
174
+ withBearer("/api/invites", bearer, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify({ provision_vault: false, vault_name: "nosuchvault", role: "read" }),
178
+ }),
179
+ deps(),
180
+ );
181
+ expect(res.status).toBe(400);
182
+ const body = (await res.json()) as { error: string };
183
+ expect(body.error).toBe("vault_not_found");
184
+ });
185
+
186
+ test("400 rejects role=read with provision_vault=true (sole user of a new vault must hold write)", async () => {
187
+ const bearer = await makeAdminBearer();
188
+ const res = await handleCreateInvite(
189
+ withBearer("/api/invites", bearer, {
190
+ method: "POST",
191
+ headers: { "content-type": "application/json" },
192
+ body: JSON.stringify({ provision_vault: true, role: "read" }),
134
193
  }),
135
194
  deps(),
136
195
  );
137
196
  expect(res.status).toBe(400);
138
197
  const body = (await res.json()) as { error: string; error_description: string };
139
198
  expect(body.error).toBe("invalid_request");
140
- expect(body.error_description).toContain("shared-vault");
199
+ expect(body.error_description).toContain("write");
141
200
  });
142
201
 
143
202
  test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
@@ -159,6 +218,107 @@ describe("POST /api/invites", () => {
159
218
  });
160
219
  });
161
220
 
221
+ describe("POST /api/invites — pre-named username", () => {
222
+ test("201 carries the username in the wire shape", async () => {
223
+ seedVaultInManifest("jonathan-vault");
224
+ const bearer = await makeAdminBearer();
225
+ const res = await handleCreateInvite(
226
+ withBearer("/api/invites", bearer, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ username: "jonathan",
231
+ provision_vault: false,
232
+ vault_name: "jonathan-vault",
233
+ role: "read",
234
+ }),
235
+ }),
236
+ deps(),
237
+ );
238
+ expect(res.status).toBe(201);
239
+ const body = (await res.json()) as { invite: { username: string | null } };
240
+ expect(body.invite.username).toBe("jonathan");
241
+ });
242
+
243
+ test("400 invalid_username on a name outside the /api/users vocabulary", async () => {
244
+ const bearer = await makeAdminBearer();
245
+ for (const bad of ["A", "Has Spaces", "admin"]) {
246
+ const res = await handleCreateInvite(
247
+ withBearer("/api/invites", bearer, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ username: bad }),
251
+ }),
252
+ deps(),
253
+ );
254
+ expect(res.status).toBe(400);
255
+ const body = (await res.json()) as { error: string };
256
+ expect(body.error).toBe("invalid_username");
257
+ }
258
+ });
259
+
260
+ test("409 username_taken when an existing user holds the name (case-insensitive)", async () => {
261
+ const bearer = await makeAdminBearer(); // creates user "operator"
262
+ const res = await handleCreateInvite(
263
+ withBearer("/api/invites", bearer, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify({ username: "operator" }),
267
+ }),
268
+ deps(),
269
+ );
270
+ expect(res.status).toBe(409);
271
+ const body = (await res.json()) as { error: string };
272
+ expect(body.error).toBe("username_taken");
273
+ });
274
+
275
+ test("409 username_reserved when another PENDING invite pre-names the same username", async () => {
276
+ const bearer = await makeAdminBearer();
277
+ const make = () =>
278
+ handleCreateInvite(
279
+ withBearer("/api/invites", bearer, {
280
+ method: "POST",
281
+ headers: { "content-type": "application/json" },
282
+ body: JSON.stringify({ username: "jonathan" }),
283
+ }),
284
+ deps(),
285
+ );
286
+ const first = await make();
287
+ expect(first.status).toBe(201);
288
+ const second = await make();
289
+ expect(second.status).toBe(409);
290
+ const body = (await second.json()) as { error: string };
291
+ expect(body.error).toBe("username_reserved");
292
+ });
293
+
294
+ test("revoking the pending invite frees the reserved username", async () => {
295
+ const bearer = await makeAdminBearer();
296
+ const first = await handleCreateInvite(
297
+ withBearer("/api/invites", bearer, {
298
+ method: "POST",
299
+ headers: { "content-type": "application/json" },
300
+ body: JSON.stringify({ username: "jonathan" }),
301
+ }),
302
+ deps(),
303
+ );
304
+ const { invite } = (await first.json()) as { invite: { id: string } };
305
+ await handleRevokeInvite(
306
+ withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
307
+ invite.id,
308
+ deps(),
309
+ );
310
+ const second = await handleCreateInvite(
311
+ withBearer("/api/invites", bearer, {
312
+ method: "POST",
313
+ headers: { "content-type": "application/json" },
314
+ body: JSON.stringify({ username: "jonathan" }),
315
+ }),
316
+ deps(),
317
+ );
318
+ expect(second.status).toBe(201);
319
+ });
320
+ });
321
+
162
322
  describe("GET /api/invites", () => {
163
323
  test("lists invites; raw token NEVER present in the wire shape", async () => {
164
324
  const bearer = await makeAdminBearer();
@@ -208,13 +208,14 @@ describe("parseModulesPath", () => {
208
208
  });
209
209
  });
210
210
 
211
- test("accepts any KNOWN module short — channel install now resolves (was the bug)", () => {
211
+ test("accepts any KNOWN module short — agent install now resolves (was the bug)", () => {
212
212
  // Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
213
213
  // `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
214
- // CURATED_MODULES whitelist. channel is in FIRST_PARTY_FALLBACKS, so its
215
- // install path now resolves — fixing the running-but-uninstallable bug.
216
- expect(parseModulesPath("/api/modules/channel/install")).toEqual({
217
- short: "channel",
214
+ // CURATED_MODULES whitelist. agent (renamed from channel 2026-06-17) is in
215
+ // KNOWN_MODULES, so its install path now resolves — fixing the
216
+ // running-but-uninstallable bug.
217
+ expect(parseModulesPath("/api/modules/agent/install")).toEqual({
218
+ short: "agent",
218
219
  rest: "install",
219
220
  });
220
221
  // Other known modules (runner / surface) resolve too.
@@ -1014,15 +1015,15 @@ describe("POST /api/modules/:short/start", () => {
1014
1015
  });
1015
1016
 
1016
1017
  test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
1017
- // The live signature: channel's row carried 19415 instead of canonical 1941.
1018
- // The API start path (admin SPA / `parachute start channel`) must apply the
1019
- // SAME reconcile the boot path does — otherwise an operator-triggered start
1020
- // re-strands the module on the dead port.
1018
+ // The live signature: the agent (then channel) row carried 19415 instead of
1019
+ // canonical 1941. The API start path (admin SPA / `parachute start agent`)
1020
+ // must apply the SAME reconcile the boot path does — otherwise an
1021
+ // operator-triggered start re-strands the module on the dead port.
1021
1022
  writeManifest(h.manifestPath, [
1022
1023
  {
1023
- name: "parachute-channel",
1024
+ name: "parachute-agent",
1024
1025
  port: 19415,
1025
- paths: ["/channel"],
1026
+ paths: ["/agent"],
1026
1027
  health: "/health",
1027
1028
  version: "0.0.0-linked",
1028
1029
  },
@@ -1032,8 +1033,8 @@ describe("POST /api/modules/:short/start", () => {
1032
1033
  const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1033
1034
 
1034
1035
  const res = await handleStart(
1035
- postReq("/api/modules/channel/start", { authorization: `Bearer ${bearer}` }),
1036
- "channel",
1036
+ postReq("/api/modules/agent/start", { authorization: `Bearer ${bearer}` }),
1037
+ "agent",
1037
1038
  {
1038
1039
  db: h.db,
1039
1040
  issuer: ISSUER,
@@ -1048,14 +1049,14 @@ describe("POST /api/modules/:short/start", () => {
1048
1049
  // The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
1049
1050
  // so it binds + the readiness probe checks the right port.
1050
1051
  expect(spawns.length).toBe(1);
1051
- expect(spawns[0]?.short).toBe("channel");
1052
+ expect(spawns[0]?.short).toBe("agent");
1052
1053
  expect(spawns[0]?.env?.PORT).toBe("1941");
1053
1054
  // services.json row is rewritten to 1941 → the reverse-proxy (which reads
1054
- // services.json) routes /channel/* to the live port.
1055
+ // services.json) routes /agent/* to the live port.
1055
1056
  const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
1056
1057
  services: { name: string; port: number }[];
1057
1058
  };
1058
- expect(onDisk.services.find((s) => s.name === "parachute-channel")?.port).toBe(1941);
1059
+ expect(onDisk.services.find((s) => s.name === "parachute-agent")?.port).toBe(1941);
1059
1060
  // The reconcile event logged on the API path too (deps.log wired — #41 review).
1060
1061
  expect(
1061
1062
  logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
@@ -220,7 +220,7 @@ describe("GET /api/modules", () => {
220
220
  // by the UNION of the bootstrap registries (KNOWN_MODULES ∪
221
221
  // FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
222
222
  // surfaces — core (vault/scribe/surface) in the headline tier, the rest
223
- // (channel/runner/notes) as `experimental` — so the channel-not-installed
223
+ // (agent/runner/notes) as `experimental` — so the agent-not-installed
224
224
  // class (running but invisible) can't recur.
225
225
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
226
226
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
@@ -244,11 +244,11 @@ describe("GET /api/modules", () => {
244
244
  // The core tier leads, in the recommended install order (vault → scribe),
245
245
  // ahead of every experimental module.
246
246
  expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
247
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("channel"));
247
+ expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
248
248
  expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
249
  // Every known module is discoverable — vault/scribe/surface (core) +
250
- // channel/runner/notes (experimental).
251
- for (const s of ["vault", "scribe", "surface", "channel", "runner", "notes"]) {
250
+ // agent/runner/notes (experimental).
251
+ for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
252
252
  expect(shorts).toContain(s);
253
253
  }
254
254
  // Focus tier resolves from the default map.
@@ -256,7 +256,7 @@ describe("GET /api/modules", () => {
256
256
  expect(byShort.get("vault")?.focus).toBe("core");
257
257
  expect(byShort.get("scribe")?.focus).toBe("core");
258
258
  expect(byShort.get("surface")?.focus).toBe("core");
259
- expect(byShort.get("channel")?.focus).toBe("experimental");
259
+ expect(byShort.get("agent")?.focus).toBe("experimental");
260
260
  expect(byShort.get("runner")?.focus).toBe("experimental");
261
261
  expect(byShort.get("notes")?.focus).toBe("experimental");
262
262
  expect(body.modules.every((m) => m.available)).toBe(true);
@@ -304,24 +304,24 @@ describe("GET /api/modules", () => {
304
304
  expect(scribe?.available).toBe(true);
305
305
  });
306
306
 
307
- test("channel (running + self-registered) appears as installed + experimental — regression for the channel-not-installed bug", async () => {
308
- // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): channel
307
+ test("agent (running + self-registered) appears as installed + experimental — regression for the channel-not-installed bug", async () => {
308
+ // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): agent (renamed from channel 2026-06-17)
309
309
  // was running, proxied, supervised, and self-registered in services.json
310
310
  // yet invisible on the Modules screen — because the old CURATED_MODULES =
311
311
  // ["vault","scribe"] whitelist gated discovery. Now discovery is driven by
312
- // self-registration ∪ the known registries, so a self-registered channel
312
+ // self-registration ∪ the known registries, so a self-registered agent
313
313
  // row surfaces as installed, in the experimental tier, with its run-state.
314
314
  writeManifest(h.manifestPath, [
315
315
  {
316
- name: "parachute-channel",
316
+ name: "parachute-agent",
317
317
  port: 1941,
318
- paths: ["/channel"],
319
- health: "/channel/health",
318
+ paths: ["/agent"],
319
+ health: "/agent/health",
320
320
  version: "0.3.1",
321
321
  },
322
322
  ]);
323
323
  const { supervisor } = makeIdleSupervisor();
324
- await supervisor.start({ short: "channel", cmd: ["parachute-channel", "daemon"] });
324
+ await supervisor.start({ short: "agent", cmd: ["parachute-agent"] });
325
325
 
326
326
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
327
327
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
@@ -340,18 +340,17 @@ describe("GET /api/modules", () => {
340
340
  supervisor_status: string | null;
341
341
  }>;
342
342
  };
343
- const channel = body.modules.find((m) => m.short === "channel");
344
- expect(channel).toBeDefined();
345
- expect(channel?.installed).toBe(true);
346
- expect(channel?.installed_version).toBe("0.3.1");
347
- expect(channel?.focus).toBe("experimental");
348
- expect(channel?.supervisor_status).toBe("running");
343
+ const agent = body.modules.find((m) => m.short === "agent");
344
+ expect(agent).toBeDefined();
345
+ expect(agent?.installed).toBe(true);
346
+ expect(agent?.installed_version).toBe("0.3.1");
347
+ expect(agent?.focus).toBe("experimental");
348
+ expect(agent?.supervisor_status).toBe("running");
349
349
  });
350
350
 
351
351
  test("every self-registered + known module appears in `modules` — no running-but-invisible class", async () => {
352
352
  // The two-registry-disagreement (services.json says installed, the curated
353
- // whitelist says invisible) is gone: a self-registered surface row + a
354
- // supervised channel both surface in `modules` (2026-06-09 modular-UI
353
+ // whitelist says invisible) is gone: a self-registered surface row + a // supervised agent both surface in `modules` (2026-06-09 modular-UI
355
354
  // architecture). `supervised` still mirrors the run-state for every
356
355
  // tracked module (hub#539) — consumers dedupe by short.
357
356
  writeManifest(h.manifestPath, [
@@ -630,19 +629,19 @@ describe("GET /api/modules", () => {
630
629
  });
631
630
 
632
631
  test("populates config_ui_url from a module's configUiUrl (2026-06-09 modular-UI P3)", async () => {
633
- // Channel declares `configUiUrl: "/channel/admin"` (a single-instance,
634
- // origin-absolute path) + `uiUrl: "/channel/ui"`. The hub surfaces
632
+ // Agent declares `configUiUrl: "/agent/admin"` (a single-instance,
633
+ // origin-absolute path) + `uiUrl: "/agent/ui"`. The hub surfaces
635
634
  // both: `config_ui_url` drives the Modules page Configure action,
636
635
  // `management_url` drives Open. configUiUrl resolves identically to
637
636
  // managementUrl (same B4 unified semantics).
638
637
  writeManifest(h.manifestPath, [
639
638
  {
640
- name: "channel",
639
+ name: "agent",
641
640
  port: 1941,
642
- paths: ["/channel"],
641
+ paths: ["/agent"],
643
642
  health: "/health",
644
643
  version: "0.1.0",
645
- installDir: "/install/dir/channel",
644
+ installDir: "/install/dir/agent",
646
645
  },
647
646
  ]);
648
647
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -652,17 +651,17 @@ describe("GET /api/modules", () => {
652
651
  manifestPath: h.manifestPath,
653
652
  fetchLatestVersion: async () => null,
654
653
  readModuleManifest: async (installDir) => {
655
- if (installDir === "/install/dir/channel") {
654
+ if (installDir === "/install/dir/agent") {
656
655
  return {
657
- name: "channel",
658
- manifestName: "parachute-channel",
659
- displayName: "Channel",
656
+ name: "agent",
657
+ manifestName: "parachute-agent",
658
+ displayName: "Agent",
660
659
  tagline: "",
661
660
  port: 1941,
662
- paths: ["/channel"],
661
+ paths: ["/agent"],
663
662
  health: "/health",
664
- uiUrl: "/channel/ui",
665
- configUiUrl: "/channel/admin",
663
+ uiUrl: "/agent/ui",
664
+ configUiUrl: "/agent/admin",
666
665
  } as unknown as Awaited<
667
666
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
668
667
  >;
@@ -678,11 +677,11 @@ describe("GET /api/modules", () => {
678
677
  management_url: string | null;
679
678
  }>;
680
679
  };
681
- const channel = body.modules.find((m) => m.short === "channel");
682
- // Origin-absolute — verbatim, never double-prepends `/channel`.
683
- expect(channel?.config_ui_url).toBe("/channel/admin");
680
+ const agent = body.modules.find((m) => m.short === "agent");
681
+ // Origin-absolute — verbatim, never double-prepends `/agent`.
682
+ expect(agent?.config_ui_url).toBe("/agent/admin");
684
683
  // uiUrl (no managementUrl) drives the Open action's management_url.
685
- expect(channel?.management_url).toBe("/channel/ui");
684
+ expect(agent?.management_url).toBe("/agent/ui");
686
685
  });
687
686
 
688
687
  test("config_ui_url is null when the module declares no configUiUrl", async () => {
@@ -92,7 +92,7 @@ describe("handleApiReady — supervisor mode", () => {
92
92
  moduleState({ short: "vault", status: "starting" }),
93
93
  moduleState({ short: "scribe", status: "restarting" }),
94
94
  moduleState({ short: "notes", status: "crashed" }),
95
- moduleState({ short: "channel", status: "stopped" }),
95
+ moduleState({ short: "agent", status: "stopped" }),
96
96
  moduleState({
97
97
  short: "runner",
98
98
  status: "running",
@@ -109,7 +109,7 @@ describe("handleApiReady — supervisor mode", () => {
109
109
  expect(body.ready).toBe(false);
110
110
  expect(body.ready_modules).toEqual(["runner"]);
111
111
  expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
112
- expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
112
+ expect(body.persistent_modules.sort()).toEqual(["agent", "notes"]);
113
113
  });
114
114
 
115
115
  test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {