@huynle/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/CHANGELOG.md +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
package/src/central/auth.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// - device pairing: short single-use numeric codes, shown in the /hub dialog or logged at startup
|
|
4
4
|
// - login links: long single-use codes opened from the terminal to sign a browser in
|
|
5
5
|
// - app link codes: long single-use codes (QR / hubapp:// deep link) a native app redeems for a device token
|
|
6
|
+
// - CLI login (device-code flow, `hub login`): a terminal asks, a signed-in user approves it as themselves with the
|
|
7
|
+
// scopes they choose, the terminal polls for the decision (the caller then issues an app token for that user)
|
|
6
8
|
// Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
|
|
7
9
|
// restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
|
|
8
10
|
// with GETDEL: exactly one redeem wins, on any pod.
|
|
9
11
|
import { randomBytes, randomInt } from "node:crypto"
|
|
10
12
|
import type { EnrollPoll, EnrollStart } from "../protocol"
|
|
13
|
+
import { APP_SCOPES, DEFAULT_CLI_LOGIN_SCOPES } from "./agent/app-tokens"
|
|
11
14
|
import { MemoryCounters } from "./backend/memory-counters"
|
|
12
15
|
import { MemoryKv } from "./backend/memory-kv"
|
|
13
16
|
import type { Counters, Kv } from "./backend/types"
|
|
@@ -32,6 +35,7 @@ export const ENROLL_INTERVAL_MS = 2000
|
|
|
32
35
|
export const PAIR_TTL_MS = 5 * 60_000
|
|
33
36
|
export const LOGIN_TTL_MS = 2 * 60_000
|
|
34
37
|
export const LINK_TTL_MS = 5 * 60_000
|
|
38
|
+
export const CLI_LOGIN_TTL_MS = 10 * 60_000
|
|
35
39
|
/** Longest device public key accepted at link time (characters). */
|
|
36
40
|
export const MAX_PUBLIC_KEY = 4096
|
|
37
41
|
|
|
@@ -63,6 +67,15 @@ function deviceLabel(value: unknown, fallback: string): string {
|
|
|
63
67
|
return text ? [...text].slice(0, 64).join("").trim() : fallback
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/** App token scopes from a client: a list of known scopes (deduplicated, in order); absent is none when `optional`. */
|
|
71
|
+
function scopeList(value: unknown, optional: boolean): string[] {
|
|
72
|
+
if (value === undefined && optional) return []
|
|
73
|
+
if (!Array.isArray(value) || !value.every((s): s is string => typeof s === "string")) throw new ValidationError("scopes must be a list of scope names")
|
|
74
|
+
const unknown = value.find((s) => !APP_SCOPES.includes(s))
|
|
75
|
+
if (unknown !== undefined) throw new ValidationError(`Unknown scope: ${unknown.slice(0, 64)}`)
|
|
76
|
+
return [...new Set(value)]
|
|
77
|
+
}
|
|
78
|
+
|
|
66
79
|
const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
|
|
67
80
|
const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
|
|
68
81
|
|
|
@@ -87,6 +100,45 @@ interface Enrollment {
|
|
|
87
100
|
readonly ownerID?: string
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
interface CliLogin {
|
|
104
|
+
readonly userCode: string
|
|
105
|
+
readonly deviceCode: string
|
|
106
|
+
readonly clientName: string
|
|
107
|
+
/** The scopes the terminal asked for (shown on the approval page, ticked by default). */
|
|
108
|
+
readonly scopes: readonly string[]
|
|
109
|
+
readonly created: number
|
|
110
|
+
readonly expiresAt: number
|
|
111
|
+
readonly status: "pending" | "approved" | "denied"
|
|
112
|
+
/** The approver: the user the app token is issued for. */
|
|
113
|
+
readonly ownerID?: string
|
|
114
|
+
/** The scopes the approver chose. */
|
|
115
|
+
readonly approved?: readonly string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** POST /cli/login → a code to approve in the console (never a credential). */
|
|
119
|
+
export interface CliLoginStart {
|
|
120
|
+
readonly userCode: string
|
|
121
|
+
readonly deviceCode: string
|
|
122
|
+
readonly approveURL: string
|
|
123
|
+
readonly intervalMs: number
|
|
124
|
+
readonly expiresAt: number
|
|
125
|
+
readonly scopes: readonly string[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A pending CLI login as the approval page shows it. */
|
|
129
|
+
export interface PendingCliLogin {
|
|
130
|
+
readonly userCode: string
|
|
131
|
+
readonly clientName: string
|
|
132
|
+
readonly scopes: readonly string[]
|
|
133
|
+
readonly created: number
|
|
134
|
+
readonly expiresAt: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The decision on a CLI login, delivered once. `approved` carries who approved it and the scopes they chose. */
|
|
138
|
+
export type CliLoginPoll =
|
|
139
|
+
| { readonly status: "pending" | "denied" | "expired" }
|
|
140
|
+
| { readonly status: "approved"; readonly ownerID: string; readonly clientName: string; readonly scopes: readonly string[] }
|
|
141
|
+
|
|
90
142
|
/** A pending pairing code, login link or app link code and the user its device will belong to. */
|
|
91
143
|
interface Grant {
|
|
92
144
|
readonly expiresAt: number
|
|
@@ -135,6 +187,10 @@ const KEYS = {
|
|
|
135
187
|
enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
|
|
136
188
|
enrollSeq: "auth:enroll-seq",
|
|
137
189
|
enrollRate: "auth:enroll-rate",
|
|
190
|
+
cliLogin: (deviceCode: string) => `auth:cli-login:${deviceCode}`,
|
|
191
|
+
cliLoginCode: (userCode: string) => `auth:cli-login-code:${userCode}`,
|
|
192
|
+
cliLoginDecided: (deviceCode: string) => `auth:cli-login-decided:${deviceCode}`,
|
|
193
|
+
cliLoginRate: "auth:cli-login-rate",
|
|
138
194
|
pairFailures: "auth:pair-failures",
|
|
139
195
|
code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
|
|
140
196
|
}
|
|
@@ -181,7 +237,8 @@ export class Auth {
|
|
|
181
237
|
const px = ENROLL_TTL_MS + 1
|
|
182
238
|
const deviceCode = newToken()
|
|
183
239
|
let code = userCode()
|
|
184
|
-
|
|
240
|
+
// A user code names one request: never one a CLI login holds.
|
|
241
|
+
while ((await this.kv.get(KEYS.cliLoginCode(code))) !== undefined || !(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
|
|
185
242
|
const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
|
|
186
243
|
await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
|
|
187
244
|
const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
|
|
@@ -256,6 +313,75 @@ export class Auth {
|
|
|
256
313
|
return expiresAt - this.now() + 1
|
|
257
314
|
}
|
|
258
315
|
|
|
316
|
+
// ---------- CLI login ----------
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Starts a CLI login for `clientName` (the terminal's machine name), asking for the default scopes plus
|
|
320
|
+
* `scopes`. Rate limited like enrollment. The device code is the poller's secret; the user code is what the
|
|
321
|
+
* approver confirms.
|
|
322
|
+
*/
|
|
323
|
+
async startCliLogin(clientName: unknown, scopes: unknown): Promise<CliLoginStart> {
|
|
324
|
+
const client = name(clientName, "client name")
|
|
325
|
+
const requested = [...new Set([...DEFAULT_CLI_LOGIN_SCOPES, ...scopeList(scopes, true)])]
|
|
326
|
+
if ((await this.counters.incr(KEYS.cliLoginRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) throw new RateLimitError("Too many login requests; try again in a minute")
|
|
327
|
+
const created = this.now()
|
|
328
|
+
const expiresAt = created + CLI_LOGIN_TTL_MS
|
|
329
|
+
const px = CLI_LOGIN_TTL_MS + 1
|
|
330
|
+
const deviceCode = newToken()
|
|
331
|
+
let code = userCode()
|
|
332
|
+
while ((await this.kv.get(KEYS.enrollCode(code))) !== undefined || !(await this.kv.set(KEYS.cliLoginCode(code), deviceCode, { px, nx: true }))) code = userCode()
|
|
333
|
+
const login: CliLogin = { userCode: code, deviceCode, clientName: client, scopes: requested, created, expiresAt, status: "pending" }
|
|
334
|
+
await this.kv.set(KEYS.cliLogin(deviceCode), JSON.stringify(login), { px })
|
|
335
|
+
return { userCode: code, deviceCode, approveURL: `${this.publicURL}/approve?code=${code}`, intervalMs: ENROLL_INTERVAL_MS, expiresAt, scopes: requested }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The pending CLI login of a user code, for the approval page; undefined once decided or expired. */
|
|
339
|
+
async cliLogin(code: string): Promise<PendingCliLogin | undefined> {
|
|
340
|
+
const login = await this.cliLoginOf(code)
|
|
341
|
+
if (!login || login.status !== "pending") return undefined
|
|
342
|
+
const { userCode, clientName, scopes, created, expiresAt } = login
|
|
343
|
+
return { userCode, clientName, scopes: [...scopes], created, expiresAt }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Approves (as `ownerID`, with the `scopes` they chose: at least one known scope) or denies a pending CLI login:
|
|
348
|
+
* its client name, or undefined when no such code is pending. The first decision wins (SET NX), on any pod.
|
|
349
|
+
*/
|
|
350
|
+
async decideCliLogin(code: string, status: "approved" | "denied", ownerID: string, scopes?: unknown): Promise<string | undefined> {
|
|
351
|
+
const approved = status === "approved" ? scopeList(scopes, false) : undefined
|
|
352
|
+
if (approved && approved.length === 0) throw new ValidationError("Choose at least one scope")
|
|
353
|
+
const login = await this.cliLoginOf(code)
|
|
354
|
+
if (!login || login.status !== "pending") return undefined
|
|
355
|
+
const px = this.remaining(login.expiresAt)
|
|
356
|
+
if (!(await this.kv.set(KEYS.cliLoginDecided(login.deviceCode), status, { px, nx: true }))) return undefined
|
|
357
|
+
const decided: CliLogin = { ...login, status, ownerID, ...(approved ? { approved } : {}) }
|
|
358
|
+
await this.kv.set(KEYS.cliLogin(login.deviceCode), JSON.stringify(decided), { px })
|
|
359
|
+
return login.clientName
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The decision on a CLI login, delivered once (GETDEL); afterwards the code reads as expired. */
|
|
363
|
+
async pollCliLogin(deviceCode: unknown): Promise<CliLoginPoll> {
|
|
364
|
+
const login = typeof deviceCode === "string" && deviceCode ? await this.pendingLogin(deviceCode) : undefined
|
|
365
|
+
if (!login) return { status: "expired" }
|
|
366
|
+
if (login.status === "pending") return { status: "pending" }
|
|
367
|
+
const taken = parse<CliLogin>(await this.kv.getdel(KEYS.cliLogin(login.deviceCode)))
|
|
368
|
+
if (!taken || taken.status === "pending") return { status: "expired" }
|
|
369
|
+
await this.kv.del(KEYS.cliLoginCode(taken.userCode))
|
|
370
|
+
if (taken.status === "denied" || !taken.ownerID || !taken.approved) return { status: "denied" }
|
|
371
|
+
return { status: "approved", ownerID: taken.ownerID, clientName: taken.clientName, scopes: [...taken.approved] }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async cliLoginOf(code: string): Promise<CliLogin | undefined> {
|
|
375
|
+
const deviceCode = await this.kv.get(KEYS.cliLoginCode(normalizeUserCode(code)))
|
|
376
|
+
return deviceCode === undefined ? undefined : await this.pendingLogin(deviceCode)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The CLI login of a device code, unless it expired. */
|
|
380
|
+
private async pendingLogin(deviceCode: string): Promise<CliLogin | undefined> {
|
|
381
|
+
const login = parse<CliLogin>(await this.kv.get(KEYS.cliLogin(deviceCode)))
|
|
382
|
+
return login && this.remaining(login.expiresAt) > 0 ? login : undefined
|
|
383
|
+
}
|
|
384
|
+
|
|
259
385
|
// ---------- device pairing ----------
|
|
260
386
|
|
|
261
387
|
/** A pairing code; the device it pairs belongs to `ownerID`. */
|
|
@@ -6,6 +6,7 @@ import { FileStateStore, type FileStateStoreOptions } from "./file-state"
|
|
|
6
6
|
import { MemoryBus } from "./memory-bus"
|
|
7
7
|
import { MemoryCatalog } from "./memory-catalog"
|
|
8
8
|
import { MemoryCounters, MemoryLocks } from "./memory-counters"
|
|
9
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX } from "../agent/events"
|
|
9
10
|
import { MemoryEventLog } from "./memory-events"
|
|
10
11
|
import { MemoryKv } from "./memory-kv"
|
|
11
12
|
import { MemoryStateStore } from "./memory-state"
|
|
@@ -37,6 +38,7 @@ export function memoryBackends(options: MemoryBackendsOptions = {}): Backends {
|
|
|
37
38
|
state: new MemoryStateStore({ now }),
|
|
38
39
|
bus: new MemoryBus(options.timers ? { timers: options.timers } : {}),
|
|
39
40
|
events: new MemoryEventLog({ now }),
|
|
41
|
+
agentEvents: new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
40
42
|
counters: new MemoryCounters({ now }),
|
|
41
43
|
locks: new MemoryLocks({ now }),
|
|
42
44
|
catalog: new MemoryCatalog({ now }),
|
|
@@ -95,6 +97,7 @@ export function clusterBackends(options: ClusterBackendsOptions): Backends {
|
|
|
95
97
|
state,
|
|
96
98
|
bus,
|
|
97
99
|
events: new ValkeyEventLog(shared, now ? { now } : {}),
|
|
100
|
+
agentEvents: new ValkeyEventLog(shared, { ...(now ? { now } : {}), max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS, prefix: "hub:agent:" }),
|
|
98
101
|
counters: new ValkeyCounters(shared),
|
|
99
102
|
locks: new ValkeyLocks(shared, now ? { now } : {}),
|
|
100
103
|
catalog: new ValkeyCatalog(shared, now ? { now } : {}),
|
|
@@ -235,6 +235,8 @@ export interface Backends {
|
|
|
235
235
|
readonly state: StateStore
|
|
236
236
|
readonly bus: Bus
|
|
237
237
|
readonly events: EventLog
|
|
238
|
+
/** The canonical agent event journal's log (24 h / 10,000 per user); the hub makes a memory one when absent. */
|
|
239
|
+
readonly agentEvents?: EventLog
|
|
238
240
|
readonly counters: Counters
|
|
239
241
|
readonly locks: Locks
|
|
240
242
|
readonly catalog: Catalog
|
package/src/central/http.ts
CHANGED
|
@@ -16,6 +16,10 @@ import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError }
|
|
|
16
16
|
import { isNotificationKind, Push, type PushSender } from "./push"
|
|
17
17
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
18
18
|
import { Webhooks } from "./agent/webhooks"
|
|
19
|
+
import { issueAppToken } from "./agent/app-tokens"
|
|
20
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
|
|
21
|
+
import { EventWriters } from "./agent/event-writers"
|
|
22
|
+
import { MemoryEventLog } from "./backend/memory-events"
|
|
19
23
|
import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
|
|
20
24
|
import { hubBackends, type Backends } from "./backend"
|
|
21
25
|
import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
|
|
@@ -33,11 +37,12 @@ import type { Launch } from "./agent/launch-store"
|
|
|
33
37
|
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
34
38
|
import { hubDirectory } from "./agent/directory-routes"
|
|
35
39
|
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
40
|
+
import { MCP_PATH, mcpHttp } from "./mcp-http"
|
|
36
41
|
import { SessionTokens } from "./agent/session-tokens"
|
|
37
42
|
import { SessionTokenPush } from "./session-token-push"
|
|
38
43
|
import { TokenKeys } from "./token-keys"
|
|
39
44
|
import { EventLog } from "./events-log"
|
|
40
|
-
import { BusyError, Registry
|
|
45
|
+
import { BusyError, Registry } from "./registry"
|
|
41
46
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
42
47
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
43
48
|
import { SpeechService } from "./speech/service"
|
|
@@ -52,9 +57,8 @@ import { randomBytes, randomUUID } from "node:crypto"
|
|
|
52
57
|
import { LaunchEngine } from "./agent/launch-engine"
|
|
53
58
|
import { LaunchTokens } from "./agent/launch-tokens"
|
|
54
59
|
import { LaunchFeed, launchInfo } from "./agent/launch-routes"
|
|
55
|
-
import type { WatchRegistry } from "./agent/watch"
|
|
56
60
|
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
57
|
-
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
61
|
+
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
58
62
|
|
|
59
63
|
export interface Speech {
|
|
60
64
|
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
@@ -137,6 +141,10 @@ export interface Central {
|
|
|
137
141
|
readonly launchTokens: LaunchTokens
|
|
138
142
|
/** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
|
|
139
143
|
readonly webhooks: Webhooks
|
|
144
|
+
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
145
|
+
readonly agentEvents: AgentEvents
|
|
146
|
+
/** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
|
|
147
|
+
readonly agentEventWriters: EventWriters
|
|
140
148
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
141
149
|
readonly routes: readonly RouteInfo[]
|
|
142
150
|
/**
|
|
@@ -232,8 +240,6 @@ const MAX_TITLE_CHARS = 200
|
|
|
232
240
|
const MAX_HERDR_WORKSPACE_CHARS = 100
|
|
233
241
|
/** View preferences: entries per list and characters per entry. */
|
|
234
242
|
const VIEW_PREF_MAX_ENTRIES = 500
|
|
235
|
-
/** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
|
|
236
|
-
const VIEW_PREF_MAX_ARCHIVED = 5000
|
|
237
243
|
const VIEW_PREF_MAX_CHARS = 4096
|
|
238
244
|
const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
|
|
239
245
|
/** Longest utterance accepted for conversation mode (characters, after trimming). */
|
|
@@ -822,20 +828,33 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
822
828
|
resolve: options.resolveHost ?? systemResolveHost,
|
|
823
829
|
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
824
830
|
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
|
|
831
|
+
appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
|
|
825
832
|
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
826
833
|
})
|
|
827
|
-
launchFeed.subscribe((userID, launch) => {
|
|
828
|
-
void webhooks
|
|
829
|
-
.onLaunch(userID, launch, launchInfo(launch))
|
|
830
|
-
// Queued here, sent by the webhooks leader (kicked when that is another pod).
|
|
831
|
-
.then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
|
|
832
|
-
.catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
|
|
833
|
-
}, { origin: true })
|
|
834
834
|
const webhookLeader = leader("webhooks", {
|
|
835
835
|
onElected: () => webhooks.start(),
|
|
836
836
|
onDeposed: () => webhooks.stop(),
|
|
837
837
|
onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
|
|
838
838
|
})
|
|
839
|
+
// The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
|
|
840
|
+
// presence by the agent-events leader, everything else where it happens (event-writers.ts).
|
|
841
|
+
const agentEvents = new AgentEvents({
|
|
842
|
+
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
843
|
+
kv: backends.kv,
|
|
844
|
+
now,
|
|
845
|
+
onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
|
|
846
|
+
})
|
|
847
|
+
const agentEventWriters = new EventWriters({
|
|
848
|
+
events: agentEvents,
|
|
849
|
+
kv: backends.kv,
|
|
850
|
+
registry,
|
|
851
|
+
machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
|
|
852
|
+
users: allUsers,
|
|
853
|
+
messages: feed,
|
|
854
|
+
launches: launchFeed,
|
|
855
|
+
})
|
|
856
|
+
agentEventWriters.start()
|
|
857
|
+
leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
|
|
839
858
|
for (const loop of leaders) void loop.start()
|
|
840
859
|
|
|
841
860
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
@@ -889,26 +908,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
889
908
|
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
890
909
|
})
|
|
891
910
|
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
892
|
-
|
|
893
|
-
const watchRegistry = live
|
|
894
|
-
? (userID: string): WatchRegistry => ({
|
|
895
|
-
catalog: () => registry.catalog(),
|
|
896
|
-
sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
897
|
-
subscribe: (listener) => {
|
|
898
|
-
const offCatalog = registry.subscribe((message) => {
|
|
899
|
-
if (message.type === "catalog") listener(message)
|
|
900
|
-
})
|
|
901
|
-
const subscription = live.subscribe(userID, (event) => {
|
|
902
|
-
if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
|
|
903
|
-
})
|
|
904
|
-
return () => {
|
|
905
|
-
offCatalog()
|
|
906
|
-
subscription.close()
|
|
907
|
-
}
|
|
908
|
-
},
|
|
909
|
-
})
|
|
910
|
-
: undefined
|
|
911
|
-
const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
|
|
911
|
+
const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
|
|
912
912
|
const resolveActor = createResolver({
|
|
913
913
|
cookieName: COOKIE,
|
|
914
914
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -927,6 +927,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
927
927
|
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
928
928
|
})
|
|
929
929
|
|
|
930
|
+
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
931
|
+
// to this handler in process.
|
|
932
|
+
const mcp = mcpHttp({
|
|
933
|
+
publicURL: () => publicURL,
|
|
934
|
+
isOwnOrigin: (origin) => origins.has(origin),
|
|
935
|
+
verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
|
|
936
|
+
dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
|
|
937
|
+
version: options.version ?? "0.0.0",
|
|
938
|
+
})
|
|
939
|
+
|
|
930
940
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
931
941
|
const url = new URL(request.url)
|
|
932
942
|
const { pathname } = url
|
|
@@ -963,12 +973,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
963
973
|
if (poll.status === "approved") broadcast({ type: "catalog" })
|
|
964
974
|
return Response.json(poll)
|
|
965
975
|
}
|
|
976
|
+
// `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
|
|
977
|
+
if (method === "POST" && pathname === "/cli/login") {
|
|
978
|
+
const input = await body(request)
|
|
979
|
+
return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
|
|
980
|
+
}
|
|
981
|
+
if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
|
|
982
|
+
if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
|
|
966
983
|
if (method === "GET" && pathname === "/.well-known/hub") {
|
|
967
984
|
return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
|
|
968
985
|
}
|
|
969
986
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
970
987
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
971
988
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
989
|
+
if (pathname === MCP_PATH) return await mcp(request)
|
|
972
990
|
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
973
991
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
974
992
|
if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
|
|
@@ -978,6 +996,31 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
978
996
|
}
|
|
979
997
|
}
|
|
980
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
|
|
1001
|
+
* one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
|
|
1002
|
+
* readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
|
|
1003
|
+
*/
|
|
1004
|
+
async function spokePresence(request: Request): Promise<Response> {
|
|
1005
|
+
const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
|
|
1006
|
+
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
|
|
1007
|
+
if (!token || token.startsWith("hlt1.")) return refused()
|
|
1008
|
+
const machine = await store.findMachine(token)
|
|
1009
|
+
if (machine) await store.recheck({ users: [machine.ownerID] })
|
|
1010
|
+
const owner = machine && store.user(machine.ownerID)
|
|
1011
|
+
if (!machine || machine.revoked || !owner || owner.disabled) return refused()
|
|
1012
|
+
const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
|
|
1013
|
+
const seen = new Set<string>()
|
|
1014
|
+
const spokes = processes.flatMap((p) => {
|
|
1015
|
+
const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
|
|
1016
|
+
const key = JSON.stringify(entry)
|
|
1017
|
+
if (seen.has(key)) return []
|
|
1018
|
+
seen.add(key)
|
|
1019
|
+
return [entry]
|
|
1020
|
+
})
|
|
1021
|
+
return Response.json({ machineID: machine.id, spokes })
|
|
1022
|
+
}
|
|
1023
|
+
|
|
981
1024
|
async function login(request: Request, url: URL): Promise<Response> {
|
|
982
1025
|
const next = url.searchParams.get("next") ?? "/"
|
|
983
1026
|
if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
|
|
@@ -1060,12 +1103,24 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1060
1103
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
1061
1104
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
1062
1105
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
1106
|
+
const before = new Set(store.viewPrefs(user.id).hiddenSessions)
|
|
1063
1107
|
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
1064
1108
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
1065
1109
|
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
1110
|
+
// Archive changes made in the console go to the agent event journal like the agent API's.
|
|
1111
|
+
const after = new Set(prefs.hiddenSessions)
|
|
1112
|
+
const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
|
|
1113
|
+
for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
|
|
1066
1114
|
return Response.json({ prefs }, { headers: NO_STORE })
|
|
1067
1115
|
}),
|
|
1068
1116
|
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
1117
|
+
// A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
|
|
1118
|
+
route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
|
|
1119
|
+
const login = await auth.cliLogin(params.code!)
|
|
1120
|
+
return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
|
|
1121
|
+
}),
|
|
1122
|
+
route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
|
|
1123
|
+
route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
|
|
1069
1124
|
route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
|
|
1070
1125
|
route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
|
|
1071
1126
|
route("GET", "/api/devices", "user", ({ device, user }) =>
|
|
@@ -1431,6 +1486,31 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1431
1486
|
return new Response(null, { status: 204 })
|
|
1432
1487
|
}
|
|
1433
1488
|
|
|
1489
|
+
async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
|
|
1490
|
+
const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
|
|
1491
|
+
if (clientName === undefined) return error(404, "No pending CLI login with that code")
|
|
1492
|
+
await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
|
|
1493
|
+
return new Response(null, { status: 204 })
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
|
|
1498
|
+
* they chose, in this response only (no-store, never logged).
|
|
1499
|
+
*/
|
|
1500
|
+
async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
|
|
1501
|
+
const poll = await auth.pollCliLogin(deviceCode)
|
|
1502
|
+
if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
|
|
1503
|
+
const owner = store.user(poll.ownerID)
|
|
1504
|
+
if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
|
|
1505
|
+
try {
|
|
1506
|
+
const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
|
|
1507
|
+
return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
|
|
1508
|
+
} catch (cause) {
|
|
1509
|
+
if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
|
|
1510
|
+
throw cause
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1434
1514
|
async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
|
|
1435
1515
|
if (!await store.revokeMachine(id)) return error(404, "No such machine")
|
|
1436
1516
|
await store.bumpMachineEpoch(id)
|
|
@@ -1930,6 +2010,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1930
2010
|
launchFeed,
|
|
1931
2011
|
launchTokens,
|
|
1932
2012
|
webhooks,
|
|
2013
|
+
agentEvents,
|
|
2014
|
+
agentEventWriters,
|
|
1933
2015
|
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
1934
2016
|
drain(drainOptions: DrainOptions = {}) {
|
|
1935
2017
|
draining ??= runDrain(drainOptions)
|
|
@@ -1940,6 +2022,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1940
2022
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
1941
2023
|
launches.shutdown()
|
|
1942
2024
|
webhooks.stop()
|
|
2025
|
+
agentEventWriters.stop()
|
|
1943
2026
|
tokenPush.stop()
|
|
1944
2027
|
delivery.stop()
|
|
1945
2028
|
decisions.stop()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// POST /mcp: the hub's MCP tools over MCP Streamable HTTP, for MCP clients and gateways (Brain decision txznll2f).
|
|
2
|
+
// Stateless: one JSON-RPC message per POST, answered as application/json (no SSE stream, no Mcp-Session-Id);
|
|
3
|
+
// notifications and client responses get 202. Batches are refused (-32600, as in protocol 2025-06-18).
|
|
4
|
+
//
|
|
5
|
+
// Auth: `Authorization: Bearer <token>`, any credential the agent API accepts (app, device, session and act
|
|
6
|
+
// tokens), checked up front with the agent API's resolver: none or invalid → 401 with `WWW-Authenticate: Bearer
|
|
7
|
+
// realm="hub"` (no OAuth resource metadata, so gateways detect plain bearer auth). The tools are `hub mcp`'s
|
|
8
|
+
// (packages/cli/src/mcp.ts) and call /api/agent with the caller's bearer, dispatched in process: scopes, grants,
|
|
9
|
+
// tenancy, kill switches and the audit are exactly the HTTP API's. A browser Origin other than the hub's own is
|
|
10
|
+
// refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
|
|
11
|
+
import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
|
|
12
|
+
import { readCredentials } from "./agent/actor"
|
|
13
|
+
|
|
14
|
+
export const MCP_PATH = "/mcp"
|
|
15
|
+
|
|
16
|
+
/** Largest request body (bytes). */
|
|
17
|
+
export const MCP_MAX_BODY_BYTES = 1 << 20
|
|
18
|
+
|
|
19
|
+
export const MCP_INSTRUCTIONS =
|
|
20
|
+
"Hub: see and steer your AI agent sessions. Tools read sessions (status, sessions, show, log, who), message them (msg, ask, reply, decline, inbox), act within your grants (approve, deny, answer, prompt, stop) and launch sessions (launch, launches, launch_show, launch_stop), as this bearer token. " +
|
|
21
|
+
NOT_FROM_USER
|
|
22
|
+
|
|
23
|
+
export interface McpHttpDeps {
|
|
24
|
+
/** The hub's public URL (the tools' base URL; requests never leave the process). */
|
|
25
|
+
publicURL(): string
|
|
26
|
+
/** Whether a request's Origin is the hub's own. */
|
|
27
|
+
isOwnOrigin(origin: string): boolean
|
|
28
|
+
/** Whether the agent API accepts this bearer token. */
|
|
29
|
+
verify(token: string): Promise<boolean>
|
|
30
|
+
/** The hub's own request handler (the tools' /api/agent calls). */
|
|
31
|
+
dispatch(request: Request): Promise<Response>
|
|
32
|
+
readonly version: string
|
|
33
|
+
readonly maxBodyBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
37
|
+
|
|
38
|
+
const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
|
|
39
|
+
Response.json({ error: { code, message } }, { status, headers: { ...NO_STORE, ...headers } })
|
|
40
|
+
|
|
41
|
+
const unauthorized = (message: string) => failure(401, "unauthorized", message, { "www-authenticate": 'Bearer realm="hub"' })
|
|
42
|
+
|
|
43
|
+
const answer = (response: RpcResponse) =>
|
|
44
|
+
// A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
|
|
45
|
+
Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
|
|
46
|
+
|
|
47
|
+
/** The body as text, or undefined when it is over `max` bytes (read no further). */
|
|
48
|
+
async function readCapped(request: Request, max: number): Promise<string | undefined> {
|
|
49
|
+
const length = Number(request.headers.get("content-length") ?? "")
|
|
50
|
+
if (Number.isFinite(length) && length > max) return undefined
|
|
51
|
+
if (!request.body) return ""
|
|
52
|
+
const reader = request.body.getReader()
|
|
53
|
+
const chunks: Uint8Array[] = []
|
|
54
|
+
let size = 0
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read()
|
|
57
|
+
if (done) break
|
|
58
|
+
size += value.byteLength
|
|
59
|
+
if (size > max) {
|
|
60
|
+
void reader.cancel().catch(() => {})
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
chunks.push(value)
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(Buffer.concat(chunks))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const acceptsJSON = (accept: string | null) => {
|
|
69
|
+
if (accept === null || !accept.trim()) return true
|
|
70
|
+
return accept.split(",").some((part) => {
|
|
71
|
+
const type = part.split(";")[0]!.trim().toLowerCase()
|
|
72
|
+
return type === "application/json" || type === "*/*" || type === "application/*"
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Answers a request to /mcp (any method). */
|
|
77
|
+
export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Response> {
|
|
78
|
+
const max = deps.maxBodyBytes ?? MCP_MAX_BODY_BYTES
|
|
79
|
+
return async (request) => {
|
|
80
|
+
if (request.method !== "POST") return failure(405, "method_not_allowed", "POST JSON-RPC messages to /mcp (no SSE stream, no sessions)", { allow: "POST" })
|
|
81
|
+
const origin = request.headers.get("origin")
|
|
82
|
+
if (origin !== null && !deps.isOwnOrigin(origin)) return failure(403, "forbidden", "Cross-origin request refused")
|
|
83
|
+
// The bearer only: a cookie never authenticates /mcp.
|
|
84
|
+
const token = readCredentials(request, "").bearer
|
|
85
|
+
if (token === undefined) return unauthorized("Send Authorization: Bearer <hub token>")
|
|
86
|
+
if (!token || !(await deps.verify(token))) return unauthorized("Invalid, expired or revoked token")
|
|
87
|
+
const version = request.headers.get("mcp-protocol-version")
|
|
88
|
+
if (version !== null && !MCP_PROTOCOL_VERSIONS.includes(version)) return failure(400, "invalid", `Unsupported MCP-Protocol-Version (supported: ${MCP_PROTOCOL_VERSIONS.join(", ")})`)
|
|
89
|
+
const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
|
|
90
|
+
if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
|
|
91
|
+
if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
|
|
92
|
+
const text = await readCapped(request, max)
|
|
93
|
+
if (text === undefined) return failure(413, "invalid", `Request body over ${max} bytes`)
|
|
94
|
+
let message: unknown
|
|
95
|
+
try {
|
|
96
|
+
message = JSON.parse(text)
|
|
97
|
+
} catch {
|
|
98
|
+
return answer(rpcError(null, RPC.parse, "parse error: not JSON"))
|
|
99
|
+
}
|
|
100
|
+
const server = createMcpServer({
|
|
101
|
+
credentials: () => ({ url: deps.publicURL(), token, source: "http", fetch: (input, init) => deps.dispatch(new Request(input, init)) }),
|
|
102
|
+
version: deps.version,
|
|
103
|
+
instructions: MCP_INSTRUCTIONS,
|
|
104
|
+
})
|
|
105
|
+
// The client went away: its tool calls are aborted.
|
|
106
|
+
const close = () => server.close()
|
|
107
|
+
request.signal.addEventListener("abort", close, { once: true })
|
|
108
|
+
try {
|
|
109
|
+
const response = await server.handle(message)
|
|
110
|
+
return response ? answer(response) : new Response(null, { status: 202, headers: NO_STORE })
|
|
111
|
+
} finally {
|
|
112
|
+
request.signal.removeEventListener("abort", close)
|
|
113
|
+
server.close()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|