@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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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", ["
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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 (
|
|
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("
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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({
|
|
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("
|
|
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 —
|
|
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
|
|
215
|
-
// install path now resolves — fixing the
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
1018
|
-
// The API start path (admin SPA / `parachute start
|
|
1019
|
-
// SAME reconcile the boot path does — otherwise an
|
|
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-
|
|
1024
|
+
name: "parachute-agent",
|
|
1024
1025
|
port: 19415,
|
|
1025
|
-
paths: ["/
|
|
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/
|
|
1036
|
-
"
|
|
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("
|
|
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 /
|
|
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-
|
|
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
|
-
// (
|
|
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("
|
|
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
|
-
//
|
|
251
|
-
for (const s of ["vault", "scribe", "surface", "
|
|
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("
|
|
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("
|
|
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
|
|
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-
|
|
316
|
+
name: "parachute-agent",
|
|
317
317
|
port: 1941,
|
|
318
|
-
paths: ["/
|
|
319
|
-
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: "
|
|
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
|
|
344
|
-
expect(
|
|
345
|
-
expect(
|
|
346
|
-
expect(
|
|
347
|
-
expect(
|
|
348
|
-
expect(
|
|
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
|
-
//
|
|
634
|
-
// origin-absolute path) + `uiUrl: "/
|
|
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: "
|
|
639
|
+
name: "agent",
|
|
641
640
|
port: 1941,
|
|
642
|
-
paths: ["/
|
|
641
|
+
paths: ["/agent"],
|
|
643
642
|
health: "/health",
|
|
644
643
|
version: "0.1.0",
|
|
645
|
-
installDir: "/install/dir/
|
|
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/
|
|
654
|
+
if (installDir === "/install/dir/agent") {
|
|
656
655
|
return {
|
|
657
|
-
name: "
|
|
658
|
-
manifestName: "parachute-
|
|
659
|
-
displayName: "
|
|
656
|
+
name: "agent",
|
|
657
|
+
manifestName: "parachute-agent",
|
|
658
|
+
displayName: "Agent",
|
|
660
659
|
tagline: "",
|
|
661
660
|
port: 1941,
|
|
662
|
-
paths: ["/
|
|
661
|
+
paths: ["/agent"],
|
|
663
662
|
health: "/health",
|
|
664
|
-
uiUrl: "/
|
|
665
|
-
configUiUrl: "/
|
|
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
|
|
682
|
-
// Origin-absolute — verbatim, never double-prepends `/
|
|
683
|
-
expect(
|
|
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(
|
|
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: "
|
|
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(["
|
|
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 () => {
|