@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
|
@@ -5,6 +5,9 @@ import { sseEvents } from "./sse.ts"
|
|
|
5
5
|
import type {
|
|
6
6
|
Approved,
|
|
7
7
|
AppTokenView,
|
|
8
|
+
Archived,
|
|
9
|
+
ArchiveInactivePreview,
|
|
10
|
+
ArchiveInactiveResult,
|
|
8
11
|
Asked,
|
|
9
12
|
CreatedAppToken,
|
|
10
13
|
Decision,
|
|
@@ -30,6 +33,7 @@ import type {
|
|
|
30
33
|
Named,
|
|
31
34
|
PromptDelivery,
|
|
32
35
|
Prompted,
|
|
36
|
+
Pinned,
|
|
33
37
|
Replied,
|
|
34
38
|
RoleClaim,
|
|
35
39
|
RoleRelease,
|
|
@@ -40,7 +44,10 @@ import type {
|
|
|
40
44
|
Status,
|
|
41
45
|
Stopped,
|
|
42
46
|
WatchEvent,
|
|
47
|
+
EventsPage,
|
|
43
48
|
Whoami,
|
|
49
|
+
WebhookDelivery,
|
|
50
|
+
WebhookView,
|
|
44
51
|
} from "./types.ts"
|
|
45
52
|
|
|
46
53
|
const AGENT = "/api/agent"
|
|
@@ -296,15 +303,16 @@ export class HubClient {
|
|
|
296
303
|
}
|
|
297
304
|
|
|
298
305
|
/**
|
|
299
|
-
* GET /api/agent/watch: the events (
|
|
300
|
-
* `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
|
|
306
|
+
* GET /api/agent/watch: the journal's compact events (the same as events()) as they happen, until the hub closes
|
|
307
|
+
* the stream or `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
|
|
308
|
+
* address; cursor: resume after an event's `id` (or an events() cursor).
|
|
301
309
|
*/
|
|
302
|
-
async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
|
|
310
|
+
async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string; readonly cursor?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
|
|
303
311
|
const { signal } = options
|
|
304
312
|
if (signal?.aborted) return
|
|
305
313
|
let response: Response
|
|
306
314
|
try {
|
|
307
|
-
response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session }, ...signalOf(options) })
|
|
315
|
+
response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session, cursor: options.cursor }, ...signalOf(options) })
|
|
308
316
|
} catch (cause) {
|
|
309
317
|
if (signal?.aborted) return
|
|
310
318
|
throw cause
|
|
@@ -312,6 +320,49 @@ export class HubClient {
|
|
|
312
320
|
yield* sseEvents(response, signal)
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
/**
|
|
324
|
+
* GET /api/agent/events: the compact events after `cursor` (none: start at the latest). wait: seconds (0–60) to
|
|
325
|
+
* hold while nothing matches; limit 1–200; types (exact, or `launch.*`) and sessions filter but still advance the
|
|
326
|
+
* cursor. App tokens need events:subscribe plus each family's scope.
|
|
327
|
+
*/
|
|
328
|
+
events(options: CallOptions & { readonly cursor?: string; readonly wait?: number; readonly limit?: number; readonly types?: readonly string[]; readonly sessions?: readonly string[] } = {}): Promise<EventsPage> {
|
|
329
|
+
return this.call("GET", `${AGENT}/events`, { query: { cursor: options.cursor, wait: options.wait, limit: options.limit, type: options.types, session: options.sessions }, ...signalOf(options) })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
webhookCreate(input: { readonly url: string; readonly events?: readonly string[]; readonly label?: string }, options: CallOptions = {}): Promise<{ webhook: WebhookView; secret: string }> {
|
|
333
|
+
return this.call("POST", `${AGENT}/webhooks`, { body: input, ...signalOf(options) })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
webhooks(options: CallOptions = {}): Promise<{ webhooks: WebhookView[] }> {
|
|
337
|
+
return this.call("GET", `${AGENT}/webhooks`, signalOf(options))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
webhook(id: string, options: CallOptions = {}): Promise<{ webhook: WebhookView; deliveries: WebhookDelivery[] }> {
|
|
341
|
+
return this.call("GET", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
webhookDelete(id: string, options: CallOptions = {}): Promise<void> {
|
|
345
|
+
return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Organization (sessions:manage).
|
|
349
|
+
|
|
350
|
+
setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
|
|
351
|
+
return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/archive`, { body: { archived }, ...signalOf(options) })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setPinned(addr: string, pinned: boolean, options: CallOptions = {}): Promise<Pinned> {
|
|
355
|
+
return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/pin`, { body: { pinned }, ...signalOf(options) })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
previewArchiveInactive(inactiveFor: string, limit: number, options: CallOptions = {}): Promise<ArchiveInactivePreview> {
|
|
359
|
+
return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { preview: true, inactiveFor, limit }, ...signalOf(options) })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
archiveInactive(previewToken: string, options: CallOptions = {}): Promise<ArchiveInactiveResult> {
|
|
363
|
+
return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { previewToken, confirm: true }, ...signalOf(options) })
|
|
364
|
+
}
|
|
365
|
+
|
|
315
366
|
// Naming.
|
|
316
367
|
|
|
317
368
|
name(name: string, options: TargetOptions = {}): Promise<Named> {
|
|
@@ -6,6 +6,7 @@ import type { Evaluation } from "./types.ts"
|
|
|
6
6
|
/** The codes the hub answers with (src/central/agent/errors.ts); later phases may add more, so any string is kept. */
|
|
7
7
|
export type ErrorCode =
|
|
8
8
|
| "invalid"
|
|
9
|
+
| "invalid_cursor"
|
|
9
10
|
| "unauthorized"
|
|
10
11
|
| "token_expired"
|
|
11
12
|
| "needs_user"
|
package/packages/sdk/src/sse.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { WatchEvent } from "./types.ts"
|
|
|
5
5
|
/** Parses SSE text incrementally: feed chunks, get complete events (events without data are skipped). */
|
|
6
6
|
export class SseParser {
|
|
7
7
|
#buffer = ""
|
|
8
|
+
/** The last `id:` seen (it carries over to later events, as in EventSource). */
|
|
9
|
+
#id = ""
|
|
8
10
|
|
|
9
11
|
push(chunk: string): WatchEvent[] {
|
|
10
12
|
this.#buffer += chunk.replace(/\r\n?/g, "\n")
|
|
@@ -22,8 +24,9 @@ export class SseParser {
|
|
|
22
24
|
const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "")
|
|
23
25
|
if (field === "event") event = value
|
|
24
26
|
else if (field === "data") data.push(value)
|
|
27
|
+
else if (field === "id" && !value.includes("\0")) this.#id = value
|
|
25
28
|
}
|
|
26
|
-
if (data.length) out.push({ event, data: data.join("\n") })
|
|
29
|
+
if (data.length) out.push({ event, data: data.join("\n"), ...(this.#id ? { id: this.#id } : {}) })
|
|
27
30
|
}
|
|
28
31
|
return out
|
|
29
32
|
}
|
|
@@ -121,6 +121,43 @@ export interface DirectoryEntry {
|
|
|
121
121
|
readonly roles: readonly RoleView[]
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** One compact event of GET /api/agent/events (no free text). Unknown types are valid: ignore them. */
|
|
125
|
+
export interface AgentEvent {
|
|
126
|
+
readonly id: string
|
|
127
|
+
readonly at: number
|
|
128
|
+
readonly type: string
|
|
129
|
+
readonly subject: Readonly<Record<string, string>>
|
|
130
|
+
readonly data: Readonly<Record<string, string | number | boolean>>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A page of GET /api/agent/events: pass `cursor` to the next call. `more`: records remain unread. `reset`: the
|
|
135
|
+
* cursor was no longer covered (expired, trimmed, hub restarted); re-read the `refresh` snapshots, then go on.
|
|
136
|
+
*/
|
|
137
|
+
export interface EventsPage {
|
|
138
|
+
readonly events: AgentEvent[]
|
|
139
|
+
readonly cursor: string
|
|
140
|
+
readonly more: boolean
|
|
141
|
+
readonly reset: boolean
|
|
142
|
+
readonly refresh?: string[]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface Archived {
|
|
146
|
+
readonly sessionID: string
|
|
147
|
+
readonly archived: boolean
|
|
148
|
+
readonly changed: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface Pinned {
|
|
152
|
+
readonly sessionID: string
|
|
153
|
+
readonly pinned: boolean
|
|
154
|
+
readonly changed: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type ArchiveSkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
|
|
158
|
+
export interface ArchiveInactivePreview { readonly candidates: readonly string[]; readonly previewToken: string; readonly expiresAt: number }
|
|
159
|
+
export interface ArchiveInactiveResult { readonly changed: readonly string[]; readonly skipped: readonly { readonly sessionID: string; readonly reason: ArchiveSkipReason }[] }
|
|
160
|
+
|
|
124
161
|
export interface Named {
|
|
125
162
|
readonly sessionID: string
|
|
126
163
|
readonly name: string
|
|
@@ -271,6 +308,8 @@ export interface Inbox {
|
|
|
271
308
|
export interface WatchEvent {
|
|
272
309
|
readonly event: string
|
|
273
310
|
readonly data: string
|
|
311
|
+
/** The stream's cursor at this event (GET /api/agent/watch?cursor= resumes after it). */
|
|
312
|
+
readonly id?: string
|
|
274
313
|
}
|
|
275
314
|
|
|
276
315
|
// Acting (POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop}; src/central/agent/acting.ts).
|
|
@@ -430,6 +469,26 @@ export interface LaunchArtifacts {
|
|
|
430
469
|
readonly dirty?: boolean
|
|
431
470
|
}
|
|
432
471
|
|
|
472
|
+
export interface WebhookView {
|
|
473
|
+
readonly id: string
|
|
474
|
+
readonly url: string
|
|
475
|
+
readonly events: readonly string[]
|
|
476
|
+
readonly by: string
|
|
477
|
+
readonly label?: string
|
|
478
|
+
readonly created: number
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export interface WebhookDelivery {
|
|
482
|
+
readonly id: string
|
|
483
|
+
readonly event: string
|
|
484
|
+
readonly type: string
|
|
485
|
+
readonly state: "pending" | "delivered" | "failed"
|
|
486
|
+
readonly attempts: number
|
|
487
|
+
readonly created: number
|
|
488
|
+
readonly nextAt?: number
|
|
489
|
+
readonly last?: { readonly at: number; readonly status?: number; readonly error?: string }
|
|
490
|
+
}
|
|
491
|
+
|
|
433
492
|
/** A launch as the hub reports it (the spec with its defaults filled in). */
|
|
434
493
|
export interface LaunchView {
|
|
435
494
|
readonly id: string
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,19 +10,40 @@ export interface EnrollOptions {
|
|
|
10
10
|
readonly signal?: AbortSignal
|
|
11
11
|
readonly fetch?: typeof fetch
|
|
12
12
|
readonly now?: () => number
|
|
13
|
+
/**
|
|
14
|
+
* "follow" (default) follows HTTP redirects. "error" refuses them: a redirect could send the device code to
|
|
15
|
+
* another origin or downgrade to plain http. A refused redirect throws EnrollmentRefused, which polling does not
|
|
16
|
+
* retry.
|
|
17
|
+
*/
|
|
18
|
+
readonly redirect?: "follow" | "error"
|
|
19
|
+
/**
|
|
20
|
+
* The hub hands out an approved credential exactly once. With keepRedeemed, `signal` never aborts a poll request
|
|
21
|
+
* in flight (its reply may carry that credential); it still stops polling before the next request.
|
|
22
|
+
*/
|
|
23
|
+
readonly keepRedeemed?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The hub answered in a way the caller's policy refuses (a redirect under redirect: "error"); not retried. */
|
|
27
|
+
export class EnrollmentRefused extends Error {
|
|
28
|
+
override readonly name = "EnrollmentRefused"
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
16
32
|
|
|
17
|
-
async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
|
|
33
|
+
async function post(server: string, path: string, body: unknown, options: EnrollOptions, abortable = true): Promise<unknown> {
|
|
18
34
|
const doFetch = options.fetch ?? fetch
|
|
19
|
-
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
|
|
35
|
+
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal && abortable ? [options.signal] : [])]
|
|
36
|
+
const strict = options.redirect === "error"
|
|
20
37
|
const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
|
|
21
38
|
method: "POST",
|
|
22
39
|
headers: { "content-type": "application/json" },
|
|
23
40
|
body: JSON.stringify(body),
|
|
24
41
|
signal: AbortSignal.any(signals),
|
|
42
|
+
...(strict ? { redirect: "manual" as const } : {}),
|
|
25
43
|
})
|
|
44
|
+
if (strict && (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400))) {
|
|
45
|
+
throw new EnrollmentRefused(`${path} was redirected; enrollment does not follow redirects`)
|
|
46
|
+
}
|
|
26
47
|
if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
|
|
27
48
|
return response.json()
|
|
28
49
|
}
|
|
@@ -67,8 +88,9 @@ export async function pollEnrollment(server: string, start: EnrollStart, options
|
|
|
67
88
|
if (options.signal?.aborted) return { status: "cancelled" }
|
|
68
89
|
let reply: Partial<EnrollPoll> & Record<string, unknown>
|
|
69
90
|
try {
|
|
70
|
-
reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
|
|
71
|
-
} catch {
|
|
91
|
+
reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options, !options.keepRedeemed)) as typeof reply
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
if (cause instanceof EnrollmentRefused) throw cause
|
|
72
94
|
continue
|
|
73
95
|
}
|
|
74
96
|
if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
|
|
@@ -19,8 +19,10 @@ import type { AgentRoute } from "./routes"
|
|
|
19
19
|
* acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
|
|
20
20
|
* needs a grant held by the token (with the act verb, or `launch` covering the repo).
|
|
21
21
|
*/
|
|
22
|
-
export const APP_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read", "sessions:act", "sessions:launch"]
|
|
22
|
+
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
|
|
23
23
|
export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
|
|
24
|
+
/** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
|
|
25
|
+
export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
|
|
24
26
|
export const MAX_APP_TOKENS = 50
|
|
25
27
|
export const MAX_APP_TOKEN_LABEL = 100
|
|
26
28
|
|
|
@@ -88,6 +90,29 @@ const view = (token: AppToken) => ({
|
|
|
88
90
|
...(typeof token.created === "number" ? { created: token.created } : {}),
|
|
89
91
|
})
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Issues an app token of `userID` (the POST route below, and an approved CLI login): the record stored (sha256 of the
|
|
95
|
+
* token only) and the token, which the caller hands out once and never logs. rate_limited at MAX_APP_TOKENS.
|
|
96
|
+
*/
|
|
97
|
+
export async function issueAppToken(store: Store, userID: string, input: { readonly label?: string; readonly scopes: readonly string[] }): Promise<{ record: AppToken; token: string }> {
|
|
98
|
+
const id = randomBytes(8).toString("hex")
|
|
99
|
+
const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
|
|
100
|
+
const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: [...input.scopes], created: Date.now() }
|
|
101
|
+
let full = false
|
|
102
|
+
await store.updateAgentData(userID, (data) => {
|
|
103
|
+
const tokens = data.appTokens ?? []
|
|
104
|
+
full = false
|
|
105
|
+
if (tokens.length >= MAX_APP_TOKENS) {
|
|
106
|
+
full = true
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
data.appTokens = [...tokens, record]
|
|
110
|
+
})
|
|
111
|
+
if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
|
|
112
|
+
await store.audit({ actor: userID, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
|
|
113
|
+
return { record, token }
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
/** POST, GET and DELETE /api/agent/app-tokens: user actors only. */
|
|
92
117
|
export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
93
118
|
return [
|
|
@@ -96,22 +121,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
|
96
121
|
path: PREFIX,
|
|
97
122
|
allow: {},
|
|
98
123
|
async handle({ request, user }) {
|
|
99
|
-
const
|
|
100
|
-
const id = randomBytes(8).toString("hex")
|
|
101
|
-
const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
|
|
102
|
-
const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: input.scopes, created: Date.now() }
|
|
103
|
-
let full = false
|
|
104
|
-
await store.updateAgentData(user.id, (data) => {
|
|
105
|
-
const tokens = data.appTokens ?? []
|
|
106
|
-
full = false
|
|
107
|
-
if (tokens.length >= MAX_APP_TOKENS) {
|
|
108
|
-
full = true
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
data.appTokens = [...tokens, record]
|
|
112
|
-
})
|
|
113
|
-
if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
|
|
114
|
-
await store.audit({ actor: user.id, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
|
|
124
|
+
const { record, token } = await issueAppToken(store, user.id, parseCreate(await request.json()))
|
|
115
125
|
return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
|
|
116
126
|
},
|
|
117
127
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
2
|
+
import type { DirectoryEntry } from "./directory"
|
|
3
|
+
import { AgentError } from "./errors"
|
|
4
|
+
|
|
5
|
+
const PREFIX = "hai1_"
|
|
6
|
+
const DOMAIN = "hub-archive-inactive/v1"
|
|
7
|
+
const TTL_MS = 5 * 60_000
|
|
8
|
+
const invalid = () => new AgentError("invalid", "This inactive-archive preview is invalid or expired; preview again")
|
|
9
|
+
|
|
10
|
+
interface Payload { readonly userID: string; readonly at: number; readonly inactiveForMs: number; readonly candidates: readonly string[] }
|
|
11
|
+
export interface ArchiveSets { readonly pinned: ReadonlySet<string>; readonly archived: ReadonlySet<string> }
|
|
12
|
+
export type SkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
|
|
13
|
+
|
|
14
|
+
export class ArchiveInactive {
|
|
15
|
+
constructor(private readonly options: { readonly secret: Buffer; readonly now?: () => number }) {}
|
|
16
|
+
private now() { return (this.options.now ?? Date.now)() }
|
|
17
|
+
|
|
18
|
+
preview(userID: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly inactiveForMs: number; readonly limit: number }) {
|
|
19
|
+
const candidates = sessions
|
|
20
|
+
.filter((s) => !s.online && typeof s.lastSeen === "number" && this.now() - s.lastSeen >= options.inactiveForMs && !options.pinned.has(s.sessionID) && !options.archived.has(s.sessionID))
|
|
21
|
+
.map((s) => s.sessionID).slice(0, options.limit)
|
|
22
|
+
const payload: Payload = { userID, at: this.now(), inactiveForMs: options.inactiveForMs, candidates }
|
|
23
|
+
return { candidates, previewToken: this.encode(payload), expiresAt: payload.at + TTL_MS }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
decode(userID: string, token: string): Payload {
|
|
27
|
+
if (!token.startsWith(PREFIX) || token.length > 10_000) throw invalid()
|
|
28
|
+
const [body, tag, ...rest] = token.slice(PREFIX.length).split(".")
|
|
29
|
+
if (rest.length || !body || !tag) throw invalid()
|
|
30
|
+
const expected = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16)
|
|
31
|
+
const given = Buffer.from(tag, "base64url")
|
|
32
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw invalid()
|
|
33
|
+
let payload: Payload
|
|
34
|
+
try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as Payload } catch { throw invalid() }
|
|
35
|
+
if (payload.userID !== userID || !Array.isArray(payload.candidates) || this.now() - payload.at > TTL_MS) throw invalid()
|
|
36
|
+
return payload
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
revalidate(userID: string, token: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly open: ReadonlySet<string>; readonly active: ReadonlySet<string> }) {
|
|
40
|
+
const payload = this.decode(userID, token)
|
|
41
|
+
const byID = new Map(sessions.map((s) => [s.sessionID, s]))
|
|
42
|
+
const candidates: string[] = []
|
|
43
|
+
const skipped: Array<{ sessionID: string; reason: SkipReason }> = []
|
|
44
|
+
for (const sessionID of payload.candidates) {
|
|
45
|
+
const s = byID.get(sessionID)
|
|
46
|
+
const reason: SkipReason | undefined = !s ? "not_found" : options.archived.has(sessionID) ? "archived" : options.pinned.has(sessionID) ? "pinned" : options.open.has(sessionID) ? "open" : options.active.has(sessionID) || s.online ? "active" : undefined
|
|
47
|
+
if (reason) skipped.push({ sessionID, reason }); else candidates.push(sessionID)
|
|
48
|
+
}
|
|
49
|
+
return { candidates, skipped }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private encode(payload: Payload) {
|
|
53
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
|
|
54
|
+
const tag = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16).toString("base64url")
|
|
55
|
+
return `${PREFIX}${body}.${tag}`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseInactiveFor(value: unknown): number {
|
|
60
|
+
if (typeof value !== "string") throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
|
|
61
|
+
const m = /^(\d+)(h|d)$/.exec(value)
|
|
62
|
+
if (!m) throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
|
|
63
|
+
const ms = Number(m[1]) * (m[2] === "d" ? 86_400_000 : 3_600_000)
|
|
64
|
+
if (ms < 3_600_000 || ms > 365 * 86_400_000) throw new AgentError("invalid", "inactiveFor must be from 1h to 365d")
|
|
65
|
+
return ms
|
|
66
|
+
}
|
|
@@ -7,15 +7,30 @@
|
|
|
7
7
|
// POST /api/agent/name {name, session?}
|
|
8
8
|
// POST /api/agent/roles/claim {role, exclusive?, project?, session?}
|
|
9
9
|
// POST /api/agent/roles/release {role, project?, session?}
|
|
10
|
+
// PUT /api/agent/sessions/:addr/archive {archived}
|
|
10
11
|
// `session` is an address (ses_…, ID prefix, @name, …) resolved for the caller; a session token defaults to
|
|
11
|
-
// its own session, a user must name one.
|
|
12
|
+
// its own session, a user or app must name one. Foreign and unknown sessions are both not_found.
|
|
13
|
+
//
|
|
14
|
+
// App tokens (lifecycle design, decision xwzersmm): naming and archiving need sessions:manage; a role claim or
|
|
15
|
+
// release also needs a grant allowing `roles` whose projects cover the (live) session's directory, in the acting
|
|
16
|
+
// order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
|
|
17
|
+
// roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
|
|
18
|
+
// journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
|
|
19
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
20
|
+
import { auditOf } from "./acting"
|
|
21
|
+
import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
|
|
22
|
+
import { partyFromActor } from "./envelope"
|
|
23
|
+
import { type AgentEvents, archiveEvent, type EventInput, namedEvent, pinnedEvent, roleEvent } from "./events"
|
|
24
|
+
import type { Grant, Grants } from "./grants"
|
|
12
25
|
import path from "node:path"
|
|
13
|
-
import type { CatalogMachine, RegistryMessage } from "../registry"
|
|
26
|
+
import type { CatalogMachine, Registry, RegistryMessage } from "../registry"
|
|
14
27
|
import type { Store } from "../store"
|
|
15
28
|
import { Directory, type ReportedSession, type SessionSource } from "./directory"
|
|
16
29
|
import { AgentError } from "./errors"
|
|
30
|
+
import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
|
|
17
31
|
import { slugify } from "./names"
|
|
18
32
|
import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
|
|
33
|
+
import type { CursorKeys } from "./event-cursor"
|
|
19
34
|
|
|
20
35
|
/** What the directory reads from the Registry. */
|
|
21
36
|
export interface DirectoryRegistry {
|
|
@@ -127,8 +142,120 @@ function target(directory: Directory, { actor, user }: AgentContext, addr: strin
|
|
|
127
142
|
const NO_STORE = { "cache-control": "no-store" }
|
|
128
143
|
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
129
144
|
|
|
130
|
-
export
|
|
145
|
+
export interface DirectoryRoutesDeps {
|
|
146
|
+
readonly directory: Directory
|
|
147
|
+
readonly store: Pick<Store, "setSessionArchived" | "setSessionPinned" | "archiveSessionIfUnpinned" | "viewPrefs" | "agentData" | "audit">
|
|
148
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
149
|
+
/** App role writes: the live session (its directory, for the grant's projects). */
|
|
150
|
+
readonly registry?: Pick<Registry, "sessionTenant" | "session">
|
|
151
|
+
readonly grants?: Pick<Grants, "usable" | "consume">
|
|
152
|
+
readonly acting?: Pick<ActingSwitch, "ensureActing">
|
|
153
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
154
|
+
readonly previewKeys?: CursorKeys
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const MANAGE = { session: true, scope: "sessions:manage" } as const
|
|
158
|
+
|
|
159
|
+
export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
|
|
160
|
+
const { directory, store, events } = deps
|
|
161
|
+
|
|
162
|
+
/** The admin audit and the journal, once per change. */
|
|
163
|
+
async function changed(context: AgentContext, action: string, sessionID: string, event: EventInput): Promise<void> {
|
|
164
|
+
await store.audit({ actor: context.user.id, action, target: sessionID, detail: context.actor.kind })
|
|
165
|
+
await events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A role write: users and sessions as before; an app under a `roles` grant on the session's project, audited. */
|
|
169
|
+
async function roleWrite<T>(context: AgentContext, verb: "roles" | "roles.release", sessionID: string, run: () => Promise<T>): Promise<T> {
|
|
170
|
+
const { actor, user } = context
|
|
171
|
+
if (actor.kind !== "app") return run()
|
|
172
|
+
const { registry, grants, acting, audit } = deps
|
|
173
|
+
if (!registry || !grants || !acting || !audit) throw new AgentError("forbidden", "This hub does not let app tokens change roles")
|
|
174
|
+
let grant: Grant | undefined
|
|
175
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
176
|
+
audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
|
|
177
|
+
try {
|
|
178
|
+
acting.ensureActing(user.id)
|
|
179
|
+
const live = registry.sessionTenant(sessionID) === user.id ? registry.session(sessionID) : undefined
|
|
180
|
+
if (!live) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
|
|
181
|
+
const usable = await grants.usable(actor, "roles", { directory: live.directory })
|
|
182
|
+
if (!usable.length) throw new AgentError("forbidden", "No grant lets this app change roles in this session's project")
|
|
183
|
+
grant = usable[0]!
|
|
184
|
+
await grants.consume(grant)
|
|
185
|
+
acting.ensureActing(user.id)
|
|
186
|
+
const result = await run()
|
|
187
|
+
await record("allow", "ok")
|
|
188
|
+
return result
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
const [decision, outcome] = auditOf(cause)
|
|
191
|
+
await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
|
|
192
|
+
throw cause
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
131
196
|
return [
|
|
197
|
+
...(deps.previewKeys ? [{
|
|
198
|
+
method: "POST" as const,
|
|
199
|
+
path: `${AGENT_PREFIX}/sessions/archive-inactive`,
|
|
200
|
+
allow: { scope: "sessions:manage" },
|
|
201
|
+
async handle(context: AgentContext) {
|
|
202
|
+
const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
|
|
203
|
+
const body = await readBody(context.request)
|
|
204
|
+
const prefs = store.viewPrefs(context.user.id)
|
|
205
|
+
const entries = (store.agentData(context.user.id).sessions ?? []).map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
|
|
206
|
+
if (body.preview === true) {
|
|
207
|
+
const limit = body.limit
|
|
208
|
+
if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
|
|
209
|
+
return ok(inactive.preview(context.user.id, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), inactiveForMs: parseInactiveFor(body.inactiveFor), limit: Number(limit) }))
|
|
210
|
+
}
|
|
211
|
+
if (body.confirm !== true || typeof body.previewToken !== "string") throw new AgentError("invalid", "Use preview:true first, then confirm:true with its previewToken")
|
|
212
|
+
const open = new Set(entries.filter((s) => deps.registry?.session(s.sessionID)?.open).map((s) => s.sessionID))
|
|
213
|
+
const active = new Set(entries.filter((s) => { const v = deps.registry?.session(s.sessionID); return !!v && (v.busy || v.status.some((x) => x.state === "working" || x.state === "blocked")) }).map((s) => s.sessionID))
|
|
214
|
+
const checked = inactive.revalidate(context.user.id, body.previewToken, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), open, active })
|
|
215
|
+
const changedIDs: string[] = []
|
|
216
|
+
const skipped = [...checked.skipped]
|
|
217
|
+
for (const sessionID of checked.candidates) {
|
|
218
|
+
const result = await store.archiveSessionIfUnpinned(context.user.id, sessionID)
|
|
219
|
+
if (result === "changed") { changedIDs.push(sessionID); await changed(context, "session.archive", sessionID, archiveEvent(sessionID, true)) }
|
|
220
|
+
else skipped.push({ sessionID, reason: result === "pinned" ? "pinned" : result === "not_found" ? "not_found" : "archived" })
|
|
221
|
+
}
|
|
222
|
+
if (changedIDs.length) await directory.sync(context.user.id)
|
|
223
|
+
return ok({ changed: changedIDs, skipped })
|
|
224
|
+
},
|
|
225
|
+
}] : []),
|
|
226
|
+
{
|
|
227
|
+
method: "PUT",
|
|
228
|
+
path: `${AGENT_PREFIX}/sessions/:addr/pin`,
|
|
229
|
+
allow: { scope: "sessions:manage" },
|
|
230
|
+
async handle(context) {
|
|
231
|
+
const body = await readBody(context.request)
|
|
232
|
+
if (flag(body, "pinned") === undefined) throw new AgentError("invalid", "pinned must be true or false")
|
|
233
|
+
const sessionID = target(directory, context, context.params.addr)
|
|
234
|
+
const pinned = body.pinned === true
|
|
235
|
+
const result = await store.setSessionPinned(context.user.id, sessionID, pinned)
|
|
236
|
+
if (!result) throw new AgentError("not_found", `No session ${context.params.addr}`)
|
|
237
|
+
if (result.changed) await changed(context, pinned ? "session.pin" : "session.unpin", sessionID, pinnedEvent(sessionID, pinned))
|
|
238
|
+
return ok({ sessionID, pinned, changed: result.changed })
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
method: "PUT",
|
|
243
|
+
path: `${AGENT_PREFIX}/sessions/:addr/archive`,
|
|
244
|
+
allow: { scope: "sessions:manage" },
|
|
245
|
+
async handle(context) {
|
|
246
|
+
const body = await readBody(context.request)
|
|
247
|
+
if (flag(body, "archived") === undefined) throw new AgentError("invalid", "archived must be true or false")
|
|
248
|
+
const sessionID = target(directory, context, context.params.addr)
|
|
249
|
+
const archived = body.archived === true
|
|
250
|
+
const result = await store.setSessionArchived(context.user.id, sessionID, archived)
|
|
251
|
+
if (!result) throw new AgentError("invalid", "The archive is full; unarchive a session first")
|
|
252
|
+
if (result.changed) {
|
|
253
|
+
await directory.sync(context.user.id)
|
|
254
|
+
await changed(context, archived ? "session.archive" : "session.unarchive", sessionID, archiveEvent(sessionID, archived))
|
|
255
|
+
}
|
|
256
|
+
return ok({ sessionID, archived, changed: result.changed })
|
|
257
|
+
},
|
|
258
|
+
},
|
|
132
259
|
{
|
|
133
260
|
method: "GET",
|
|
134
261
|
path: `${AGENT_PREFIX}/who`,
|
|
@@ -143,28 +270,39 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
|
|
|
143
270
|
{
|
|
144
271
|
method: "POST",
|
|
145
272
|
path: `${AGENT_PREFIX}/name`,
|
|
146
|
-
allow:
|
|
273
|
+
allow: MANAGE,
|
|
147
274
|
handle: async (context) => {
|
|
148
275
|
const body = await readBody(context.request)
|
|
149
276
|
const name = text(body, "name", true)
|
|
150
277
|
const sessionID = target(directory, context, text(body, "session"))
|
|
151
|
-
|
|
278
|
+
const before = directory.resolveOne(context.user.id, sessionID).name
|
|
279
|
+
const named = await directory.setName(context.user.id, sessionID, name)
|
|
280
|
+
if (named.name !== before) await changed(context, "session.name", sessionID, namedEvent(sessionID, named.name))
|
|
281
|
+
return ok(named)
|
|
152
282
|
},
|
|
153
283
|
},
|
|
154
284
|
{
|
|
155
285
|
method: "POST",
|
|
156
286
|
path: `${AGENT_PREFIX}/roles/claim`,
|
|
157
|
-
allow:
|
|
287
|
+
allow: MANAGE,
|
|
158
288
|
handle: async (context) => {
|
|
159
289
|
const body = await readBody(context.request)
|
|
160
290
|
const role = text(body, "role", true)
|
|
161
291
|
const exclusive = flag(body, "exclusive")
|
|
162
292
|
const project = text(body, "project")
|
|
163
293
|
const sessionID = target(directory, context, text(body, "session"))
|
|
164
|
-
const claim = await
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
294
|
+
const claim = await roleWrite(context, "roles", sessionID, async () => {
|
|
295
|
+
const before = directory.resolveOne(context.user.id, sessionID).roles
|
|
296
|
+
const claim = await directory.claimRole(context.user.id, sessionID, role, {
|
|
297
|
+
...(exclusive !== undefined ? { exclusive } : {}),
|
|
298
|
+
...(project !== undefined ? { project } : {}),
|
|
299
|
+
byUser: context.actor.kind === "user",
|
|
300
|
+
})
|
|
301
|
+
const prev = before.find((r) => r.role === claim.role && r.project === claim.project)
|
|
302
|
+
if (!prev || prev.generation !== claim.generation || !!prev.exclusive !== !!claim.exclusive) {
|
|
303
|
+
await changed(context, "role.claim", sessionID, roleEvent(sessionID, true, claim))
|
|
304
|
+
}
|
|
305
|
+
return claim
|
|
168
306
|
})
|
|
169
307
|
return ok({ sessionID, ...claim })
|
|
170
308
|
},
|
|
@@ -172,13 +310,18 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
|
|
|
172
310
|
{
|
|
173
311
|
method: "POST",
|
|
174
312
|
path: `${AGENT_PREFIX}/roles/release`,
|
|
175
|
-
allow:
|
|
313
|
+
allow: MANAGE,
|
|
176
314
|
handle: async (context) => {
|
|
177
315
|
const body = await readBody(context.request)
|
|
178
316
|
const role = text(body, "role", true)
|
|
179
317
|
const project = text(body, "project")
|
|
180
318
|
const sessionID = target(directory, context, text(body, "session"))
|
|
181
|
-
|
|
319
|
+
const released = await roleWrite(context, "roles.release", sessionID, async () => {
|
|
320
|
+
const released = await directory.releaseRole(context.user.id, sessionID, role, project)
|
|
321
|
+
await changed(context, "role.release", sessionID, roleEvent(sessionID, false, released))
|
|
322
|
+
return released
|
|
323
|
+
})
|
|
324
|
+
return ok({ sessionID, ...released, released: true })
|
|
182
325
|
},
|
|
183
326
|
},
|
|
184
327
|
]
|
|
@@ -207,7 +207,7 @@ export class Directory {
|
|
|
207
207
|
/**
|
|
208
208
|
* Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
|
|
209
209
|
* (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
|
|
210
|
-
* can claim while an exclusive holder is live. Reserved roles (orchestrator
|
|
210
|
+
* can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
|
|
211
211
|
* (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
|
|
212
212
|
*/
|
|
213
213
|
async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export const AGENT_ERROR_STATUS = {
|
|
6
6
|
/** Malformed request (bad JSON, bad fields, cookie and bearer together). */
|
|
7
7
|
invalid: 400,
|
|
8
|
+
/** An events cursor this hub did not give this user, or cannot read (start again without one). */
|
|
9
|
+
invalid_cursor: 400,
|
|
8
10
|
/** No credential, or an unknown, revoked or disabled one. */
|
|
9
11
|
unauthorized: 401,
|
|
10
12
|
/** A session or app token that expired: get a new one. */
|