@huynle/hub 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- 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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { roleKey, type AgentData, type AgentName, type AgentRole, type AgentSession } from "../agent-data"
|
|
29
29
|
import type { Store } from "../store"
|
|
30
30
|
import { AgentError } from "./errors"
|
|
31
|
-
import { deriveName, isPlaceholderTitle, isReservedName, isReservedRole, parseAddress, validName, withSuffix, type Address } from "./names"
|
|
31
|
+
import { addressProblem, deriveName, isManagedRole, isPlaceholderTitle, isReservedName, isReservedRole, MANAGED_ROLE_MESSAGE, parseAddress, validName, withSuffix, type Address } from "./names"
|
|
32
32
|
|
|
33
33
|
const MINUTE = 60_000
|
|
34
34
|
const HOUR = 60 * MINUTE
|
|
@@ -52,6 +52,8 @@ export interface ReportedSession {
|
|
|
52
52
|
/** Project name, lowercased (the `.project` scope). */
|
|
53
53
|
readonly project?: string
|
|
54
54
|
readonly state?: string
|
|
55
|
+
/** The harness kind of the process reporting it (kept on the record as the last-known harness). */
|
|
56
|
+
readonly harness?: string
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** What the directory needs to know about the user's live sessions. */
|
|
@@ -97,6 +99,11 @@ export interface DirectoryEntry {
|
|
|
97
99
|
/** Hears an explicit name change (setName); derived names are never announced. */
|
|
98
100
|
export type NamedListener = (userID: string, sessionID: string, name: string) => void
|
|
99
101
|
|
|
102
|
+
/** Team-aware discovery (HWA-40): sessions the caller may not see resolve like unknown ones. */
|
|
103
|
+
export interface ResolveOptions {
|
|
104
|
+
readonly visible?: (sessionID: string) => boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
export interface Resolved {
|
|
101
108
|
readonly address: Address
|
|
102
109
|
/** One session for an ID or @name; every live holder for a @@role. */
|
|
@@ -118,6 +125,9 @@ const ended = (s: AgentSession | undefined): number | undefined => {
|
|
|
118
125
|
return times.length ? Math.min(...times) : undefined
|
|
119
126
|
}
|
|
120
127
|
|
|
128
|
+
/** Sessions remembered as unnamed before the directory knew them (Directory.unname). */
|
|
129
|
+
export const UNNAMED_MAX = 500
|
|
130
|
+
|
|
121
131
|
export class Directory {
|
|
122
132
|
private readonly store: DirectoryStore
|
|
123
133
|
private readonly source: SessionSource
|
|
@@ -202,16 +212,39 @@ export class Directory {
|
|
|
202
212
|
})
|
|
203
213
|
}
|
|
204
214
|
|
|
215
|
+
/**
|
|
216
|
+
* (CON-09a) Sessions that hold no @name (the Concierge's: hub-managed): their names and aliases are dropped (free
|
|
217
|
+
* again at once) and they are never derived one later. A session the directory does not know yet is remembered
|
|
218
|
+
* (UNNAMED_MAX, oldest dropped) until its record appears. Answers how many names went.
|
|
219
|
+
*/
|
|
220
|
+
async unname(userID: string, sessionIDs: readonly string[]): Promise<number> {
|
|
221
|
+
const ids = new Set(sessionIDs)
|
|
222
|
+
return this.update(userID, (draft) => {
|
|
223
|
+
const pending = new Set(draft.data.unnamed ?? [])
|
|
224
|
+
for (const id of ids) {
|
|
225
|
+
const record = draft.byID.get(id)
|
|
226
|
+
if (!record) pending.add(id)
|
|
227
|
+
else if (record.named !== true) record.named = true
|
|
228
|
+
}
|
|
229
|
+
if (pending.size) draft.data.unnamed = [...pending].slice(-UNNAMED_MAX)
|
|
230
|
+
const before = draft.names.length
|
|
231
|
+
draft.names = draft.names.filter((n) => !ids.has(n.sessionID))
|
|
232
|
+
return before - draft.names.length
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
205
236
|
// ---------- roles ----------
|
|
206
237
|
|
|
207
238
|
/**
|
|
208
239
|
* Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
|
|
209
240
|
* (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
|
|
210
241
|
* can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
|
|
211
|
-
* (grants with `roles` come in phase 3).
|
|
242
|
+
* (grants with `roles` come in phase 3). Managed roles (concierge, CON-04) need `managed`: only the hub's own
|
|
243
|
+
* service passes it. Exclusivity and generations are per (role, project scope).
|
|
212
244
|
*/
|
|
213
|
-
async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
|
|
245
|
+
async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean; managed?: boolean } = {}): Promise<RoleView> {
|
|
214
246
|
const { role, project } = parseRole(requested, options.project)
|
|
247
|
+
if (isManagedRole(role) && !options.managed) throw new AgentError("forbidden", MANAGED_ROLE_MESSAGE(role))
|
|
215
248
|
if (isReservedRole(role) && !options.byUser) throw new AgentError("forbidden", `@@${role} is reserved: only the user can assign it`)
|
|
216
249
|
return this.update(userID, (draft) => {
|
|
217
250
|
this.known(draft, sessionID)
|
|
@@ -236,9 +269,10 @@ export class Directory {
|
|
|
236
269
|
})
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
/** Gives a role up; not_found when the session does not hold it. */
|
|
240
|
-
async releaseRole(userID: string, sessionID: string, requested: string, scope?: string): Promise<{ role: string; project?: string }> {
|
|
272
|
+
/** Gives a role up; not_found when the session does not hold it. A managed role (CON-04) needs `managed`. */
|
|
273
|
+
async releaseRole(userID: string, sessionID: string, requested: string, scope?: string, options: { managed?: boolean } = {}): Promise<{ role: string; project?: string }> {
|
|
241
274
|
const { role, project } = parseRole(requested, scope)
|
|
275
|
+
if (isManagedRole(role) && !options.managed) throw new AgentError("forbidden", MANAGED_ROLE_MESSAGE(role))
|
|
242
276
|
return this.update(userID, (draft) => {
|
|
243
277
|
const held = draft.roles.find((r) => r.sessionID === sessionID && r.role === role && r.project === project)
|
|
244
278
|
if (!held) throw new AgentError("not_found", `Not holding @@${scoped(role, project)}`)
|
|
@@ -263,16 +297,31 @@ export class Directory {
|
|
|
263
297
|
)
|
|
264
298
|
}
|
|
265
299
|
|
|
300
|
+
/** (RP-08) The entries of these sessions in one read (unknown and deleted ones are absent). */
|
|
301
|
+
entries(userID: string, sessionIDs: readonly string[]): Map<string, DirectoryEntry> {
|
|
302
|
+
return this.read(userID, (draft) => {
|
|
303
|
+
const out = new Map<string, DirectoryEntry>()
|
|
304
|
+
for (const id of sessionIDs) {
|
|
305
|
+
const record = draft.byID.get(id)
|
|
306
|
+
if (record && record.deletedAt === undefined) out.set(id, this.entry(draft, id))
|
|
307
|
+
}
|
|
308
|
+
return out
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
// ---------- resolution ----------
|
|
267
313
|
|
|
268
314
|
/**
|
|
269
315
|
* The sessions an address names, for this user only: `ses_…` (an ID or a unique ID prefix), `@name[.project]`
|
|
270
316
|
* or `@@role[.project]` (every live holder). Throws invalid (malformed), not_found or ambiguous {candidates}.
|
|
271
317
|
*/
|
|
272
|
-
resolve(userID: string, addr: string): Resolved {
|
|
318
|
+
resolve(userID: string, addr: string, options: ResolveOptions = {}): Resolved {
|
|
273
319
|
const address = parseAddress(addr)
|
|
274
|
-
if (!address)
|
|
275
|
-
|
|
320
|
+
if (!address) {
|
|
321
|
+
const { message, hint } = addressProblem(addr)
|
|
322
|
+
throw new AgentError("invalid", message, { hint })
|
|
323
|
+
}
|
|
324
|
+
return this.read(userID, (draft) => ({ address, sessions: this.lookup(draft, address, addr, options.visible ?? (() => true)) }))
|
|
276
325
|
}
|
|
277
326
|
|
|
278
327
|
/** Exactly one session; several (a role with several holders) is ambiguous. */
|
|
@@ -282,11 +331,11 @@ export class Directory {
|
|
|
282
331
|
return sessions[0]!
|
|
283
332
|
}
|
|
284
333
|
|
|
285
|
-
private lookup(draft: Draft, address: Address, addr: string): DirectoryEntry[] {
|
|
334
|
+
private lookup(draft: Draft, address: Address, addr: string, visible: (sessionID: string) => boolean): DirectoryEntry[] {
|
|
286
335
|
const missing = () => new AgentError("not_found", `No session ${addr}`)
|
|
287
336
|
if (address.kind === "id") {
|
|
288
|
-
if (draft.byID.has(address.id)) return [this.entry(draft, address.id)]
|
|
289
|
-
const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id)).map((s) => this.entry(draft, s.sessionID))
|
|
337
|
+
if (draft.byID.has(address.id) && visible(address.id)) return [this.entry(draft, address.id)]
|
|
338
|
+
const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id) && visible(s.sessionID)).map((s) => this.entry(draft, s.sessionID))
|
|
290
339
|
if (matches.length > 1) throw ambiguous(addr, matches)
|
|
291
340
|
if (!matches.length) throw missing()
|
|
292
341
|
return matches
|
|
@@ -294,11 +343,11 @@ export class Directory {
|
|
|
294
343
|
if (address.kind === "name") {
|
|
295
344
|
const held = draft.names.find((n) => n.name === address.name)
|
|
296
345
|
const entry = held && this.entry(draft, held.sessionID)
|
|
297
|
-
if (!entry || (address.project !== undefined && entry.project !== address.project)) throw missing()
|
|
346
|
+
if (!entry || !visible(entry.sessionID) || (address.project !== undefined && entry.project !== address.project)) throw missing()
|
|
298
347
|
return [entry]
|
|
299
348
|
}
|
|
300
349
|
const holders = draft.roles.filter((r) => r.role === address.role && inScope(r.project, draft.byID.get(r.sessionID)?.project, address.project))
|
|
301
|
-
const ids = [...new Set(holders.map((r) => r.sessionID))]
|
|
350
|
+
const ids = [...new Set(holders.map((r) => r.sessionID))].filter(visible)
|
|
302
351
|
if (!ids.length) throw missing()
|
|
303
352
|
return ids.map((id) => this.entry(draft, id))
|
|
304
353
|
}
|
|
@@ -377,10 +426,17 @@ export class Directory {
|
|
|
377
426
|
let record = byID.get(live.sessionID)
|
|
378
427
|
if (!record) {
|
|
379
428
|
record = { sessionID: live.sessionID, firstSeen: t }
|
|
429
|
+
// (CON-09a) Unnamed before it was known: never derived a name.
|
|
430
|
+
if (data.unnamed?.includes(live.sessionID)) {
|
|
431
|
+
record.named = true
|
|
432
|
+
const rest = data.unnamed.filter((id) => id !== live.sessionID)
|
|
433
|
+
if (rest.length) data.unnamed = rest
|
|
434
|
+
else delete data.unnamed
|
|
435
|
+
}
|
|
380
436
|
sessions.push(record)
|
|
381
437
|
byID.set(live.sessionID, record)
|
|
382
438
|
}
|
|
383
|
-
for (const field of ["machineID", "parentID", "project", "title"] as const) {
|
|
439
|
+
for (const field of ["machineID", "parentID", "project", "title", "harness"] as const) {
|
|
384
440
|
const value = live[field]
|
|
385
441
|
if (value !== undefined && value !== "" && record[field] !== value) record[field] = value
|
|
386
442
|
}
|
|
@@ -21,6 +21,8 @@ export const AGENT_ERROR_STATUS = {
|
|
|
21
21
|
ambiguous: 409,
|
|
22
22
|
/** Explicit name or exclusive role already held. */
|
|
23
23
|
name_taken: 409,
|
|
24
|
+
/** A stale expectedRevision / base revision, or an Idempotency-Key reused with another body (`currentRevision`). */
|
|
25
|
+
conflict: 409,
|
|
24
26
|
/** Already answered, expired or ended. */
|
|
25
27
|
gone: 410,
|
|
26
28
|
/** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
|
|
@@ -33,6 +35,8 @@ export const AGENT_ERROR_STATUS = {
|
|
|
33
35
|
internal: 500,
|
|
34
36
|
/** The machine is offline (actions only; messages queue instead). */
|
|
35
37
|
offline: 503,
|
|
38
|
+
/** (FX-07) The hub's state did not answer in time (`retryAfter`, seconds): refused, try again. */
|
|
39
|
+
busy: 503,
|
|
36
40
|
} as const satisfies Record<string, number>
|
|
37
41
|
|
|
38
42
|
export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
|
|
@@ -41,6 +45,8 @@ export interface AgentErrorOptions {
|
|
|
41
45
|
readonly hint?: string
|
|
42
46
|
/** More fields in the envelope, e.g. candidates, retryAfter, feature; never code or message. */
|
|
43
47
|
readonly extra?: Record<string, unknown>
|
|
48
|
+
/** Answer with `Connection: close` (the request body was left unread on the socket: agent/body.ts). */
|
|
49
|
+
readonly close?: boolean
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Throw from an agent handler; answered as the envelope with the code's status. */
|
|
@@ -59,6 +65,7 @@ export function agentError(error: AgentError): Response {
|
|
|
59
65
|
const headers: Record<string, string> = { "cache-control": "no-store" }
|
|
60
66
|
const retryAfter = extra.retryAfter
|
|
61
67
|
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) headers["retry-after"] = String(Math.ceil(retryAfter))
|
|
68
|
+
if (error.options.close) headers.connection = "close"
|
|
62
69
|
return Response.json(
|
|
63
70
|
{ error: { ...extra, code: error.code, message: error.message, ...(hint ? { hint } : {}) } },
|
|
64
71
|
{ status: AGENT_ERROR_STATUS[error.code], headers },
|
|
@@ -25,7 +25,7 @@ import type { BlockerRecommendation } from "../../status"
|
|
|
25
25
|
import type { Escalation } from "./acting"
|
|
26
26
|
import type { AgentActor, UserActor } from "./actor"
|
|
27
27
|
import type { AgentAudit } from "./audit"
|
|
28
|
-
import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
|
|
28
|
+
import { type ActOutcome, type Decision, type DecisionActor, type Decisions, decisionRequest, WHY_MAX } from "./decisions"
|
|
29
29
|
import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
|
|
30
30
|
import { AgentError } from "./errors"
|
|
31
31
|
import type { Message } from "./messages"
|
|
@@ -168,7 +168,15 @@ export class Escalations {
|
|
|
168
168
|
about: [escalation.sessionID],
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
|
-
action: {
|
|
171
|
+
action: {
|
|
172
|
+
kind: "permission",
|
|
173
|
+
requestID: escalation.requestID,
|
|
174
|
+
rootID: escalation.rootID,
|
|
175
|
+
sessionID: escalation.sessionID,
|
|
176
|
+
always: escalation.always,
|
|
177
|
+
...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
|
|
178
|
+
...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
|
|
179
|
+
},
|
|
172
180
|
quiet: pushes > 1,
|
|
173
181
|
},
|
|
174
182
|
)
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Who sees what: the user everything. App tokens need events:subscribe for the route and, per family, sessions:read
|
|
15
15
|
// (session), sessions:manage (manage), messages:read and being a party (message), sessions:launch and having started
|
|
16
|
-
// it (launch). Session tokens see session and manage events, messages they are a party to and launches they started
|
|
16
|
+
// it (launch). Session tokens see session and manage events, messages they are a party to and launches they started;
|
|
17
|
+
// a session holding a team seat (team-scoped, HWA-40) additionally sees no event naming a session or team that team
|
|
18
|
+
// discovery hides from it (deps.scope), with or without filters, so IDs of hidden sessions never leak.
|
|
17
19
|
// Reader-only fields (family, audience, position) never leave the hub.
|
|
18
20
|
import type { AgentActor } from "./actor"
|
|
19
21
|
import type { Directory } from "./directory"
|
|
@@ -29,7 +31,7 @@ export const EVENTS_LIMIT_MAX = 200
|
|
|
29
31
|
export const EVENTS_LIMIT_DEFAULT = 50
|
|
30
32
|
export const INSPECT_MAX = 1000
|
|
31
33
|
export const RECHECK_MS = 1000
|
|
32
|
-
export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches"] as const
|
|
34
|
+
export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches", "workspaces"] as const
|
|
33
35
|
const FILTER_MAX = 20
|
|
34
36
|
const TYPE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*(\.\*)?$/
|
|
35
37
|
|
|
@@ -39,6 +41,16 @@ export interface EventsRouteDeps {
|
|
|
39
41
|
readonly events: Pick<AgentEvents, "latest" | "since" | "appended">
|
|
40
42
|
readonly keys: CursorKeys
|
|
41
43
|
readonly directory: Pick<Directory, "resolve">
|
|
44
|
+
/**
|
|
45
|
+
* Narrows what a caller reads beyond scopes (HWA-28/40: a team-scoped session sees no events naming sessions or
|
|
46
|
+
* teams hidden from it). Read fresh per inspected batch; undefined: no narrowing.
|
|
47
|
+
*/
|
|
48
|
+
readonly scope?: (userID: string, actor: AgentActor) => Promise<((subject: AgentEvent["subject"]) => boolean) | undefined>
|
|
49
|
+
/**
|
|
50
|
+
* Resolves `?session=` addresses for the caller (HWA-28/40: a session hidden from a team-scoped caller is not_found
|
|
51
|
+
* exactly like an unknown one, as watch?session= answers). Absent: the directory.
|
|
52
|
+
*/
|
|
53
|
+
readonly resolve?: (userID: string, actor: AgentActor, addr: string) => Promise<readonly string[]>
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export interface EventsPage {
|
|
@@ -73,6 +85,9 @@ export function visible(record: EventRecord, actor: AgentActor): boolean {
|
|
|
73
85
|
return has("messages:read") && (record.audience?.parties ?? []).includes(party)
|
|
74
86
|
case "launch":
|
|
75
87
|
return has("sessions:launch") && record.audience?.by === launchedBy(actor)
|
|
88
|
+
case "artifact":
|
|
89
|
+
// Artifact IDs of the user's library: app tokens with artifacts:read; sessions never (most are private).
|
|
90
|
+
return actor.kind === "app" && actor.scopes.includes("artifacts:read")
|
|
76
91
|
}
|
|
77
92
|
}
|
|
78
93
|
|
|
@@ -89,7 +104,8 @@ export function eventsRoutes(deps: EventsRouteDeps): AgentRoute[] {
|
|
|
89
104
|
const addrs = url.searchParams.getAll("session").filter(Boolean)
|
|
90
105
|
if (types.length > FILTER_MAX || addrs.length > FILTER_MAX) throw invalid(`At most ${FILTER_MAX} type and ${FILTER_MAX} session filters`)
|
|
91
106
|
if (types.some((t) => t.length > 100 || !TYPE.test(t))) throw invalid("type must be an event type such as session.changed or launch.*")
|
|
92
|
-
const
|
|
107
|
+
const resolve = deps.resolve ?? (async (userID: string, _actor: AgentActor, addr: string) => deps.directory.resolve(userID, addr).sessions.map((s) => s.sessionID))
|
|
108
|
+
const sessions = addrs.length ? new Set((await Promise.all(addrs.map((addr) => resolve(user.id, actor, addr)))).flat()) : undefined
|
|
93
109
|
const page = await read(deps, user.id, actor, { cursor: url.searchParams.get("cursor") ?? undefined, wait, limit, types, sessions }, request.signal)
|
|
94
110
|
return Response.json(page, { headers: NO_STORE })
|
|
95
111
|
},
|
|
@@ -131,8 +147,9 @@ export async function read(deps: EventsRouteDeps, userID: string, actor: AgentAc
|
|
|
131
147
|
if ("reset" in result) return { events: [], cursor: encodeCursor(keys, userID, await events.latest(userID)), more: false, reset: true, refresh: RESET_REFRESH }
|
|
132
148
|
const found: AgentEvent[] = []
|
|
133
149
|
let more = result.records.length > INSPECT_MAX
|
|
150
|
+
const scoped = result.records.length && deps.scope && actor.kind === "session" ? await deps.scope(userID, actor) : undefined
|
|
134
151
|
for (const record of result.records.slice(0, INSPECT_MAX)) {
|
|
135
|
-
if (visible(record, actor) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
|
|
152
|
+
if (visible(record, actor) && (!scoped || scoped(record.event.subject)) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
|
|
136
153
|
if (found.length === options.limit) {
|
|
137
154
|
more = true
|
|
138
155
|
break
|
|
@@ -23,8 +23,8 @@ export const AGENT_EVENTS_AGE_MS = 86_400_000
|
|
|
23
23
|
/** Record format version (readers reject others rather than skip them). */
|
|
24
24
|
export const AGENT_EVENT_VERSION = 1
|
|
25
25
|
|
|
26
|
-
/** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
|
|
27
|
-
export type EventFamily = "session" | "manage" | "message" | "launch"
|
|
26
|
+
/** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch, artifacts:read. */
|
|
27
|
+
export type EventFamily = "session" | "manage" | "message" | "launch" | "artifact"
|
|
28
28
|
|
|
29
29
|
export type EventSubject = Readonly<Record<string, string>>
|
|
30
30
|
export type EventData = Readonly<Record<string, string | number | boolean>>
|
|
@@ -66,7 +66,7 @@ export interface EventRecord {
|
|
|
66
66
|
/** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
|
|
67
67
|
export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
|
|
68
68
|
|
|
69
|
-
const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
|
|
69
|
+
const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch", "artifact"])
|
|
70
70
|
const EVENT_ID = /^evt_[0-9a-f]{20}$/
|
|
71
71
|
|
|
72
72
|
export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
|
|
@@ -193,7 +193,13 @@ export function launchEvent(launch: Launch): EventInput {
|
|
|
193
193
|
type: "launch.changed",
|
|
194
194
|
family: "launch",
|
|
195
195
|
subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
|
|
196
|
-
data: {
|
|
196
|
+
data: {
|
|
197
|
+
state: launch.state,
|
|
198
|
+
...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
|
|
199
|
+
...(launch.name !== undefined ? { name: launch.name } : {}),
|
|
200
|
+
// FX-02: a queued launch's wait kind (never its text); the engine journals a change of kind only.
|
|
201
|
+
...(launch.state === "queued" && launch.waiting ? { wait: launch.waiting.wait } : {}),
|
|
202
|
+
},
|
|
197
203
|
...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
|
|
198
204
|
}
|
|
199
205
|
}
|
|
@@ -203,6 +209,14 @@ export const archiveEvent = (sessionID: string, archived: boolean): EventInput =
|
|
|
203
209
|
|
|
204
210
|
export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
|
|
205
211
|
|
|
212
|
+
/**
|
|
213
|
+
* (CON-04) The user's Concierge changed: its status (state, holder generation) or a record's revision. IDs, enums and
|
|
214
|
+
* counts only: never settings, notes, intents or board text.
|
|
215
|
+
*/
|
|
216
|
+
export function conciergeEvent(data: { readonly what: "status"; readonly state: string; readonly generation: number } | { readonly what: "watchlist" | "settings" | "notes"; readonly revision: number }): EventInput {
|
|
217
|
+
return { type: "concierge.changed", family: "manage", subject: {}, data: { ...data } }
|
|
218
|
+
}
|
|
219
|
+
|
|
206
220
|
/** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
|
|
207
221
|
export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
|
|
208
222
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// CON-13 an agent's form answer, checked against the form's schema (the blocker's fields, src/status.ts) before the
|
|
2
|
+
// hub acts on it or proposes it: one problem at a time, naming the field and what it takes, so a model corrects
|
|
3
|
+
// itself instead of sending OpenCode an answer it refuses. Only the fields the hub knows are checked: a form without
|
|
4
|
+
// fields (an older spoke, or fields it could not render) is left to OpenCode.
|
|
5
|
+
import type { FormAnswer } from "../../protocol"
|
|
6
|
+
import type { FormFieldSpec } from "../../status"
|
|
7
|
+
|
|
8
|
+
type Value = FormAnswer[string]
|
|
9
|
+
|
|
10
|
+
const quote = (s: string) => JSON.stringify(s.length > 80 ? `${s.slice(0, 80)}…` : s)
|
|
11
|
+
const listed = (items: readonly string[], max = 10) => items.slice(0, max).map(quote).join(", ") + (items.length > max ? ` (${items.length - max} more)` : "")
|
|
12
|
+
|
|
13
|
+
/** Whether the field's `when` conditions hold for the answer (as OpenCode compares them). */
|
|
14
|
+
function visible(field: FormFieldSpec, answer: FormAnswer): boolean {
|
|
15
|
+
return (field.when ?? []).every((w) => (w.op === "eq" ? answer[w.key] === w.value : answer[w.key] !== w.value))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function optionsProblem(field: FormFieldSpec, values: readonly string[]): string | undefined {
|
|
19
|
+
if (!field.options?.length || field.custom) return undefined
|
|
20
|
+
const allowed = field.options.map((o) => o.value)
|
|
21
|
+
const wrong = values.find((v) => !allowed.includes(v))
|
|
22
|
+
if (wrong === undefined) return undefined
|
|
23
|
+
return `Form field ${quote(field.key)}: ${quote(wrong)} is not one of the options; use an option value: ${listed(allowed)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function valueProblem(field: FormFieldSpec, value: Value): string | undefined {
|
|
27
|
+
const name = `Form field ${quote(field.key)}`
|
|
28
|
+
switch (field.type) {
|
|
29
|
+
case "external":
|
|
30
|
+
return `${name} is a link for the user: leave it out`
|
|
31
|
+
case "boolean":
|
|
32
|
+
return typeof value === "boolean" ? undefined : `${name} takes true or false`
|
|
33
|
+
case "number":
|
|
34
|
+
case "integer": {
|
|
35
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return `${name} takes a number`
|
|
36
|
+
if (field.type === "integer" && !Number.isInteger(value)) return `${name} takes a whole number`
|
|
37
|
+
if (field.minimum !== undefined && value < field.minimum) return `${name} is at least ${field.minimum}`
|
|
38
|
+
if (field.maximum !== undefined && value > field.maximum) return `${name} is at most ${field.maximum}`
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
case "multiselect": {
|
|
42
|
+
if (!Array.isArray(value)) return `${name} takes a list of option values`
|
|
43
|
+
if (field.minItems !== undefined && value.length < field.minItems) return `${name} takes at least ${field.minItems} choice${field.minItems === 1 ? "" : "s"}`
|
|
44
|
+
if (field.maxItems !== undefined && value.length > field.maxItems) return `${name} takes at most ${field.maxItems} choice${field.maxItems === 1 ? "" : "s"}`
|
|
45
|
+
return optionsProblem(field, value)
|
|
46
|
+
}
|
|
47
|
+
case "string": {
|
|
48
|
+
if (typeof value !== "string") return `${name} takes text`
|
|
49
|
+
const options = optionsProblem(field, [value])
|
|
50
|
+
if (options) return options
|
|
51
|
+
if (field.minLength !== undefined && value.length < field.minLength) return `${name} is at least ${field.minLength} characters`
|
|
52
|
+
if (field.maxLength !== undefined && value.length > field.maxLength) return `${name} is at most ${field.maxLength} characters`
|
|
53
|
+
if (field.pattern) {
|
|
54
|
+
let pattern: RegExp | undefined
|
|
55
|
+
try {
|
|
56
|
+
pattern = new RegExp(field.pattern)
|
|
57
|
+
} catch {
|
|
58
|
+
// a pattern the hub cannot compile is left to OpenCode
|
|
59
|
+
}
|
|
60
|
+
if (pattern && !pattern.test(value)) return `${name} does not match its pattern ${field.pattern}`
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The first problem of `answer` for these fields (undefined: none, or no fields known). */
|
|
68
|
+
export function formAnswerFieldProblem(fields: readonly FormFieldSpec[], answer: FormAnswer): string | undefined {
|
|
69
|
+
if (!fields.length) return undefined
|
|
70
|
+
const byKey = new Map(fields.map((f) => [f.key, f]))
|
|
71
|
+
for (const key of Object.keys(answer)) {
|
|
72
|
+
if (!byKey.has(key)) return `Unknown form field ${quote(key)}: the form's fields are ${listed(fields.map((f) => f.key))} (read them with the form's schema)`
|
|
73
|
+
}
|
|
74
|
+
for (const field of fields) {
|
|
75
|
+
const value = answer[field.key]
|
|
76
|
+
// (CON-13 decision) A hidden field's value is checked too; only its required flag waits for its condition.
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
if (visible(field, answer) && field.required && field.type !== "external" && field.type !== "boolean") return `Form field ${quote(field.key)} is required`
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const problem = valueProblem(field, value)
|
|
82
|
+
if (problem) return problem
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
@@ -50,7 +50,7 @@ export interface GrantRequestsDeps {
|
|
|
50
50
|
readonly state: StateStore
|
|
51
51
|
readonly counters: Counters
|
|
52
52
|
readonly decisions: Pick<Decisions, "ask" | "get">
|
|
53
|
-
readonly grants: Pick<Grants, "create">
|
|
53
|
+
readonly grants: Pick<Grants, "create"> & Partial<Pick<Grants, "assertHolder">>
|
|
54
54
|
readonly directory: Pick<Directory, "resolve">
|
|
55
55
|
readonly audit?: Pick<AgentAudit, "record">
|
|
56
56
|
/** The requester as envelopes name it; default: from the actor alone. */
|
|
@@ -83,6 +83,7 @@ export class GrantRequests {
|
|
|
83
83
|
const own = ownHolder(actor)
|
|
84
84
|
const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
|
|
85
85
|
const holder = this.holder(userID, actor, spec.holder, own)
|
|
86
|
+
await this.deps.grants.assertHolder?.(userID, holder)
|
|
86
87
|
const grant = canonical({ ...spec, holder })
|
|
87
88
|
|
|
88
89
|
const requester = this.describe(userID, actor)
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Agents only request one (grant-requests.ts): POST /api/agent/grants/requests {grant, why} from a session (any of
|
|
10
10
|
// its tokens) or an app token with messages:send → 201 {message, decision} (a tap Decision for the user); the user
|
|
11
11
|
// gets invalid (create it directly).
|
|
12
|
+
import { boundedJson } from "./body"
|
|
12
13
|
import type { GrantRequests } from "./grant-requests"
|
|
13
14
|
import { type Grant, type Grants, grantState } from "./grants"
|
|
14
15
|
import type { AgentRoute } from "./routes"
|
|
@@ -32,7 +33,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
|
|
|
32
33
|
method: "POST",
|
|
33
34
|
path: `${PREFIX}/requests`,
|
|
34
35
|
allow: { session: true, scope: "messages:send" },
|
|
35
|
-
handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await request
|
|
36
|
+
handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await boundedJson(request)), { status: 201, headers: NO_STORE }),
|
|
36
37
|
} satisfies AgentRoute,
|
|
37
38
|
]
|
|
38
39
|
: []),
|
|
@@ -40,7 +41,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
|
|
|
40
41
|
method: "POST",
|
|
41
42
|
path: PREFIX,
|
|
42
43
|
allow: {},
|
|
43
|
-
handle: async ({ request, user }) => one(await grants.create(user.id, await request
|
|
44
|
+
handle: async ({ request, user }) => one(await grants.create(user.id, await boundedJson(request)), 201),
|
|
44
45
|
},
|
|
45
46
|
{
|
|
46
47
|
method: "GET",
|
|
@@ -58,7 +59,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
|
|
|
58
59
|
method: "PUT",
|
|
59
60
|
path: `${PREFIX}/:id`,
|
|
60
61
|
allow: {},
|
|
61
|
-
handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await request
|
|
62
|
+
handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await boundedJson(request))),
|
|
62
63
|
},
|
|
63
64
|
{
|
|
64
65
|
method: "DELETE",
|
|
@@ -20,6 +20,7 @@ import { AgentError } from "./errors"
|
|
|
20
20
|
import type { AgentActor, AppActor, SessionActor } from "./actor"
|
|
21
21
|
import { type GrantSpec, type GrantVerb, inProjects, validateGrant } from "./policy"
|
|
22
22
|
import { updateVersioned } from "./versioned"
|
|
23
|
+
import { isManagedRole } from "./names"
|
|
23
24
|
|
|
24
25
|
/** Active (unexpired, unrevoked) grants per user. */
|
|
25
26
|
export const GRANTS_ACTIVE_MAX = 50
|
|
@@ -63,6 +64,8 @@ export interface GrantsDeps {
|
|
|
63
64
|
/** The hub-wide admin audit (Store): summaries only. */
|
|
64
65
|
readonly admin?: { audit(entry: Omit<AuditEntry, "at">): void | Promise<void> }
|
|
65
66
|
readonly now?: () => number
|
|
67
|
+
/** (CON-04) The user's Concierge sessions: only the Concierge service (managed) grants to them or to @@concierge. */
|
|
68
|
+
readonly concierge?: (owner: string) => Promise<readonly string[]>
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const notFound = () => new AgentError("not_found", "No such grant")
|
|
@@ -74,10 +77,33 @@ export class Grants {
|
|
|
74
77
|
this.now = deps.now ?? Date.now
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/**
|
|
81
|
+
* (CON-04) Refuses a holder that is the Concierge (a concierge* role, or a Concierge session by ID or name) unless
|
|
82
|
+
* the Concierge service itself (managed) grants it: 403 {policy: "concierge"}.
|
|
83
|
+
*/
|
|
84
|
+
async assertHolder(owner: string, holder: string, options: { readonly managed?: boolean } = {}): Promise<void> {
|
|
85
|
+
if (options.managed) return
|
|
86
|
+
const role = holder.startsWith("@@") ? holder.slice(2).split(".")[0]! : undefined
|
|
87
|
+
let concierge = role !== undefined && isManagedRole(role)
|
|
88
|
+
if (!concierge && role === undefined && !holder.startsWith("app:") && this.deps.concierge) {
|
|
89
|
+
const hidden = await this.deps.concierge(owner)
|
|
90
|
+
if (hidden.includes(holder)) concierge = true
|
|
91
|
+
else {
|
|
92
|
+
try {
|
|
93
|
+
concierge = this.deps.directory.resolve(owner, holder).sessions.some((s) => hidden.includes(s.sessionID))
|
|
94
|
+
} catch {
|
|
95
|
+
concierge = false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (concierge) throw new AgentError("forbidden", "The Concierge's grant is managed by the hub", { hint: "Use Settings → Concierge", extra: { policy: "concierge" } })
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
/** Validates and stores a new grant of the owner (the holder resolved and pinned for the owner). */
|
|
78
|
-
async create(owner: string, input: unknown): Promise<Grant> {
|
|
103
|
+
async create(owner: string, input: unknown, options: { readonly managed?: boolean } = {}): Promise<Grant> {
|
|
79
104
|
this.owner(owner)
|
|
80
105
|
const spec = validateGrant(input)
|
|
106
|
+
await this.assertHolder(owner, spec.holder, options)
|
|
81
107
|
const holderSessionID = this.pin(owner, spec.holder)
|
|
82
108
|
const all = await this.all(owner)
|
|
83
109
|
const at = this.now()
|
|
@@ -109,8 +135,9 @@ export class Grants {
|
|
|
109
135
|
* Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
|
|
110
136
|
* stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
|
|
111
137
|
*/
|
|
112
|
-
async update(owner: string, id: string, input: unknown): Promise<Grant> {
|
|
138
|
+
async update(owner: string, id: string, input: unknown, options: { readonly managed?: boolean } = {}): Promise<Grant> {
|
|
113
139
|
const spec = validateGrant(input)
|
|
140
|
+
await this.assertHolder(owner, spec.holder, options)
|
|
114
141
|
return this.change(owner, id, "grant.update", (grant, at) => {
|
|
115
142
|
if (spec.holder !== grant.holder) throw new AgentError("invalid", "A grant's holder cannot change: revoke it and create another")
|
|
116
143
|
return { ...grant, ...fields(spec, at), updated: at }
|