@openparachute/hub 0.7.1 → 0.7.2
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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/agent-token` — exchange a valid admin session cookie for a
|
|
3
|
+
* short-lived JWT carrying `agent:read agent:send agent:admin`.
|
|
4
|
+
*
|
|
5
|
+
* Renamed from `/admin/channel-token` 2026-06-17 (parachute-channel →
|
|
6
|
+
* parachute-agent). The old path keeps working via a 301 redirect in
|
|
7
|
+
* hub-server.ts for one release cycle.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: two agent-owned UIs, both served behind hub's proxy to a
|
|
10
|
+
* logged-in portal operator, need a Bearer to talk to agent's API the same
|
|
11
|
+
* way the vault-management and scribe-config SPAs do, without running the
|
|
12
|
+
* public `/oauth/authorize` flow:
|
|
13
|
+
* - The **chat UI** (`/agent/ui`) receives replies over SSE
|
|
14
|
+
* (`agent:read`) and posts a message (`agent:send`).
|
|
15
|
+
* - The **config/admin UI** (`/agent/admin`, the 2026-06-09 modular-UI
|
|
16
|
+
* architecture P3/P4 config surface) lists + edits configured channels via
|
|
17
|
+
* `agent:admin`-gated endpoints (`requireScope(SCOPE_ADMIN)` in agent's
|
|
18
|
+
* daemon).
|
|
19
|
+
*
|
|
20
|
+
* Both UIs fetch this single endpoint (`fetchToken()` against
|
|
21
|
+
* `/admin/agent-token`), so the minted token carries the union of the scopes
|
|
22
|
+
* either UI needs. The chat UI simply ignores the extra `agent:admin` scope;
|
|
23
|
+
* `requireScope` checks for the *presence* of a specific scope, so extra
|
|
24
|
+
* scopes never break a read/send call. This is what makes the agent config
|
|
25
|
+
* UI work without re-touching the agent repo — the hub endpoint the config
|
|
26
|
+
* UI already calls now mints the admin scope it needs (2026-06-09 modular-UI
|
|
27
|
+
* architecture, P3).
|
|
28
|
+
*
|
|
29
|
+
* Scope choice — `agent:read agent:send agent:admin`, deliberately NOT
|
|
30
|
+
* `agent:write`:
|
|
31
|
+
* - `agent:read` — receive replies over SSE.
|
|
32
|
+
* - `agent:send` — post a message into the channel.
|
|
33
|
+
* - `agent:admin` — list + edit channel config (the config UI).
|
|
34
|
+
* - `agent:write` is the *session-reply* scope (a connected Claude Code
|
|
35
|
+
* session replying on a channel). A UI token must not be able to
|
|
36
|
+
* impersonate a session, so we never mint `agent:write` here.
|
|
37
|
+
*
|
|
38
|
+
* Audience: `agent` (the bare service prefix). Agent validates the JWT's
|
|
39
|
+
* `aud` claim against the literal string `"agent"` (parachute-agent
|
|
40
|
+
* `src/hub-jwt.ts`), the same shape `inferAudience` in oauth-handlers.ts
|
|
41
|
+
* stamps for the public OAuth flow — so hub-minted and OAuth-minted agent
|
|
42
|
+
* tokens are indistinguishable to agent. Unlike the per-vault admin token
|
|
43
|
+
* (`vault.<name>`), agent has a single bare audience. (During the rename
|
|
44
|
+
* transition window the agent daemon dual-accepts both `aud: "channel"` and
|
|
45
|
+
* `aud: "agent"`, so pre-rename tokens keep validating until re-minted — see
|
|
46
|
+
* the channel→agent migration doc; that back-compat lives in the daemon, not
|
|
47
|
+
* here.)
|
|
48
|
+
*
|
|
49
|
+
* Multi-user Phase 1 gate: the session must belong to the first admin (the
|
|
50
|
+
* single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`),
|
|
51
|
+
* mirroring host-admin-token and vault-admin-token. Friends pinned to a vault
|
|
52
|
+
* use the OAuth flow for their assigned scopes; they don't get an agent
|
|
53
|
+
* Bearer via this endpoint.
|
|
54
|
+
*
|
|
55
|
+
* Tokens minted here are short-lived (10 min — matches host/vault admin
|
|
56
|
+
* tokens); the UI re-fetches on near-expiry.
|
|
57
|
+
*/
|
|
58
|
+
import type { Database } from "bun:sqlite";
|
|
59
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
60
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
61
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
62
|
+
import { isFirstAdmin } from "./users.ts";
|
|
63
|
+
|
|
64
|
+
/** Short TTL — matches host/vault admin-token. UI re-fetches on near-expiry. */
|
|
65
|
+
export const AGENT_TOKEN_TTL_SECONDS = 10 * 60;
|
|
66
|
+
const AGENT_AUDIENCE = "agent";
|
|
67
|
+
const AGENT_CLIENT_ID = "parachute-hub-spa";
|
|
68
|
+
/**
|
|
69
|
+
* `agent:read` (SSE replies) + `agent:send` (post a message) +
|
|
70
|
+
* `agent:admin` (list + edit channel config — the config UI). Deliberately
|
|
71
|
+
* NOT `agent:write` — that's the session-reply scope, and a UI token must
|
|
72
|
+
* not be able to impersonate a connected session. The chat UI ignores the
|
|
73
|
+
* extra `agent:admin`; the config UI needs it (2026-06-09 modular-UI
|
|
74
|
+
* architecture, P3 — the hub endpoint the agent config UI already calls
|
|
75
|
+
* mints the admin scope so the agent repo doesn't have to change).
|
|
76
|
+
*/
|
|
77
|
+
export const AGENT_TOKEN_SCOPES = ["agent:read", "agent:send", "agent:admin"] as const;
|
|
78
|
+
|
|
79
|
+
export interface MintAgentTokenDeps {
|
|
80
|
+
db: Database;
|
|
81
|
+
/** Hub origin — written into JWT `iss`. */
|
|
82
|
+
issuer: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function handleAgentToken(req: Request, deps: MintAgentTokenDeps): Promise<Response> {
|
|
86
|
+
if (req.method !== "GET") {
|
|
87
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
88
|
+
}
|
|
89
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
90
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
91
|
+
if (!session || !sid) {
|
|
92
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
93
|
+
}
|
|
94
|
+
// First-admin gate (mirrors host/vault-admin-token). A friend account
|
|
95
|
+
// (non-first-admin user created via `/api/users`) holds a valid session but
|
|
96
|
+
// must not mint an agent Bearer. Without this check, any signed-in friend
|
|
97
|
+
// hitting `GET /admin/agent-token` would walk away with a token carrying
|
|
98
|
+
// `agent:read agent:send`.
|
|
99
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
100
|
+
return jsonError(
|
|
101
|
+
403,
|
|
102
|
+
"not_admin",
|
|
103
|
+
"agent token mint is restricted to the hub admin — your account home is at /account/",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
|
|
107
|
+
// session can't mint an agent Bearer, so agent's config + chat UIs show
|
|
108
|
+
// the lock screen / fail closed until the operator unlocks. Off by default.
|
|
109
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
110
|
+
return lockedResponse();
|
|
111
|
+
}
|
|
112
|
+
const minted = await signAccessToken(deps.db, {
|
|
113
|
+
sub: session.userId,
|
|
114
|
+
scopes: [...AGENT_TOKEN_SCOPES],
|
|
115
|
+
audience: AGENT_AUDIENCE,
|
|
116
|
+
clientId: AGENT_CLIENT_ID,
|
|
117
|
+
issuer: deps.issuer,
|
|
118
|
+
ttlSeconds: AGENT_TOKEN_TTL_SECONDS,
|
|
119
|
+
// Agent tokens carry no per-user vault pin — the UI Bearer talks to an
|
|
120
|
+
// agent-scoped endpoint, not to a single vault. Empty `vault_scope` is
|
|
121
|
+
// the "no per-user restriction" sentinel matching host-admin tokens.
|
|
122
|
+
vaultScope: [],
|
|
123
|
+
});
|
|
124
|
+
return new Response(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
token: minted.token,
|
|
127
|
+
expires_at: minted.expiresAt,
|
|
128
|
+
scopes: AGENT_TOKEN_SCOPES,
|
|
129
|
+
}),
|
|
130
|
+
{
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: {
|
|
133
|
+
"content-type": "application/json",
|
|
134
|
+
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
135
|
+
// back/forward navigation could hand the UI a long-expired JWT.
|
|
136
|
+
"cache-control": "no-store",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
143
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
144
|
+
status,
|
|
145
|
+
headers: { "content-type": "application/json" },
|
|
146
|
+
});
|
|
147
|
+
}
|
package/src/admin-connections.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* architecture, P5). Generalizes the channel-specific `/admin/channels`
|
|
5
5
|
* endpoint (hub#624 era; retired in boundary D1): "add a vault-backed channel"
|
|
6
6
|
* is just the first connection, `vault.note.created (filter
|
|
7
|
-
* #
|
|
7
|
+
* #agent-message/inbound) → agent.message.deliver`.
|
|
8
8
|
*
|
|
9
9
|
* THE CONCEPT. A connection wires "when [EVENT] in [source module] (filter) →
|
|
10
10
|
* do [ACTION] in [sink module]". The sink is ALWAYS an action. Modules declare
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - the SINK action's `endpoint` → the hub-proxied webhook the vault calls
|
|
18
18
|
* (`<hub-origin>/<sink-mount><endpoint>`). NOT a hardcoded channel path.
|
|
19
19
|
* - the SINK action's `scope` → the OAuth scope minted into the webhook's
|
|
20
|
-
* `Authorization: Bearer`. NOT a hardcoded `
|
|
20
|
+
* `Authorization: Bearer`. NOT a hardcoded `agent:send`.
|
|
21
21
|
* - the SOURCE event key → the vault trigger's `events` (`note.created` →
|
|
22
22
|
* `["created"]`, `note.updated` → `["updated"]`).
|
|
23
23
|
* - the SOURCE filter → the vault trigger's `when` predicate (`tags` /
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
*
|
|
28
28
|
* THE ONE SINK-SPECIFIC PREREQUISITE. A vault-backed channel additionally needs
|
|
29
29
|
* its reply path wired: a `vault:<v>:write` token + a `channels.json` entry so
|
|
30
|
-
* the session can reply. That's a property of the
|
|
31
|
-
* engine — it runs only for `sink.module === "
|
|
32
|
-
* (`
|
|
30
|
+
* the session can reply. That's a property of the agent SINK, not of the
|
|
31
|
+
* engine — it runs only for `sink.module === "agent"` and is clearly fenced
|
|
32
|
+
* (`prepareAgentSink`). Everything else is declaration-driven.
|
|
33
33
|
*
|
|
34
34
|
* AUTH. Same gate as the admin-token mints: a cookie-gated operator session
|
|
35
35
|
* pinned to the first admin. The catalog (`/api/connections/catalog`) is
|
|
@@ -137,7 +137,7 @@ export interface InstalledModuleInfo {
|
|
|
137
137
|
/** Parsed `.parachute/module.json`. */
|
|
138
138
|
readonly manifest: ModuleManifest;
|
|
139
139
|
/**
|
|
140
|
-
* The module's user-facing mount path under the hub origin (e.g. `/
|
|
140
|
+
* The module's user-facing mount path under the hub origin (e.g. `/agent`),
|
|
141
141
|
* used to build a hub-proxied webhook from a sink action's `endpoint`.
|
|
142
142
|
* `null` when the module declares no user-facing mount.
|
|
143
143
|
*/
|
|
@@ -169,8 +169,8 @@ export interface ConnectionsDeps {
|
|
|
169
169
|
* teardown logs the skipped notification.
|
|
170
170
|
*/
|
|
171
171
|
resolveModuleOrigin?: (short: string) => string | null;
|
|
172
|
-
/** Loopback origin for the
|
|
173
|
-
|
|
172
|
+
/** Loopback origin for the agent daemon, or `null` when not installed. */
|
|
173
|
+
agentOrigin: string | null;
|
|
174
174
|
/** Absolute path to `connections.json` in the hub state dir. */
|
|
175
175
|
storePath: string;
|
|
176
176
|
/** Test seam — `globalThis.fetch` in production. */
|
|
@@ -452,7 +452,7 @@ interface CreateBody {
|
|
|
452
452
|
/**
|
|
453
453
|
* Provenance — WHO requested this connection (modular-UI R2). A module-owned
|
|
454
454
|
* config UI calling this endpoint on the operator's behalf labels itself (e.g.
|
|
455
|
-
* `"
|
|
455
|
+
* `"agent"`); the hub's own builder omits it and falls back to `"custom"`.
|
|
456
456
|
*/
|
|
457
457
|
requestedBy?: unknown;
|
|
458
458
|
}
|
|
@@ -601,20 +601,28 @@ async function createConnection(
|
|
|
601
601
|
// the record so teardown can revoke them (registered-mint rule).
|
|
602
602
|
const mintedJtis: string[] = [];
|
|
603
603
|
|
|
604
|
-
// --- Sink prerequisite (
|
|
604
|
+
// --- Sink prerequisite (agent message-delivery reply path). --------------
|
|
605
605
|
// Everything below this is general; THIS block is the only sink-specific step.
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
606
|
+
// It is gated on the ACTION, not just the module: ONLY `message.deliver` needs
|
|
607
|
+
// a session reply path (a vault-backed channel's connected session replies via
|
|
608
|
+
// a `vault:<v>:write` token + a `channels.json` entry). Other agent actions —
|
|
609
|
+
// e.g. `definition.reload`, a pure inbound webhook with no session and no
|
|
610
|
+
// reply — need none of it. A module-level gate here 400'd every
|
|
611
|
+
// non-`message.deliver` agent sink for want of a `channel` param (agent#117:
|
|
612
|
+
// the def-reload connectors could never provision). The channel name comes
|
|
613
|
+
// from `sink.params.channel` — it becomes a services.json key + an MCP server
|
|
614
|
+
// name, so it must be a slug. (The session-channel concept is kept; only the
|
|
615
|
+
// MODULE renamed channel → agent in the 2026-06-17 rename.)
|
|
616
|
+
if (sinkModule === "agent" && sinkAction === "message.deliver") {
|
|
609
617
|
const channelName = typeof sinkParams?.channel === "string" ? sinkParams.channel : "";
|
|
610
618
|
if (!CHANNEL_NAME_RE.test(channelName)) {
|
|
611
619
|
return jsonError(
|
|
612
620
|
400,
|
|
613
621
|
"invalid_request",
|
|
614
|
-
`
|
|
622
|
+
`agent sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
|
|
615
623
|
);
|
|
616
624
|
}
|
|
617
|
-
const prep = await
|
|
625
|
+
const prep = await prepareAgentSink(channelName, vault, vaultOrigin, userId, deps);
|
|
618
626
|
if (prep.error) return prep.error;
|
|
619
627
|
mintedJtis.push(prep.replyTokenJti);
|
|
620
628
|
}
|
|
@@ -681,35 +689,33 @@ async function createConnection(
|
|
|
681
689
|
connection: typeof record;
|
|
682
690
|
connect?: { mcpAdd: string; launch: string };
|
|
683
691
|
} = { ok: true, connection: record };
|
|
684
|
-
if (sinkModule === "
|
|
692
|
+
if (sinkModule === "agent" && typeof sinkParams?.channel === "string") {
|
|
685
693
|
out.connect = channelConnectLines(deps.hubOrigin, sinkParams.channel);
|
|
686
694
|
}
|
|
687
695
|
return json(200, out);
|
|
688
696
|
}
|
|
689
697
|
|
|
690
698
|
/**
|
|
691
|
-
* The
|
|
699
|
+
* The agent sink's reply-path prerequisite (mirrors hub#624). Mints a
|
|
692
700
|
* `vault:<v>:write` for the channel + writes the `channels.json` entry on the
|
|
693
|
-
*
|
|
694
|
-
*
|
|
701
|
+
* agent daemon so the session can reply. Fenced to the agent `message.deliver`
|
|
702
|
+
* action (the only one with a reply path) — sink-specific config, not part of
|
|
703
|
+
* the general vault-trigger engine.
|
|
695
704
|
* Returns `{ error }` on failure, or `{ error: null, replyTokenJti }` on
|
|
696
705
|
* success — the jti of the long-lived reply token, so the caller can persist
|
|
697
|
-
* it for teardown revocation.
|
|
706
|
+
* it for teardown revocation. (Renamed from `prepareChannelSink` 2026-06-17;
|
|
707
|
+
* the agent daemon's session-channel CRUD is still `/api/channels`.)
|
|
698
708
|
*/
|
|
699
|
-
async function
|
|
709
|
+
async function prepareAgentSink(
|
|
700
710
|
channelName: string,
|
|
701
711
|
vault: string,
|
|
702
712
|
vaultOrigin: string,
|
|
703
713
|
userId: string,
|
|
704
714
|
deps: ConnectionsDeps,
|
|
705
715
|
): Promise<{ error: Response } | { error: null; replyTokenJti: string }> {
|
|
706
|
-
if (deps.
|
|
716
|
+
if (deps.agentOrigin === null) {
|
|
707
717
|
return {
|
|
708
|
-
error: jsonError(
|
|
709
|
-
503,
|
|
710
|
-
"channel_unavailable",
|
|
711
|
-
"the channel module is not installed on this hub",
|
|
712
|
-
),
|
|
718
|
+
error: jsonError(503, "agent_unavailable", "the agent module is not installed on this hub"),
|
|
713
719
|
};
|
|
714
720
|
}
|
|
715
721
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
@@ -718,17 +724,17 @@ async function prepareChannelSink(
|
|
|
718
724
|
scopes: [`vault:${vault}:write`],
|
|
719
725
|
audience: `vault.${vault}`,
|
|
720
726
|
vaultScope: [vault],
|
|
721
|
-
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, //
|
|
727
|
+
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // agent keeps it for its lifetime
|
|
722
728
|
});
|
|
723
729
|
const channelAdminToken = (
|
|
724
730
|
await mint(deps, userId, {
|
|
725
|
-
scopes: ["
|
|
726
|
-
audience: "
|
|
731
|
+
scopes: ["agent:admin"],
|
|
732
|
+
audience: "agent",
|
|
727
733
|
vaultScope: [],
|
|
728
734
|
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
729
735
|
})
|
|
730
736
|
).token;
|
|
731
|
-
const res = await fetchImpl(`${deps.
|
|
737
|
+
const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
|
|
732
738
|
method: "POST",
|
|
733
739
|
headers: {
|
|
734
740
|
authorization: `Bearer ${channelAdminToken}`,
|
|
@@ -740,10 +746,10 @@ async function prepareChannelSink(
|
|
|
740
746
|
config: { vault, vaultUrl: vaultOrigin, token: vaultWriteSigned.token },
|
|
741
747
|
}),
|
|
742
748
|
});
|
|
743
|
-
if (!res.ok) return { error: stepError("
|
|
749
|
+
if (!res.ok) return { error: stepError("agent_config", await describeRemote(res)) };
|
|
744
750
|
return { error: null, replyTokenJti: vaultWriteSigned.jti };
|
|
745
751
|
} catch (err) {
|
|
746
|
-
return { error: stepError("
|
|
752
|
+
return { error: stepError("agent_config", err) };
|
|
747
753
|
}
|
|
748
754
|
}
|
|
749
755
|
|
|
@@ -1538,37 +1544,48 @@ export async function teardownConnection(
|
|
|
1538
1544
|
}
|
|
1539
1545
|
}
|
|
1540
1546
|
|
|
1541
|
-
// ---
|
|
1542
|
-
// Fenced to event→action records
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1547
|
+
// --- Agent-sink teardown (remove the channel config entry). --------------
|
|
1548
|
+
// Fenced to event→action records (a credential connection whose HOLDER is the
|
|
1549
|
+
// agent module must not delete an unrelated channel config entry) AND — like
|
|
1550
|
+
// the create-side prerequisite — to the `message.deliver` action: only that
|
|
1551
|
+
// action created a channel config entry, so only it has one to remove. A
|
|
1552
|
+
// module-level gate here would issue a spurious DELETE /api/channels/<id> for
|
|
1553
|
+
// a channel-less action (e.g. definition.reload — `record.id` as the channel
|
|
1554
|
+
// fallback), wasting an agent:admin mint on a never-created channel and
|
|
1555
|
+
// risking a real same-named channel. Symmetric to the create-side gate.
|
|
1556
|
+
if (
|
|
1557
|
+
record.kind !== "credential" &&
|
|
1558
|
+
record.sink.module === "agent" &&
|
|
1559
|
+
record.sink.action === "message.deliver" &&
|
|
1560
|
+
deps.agentOrigin
|
|
1561
|
+
) {
|
|
1545
1562
|
const channelName =
|
|
1546
1563
|
typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
|
|
1547
1564
|
try {
|
|
1548
1565
|
const channelAdminToken = (
|
|
1549
1566
|
await mint(deps, userId, {
|
|
1550
|
-
scopes: ["
|
|
1551
|
-
audience: "
|
|
1567
|
+
scopes: ["agent:admin"],
|
|
1568
|
+
audience: "agent",
|
|
1552
1569
|
vaultScope: [],
|
|
1553
1570
|
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
1554
1571
|
})
|
|
1555
1572
|
).token;
|
|
1556
1573
|
const res = await fetchImpl(
|
|
1557
|
-
`${deps.
|
|
1574
|
+
`${deps.agentOrigin}/api/channels/${encodeURIComponent(channelName)}`,
|
|
1558
1575
|
{ method: "DELETE", headers: { authorization: `Bearer ${channelAdminToken}` } },
|
|
1559
1576
|
);
|
|
1560
1577
|
if (!res.ok && res.status !== 404) {
|
|
1561
|
-
errors.push({ step: "
|
|
1578
|
+
errors.push({ step: "agent_config", detail: await remoteDetail(res) });
|
|
1562
1579
|
}
|
|
1563
1580
|
} catch (err) {
|
|
1564
|
-
errors.push({ step: "
|
|
1581
|
+
errors.push({ step: "agent_config", detail: errMsg(err) });
|
|
1565
1582
|
}
|
|
1566
1583
|
}
|
|
1567
1584
|
|
|
1568
1585
|
// --- Revoke the registered long-lived mints (B0, registered-mint rule). ---
|
|
1569
1586
|
// Marks each tokens-registry row revoked → the revocation list at
|
|
1570
1587
|
// `/.well-known/parachute-revocation.json` advertises the jtis, and every
|
|
1571
|
-
// resource server (vault,
|
|
1588
|
+
// resource server (vault, agent) rejects the credential from its next
|
|
1572
1589
|
// poll. Runs regardless of remote-teardown outcome — revocation is the safe
|
|
1573
1590
|
// direction. Legacy records (provisioned before B0) carry no jtis: teardown
|
|
1574
1591
|
// proceeds, but their tokens were never registered and ride to expiry.
|
|
@@ -1695,8 +1712,8 @@ function readProvisionType(provision: unknown): string | null {
|
|
|
1695
1712
|
|
|
1696
1713
|
/**
|
|
1697
1714
|
* Audience for a minted sink bearer. A `<module>:<verb>` scope (e.g.
|
|
1698
|
-
* `
|
|
1699
|
-
* the
|
|
1715
|
+
* `agent:send`) takes the module namespace as its audience — matching how
|
|
1716
|
+
* the agent validates `aud: agent`. Falls back to the sink module name.
|
|
1700
1717
|
*/
|
|
1701
1718
|
function audienceForScope(scope: string, sinkModule: string): string {
|
|
1702
1719
|
const colon = scope.indexOf(":");
|
|
@@ -1717,15 +1734,15 @@ function str(v: unknown): string {
|
|
|
1717
1734
|
}
|
|
1718
1735
|
|
|
1719
1736
|
/**
|
|
1720
|
-
* Derive the connection id. Operator-supplied wins; else for
|
|
1737
|
+
* Derive the connection id. Operator-supplied wins; else for an agent sink use
|
|
1721
1738
|
* the channel name (so the trigger + channel-config share a stable key), else a
|
|
1722
1739
|
* `<srcModule>-<event>-<sinkModule>-<action>` slug.
|
|
1723
1740
|
*/
|
|
1724
1741
|
function deriveId(rawId: unknown, source: ConnectionSource, sink: ConnectionSink): string {
|
|
1725
1742
|
const supplied = str(rawId);
|
|
1726
1743
|
if (supplied) return supplied.toLowerCase();
|
|
1727
|
-
if (sink.module === "
|
|
1728
|
-
return `
|
|
1744
|
+
if (sink.module === "agent" && typeof sink.params?.channel === "string") {
|
|
1745
|
+
return `agent-${sink.params.channel}`.toLowerCase();
|
|
1729
1746
|
}
|
|
1730
1747
|
const slug = `${source.module}-${source.event}-${sink.module}-${sink.action}`
|
|
1731
1748
|
.toLowerCase()
|
|
@@ -1740,8 +1757,8 @@ function channelConnectLines(
|
|
|
1740
1757
|
): { mcpAdd: string; launch: string } {
|
|
1741
1758
|
const origin = hubOrigin.replace(/\/+$/, "");
|
|
1742
1759
|
return {
|
|
1743
|
-
mcpAdd: `claude mcp add --transport http --scope user
|
|
1744
|
-
launch: `claude --dangerously-load-development-channels=server:
|
|
1760
|
+
mcpAdd: `claude mcp add --transport http --scope user agent-${channelName} ${origin}/agent/mcp/${channelName}`,
|
|
1761
|
+
launch: `claude --dangerously-load-development-channels=server:agent-${channelName} --dangerously-skip-permissions`,
|
|
1745
1762
|
};
|
|
1746
1763
|
}
|
|
1747
1764
|
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* in line with the admin scope-set semantics from hub#214 / #222.
|
|
48
48
|
*/
|
|
49
49
|
import type { Database } from "bun:sqlite";
|
|
50
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
50
51
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
51
52
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
52
53
|
import { isFirstAdmin } from "./users.ts";
|
|
@@ -72,7 +73,7 @@ export async function handleHostAdminToken(
|
|
|
72
73
|
}
|
|
73
74
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
74
75
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
75
|
-
if (!session) {
|
|
76
|
+
if (!session || !sid) {
|
|
76
77
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
77
78
|
}
|
|
78
79
|
// First-admin gate. A friend account (non-first-admin user created via
|
|
@@ -89,6 +90,18 @@ export async function handleHostAdminToken(
|
|
|
89
90
|
"host-admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
90
91
|
);
|
|
91
92
|
}
|
|
93
|
+
// Admin screen-lock gate (optional, off by default). When a lock PIN is set
|
|
94
|
+
// AND this session isn't within an unlock window, refuse to mint — the SPA
|
|
95
|
+
// shows the lock screen on the 423. No PIN configured → always allowed
|
|
96
|
+
// (today's behavior). This mint is a PURE CHECK — it does NOT slide the idle
|
|
97
|
+
// window; sliding is driven only by genuine user activity (the SPA's debounced
|
|
98
|
+
// `/heartbeat`), so a background re-mint (e.g. the 30s version-badge poll) can't
|
|
99
|
+
// keep an idle tab unlocked. See the `requireUnlocked` docblock in admin-lock.ts.
|
|
100
|
+
// The OAuth issuer (`/oauth/*`) never reaches this endpoint, so it's
|
|
101
|
+
// unaffected by the lock.
|
|
102
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
103
|
+
return lockedResponse();
|
|
104
|
+
}
|
|
92
105
|
const minted = await signAccessToken(deps.db, {
|
|
93
106
|
sub: session.userId,
|
|
94
107
|
scopes: [...HOST_ADMIN_SCOPES],
|