@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.
- package/README.md +13 -14
- package/package.json +1 -1
- 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 +8 -4
- 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-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__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- 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/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- 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 +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -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 +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- 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-Cxtod68O.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();
|
|
@@ -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 () => {
|
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import {
|
|
6
6
|
InvalidRedirectUriError,
|
|
7
7
|
approveClient,
|
|
8
|
+
expandRedirectUrisForHubOrigins,
|
|
8
9
|
getClient,
|
|
9
10
|
isValidRedirectUri,
|
|
10
11
|
listClientsByStatus,
|
|
@@ -144,6 +145,96 @@ describe("requireRegisteredRedirectUri", () => {
|
|
|
144
145
|
});
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expansion)", () => {
|
|
149
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
150
|
+
const PUBLIC = "https://box.taildf9ce2.ts.net";
|
|
151
|
+
const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
152
|
+
|
|
153
|
+
test("expands a loopback-rooted URI onto every other hub origin", () => {
|
|
154
|
+
const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
|
|
155
|
+
// Original is preserved + the public + localhost variants are added.
|
|
156
|
+
expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
157
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
158
|
+
expect(out).toContain("http://localhost:1939/surface/notes/oauth/callback");
|
|
159
|
+
// The submitted URI comes first (order-preserving).
|
|
160
|
+
expect(out[0]).toBe(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("INVARIANT: a foreign-origin URI is stored verbatim — not expanded, not dropped", () => {
|
|
164
|
+
const foreign = "https://my-vault-ui.example/oauth/callback";
|
|
165
|
+
const out = expandRedirectUrisForHubOrigins([foreign], hubOrigins);
|
|
166
|
+
// Stored exactly as submitted...
|
|
167
|
+
expect(out).toContain(foreign);
|
|
168
|
+
// ...and NOTHING was minted on any hub origin from it (no open redirect).
|
|
169
|
+
expect(out).toEqual([foreign]);
|
|
170
|
+
for (const o of hubOrigins) {
|
|
171
|
+
expect(out).not.toContain(`${o}/oauth/callback`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("mixed submit: hub-origin URI expands, foreign URI rides verbatim alongside", () => {
|
|
176
|
+
const foreign = "https://evil.example/cb";
|
|
177
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
178
|
+
[`${LOOPBACK}/surface/notes/`, foreign],
|
|
179
|
+
hubOrigins,
|
|
180
|
+
);
|
|
181
|
+
// Hub-origin URI fanned out to the public origin.
|
|
182
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/`);
|
|
183
|
+
// Foreign URI present, but no hub-origin variant of it exists.
|
|
184
|
+
expect(out).toContain(foreign);
|
|
185
|
+
for (const o of hubOrigins) {
|
|
186
|
+
expect(out).not.toContain(`${o}/cb`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("single known hub origin → no expansion (submitted set returned as-is)", () => {
|
|
191
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
192
|
+
[`${LOOPBACK}/surface/notes/`],
|
|
193
|
+
[LOOPBACK],
|
|
194
|
+
);
|
|
195
|
+
expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("dedupes — already-public + loopback submit doesn't double-register", () => {
|
|
199
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
200
|
+
[`${LOOPBACK}/surface/notes/`, `${PUBLIC}/surface/notes/`],
|
|
201
|
+
hubOrigins,
|
|
202
|
+
);
|
|
203
|
+
const publicCount = out.filter((u) => u === `${PUBLIC}/surface/notes/`).length;
|
|
204
|
+
expect(publicCount).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("path + query + hash are preserved across origins", () => {
|
|
208
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
209
|
+
[`${LOOPBACK}/surface/notes/oauth-callback?foo=bar`],
|
|
210
|
+
hubOrigins,
|
|
211
|
+
);
|
|
212
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/oauth-callback?foo=bar`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("expanded URIs survive registerClient → requireRegisteredRedirectUri strict match", () => {
|
|
216
|
+
const { db, cleanup } = makeDb();
|
|
217
|
+
try {
|
|
218
|
+
const expanded = expandRedirectUrisForHubOrigins(
|
|
219
|
+
[`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
220
|
+
hubOrigins,
|
|
221
|
+
);
|
|
222
|
+
const r = registerClient(db, { redirectUris: expanded });
|
|
223
|
+
// The public-origin variant now matches exactly at authorize time — the
|
|
224
|
+
// off-localhost sign-in that surface#118 broke.
|
|
225
|
+
expect(
|
|
226
|
+
requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
|
|
227
|
+
).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
228
|
+
// A truly-unregistered URI is still rejected — strict match unchanged.
|
|
229
|
+
expect(() =>
|
|
230
|
+
requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
|
|
231
|
+
).toThrow(InvalidRedirectUriError);
|
|
232
|
+
} finally {
|
|
233
|
+
cleanup();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
147
238
|
describe("verifyClientSecret", () => {
|
|
148
239
|
test("matches the issued secret, rejects others", () => {
|
|
149
240
|
const { db, cleanup } = makeDb();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the agent-connector grant store (`grants-store.ts`, 4b-1).
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `grants.test.ts` (the OAuth consent skip-list in SQLite). This
|
|
5
|
+
* is the on-disk JSON store for approval-gated agent grants: round-trip, 0600
|
|
6
|
+
* perms (the file holds the granted secrets), idempotency-key derivation, and
|
|
7
|
+
* the lenient-read posture.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
type ConnectionSpec,
|
|
15
|
+
type GrantRecord,
|
|
16
|
+
connectionKey,
|
|
17
|
+
getGrant,
|
|
18
|
+
grantId,
|
|
19
|
+
listGrantsForAgent,
|
|
20
|
+
putGrant,
|
|
21
|
+
readGrants,
|
|
22
|
+
removeGrant,
|
|
23
|
+
} from "../grants-store.ts";
|
|
24
|
+
|
|
25
|
+
let dir: string;
|
|
26
|
+
let storePath: string;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
dir = mkdtempSync(join(tmpdir(), "phub-grants-store-"));
|
|
29
|
+
storePath = join(dir, "agent-grants.json");
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function vaultSpec(over: Partial<ConnectionSpec> = {}): ConnectionSpec {
|
|
36
|
+
return { kind: "vault", target: "research", access: "read", ...over };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rec(over: Partial<GrantRecord> = {}): GrantRecord {
|
|
40
|
+
const connection = over.connection ?? vaultSpec();
|
|
41
|
+
return {
|
|
42
|
+
id: over.id ?? grantId("agent1", connection),
|
|
43
|
+
agent: over.agent ?? "agent1",
|
|
44
|
+
connection,
|
|
45
|
+
status: over.status ?? "pending",
|
|
46
|
+
createdAt: over.createdAt ?? "2026-06-17T00:00:00.000Z",
|
|
47
|
+
...(over.reason ? { reason: over.reason } : {}),
|
|
48
|
+
...(over.material ? { material: over.material } : {}),
|
|
49
|
+
...(over.approvedAt ? { approvedAt: over.approvedAt } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("round-trip", () => {
|
|
54
|
+
test("a missing file reads as empty", () => {
|
|
55
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("put → read returns the record", () => {
|
|
59
|
+
const r = rec();
|
|
60
|
+
putGrant(storePath, r);
|
|
61
|
+
const back = readGrants(storePath);
|
|
62
|
+
expect(back).toHaveLength(1);
|
|
63
|
+
expect(back[0]).toEqual(r);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("put upserts by id (no duplicate row)", () => {
|
|
67
|
+
const r = rec();
|
|
68
|
+
putGrant(storePath, r);
|
|
69
|
+
putGrant(storePath, { ...r, status: "approved", approvedAt: "2026-06-18T00:00:00.000Z" });
|
|
70
|
+
const back = readGrants(storePath);
|
|
71
|
+
expect(back).toHaveLength(1);
|
|
72
|
+
expect(back[0]?.status).toBe("approved");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("getGrant + removeGrant", () => {
|
|
76
|
+
const r = rec();
|
|
77
|
+
putGrant(storePath, r);
|
|
78
|
+
expect(getGrant(storePath, r.id)?.id).toBe(r.id);
|
|
79
|
+
const removed = removeGrant(storePath, r.id);
|
|
80
|
+
expect(removed?.id).toBe(r.id);
|
|
81
|
+
expect(getGrant(storePath, r.id)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("listGrantsForAgent filters by agent (case-insensitive)", () => {
|
|
85
|
+
putGrant(storePath, rec({ id: "a", agent: "AgentOne" }));
|
|
86
|
+
putGrant(
|
|
87
|
+
storePath,
|
|
88
|
+
rec({ id: "b", agent: "agenttwo", connection: vaultSpec({ target: "x" }) }),
|
|
89
|
+
);
|
|
90
|
+
expect(listGrantsForAgent(storePath, "agentone")).toHaveLength(1);
|
|
91
|
+
expect(listGrantsForAgent(storePath, "AGENTTWO")).toHaveLength(1);
|
|
92
|
+
expect(listGrantsForAgent(storePath, "nope")).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("0600 perms (the file holds secrets)", () => {
|
|
97
|
+
test("the store file is created mode 0600", () => {
|
|
98
|
+
putGrant(
|
|
99
|
+
storePath,
|
|
100
|
+
rec({
|
|
101
|
+
status: "approved",
|
|
102
|
+
material: { kind: "service", token: "ghp_secret" },
|
|
103
|
+
connection: { kind: "service", target: "github" },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const mode = statSync(storePath).mode & 0o777;
|
|
107
|
+
expect(mode).toBe(0o600);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("lenient read", () => {
|
|
112
|
+
test("garbage JSON reads as empty", () => {
|
|
113
|
+
writeFileSync(storePath, "not json {{{");
|
|
114
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a top-level array (wrong shape) reads as empty", () => {
|
|
118
|
+
writeFileSync(storePath, JSON.stringify([{ id: "x" }]));
|
|
119
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a malformed row is dropped, valid rows survive", () => {
|
|
123
|
+
const valid = rec();
|
|
124
|
+
writeFileSync(
|
|
125
|
+
storePath,
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
grants: [
|
|
128
|
+
valid,
|
|
129
|
+
{ id: "missing-agent" }, // no agent/connection/status → dropped
|
|
130
|
+
{ id: "bad-status", agent: "a", connection: vaultSpec(), status: "weird" }, // bad status → dropped
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
const back = readGrants(storePath);
|
|
135
|
+
expect(back).toHaveLength(1);
|
|
136
|
+
expect(back[0]?.id).toBe(valid.id);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("4b-2 mcp material + needs_consent (regression)", () => {
|
|
141
|
+
test("mcp material round-trips (OAuth shape)", () => {
|
|
142
|
+
const r = rec({
|
|
143
|
+
status: "approved",
|
|
144
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
145
|
+
material: {
|
|
146
|
+
kind: "mcp",
|
|
147
|
+
access_token: "at-1",
|
|
148
|
+
refresh_token: "rt-1",
|
|
149
|
+
expiresAt: "2026-06-18T13:00:00.000Z",
|
|
150
|
+
issuer: "https://issuer.test",
|
|
151
|
+
clientId: "cid-1",
|
|
152
|
+
tokenEndpoint: "https://issuer.test/oauth/token",
|
|
153
|
+
revocationEndpoint: "https://issuer.test/oauth/revoke",
|
|
154
|
+
mcpUrl: "https://remote.test/mcp",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
putGrant(storePath, r);
|
|
158
|
+
const back = readGrants(storePath);
|
|
159
|
+
expect(back).toHaveLength(1);
|
|
160
|
+
expect(back[0]).toEqual(r);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("mcp static-bearer material round-trips (no refresh)", () => {
|
|
164
|
+
const r = rec({
|
|
165
|
+
status: "approved",
|
|
166
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
167
|
+
material: { kind: "mcp", access_token: "static-paste", mcpUrl: "https://remote.test/mcp" },
|
|
168
|
+
});
|
|
169
|
+
putGrant(storePath, r);
|
|
170
|
+
expect(readGrants(storePath)[0]).toEqual(r);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a needs_consent row SURVIVES readGrants (the 4b-2 blocker regression)", () => {
|
|
174
|
+
const r = rec({
|
|
175
|
+
status: "needs_consent",
|
|
176
|
+
reason: "refresh failed: invalid_grant",
|
|
177
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
178
|
+
});
|
|
179
|
+
putGrant(storePath, r);
|
|
180
|
+
const back = readGrants(storePath);
|
|
181
|
+
expect(back).toHaveLength(1);
|
|
182
|
+
expect(back[0]?.status).toBe("needs_consent");
|
|
183
|
+
expect(getGrant(storePath, r.id)?.status).toBe("needs_consent");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("connectionKey / grantId derivation (idempotency)", () => {
|
|
188
|
+
test("vault key includes access + sorted tags", () => {
|
|
189
|
+
expect(connectionKey(vaultSpec())).toBe("vault:research:read");
|
|
190
|
+
expect(connectionKey(vaultSpec({ access: "write" }))).toBe("vault:research:write");
|
|
191
|
+
// tag order doesn't change the key
|
|
192
|
+
const a = connectionKey(vaultSpec({ tags: ["#b", "#a"] }));
|
|
193
|
+
const b = connectionKey(vaultSpec({ tags: ["#a", "#b"] }));
|
|
194
|
+
expect(a).toBe(b);
|
|
195
|
+
expect(a).toBe("vault:research:read##a,#b");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("service key ignores inject (re-declare with new inject → same key)", () => {
|
|
199
|
+
const k1 = connectionKey({ kind: "service", target: "github", inject: ["env"] });
|
|
200
|
+
const k2 = connectionKey({ kind: "service", target: "github", inject: ["env", "mcp"] });
|
|
201
|
+
expect(k1).toBe(k2);
|
|
202
|
+
expect(k1).toBe("service:github");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("mcp key is the url target", () => {
|
|
206
|
+
expect(connectionKey({ kind: "mcp", target: "https://x.test/mcp" })).toBe(
|
|
207
|
+
"mcp:https://x.test/mcp",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("grantId is a stable URL-safe slug from agent + key", () => {
|
|
212
|
+
const id = grantId("agent1", vaultSpec());
|
|
213
|
+
expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
|
214
|
+
// deterministic
|
|
215
|
+
expect(grantId("agent1", vaultSpec())).toBe(id);
|
|
216
|
+
// different connection → different id
|
|
217
|
+
expect(grantId("agent1", vaultSpec({ access: "write" }))).not.toBe(id);
|
|
218
|
+
});
|
|
219
|
+
});
|