@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
package/src/oauth-handlers.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type OAuthClient,
|
|
39
39
|
type RegisteredClient,
|
|
40
40
|
approveClient,
|
|
41
|
+
expandRedirectUrisForHubOrigins,
|
|
41
42
|
getClient,
|
|
42
43
|
isValidRedirectUri,
|
|
43
44
|
registerClient,
|
|
@@ -71,6 +72,7 @@ import { isHttpsRequest } from "./request-protocol.ts";
|
|
|
71
72
|
import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
|
|
72
73
|
import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
|
|
73
74
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
75
|
+
import { shortNameForManifest } from "./service-spec.ts";
|
|
74
76
|
import {
|
|
75
77
|
type ServicesManifest,
|
|
76
78
|
// Hot-path OAuth flows use the lenient reader so a single malformed
|
|
@@ -391,13 +393,20 @@ function oauthErrorRedirect(
|
|
|
391
393
|
|
|
392
394
|
/**
|
|
393
395
|
* Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
|
|
394
|
-
* statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
|
|
395
|
-
*
|
|
396
|
+
* statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the module
|
|
397
|
+
* SHORT name that means "installed." Vault + hub are core and always
|
|
396
398
|
* advertised; these are the modules a hub may not have.
|
|
399
|
+
*
|
|
400
|
+
* Keyed on the SHORT name (resolved from each services.json row via
|
|
401
|
+
* `shortNameForManifest`) rather than the raw manifest name so a row written
|
|
402
|
+
* under a legacy manifest name still counts as installed — e.g. an un-upgraded
|
|
403
|
+
* `parachute-channel` row aliases to short `agent` (channel→agent rename
|
|
404
|
+
* 2026-06-17), so `agent:send` is still correctly advertised until the daemon
|
|
405
|
+
* re-registers under `parachute-agent`.
|
|
397
406
|
*/
|
|
398
|
-
const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string,
|
|
399
|
-
["scribe:", "
|
|
400
|
-
["
|
|
407
|
+
const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
|
|
408
|
+
["scribe:", "scribe"],
|
|
409
|
+
["agent:", "agent"],
|
|
401
410
|
];
|
|
402
411
|
|
|
403
412
|
/**
|
|
@@ -406,20 +415,22 @@ const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: s
|
|
|
406
415
|
* module isn't installed.
|
|
407
416
|
*
|
|
408
417
|
* Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
|
|
409
|
-
* `scribe:*` + `
|
|
418
|
+
* `scribe:*` + `agent:send`. Discovery clients list the advertised catalog
|
|
410
419
|
* verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
|
|
411
|
-
* request for Scribe +
|
|
420
|
+
* request for Scribe + Agent access the hub can't even honor. So advertise an
|
|
412
421
|
* optional module's scopes only when its service is present in services.json.
|
|
413
422
|
* (Trims the ADVERTISEMENT only; issuance/validation still use the full
|
|
414
423
|
* `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
|
|
415
424
|
*/
|
|
416
425
|
function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
|
|
417
|
-
const
|
|
426
|
+
const installedShorts = new Set(
|
|
427
|
+
manifest.services.map((s) => shortNameForManifest(s.name) ?? s.name),
|
|
428
|
+
);
|
|
418
429
|
return Array.from(declared)
|
|
419
430
|
.filter(isRequestableScope)
|
|
420
431
|
.filter((scope) => {
|
|
421
|
-
for (const [prefix,
|
|
422
|
-
if (scope.startsWith(prefix) && !
|
|
432
|
+
for (const [prefix, short] of OPTIONAL_MODULE_SCOPES) {
|
|
433
|
+
if (scope.startsWith(prefix) && !installedShorts.has(short)) return false;
|
|
423
434
|
}
|
|
424
435
|
return true;
|
|
425
436
|
});
|
|
@@ -888,11 +899,11 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
888
899
|
// 1. The consent screen — and the session-less "App not yet approved"
|
|
889
900
|
// page (`pendingClientResponse`) — shows ONLY that vault's scopes
|
|
890
901
|
// instead of the whole-hub catalog. Narrowing DROPS non-vault scopes
|
|
891
|
-
// (`scribe:*`, `
|
|
902
|
+
// (`scribe:*`, `agent:send`, `hub:admin`) outright: the token this
|
|
892
903
|
// flow mints is stamped `aud=vault.<name>`, so they're unusable inside
|
|
893
904
|
// it and only inflate the consent surface — the exact "scary consent"
|
|
894
905
|
// a friend hit connecting Claude to ONE vault (scribe isn't even
|
|
895
|
-
// installed; `
|
|
906
|
+
// installed; `agent:send` is meaningless to them).
|
|
896
907
|
// 2. The minted token carries the named scope, so `inferAudience` stamps
|
|
897
908
|
// `aud=vault.<name>` and a current-line vault accepts it (an unnamed
|
|
898
909
|
// `vault:read` token is rejected by `findBroadVaultScopes`).
|
|
@@ -2546,10 +2557,26 @@ export async function handleRegister(
|
|
|
2546
2557
|
}
|
|
2547
2558
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
2548
2559
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
2560
|
+
// Cross-hub-origin expansion (surface#118). A hub-served module (surface,
|
|
2561
|
+
// notes) registers at install time knowing only the loopback hub origin;
|
|
2562
|
+
// once exposed, the browser computes its redirect_uri from the PUBLIC hub
|
|
2563
|
+
// origin, which strict authorize-time matching would reject. We expand each
|
|
2564
|
+
// submitted URI rooted at one of THIS hub's known origins onto every other
|
|
2565
|
+
// known hub origin, so a loopback-registered hub-served client is valid on
|
|
2566
|
+
// the public origin too. FOREIGN-origin redirect_uris (a separate-origin
|
|
2567
|
+
// surface on its own domain) are stored verbatim — never expanded onto hub
|
|
2568
|
+
// origins, never dropped — preserving the strict anti-open-redirect
|
|
2569
|
+
// invariant. Authorize-time matching stays exact-match
|
|
2570
|
+
// (`requireRegisteredRedirectUri`); this only ever ADDS correct hub-origin
|
|
2571
|
+
// URIs the operator already controls.
|
|
2572
|
+
const expandedRedirectUris = expandRedirectUrisForHubOrigins(
|
|
2573
|
+
redirectUris,
|
|
2574
|
+
resolveBoundOrigins(deps),
|
|
2575
|
+
);
|
|
2549
2576
|
let registered: RegisteredClient;
|
|
2550
2577
|
try {
|
|
2551
2578
|
registered = registerClient(db, {
|
|
2552
|
-
redirectUris,
|
|
2579
|
+
redirectUris: expandedRedirectUris,
|
|
2553
2580
|
scopes,
|
|
2554
2581
|
clientName: body.client_name,
|
|
2555
2582
|
confidential,
|
package/src/operator-token.ts
CHANGED
package/src/origin-check.ts
CHANGED
|
@@ -180,17 +180,25 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
180
180
|
* mutation under `/admin/*`; keep this list in sync with the dispatch in
|
|
181
181
|
* `hub-server.ts`):
|
|
182
182
|
*
|
|
183
|
-
* - `POST /admin/connections` + `DELETE /admin/connections/<id>`
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
183
|
+
* - `POST /admin/connections` + `DELETE /admin/connections/<id>` +
|
|
184
|
+
* `POST /admin/connections/<id>/approve`
|
|
185
|
+
* (connection provision/teardown/claim-approval — the seam's canonical
|
|
186
|
+
* consumers are the agent module's admin page and the hub SPA, both same-origin
|
|
187
|
+
* `fetch()` with `credentials: "include"`. The Bearer-authed
|
|
188
|
+
* `/<id>/renew` + `/<id>/claim` siblings pass the belt via the
|
|
189
|
+
* Authorization carve-out below.)
|
|
190
|
+
* - `POST /admin/grants/<id>/approve` + `POST /admin/grants/<id>/revoke`
|
|
191
|
+
* (agent-connector grant approval/revoke — operator cookie-authed in the
|
|
192
|
+
* hub SPA, 4b-1. The `PUT /admin/grants` + `GET /admin/grants[/...]`
|
|
193
|
+
* siblings are host-admin-Bearer-authed and pass via the Authorization
|
|
194
|
+
* carve-out below.)
|
|
187
195
|
*
|
|
188
196
|
* (The legacy `POST/DELETE /admin/channels` pair was belted here until
|
|
189
197
|
* boundary D1 retired the endpoint — superseded by `/admin/connections`.)
|
|
190
198
|
*
|
|
191
199
|
* NOT belted, and why:
|
|
192
200
|
* - GET/HEAD/OPTIONS — read-shaped; the mint GETs
|
|
193
|
-
* (`/admin/host-admin-token`, `/admin/
|
|
201
|
+
* (`/admin/host-admin-token`, `/admin/agent-token`,
|
|
194
202
|
* `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
|
|
195
203
|
* enforce GET-only with a 405 and their response bodies are unreadable
|
|
196
204
|
* cross-origin (no CORS on these routes).
|
package/src/resource-binding.ts
CHANGED
|
@@ -117,15 +117,15 @@ function decodeVaultName(segment: string): string | null {
|
|
|
117
117
|
* - already-named `vault:<other>:<verb>` is LEFT UNTOUCHED — a client that
|
|
118
118
|
* explicitly named a different vault is not silently re-pointed; the
|
|
119
119
|
* downstream picker / assignment defenses decide whether that's allowed.
|
|
120
|
-
* - non-vault scopes (`scribe:*`, `
|
|
120
|
+
* - non-vault scopes (`scribe:*`, `agent:send`, `hub:admin`, …) are
|
|
121
121
|
* DROPPED. This flow mints a token stamped `aud=vault.<name>` (RFC 8707),
|
|
122
|
-
* so a scribe/
|
|
122
|
+
* so a scribe/agent/hub scope inside it is unusable — keeping it only
|
|
123
123
|
* inflates the consent surface a friend sees when connecting ONE vault.
|
|
124
124
|
* That "scary consent" is the failure mode this module exists to kill
|
|
125
125
|
* (see the header docstring): the verb-narrowing alone left the foreign
|
|
126
126
|
* scopes riding through, so a client that over-requests the whole-hub
|
|
127
127
|
* catalog (claude.ai reads it from the AS-metadata `scopes_supported`)
|
|
128
|
-
* still surfaced `scribe:admin` + `
|
|
128
|
+
* still surfaced `scribe:admin` + `agent:send` on the consent screen.
|
|
129
129
|
* A client that genuinely wants a scribe token runs a separate flow
|
|
130
130
|
* naming the scribe resource.
|
|
131
131
|
*
|
|
@@ -137,7 +137,7 @@ export function narrowResourceVaultScopes(scopes: readonly string[], vaultName:
|
|
|
137
137
|
const out: string[] = [];
|
|
138
138
|
for (const s of scopes) {
|
|
139
139
|
const parts = s.split(":");
|
|
140
|
-
if (parts[0] !== "vault") continue; // drop scribe:/
|
|
140
|
+
if (parts[0] !== "vault") continue; // drop scribe:/agent:/hub:/… — foreign to a vault-audience token
|
|
141
141
|
const verb = parts[1];
|
|
142
142
|
if (parts.length === 2 && verb && VAULT_VERBS.has(verb)) {
|
|
143
143
|
out.push(`vault:${vaultName}:${verb}`);
|
|
@@ -45,7 +45,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
45
45
|
label: "Full vault access plus configuration changes (rotate tokens, change settings).",
|
|
46
46
|
level: "admin",
|
|
47
47
|
},
|
|
48
|
-
// Optional-module scopes (scribe /
|
|
48
|
+
// Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
|
|
49
49
|
// (= Object.keys(this map)) but the modules may not be installed — so they're
|
|
50
50
|
// GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
|
|
51
51
|
// `scopes_supported` when the service is in services.json. If you add scopes
|
|
@@ -59,8 +59,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
59
59
|
label: "Manage Scribe configuration (provider keys, models, quotas).",
|
|
60
60
|
level: "admin",
|
|
61
61
|
},
|
|
62
|
-
"
|
|
63
|
-
label: "Post messages to your
|
|
62
|
+
"agent:send": {
|
|
63
|
+
label: "Post messages to your Agent.",
|
|
64
64
|
level: "send",
|
|
65
65
|
},
|
|
66
66
|
"hub:admin": {
|
package/src/service-spec.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
8
8
|
*
|
|
9
9
|
* 1939 parachute-hub internal static + proxy, CLI-managed
|
|
10
10
|
* 1940 parachute-vault committed core
|
|
11
|
-
* 1941 parachute-
|
|
11
|
+
* 1941 parachute-agent exploration (renamed from parachute-channel)
|
|
12
12
|
* 1942 parachute-notes committed core (PWA bundle)
|
|
13
13
|
* 1943 parachute-scribe committed core
|
|
14
14
|
* 1944–1949 unassigned
|
|
@@ -64,7 +64,7 @@ export interface PortReservation {
|
|
|
64
64
|
export const PORT_RESERVATIONS: readonly PortReservation[] = [
|
|
65
65
|
{ port: 1939, name: "parachute-hub", status: "assigned" },
|
|
66
66
|
{ port: 1940, name: "parachute-vault", status: "assigned" },
|
|
67
|
-
{ port: 1941, name: "parachute-
|
|
67
|
+
{ port: 1941, name: "parachute-agent", status: "assigned" },
|
|
68
68
|
{ port: 1942, name: "parachute-notes", status: "assigned" },
|
|
69
69
|
{ port: 1943, name: "parachute-scribe", status: "assigned" },
|
|
70
70
|
{ port: 1944, name: "unassigned", status: "reserved" },
|
|
@@ -110,7 +110,7 @@ export interface FirstPartyExtras {
|
|
|
110
110
|
/**
|
|
111
111
|
* Does the service gate its endpoints behind auth today? Drives
|
|
112
112
|
* `effectivePublicExposure`'s default for api/tool services. True for
|
|
113
|
-
* vault/
|
|
113
|
+
* vault/agent; conservatively false for scribe until its auth-gate ships.
|
|
114
114
|
*/
|
|
115
115
|
readonly hasAuth?: boolean;
|
|
116
116
|
/**
|
|
@@ -260,9 +260,10 @@ export function composeServiceSpec(opts: {
|
|
|
260
260
|
//
|
|
261
261
|
// As of 2026-05-21 (hub#310), vault / scribe / runner have all retired their
|
|
262
262
|
// FALLBACK entries: each ships `module.json` AND self-registers its
|
|
263
|
-
// services.json row at boot (vault#356, scribe#50, runner#3).
|
|
264
|
-
// followed in the 2026-06-09 hub-module-
|
|
265
|
-
// `.parachute/module.json` and
|
|
263
|
+
// services.json row at boot (vault#356, scribe#50, runner#3). Agent
|
|
264
|
+
// (renamed from channel 2026-06-17) followed in the 2026-06-09 hub-module-
|
|
265
|
+
// boundary migration (D3) — it ships `.parachute/module.json` and
|
|
266
|
+
// self-registers (channel#34 era, pre-rename), so its
|
|
266
267
|
// vendored manifest retired to a KNOWN_MODULES row. Hub reads the
|
|
267
268
|
// canonical fields from services.json (operator-authoritative) and falls
|
|
268
269
|
// through to `<installDir>/.parachute/module.json` when a lifecycle command
|
|
@@ -321,7 +322,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
321
322
|
* Indexed by short name (the `parachute install <X>` token).
|
|
322
323
|
*
|
|
323
324
|
* Only notes remains — see the block comment above for the rationale
|
|
324
|
-
* (vault/scribe/runner/
|
|
325
|
+
* (vault/scribe/runner/agent now self-register and ship their own
|
|
325
326
|
* module.json). Other code paths consult both this table AND `KNOWN_MODULES`
|
|
326
327
|
* (which carries the post-self-register-retirement entries) via the helpers
|
|
327
328
|
* in this file (`shortNameForManifest`, `knownServices`, …).
|
|
@@ -332,7 +333,7 @@ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
|
332
333
|
|
|
333
334
|
/**
|
|
334
335
|
* Minimal install-time registry for first-party modules whose FALLBACK has
|
|
335
|
-
* retired (vault / scribe / runner as of hub#310;
|
|
336
|
+
* retired (vault / scribe / runner as of hub#310; agent as of the
|
|
336
337
|
* 2026-06-09 hub-module-boundary migration, D3). Hub uses this for:
|
|
337
338
|
*
|
|
338
339
|
* 1. **Install bootstrap**: mapping `parachute install <short>` to the npm
|
|
@@ -457,28 +458,26 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
457
458
|
hasAuth: true,
|
|
458
459
|
},
|
|
459
460
|
},
|
|
460
|
-
|
|
461
|
-
short: "
|
|
462
|
-
package: "@openparachute/
|
|
463
|
-
manifestName: "parachute-
|
|
461
|
+
agent: {
|
|
462
|
+
short: "agent",
|
|
463
|
+
package: "@openparachute/agent",
|
|
464
|
+
manifestName: "parachute-agent",
|
|
464
465
|
canonicalPort: 1941,
|
|
465
|
-
displayName: "
|
|
466
|
-
// Mirrors
|
|
467
|
-
// keep in sync if
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
// module.json + self-registration and had drifted on all three.
|
|
466
|
+
displayName: "Agent",
|
|
467
|
+
// Mirrors agent's own module.json (the canonical fields below do too —
|
|
468
|
+
// keep in sync if agent's declaration changes). Renamed from `channel`
|
|
469
|
+
// 2026-06-17 (parachute-channel → parachute-agent); the module is the
|
|
470
|
+
// same webhook-fan-out + MCP bridge daemon, on the same port 1941.
|
|
471
471
|
tagline: "Chat with your Claude Code sessions — a channel per session.",
|
|
472
|
-
canonicalPaths: ["/
|
|
472
|
+
canonicalPaths: ["/agent"],
|
|
473
473
|
canonicalHealth: "/health",
|
|
474
474
|
canonicalStripPrefix: true,
|
|
475
475
|
extras: {
|
|
476
476
|
// Backward-compat startCmd for rows without installDir — same rationale
|
|
477
|
-
// as scribe / vault / runner. The bare binary IS the daemon (
|
|
478
|
-
// package.json bin maps `parachute-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// Channel gates its endpoints behind hub-issued JWTs (channel:* scopes).
|
|
477
|
+
// as scribe / vault / runner. The bare binary IS the daemon (agent's
|
|
478
|
+
// package.json bin maps `parachute-agent` → src/daemon.ts).
|
|
479
|
+
startCmd: () => ["parachute-agent"],
|
|
480
|
+
// Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
|
|
482
481
|
hasAuth: true,
|
|
483
482
|
},
|
|
484
483
|
},
|
|
@@ -535,15 +534,20 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
535
534
|
* reappear silently on legacy installs.
|
|
536
535
|
*/
|
|
537
536
|
export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
|
|
538
|
-
agent:
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
537
|
+
// NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
|
|
538
|
+
// names were retired here on 2026-05-20 (the Claude-in-containers module).
|
|
539
|
+
// They are NOW RE-ASSIGNED to the renamed channel module (parachute-channel
|
|
540
|
+
// → parachute-agent). Keeping these as RETIRED entries would make
|
|
541
|
+
// `dropRetiredModuleRows` drop the NEW agent module's services.json row on
|
|
542
|
+
// load — breaking the live module. The names are no longer retired; they
|
|
543
|
+
// belong to the active agent module. The retired containers code now lives
|
|
544
|
+
// under `parachute-agent-legacy` (GitHub) and is not installable, so there
|
|
545
|
+
// is no live `agent` daemon to GC. (The historical `claw → agent`
|
|
546
|
+
// migration in services-manifest.ts still rewrites stale paraclaw rows to
|
|
547
|
+
// `name: "agent"` — they now resolve to the LIVE agent module's mount,
|
|
548
|
+
// which is harmless / arguably correct since paraclaw was the original
|
|
549
|
+
// "agent".)
|
|
550
|
+
//
|
|
547
551
|
// The `parachute-app` row name retires 2026-05-27 along with the
|
|
548
552
|
// app → surface rename (patterns#102). Operators upgrading from
|
|
549
553
|
// 0.5.13-stable will have a `parachute-app` row in services.json
|
|
@@ -637,7 +641,7 @@ export function knownServices(): string[] {
|
|
|
637
641
|
* The hub PREFERS a module's manifest-declared `focus`; this map is the
|
|
638
642
|
* fallback when `module.json` omits it (and the bootstrap value before a
|
|
639
643
|
* module is installed). vault / scribe / hub / surface are `core`; everything
|
|
640
|
-
* else (
|
|
644
|
+
* else (agent / runner / notes / unknown third-party) defaults to
|
|
641
645
|
* `experimental`. **Show all; never hide** — `focus` only groups + labels.
|
|
642
646
|
*/
|
|
643
647
|
const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
@@ -645,7 +649,7 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
|
645
649
|
scribe: "core",
|
|
646
650
|
hub: "core",
|
|
647
651
|
surface: "core",
|
|
648
|
-
|
|
652
|
+
agent: "experimental",
|
|
649
653
|
runner: "experimental",
|
|
650
654
|
notes: "experimental",
|
|
651
655
|
};
|
|
@@ -668,7 +672,7 @@ export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocu
|
|
|
668
672
|
* `CURATED_MODULES` whitelist (2026-06-09 modular-UI architecture, P2): every
|
|
669
673
|
* module the hub can resolve a package/manifest for is discoverable + installable,
|
|
670
674
|
* regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
|
|
671
|
-
* (notes) then KNOWN_MODULES (vault / scribe / runner /
|
|
675
|
+
* (notes) then KNOWN_MODULES (vault / scribe / runner / agent / surface).
|
|
672
676
|
*
|
|
673
677
|
* `notes` is intentionally included — it's still resolvable (vendored fallback)
|
|
674
678
|
* for legacy installs; it surfaces as `experimental` and isn't pushed as a
|
|
@@ -727,7 +731,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
727
731
|
* spec with embedded manifest + extras — the vendored manifest is the
|
|
728
732
|
* source of truth pre-install and the install path preserves it through.
|
|
729
733
|
*
|
|
730
|
-
* KNOWN_MODULES shorts (vault / scribe / runner /
|
|
734
|
+
* KNOWN_MODULES shorts (vault / scribe / runner / agent / surface — post
|
|
731
735
|
* FALLBACK retirement) return a **minimal** spec carrying `package`, `manifestName`,
|
|
732
736
|
* and the imperative `extras` fields
|
|
733
737
|
* (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
|
|
@@ -821,14 +825,23 @@ export async function getSpecFromInstallDir(
|
|
|
821
825
|
* their install — without this alias, `parachute start/stop/logs/status`
|
|
822
826
|
* silently skip those rows. Remove after launch, alongside the `lens →
|
|
823
827
|
* notes` install alias.
|
|
828
|
+
*
|
|
829
|
+
* `parachute-channel` → `agent` (2026-06-17): the channel module was renamed
|
|
830
|
+
* to agent (parachute-channel → parachute-agent). Operators with an
|
|
831
|
+
* un-upgraded `services.json` carry a `name: "parachute-channel"` row; this
|
|
832
|
+
* alias keeps `shortNameForManifest("parachute-channel") === "agent"` so the
|
|
833
|
+
* legacy row still resolves to the live agent module for routing + lifecycle
|
|
834
|
+
* until the daemon re-registers under `parachute-agent`. Remove after one
|
|
835
|
+
* release cycle, alongside the `channel → agent` install alias.
|
|
824
836
|
*/
|
|
825
837
|
const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
|
|
826
838
|
"parachute-lens": "notes",
|
|
839
|
+
"parachute-channel": "agent",
|
|
827
840
|
};
|
|
828
841
|
|
|
829
842
|
/** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
|
|
830
843
|
* Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
|
|
831
|
-
* (vault / scribe / runner /
|
|
844
|
+
* (vault / scribe / runner / agent / surface — post-FALLBACK-retirement).
|
|
832
845
|
* Returns undefined for unknown manifests. */
|
|
833
846
|
export function shortNameForManifest(manifestName: string): string | undefined {
|
|
834
847
|
for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
|
|
@@ -841,13 +854,13 @@ export function shortNameForManifest(manifestName: string): string | undefined {
|
|
|
841
854
|
}
|
|
842
855
|
|
|
843
856
|
/**
|
|
844
|
-
* Find a services.json row by its SHORT name (e.g. `"
|
|
845
|
-
* row's manifest name (`parachute-
|
|
857
|
+
* Find a services.json row by its SHORT name (e.g. `"agent"`), resolving each
|
|
858
|
+
* row's manifest name (`parachute-agent`) back through `shortNameForManifest`.
|
|
846
859
|
*
|
|
847
860
|
* services.json rows carry the MANIFEST name, not the bare short — so a direct
|
|
848
861
|
* `s.name === short` comparison silently misses every first-party module. That
|
|
849
|
-
* exact mismatch (`s.name === "
|
|
850
|
-
* surfaced a spurious "
|
|
862
|
+
* exact mismatch (`s.name === "agent"` vs a `parachute-agent` row) is what
|
|
863
|
+
* surfaced a spurious "agent module is not installed" when wiring the agent
|
|
851
864
|
* connection sink. Resolve through the short↔manifest map instead. Returns the
|
|
852
865
|
* first match, or undefined when no installed row maps to `short`.
|
|
853
866
|
*
|
|
@@ -855,7 +868,7 @@ export function shortNameForManifest(manifestName: string): string | undefined {
|
|
|
855
868
|
* here — `shortNameForManifest` only knows the canonical `parachute-vault`, so
|
|
856
869
|
* `findServiceByShort(services, "vault")` returns undefined even when a vault is
|
|
857
870
|
* installed. Vault rows are resolved by mount path via `findVaultUpstream`; this
|
|
858
|
-
* helper is for single-instance modules (
|
|
871
|
+
* helper is for single-instance modules (agent / scribe / runner / surface).
|
|
859
872
|
*/
|
|
860
873
|
export function findServiceByShort<T extends { name: string }>(
|
|
861
874
|
services: readonly T[],
|
package/src/services-manifest.ts
CHANGED
|
@@ -71,6 +71,38 @@ function normalizeUiSubUnitStatus(value: string): UiSubUnitStatus | undefined {
|
|
|
71
71
|
return UI_SUB_UNIT_STATUS_ALIASES[value];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Who may load a UI sub-unit through the hub proxy (H3, surface-runtime
|
|
76
|
+
* design §12 — fixes parachute-surface#88, where `public` was declared but
|
|
77
|
+
* never enforced):
|
|
78
|
+
*
|
|
79
|
+
* "public" — anyone; no hub identity required (chrome strip off — H5).
|
|
80
|
+
* "hub-users" — a valid hub session cookie OR a hub-issued Bearer whose
|
|
81
|
+
* scopes satisfy the surface's `scopes_required` (the OR
|
|
82
|
+
* keeps installed PWAs working). THE DEFAULT when absent.
|
|
83
|
+
* "operator" — the first-admin session only.
|
|
84
|
+
* "surface" — the surface backend owns admission END-TO-END; the hub
|
|
85
|
+
* proxy passes every request through (backed surfaces —
|
|
86
|
+
* kit-authenticated via @openparachute/surface-server: hub
|
|
87
|
+
* JWTs / capability links / anon, deny-by-default). Chrome
|
|
88
|
+
* strip off like `public` — capability-link invitees are
|
|
89
|
+
* NOT hub users.
|
|
90
|
+
*
|
|
91
|
+
* Enforced at the hub proxy BEFORE forwarding (`src/audience-gate.ts`).
|
|
92
|
+
* Legacy boolean `public` on the wire is accepted one alias window:
|
|
93
|
+
* `public: true` → `"public"`, `public: false` → default. Fail-closed: an
|
|
94
|
+
* unrecognized `audience` value rejects the row (the lenient manifest read
|
|
95
|
+
* then drops it — unroutable beats accidentally public). Version-skew
|
|
96
|
+
* corollary: a surface declaring `audience: "surface"` registered against a
|
|
97
|
+
* hub OLDER than this value's introduction gets its row rejected by that
|
|
98
|
+
* same fail-closed rule — the mount 404s until the hub is upgraded. The
|
|
99
|
+
* surface-side meta-schema ships the value with a hub-version requirement
|
|
100
|
+
* note for exactly this reason.
|
|
101
|
+
*/
|
|
102
|
+
export type UiAudience = "public" | "hub-users" | "operator" | "surface";
|
|
103
|
+
|
|
104
|
+
const UI_AUDIENCE_VALUES: readonly UiAudience[] = ["public", "hub-users", "operator", "surface"];
|
|
105
|
+
|
|
74
106
|
/**
|
|
75
107
|
* A sub-unit beneath a module — used today by parachute-app to surface each
|
|
76
108
|
* hosted UI as its own discoverable row under the App module, and the shape
|
|
@@ -129,6 +161,20 @@ export interface UiSubUnit {
|
|
|
129
161
|
oauthClientId?: string;
|
|
130
162
|
/** UI sub-unit lifecycle state. Absent → discovery treats as "active". */
|
|
131
163
|
status?: UiSubUnitStatus;
|
|
164
|
+
/**
|
|
165
|
+
* Audience exposure (H3). Absent → "hub-users". The legacy boolean
|
|
166
|
+
* `public` field is normalized into this on read (true → "public") for
|
|
167
|
+
* one alias window. See {@link UiAudience}.
|
|
168
|
+
*/
|
|
169
|
+
audience?: UiAudience;
|
|
170
|
+
/**
|
|
171
|
+
* OAuth scopes the UI declares as required (surface-host stamps these from
|
|
172
|
+
* the surface's meta.json `scopes_required`, e.g. `["vault:*:read"]` —
|
|
173
|
+
* `*` is a single-segment wildcard). The audience gate's Bearer branch
|
|
174
|
+
* checks a presented token's scopes against these. Snake_case to match
|
|
175
|
+
* the wire shape surface already writes.
|
|
176
|
+
*/
|
|
177
|
+
scopes_required?: string[];
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
export interface ServiceEntry {
|
|
@@ -170,6 +216,16 @@ export interface ServiceEntry {
|
|
|
170
216
|
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
171
217
|
*/
|
|
172
218
|
stripPrefix?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* When `true`, the module's daemon accepts WebSocket upgrades and the hub's
|
|
221
|
+
* Bun-native upgrade bridge (H1, surface-runtime design) will forward
|
|
222
|
+
* `Upgrade: websocket` requests on this module's mounts to it. DENY BY
|
|
223
|
+
* DEFAULT: absent/false means an upgrade request to this mount is refused
|
|
224
|
+
* (426) without ever reaching the daemon. Modules declare this on their
|
|
225
|
+
* `.parachute/module.json` (the install-time contract) and carry it onto
|
|
226
|
+
* their self-registered services.json row; the hub honors either source.
|
|
227
|
+
*/
|
|
228
|
+
websocket?: boolean;
|
|
173
229
|
/**
|
|
174
230
|
* Sub-units hosted under this module — parachute-app's bag of UIs, and
|
|
175
231
|
* the shape vault is expected to adopt for per-instance metadata in a
|
|
@@ -282,6 +338,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
282
338
|
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
283
339
|
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
284
340
|
}
|
|
341
|
+
const websocket = e.websocket;
|
|
342
|
+
if (websocket !== undefined && typeof websocket !== "boolean") {
|
|
343
|
+
throw new ServicesManifestError(`${where}: "websocket" must be a boolean if present`);
|
|
344
|
+
}
|
|
285
345
|
const uis = e.uis;
|
|
286
346
|
const validatedUis = validateUis(uis, where);
|
|
287
347
|
const validatedStartError = validateStartError(e.lastStartError, where);
|
|
@@ -291,6 +351,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
291
351
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
292
352
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
293
353
|
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
354
|
+
if (websocket !== undefined) entry.websocket = websocket;
|
|
294
355
|
if (validatedUis !== undefined) entry.uis = validatedUis;
|
|
295
356
|
if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
|
|
296
357
|
return entry;
|
|
@@ -405,12 +466,48 @@ function validateUiSubUnit(raw: unknown, where: string): UiSubUnit {
|
|
|
405
466
|
}
|
|
406
467
|
normalizedStatus = norm;
|
|
407
468
|
}
|
|
469
|
+
// Audience (H3). Explicit `audience` wins; the legacy boolean `public` is
|
|
470
|
+
// accepted as an alias for one release window (true → "public"; false →
|
|
471
|
+
// the default, i.e. no field). FAIL-CLOSED on malformed values: throwing
|
|
472
|
+
// here makes the lenient manifest read drop the whole row — an unroutable
|
|
473
|
+
// surface beats one that silently falls open to the wrong audience.
|
|
474
|
+
let audience: UiAudience | undefined;
|
|
475
|
+
if (u.audience !== undefined) {
|
|
476
|
+
if (
|
|
477
|
+
typeof u.audience !== "string" ||
|
|
478
|
+
!(UI_AUDIENCE_VALUES as readonly string[]).includes(u.audience)
|
|
479
|
+
) {
|
|
480
|
+
throw new ServicesManifestError(
|
|
481
|
+
`${where}: "audience" must be "public" | "hub-users" | "operator" | "surface" if present (got ${JSON.stringify(u.audience)})`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
audience = u.audience as UiAudience;
|
|
485
|
+
} else if (u.public !== undefined) {
|
|
486
|
+
if (typeof u.public !== "boolean") {
|
|
487
|
+
throw new ServicesManifestError(`${where}: legacy "public" must be a boolean if present`);
|
|
488
|
+
}
|
|
489
|
+
if (u.public) audience = "public";
|
|
490
|
+
}
|
|
491
|
+
let scopesRequired: string[] | undefined;
|
|
492
|
+
if (u.scopes_required !== undefined) {
|
|
493
|
+
if (
|
|
494
|
+
!Array.isArray(u.scopes_required) ||
|
|
495
|
+
u.scopes_required.some((s) => typeof s !== "string" || s.length === 0)
|
|
496
|
+
) {
|
|
497
|
+
throw new ServicesManifestError(
|
|
498
|
+
`${where}: "scopes_required" must be an array of non-empty strings if present`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
scopesRequired = u.scopes_required as string[];
|
|
502
|
+
}
|
|
408
503
|
const out: UiSubUnit = { displayName, path };
|
|
409
504
|
if (tagline !== undefined) out.tagline = tagline;
|
|
410
505
|
if (iconUrl !== undefined) out.iconUrl = iconUrl;
|
|
411
506
|
if (version !== undefined) out.version = version;
|
|
412
507
|
if (oauthClientId !== undefined) out.oauthClientId = oauthClientId;
|
|
413
508
|
if (normalizedStatus !== undefined) out.status = normalizedStatus;
|
|
509
|
+
if (audience !== undefined) out.audience = audience;
|
|
510
|
+
if (scopesRequired !== undefined) out.scopes_required = scopesRequired;
|
|
414
511
|
return out;
|
|
415
512
|
}
|
|
416
513
|
|