@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
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
// - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
|
|
20
20
|
// paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
|
|
21
21
|
// - Races: the request answered elsewhere first is gone (410).
|
|
22
|
+
// - (CON-06) preflight: a Concierge proposal is checked exactly like the act verb would check the agent, short of
|
|
23
|
+
// its grant (kill switch, tenancy, the Concierge guard, pending request/form, self-tree, prompt rules); nothing is
|
|
24
|
+
// consumed, audited or sent. When the user says go, the verb runs as the user (no grant, tenancy re-checked).
|
|
22
25
|
import {
|
|
23
26
|
DELIVER_TEXT_MAX,
|
|
24
27
|
type Delivered,
|
|
@@ -35,7 +38,8 @@ import {
|
|
|
35
38
|
import { type Actions, type Unsupported, unsupportedMessage } from "../actions"
|
|
36
39
|
import { BusyError, type Registry } from "../registry"
|
|
37
40
|
import { SpokeError } from "../spoke-ws"
|
|
38
|
-
import type { PermissionRequest } from "../../status"
|
|
41
|
+
import type { FormFieldSpec, PermissionRequest } from "../../status"
|
|
42
|
+
import { formAnswerFieldProblem } from "./form-check"
|
|
39
43
|
import type { ActingSwitch } from "./acting-switch"
|
|
40
44
|
import type { AgentActor } from "./actor"
|
|
41
45
|
import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
|
|
@@ -51,7 +55,9 @@ import { evaluatePermission, formatPattern, type GrantVerb, parsePattern, type P
|
|
|
51
55
|
export type ActingRegistry = Pick<
|
|
52
56
|
Registry,
|
|
53
57
|
"sessionTenant" | "session" | "rootOf" | "permissionRequest" | "permissionTargets" | "formTargets" | "owner" | "supports" | "harnessOf"
|
|
54
|
-
>
|
|
58
|
+
> &
|
|
59
|
+
/** (CON-13) The tree's live status: a pending form's fields (its schema). Without it no answer is checked. */
|
|
60
|
+
Partial<Pick<Registry, "status">>
|
|
55
61
|
|
|
56
62
|
/** Why a request needs the user, as the policy saw it. */
|
|
57
63
|
export interface Evaluation {
|
|
@@ -97,8 +103,20 @@ export interface ActingDeps {
|
|
|
97
103
|
readonly newMessageID?: () => string
|
|
98
104
|
/** Longest prompt a user sends (the console's limit); agents' prompts are at most DELIVER_TEXT_MAX. */
|
|
99
105
|
readonly maxPromptChars?: number
|
|
106
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): agents get forbidden {policy: "concierge"}. */
|
|
107
|
+
readonly protect?: (userID: string, sessionID: string) => Promise<boolean>
|
|
108
|
+
/**
|
|
109
|
+
* (CON-13 decision, design §11.4) Whether the acting session is a Concierge session (current or past). Such an
|
|
110
|
+
* actor never executes a tap-tier approval (shell, outside folders, MCP and unknown tools, near-never) or an
|
|
111
|
+
* "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
|
|
112
|
+
*/
|
|
113
|
+
readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
/** (CON-13 decision) Why the Concierge escalates a covered request. */
|
|
117
|
+
export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
|
|
118
|
+
export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
|
|
119
|
+
|
|
102
120
|
/** Who acts, for which user. */
|
|
103
121
|
export interface Caller {
|
|
104
122
|
readonly actor: AgentActor
|
|
@@ -162,6 +180,8 @@ export interface PromptInput {
|
|
|
162
180
|
/** Agents' prompts always wake the session: false is refused. */
|
|
163
181
|
readonly wake?: boolean
|
|
164
182
|
readonly why?: string
|
|
183
|
+
/** (CON-09a) false: not the user's own text (a proposal run as the user): never the session's first-prompt intent. */
|
|
184
|
+
readonly intent?: false
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/** An agent's prompt, admitted by the spoke under its provenance header. */
|
|
@@ -176,6 +196,28 @@ export interface AgentPrompted {
|
|
|
176
196
|
/** The user's prompt: steered into the running tree, or a new turn (its reply streams to the console). */
|
|
177
197
|
export type UserPrompted = { readonly sessionID: string; readonly mode: "steer"; readonly inboxID: string } | { readonly sessionID: string; readonly mode: "turn" }
|
|
178
198
|
|
|
199
|
+
/** (CON-13) A pending form's schema, as an agent may read it: the fields it takes (bounded by the spoke). */
|
|
200
|
+
export interface PendingForm {
|
|
201
|
+
readonly sessionID: string
|
|
202
|
+
readonly formID: string
|
|
203
|
+
/** The session in the tree that asked (a subagent's form is answered through its root). */
|
|
204
|
+
readonly askedBy: string
|
|
205
|
+
readonly title: string
|
|
206
|
+
readonly question?: true
|
|
207
|
+
readonly fields: readonly FormFieldSpec[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** (CON-06) The verbs a Concierge proposal covers, and what preflight answers about the target. */
|
|
211
|
+
export type ProposalVerb = "stop" | "deny" | "prompt" | "answer"
|
|
212
|
+
export interface Preflighted {
|
|
213
|
+
readonly sessionID: string
|
|
214
|
+
readonly rootID: string
|
|
215
|
+
/** The target's @name. */
|
|
216
|
+
readonly name?: string
|
|
217
|
+
/** deny: the pending request's details (when known). */
|
|
218
|
+
readonly request?: PermissionRequest
|
|
219
|
+
}
|
|
220
|
+
|
|
179
221
|
/** The console's prompt limit (http.ts MAX_PROMPT_CHARS; Central passes it). */
|
|
180
222
|
const DEFAULT_MAX_PROMPT_CHARS = 100_000
|
|
181
223
|
|
|
@@ -258,9 +300,10 @@ export class Acting {
|
|
|
258
300
|
const origin = this.pendingPermission(target, input.requestID)
|
|
259
301
|
const always = input.always === true
|
|
260
302
|
const decision: PermissionDecision = always ? "always" : "once"
|
|
303
|
+
const concierge = await this.isConcierge(caller)
|
|
261
304
|
return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
|
|
262
305
|
selfTree: true,
|
|
263
|
-
choose: (usable) => this.choosePermission(usable, origin.request, always),
|
|
306
|
+
choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
|
|
264
307
|
escalation: (evaluation) => ({
|
|
265
308
|
verb: "approve",
|
|
266
309
|
target: target.sessionID,
|
|
@@ -281,6 +324,39 @@ export class Acting {
|
|
|
281
324
|
})
|
|
282
325
|
}
|
|
283
326
|
|
|
327
|
+
/**
|
|
328
|
+
* (CON-06) Checks an agent's proposed act as the verb would, short of its grant: kill switch (paused), tenancy
|
|
329
|
+
* (not_found/offline), the Concierge guard (forbidden {policy: concierge}), the pending request or form
|
|
330
|
+
* (not_found), self-tree (forbidden), the prompt rules (invalid). Consumes, audits and sends nothing.
|
|
331
|
+
*/
|
|
332
|
+
async preflight(caller: Caller, verb: ProposalVerb, addr: string, ids: { readonly requestID?: string; readonly formID?: string; readonly text?: string; readonly answer?: FormAnswer }): Promise<Preflighted> {
|
|
333
|
+
const { actor, userID } = caller
|
|
334
|
+
if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
|
|
335
|
+
if (verb === "prompt" && actor.kind !== "user") {
|
|
336
|
+
const text = (ids.text ?? "").trim()
|
|
337
|
+
if (!text) throw invalid("Empty prompt")
|
|
338
|
+
if (text.length > DELIVER_TEXT_MAX) throw invalid(`Prompt longer than ${DELIVER_TEXT_MAX} characters`)
|
|
339
|
+
}
|
|
340
|
+
const target = this.locate(userID, addr)
|
|
341
|
+
if (actor.kind !== "user") await this.guard(userID, target)
|
|
342
|
+
let request: PermissionRequest | undefined
|
|
343
|
+
if (verb === "deny") request = this.pendingPermission(target, ids.requestID ?? "").request
|
|
344
|
+
if (verb === "answer" && !this.deps.registry.formTargets(target.rootID, ids.formID ?? "").length) throw new AgentError("not_found", `No pending form ${ids.formID} in this session's tree`)
|
|
345
|
+
if (verb === "answer" && ids.answer !== undefined && actor.kind !== "user") this.checkAnswer(target, ids.formID ?? "", ids.answer)
|
|
346
|
+
if ((verb === "deny" || verb === "answer") && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
347
|
+
throw forbidden("A session never answers requests in its own session tree")
|
|
348
|
+
}
|
|
349
|
+
if (verb === "prompt" && actor.kind !== "user") {
|
|
350
|
+
if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
|
|
351
|
+
if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
|
|
352
|
+
}
|
|
353
|
+
let name: string | undefined
|
|
354
|
+
try {
|
|
355
|
+
name = this.deps.directory.resolveOne(userID, target.sessionID).name
|
|
356
|
+
} catch {}
|
|
357
|
+
return { sessionID: target.sessionID, rootID: target.rootID, ...(name ? { name } : {}), ...(request ? { request } : {}) }
|
|
358
|
+
}
|
|
359
|
+
|
|
284
360
|
/** Rejects a pending permission request (any request: a reject is always safe), with an optional reason. */
|
|
285
361
|
async deny(caller: Caller, addr: string, input: DenyInput): Promise<Denied> {
|
|
286
362
|
const target = this.locate(caller.userID, addr)
|
|
@@ -306,6 +382,8 @@ export class Acting {
|
|
|
306
382
|
if (!this.deps.registry.formTargets(target.rootID, input.formID).length) throw new AgentError("not_found", `No pending form ${input.formID} in this session's tree`)
|
|
307
383
|
return this.act(caller, "answer", target, { formID: input.formID }, input.why, {
|
|
308
384
|
selfTree: true,
|
|
385
|
+
// (CON-13) Checked after the grant's checks (a holder without the verb learns nothing of the form), before it runs.
|
|
386
|
+
check: () => (input.cancel || caller.actor.kind === "user" ? undefined : this.checkAnswer(target, input.formID, input.answer)),
|
|
309
387
|
run: async (grant) => {
|
|
310
388
|
const form = input.cancel
|
|
311
389
|
? { rootID: target.rootID, formID: input.formID, cancel: true as const }
|
|
@@ -318,6 +396,88 @@ export class Acting {
|
|
|
318
396
|
})
|
|
319
397
|
}
|
|
320
398
|
|
|
399
|
+
/**
|
|
400
|
+
* (CON-13) A pending form's schema in the addressed session's tree: its title and fields (keys, types, options,
|
|
401
|
+
* required…), never anything else of the session. Checked like the answer verb short of its grant: kill switch,
|
|
402
|
+
* tenancy, the Concierge guard, the pending form, self-tree. Consumes, audits and sends nothing.
|
|
403
|
+
*/
|
|
404
|
+
async form(caller: Caller, addr: string, formID: string): Promise<PendingForm> {
|
|
405
|
+
const { actor, userID } = caller
|
|
406
|
+
if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
|
|
407
|
+
const target = this.locate(userID, addr)
|
|
408
|
+
if (actor.kind !== "user") await this.guard(userID, target)
|
|
409
|
+
if (!this.deps.registry.formTargets(target.rootID, formID).length) throw new AgentError("not_found", `No pending form ${formID} in this session's tree`)
|
|
410
|
+
if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) throw forbidden("A session never answers requests in its own session tree")
|
|
411
|
+
const found = this.formBlocker(target, formID)
|
|
412
|
+
return {
|
|
413
|
+
sessionID: target.sessionID,
|
|
414
|
+
formID,
|
|
415
|
+
askedBy: found?.sessionID ?? target.sessionID,
|
|
416
|
+
title: found?.blocker.summary ?? "question",
|
|
417
|
+
...(found?.blocker.question ? { question: true as const } : {}),
|
|
418
|
+
fields: found?.blocker.fields ?? [],
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* (CON-13) Whether the agent's grant would approve this pending request once, as approve() decides it: kill switch,
|
|
424
|
+
* tenancy, the Concierge guard, pending in the tree, self-tree, a usable grant with approve, then every never rule
|
|
425
|
+
* and the allow rules (choosePermission). Never acts, consumes, audits or escalates; any refusal is false. The
|
|
426
|
+
* hourly cap is not counted (the act may still be rate limited). The board's `inGrant` (the model never decides).
|
|
427
|
+
*/
|
|
428
|
+
async covers(caller: Caller, addr: string, requestID: string): Promise<boolean> {
|
|
429
|
+
const { actor, userID } = caller
|
|
430
|
+
if (actor.kind === "user") return false
|
|
431
|
+
try {
|
|
432
|
+
this.deps.acting.ensureActing(userID)
|
|
433
|
+
const target = this.locate(userID, addr)
|
|
434
|
+
await this.guard(userID, target)
|
|
435
|
+
const { request } = this.pendingPermission(target, requestID)
|
|
436
|
+
if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) return false
|
|
437
|
+
const usable = await this.deps.grants.usable(actor, "approve", { directory: target.directory })
|
|
438
|
+
if (!usable.length) return false
|
|
439
|
+
this.choosePermission(usable, request, false)
|
|
440
|
+
return true
|
|
441
|
+
} catch {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* (CON-13 decision) The board's facts for a pending request: `inGrant` (covers(): the policy's truth) and `auto`:
|
|
448
|
+
* inGrant and not the tap risk tier, i.e. the Concierge may approve it once without the user.
|
|
449
|
+
*/
|
|
450
|
+
async coverage(caller: Caller, addr: string, requestID: string): Promise<{ inGrant: boolean; auto: boolean }> {
|
|
451
|
+
const inGrant = await this.covers(caller, addr, requestID)
|
|
452
|
+
if (!inGrant) return { inGrant, auto: false }
|
|
453
|
+
try {
|
|
454
|
+
const target = this.locate(caller.userID, addr)
|
|
455
|
+
const { request } = this.pendingPermission(target, requestID)
|
|
456
|
+
if (!request) return { inGrant, auto: false }
|
|
457
|
+
const usable = await this.deps.grants.usable(caller.actor, "approve", { directory: target.directory })
|
|
458
|
+
const never = [...this.deps.acting.hubNever(), ...usable.flatMap((g) => patterns(g.permissions.never))]
|
|
459
|
+
return { inGrant, auto: riskTier(request, never) !== "tap" }
|
|
460
|
+
} catch {
|
|
461
|
+
return { inGrant, auto: false }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The form's blocker in the target's tree (with the session that shows it), when the registry reports it. */
|
|
466
|
+
private formBlocker(target: Located, formID: string) {
|
|
467
|
+
for (const s of this.deps.registry.status?.(target.rootID) ?? []) {
|
|
468
|
+
const blocker = s.blockers.find((b) => b.kind === "form" && b.id === formID)
|
|
469
|
+
if (blocker) return { sessionID: s.id, blocker }
|
|
470
|
+
}
|
|
471
|
+
return undefined
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** (CON-13) 400 invalid with the precise problem when an agent's answer does not fit the form's fields. */
|
|
475
|
+
private checkAnswer(target: Located, formID: string, answer: FormAnswer): void {
|
|
476
|
+
const fields = this.formBlocker(target, formID)?.blocker.fields
|
|
477
|
+
const problem = fields ? formAnswerFieldProblem(fields, answer) : undefined
|
|
478
|
+
if (problem) throw new AgentError("invalid", problem, { hint: "Read the form's fields first (concierge_form, or show) and answer with their keys and option values" })
|
|
479
|
+
}
|
|
480
|
+
|
|
321
481
|
/** Stops the addressed session's tree. */
|
|
322
482
|
async stop(caller: Caller, addr: string, input: StopInput): Promise<StoppedSession> {
|
|
323
483
|
const target = this.locate(caller.userID, addr)
|
|
@@ -361,16 +521,16 @@ export class Acting {
|
|
|
361
521
|
}
|
|
362
522
|
return this.act(caller, "prompt", target, {}, input.why, {
|
|
363
523
|
run: async (grant) => {
|
|
364
|
-
if (!grant) return this.userPrompt(target.sessionID, text, images)
|
|
524
|
+
if (!grant) return this.userPrompt(target.sessionID, text, images, input.intent)
|
|
365
525
|
return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
|
|
366
526
|
},
|
|
367
527
|
})
|
|
368
528
|
}
|
|
369
529
|
|
|
370
|
-
private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined): Promise<UserPrompted> {
|
|
530
|
+
private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined, intent?: false): Promise<UserPrompted> {
|
|
371
531
|
let started
|
|
372
532
|
try {
|
|
373
|
-
started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
533
|
+
started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true, ...(intent === false ? { intent } : {}) })
|
|
374
534
|
} catch (cause) {
|
|
375
535
|
if (cause instanceof BusyError) throw new AgentError("rate_limited", "Another turn is starting in this session", { hint: "Try again", extra: { retryAfter: 1 } })
|
|
376
536
|
throw cause
|
|
@@ -427,6 +587,8 @@ export class Acting {
|
|
|
427
587
|
/** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
|
|
428
588
|
readonly choose?: (usable: Grant[]) => Grant
|
|
429
589
|
readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
|
|
590
|
+
/** (CON-13) Input checks that need the target (throw invalid): after the grant's checks, before it is consumed. */
|
|
591
|
+
readonly check?: () => void
|
|
430
592
|
readonly run: (grant: Grant | undefined) => Promise<T>
|
|
431
593
|
},
|
|
432
594
|
): Promise<T> {
|
|
@@ -446,16 +608,21 @@ export class Acting {
|
|
|
446
608
|
try {
|
|
447
609
|
if (actor.kind !== "user") {
|
|
448
610
|
acting.ensureActing(userID)
|
|
611
|
+
await this.guard(userID, target)
|
|
449
612
|
const usable = await grants.usable(actor, verb, { directory: target.directory })
|
|
450
613
|
if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
451
614
|
if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
452
615
|
throw forbidden("A session never answers requests in its own session tree")
|
|
453
616
|
}
|
|
454
617
|
grant = plan.choose ? plan.choose(usable) : usable[0]!
|
|
618
|
+
plan.check?.()
|
|
455
619
|
await grants.consume(grant)
|
|
456
620
|
acting.ensureActing(userID)
|
|
457
621
|
}
|
|
458
|
-
|
|
622
|
+
// (CON-13) What the spoke said when it refused, never a bare 500.
|
|
623
|
+
const result = await plan.run(grant).catch((cause: unknown) => {
|
|
624
|
+
throw refusedBySession(cause)
|
|
625
|
+
})
|
|
459
626
|
await record("allow", "ok", why)
|
|
460
627
|
return result
|
|
461
628
|
} catch (cause) {
|
|
@@ -474,6 +641,13 @@ export class Acting {
|
|
|
474
641
|
}
|
|
475
642
|
}
|
|
476
643
|
|
|
644
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): agents are forbidden {policy: concierge}. */
|
|
645
|
+
private async guard(userID: string, target: Located): Promise<void> {
|
|
646
|
+
if (this.deps.protect && ((await this.deps.protect(userID, target.rootID)) || (await this.deps.protect(userID, target.sessionID)))) {
|
|
647
|
+
throw new AgentError("forbidden", "Only the user acts on the Concierge", { extra: { policy: "concierge" } })
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
477
651
|
/** Hands the escalation to the hook: undefined when there is none or it failed. */
|
|
478
652
|
private async escalate(caller: Caller, escalation: Omit<Escalation, "userID" | "actor"> | undefined): Promise<{ decisionID?: string } | undefined> {
|
|
479
653
|
if (!this.deps.escalate || !escalation) return undefined
|
|
@@ -485,10 +659,25 @@ export class Acting {
|
|
|
485
659
|
}
|
|
486
660
|
}
|
|
487
661
|
|
|
488
|
-
/** The
|
|
489
|
-
private
|
|
662
|
+
/** (CON-13 decision) The actor is a Concierge session (current or past); a failed read counts as yes. */
|
|
663
|
+
private async isConcierge(caller: Caller): Promise<boolean> {
|
|
664
|
+
const { actor } = caller
|
|
665
|
+
if (actor.kind !== "session" || !this.deps.conciergeActor) return false
|
|
666
|
+
try {
|
|
667
|
+
return await this.deps.conciergeActor(caller.userID, actor.sessionID)
|
|
668
|
+
} catch {
|
|
669
|
+
return true
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
|
|
675
|
+
* `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
|
|
676
|
+
*/
|
|
677
|
+
private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
|
|
490
678
|
const hubNever = this.deps.acting.hubNever()
|
|
491
679
|
const ids = usable.map((g) => g.id)
|
|
680
|
+
if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
|
|
492
681
|
if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
|
|
493
682
|
const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
|
|
494
683
|
for (const form of neverForms(request)) {
|
|
@@ -504,7 +693,10 @@ export class Acting {
|
|
|
504
693
|
let reason: string | undefined
|
|
505
694
|
for (const { grant, allow, never } of rules) {
|
|
506
695
|
const verdict = evaluatePermission(request, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
507
|
-
if (verdict.verdict === "allow")
|
|
696
|
+
if (verdict.verdict === "allow") {
|
|
697
|
+
if (concierge && riskTier(request, allNever) === "tap") throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_TAP_REASON, tier: "tap", grants: ids })
|
|
698
|
+
return grant
|
|
699
|
+
}
|
|
508
700
|
if (verdict.verdict === "outside") reason ??= verdict.reason
|
|
509
701
|
}
|
|
510
702
|
throw new NeedsUser({ verdict: "outside", reason: reason ?? "Outside the grant", tier: riskTier(request, allNever), grants: ids })
|
|
@@ -570,12 +762,25 @@ function bounded(text: string, max: number): string {
|
|
|
570
762
|
}
|
|
571
763
|
|
|
572
764
|
const gone = () => new AgentError("gone", "Already answered, expired or ended")
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* (CON-13) The session's harness refused the act (OpenCode said why, e.g. "Unknown form field: Questions"): 400
|
|
768
|
+
* invalid with its words (bounded, one line), never a 500 "Internal error" a model would repeat to the user. The
|
|
769
|
+
* audit keeps it as allowed · failed. A disconnect stays offline; hub errors pass through.
|
|
770
|
+
*/
|
|
771
|
+
export function refusedBySession(cause: unknown): unknown {
|
|
772
|
+
if (cause instanceof AgentError || cause instanceof NeedsUser || cause instanceof BusyError) return cause
|
|
773
|
+
if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) return new AgentError("offline", "The session's spoke disconnected")
|
|
774
|
+
const said = sanitizeText(cause instanceof Error ? cause.message : String(cause)).replace(/\s+/g, " ").trim().slice(0, 300) || "no reason given"
|
|
775
|
+
return new AgentError("invalid", `The session refused it: ${said}`, { extra: { refusedBy: "session" } })
|
|
776
|
+
}
|
|
573
777
|
/** The spoke lacks the core op: unsupported {feature, harness}, as the read routes answer. */
|
|
574
778
|
const unsupported = ({ feature, harness }: Unsupported) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
575
779
|
|
|
576
780
|
/** An error's audit decision and outcome (shared with the launch routes). */
|
|
577
781
|
export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
578
782
|
if (!(cause instanceof AgentError)) return ["allow", "failed"]
|
|
783
|
+
if (cause.options.extra?.refusedBy === "session") return ["allow", "failed"]
|
|
579
784
|
switch (cause.code) {
|
|
580
785
|
case "paused":
|
|
581
786
|
return ["paused", "refused"]
|
|
@@ -585,6 +790,9 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
|
585
790
|
return ["forbidden", "refused"]
|
|
586
791
|
case "rate_limited":
|
|
587
792
|
return ["rate_limited", "refused"]
|
|
793
|
+
// (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
|
|
794
|
+
case "busy":
|
|
795
|
+
return ["busy", "refused"]
|
|
588
796
|
case "needs_user":
|
|
589
797
|
return ["needs_user", "refused"]
|
|
590
798
|
case "gone":
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Management (create, list, revoke) is for user actors only (allow {}): a session or app token cannot mint or
|
|
9
9
|
// see tokens (escalation guard).
|
|
10
|
+
import { boundedJson } from "./body"
|
|
10
11
|
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
11
12
|
import type { AppToken } from "../agent-data"
|
|
12
13
|
import { hashToken, type Store } from "../store"
|
|
@@ -19,7 +20,7 @@ import type { AgentRoute } from "./routes"
|
|
|
19
20
|
* acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
|
|
20
21
|
* needs a grant held by the token (with the act verb, or `launch` covering the repo).
|
|
21
22
|
*/
|
|
22
|
-
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
|
|
23
|
+
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe", "profiles:read", "profiles:manage", "profiles:assign", "workspaces:read", "workspaces:manage", "artifacts:read", "artifacts:write", "artifacts:ai", "teams:submit", "teams:read", "teams:manage"]
|
|
23
24
|
export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
|
|
24
25
|
/** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
|
|
25
26
|
export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
|
|
@@ -121,7 +122,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
|
121
122
|
path: PREFIX,
|
|
122
123
|
allow: {},
|
|
123
124
|
async handle({ request, user }) {
|
|
124
|
-
const { record, token } = await issueAppToken(store, user.id, parseCreate(await request
|
|
125
|
+
const { record, token } = await issueAppToken(store, user.id, parseCreate(await boundedJson(request)))
|
|
125
126
|
return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
|
|
126
127
|
},
|
|
127
128
|
},
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// fails the record.
|
|
11
11
|
// - Admins see only a hub-wide summary in the Store's audit (who: the user ID; kind: agent.<verb> and the actor
|
|
12
12
|
// kind; when), never targets, reasons or text (design §7).
|
|
13
|
+
// - (CON-06) `as: "concierge"`: entries whose actor is one of the user's Concierge sessions (current or past) are
|
|
14
|
+
// marked here, centrally, so every recording route (acting, launch, message, roles) gets it; the console says
|
|
15
|
+
// "by @@concierge". A failed identity read leaves the entry unmarked (it is still recorded).
|
|
13
16
|
import type { Clock, StateStore } from "../backend/types"
|
|
14
17
|
import type { AuditEntry } from "../store"
|
|
15
18
|
import { type Party, sanitizeText } from "./envelope"
|
|
@@ -26,7 +29,7 @@ export const AUDIT_PAGE_DEFAULT = 100
|
|
|
26
29
|
export const AUDIT_PAGE_MAX = 500
|
|
27
30
|
|
|
28
31
|
/** The policy's decision: allowed, or why not. */
|
|
29
|
-
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited"
|
|
32
|
+
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited" | "busy"
|
|
30
33
|
/** What happened: done, failed, raced (gone), the spoke could not (offline, unsupported), escalated or refused. */
|
|
31
34
|
export type AuditOutcome = "ok" | "failed" | "gone" | "offline" | "unsupported" | "escalated" | "refused"
|
|
32
35
|
|
|
@@ -50,6 +53,8 @@ export interface AuditInput {
|
|
|
50
53
|
/** The agent's reason (`--why`) or the policy's. */
|
|
51
54
|
readonly reason?: string
|
|
52
55
|
readonly outcome: AuditOutcome
|
|
56
|
+
/** (CON-06) Done by one of the user's Concierge sessions (current or past). */
|
|
57
|
+
readonly as?: "concierge"
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export interface AgentAuditEntry extends AuditInput {
|
|
@@ -80,6 +85,8 @@ export interface AgentAuditDeps {
|
|
|
80
85
|
readonly recorded?: (userID: string, entry: AgentAuditEntry) => void
|
|
81
86
|
/** Ring size (default AGENT_AUDIT_LIMIT). */
|
|
82
87
|
readonly limit?: number
|
|
88
|
+
/** (CON-06) Whether a session is one of the user's Concierge sessions (current or past). */
|
|
89
|
+
readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
const ENTRIES = "agent-audit"
|
|
@@ -122,6 +129,7 @@ export class AgentAudit {
|
|
|
122
129
|
const seq = (await updateVersioned<number>(state, userID, SEQ, SEQ_KEY, (current) => (current ?? 0) + 1))!
|
|
123
130
|
const grant = cleanID(input.grant)
|
|
124
131
|
const reason = cleanReason(input.reason)
|
|
132
|
+
const as = input.as === "concierge" || (input.actor.kind === "session" && (await this.isConcierge(userID, input.actor.sessionID))) ? ("concierge" as const) : undefined
|
|
125
133
|
const entry: AgentAuditEntry = {
|
|
126
134
|
seq,
|
|
127
135
|
at: this.now(),
|
|
@@ -132,6 +140,7 @@ export class AgentAudit {
|
|
|
132
140
|
decision: input.decision,
|
|
133
141
|
...(reason ? { reason } : {}),
|
|
134
142
|
outcome: input.outcome,
|
|
143
|
+
...(as ? { as } : {}),
|
|
135
144
|
}
|
|
136
145
|
await state.put(userID, ENTRIES, keyOf(seq), entry)
|
|
137
146
|
if (seq > this.limit) await state.delete(userID, ENTRIES, keyOf(seq - this.limit))
|
|
@@ -144,6 +153,14 @@ export class AgentAudit {
|
|
|
144
153
|
return entry
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
return (await this.deps.concierge?.(userID, sessionID)) === true
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
/** The user's entries, newest first, a page at a time. */
|
|
148
165
|
async list(userID: string, options: AuditListOptions = {}): Promise<AuditPage> {
|
|
149
166
|
const limit = Math.min(Math.max(Math.floor(options.limit ?? AUDIT_PAGE_DEFAULT), 1), AUDIT_PAGE_MAX)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Bounded request bodies for JSON routes (HWA-28 review of HWA-21/24). The server's maxRequestBodySize is ~41 MiB
|
|
2
|
+
// (artifact blob uploads plus drain headroom; Bun 1.3.5 did not even enforce it for a chunked body the handler keeps
|
|
3
|
+
// up with), so every JSON route keeps its own bounds, in bytes AND time:
|
|
4
|
+
// - a declared content-length over the bound is refused without keeping anything (read and discarded within the
|
|
5
|
+
// drain budget below, else unread with Connection: close);
|
|
6
|
+
// - a streamed (chunked) body is kept only up to the bound; the rest is then read and discarded, but at most
|
|
7
|
+
// BODY_LIMITS.drainBytes more within BODY_LIMITS.drainMs. A body that ends within that budget leaves the
|
|
8
|
+
// kept-alive connection clean (cancelling mid-stream made Bun answer the client's NEXT request on it with a bare
|
|
9
|
+
// 400, quirk cmramwb8). One that doesn't (endless, huge or dripping) is cancelled and the answer carries
|
|
10
|
+
// `Connection: close`, so clients and proxies do not reuse the dirty socket;
|
|
11
|
+
// - time is budgeted by RATE, not a fixed deadline: a body may take graceMs + received / minBytesPerSecond
|
|
12
|
+
// (so at most graceMs + max / minBytesPerSecond for the route's byte limit), and no gap between bytes may exceed
|
|
13
|
+
// idleMs. Slow but steady clients succeed (a 1.4 MB body at 20 KiB/s); a drip cannot hold a handler.
|
|
14
|
+
// - the body is read continuously (nothing but the next read is awaited), so Bun never gathers a flood into one
|
|
15
|
+
// huge chunk while JS waits on something else (a full-speed endless body stays at a few MiB of memory).
|
|
16
|
+
// Over the bound answers 400 invalid "The body is larger than N bytes" (extra.limit) before any validation; too slow
|
|
17
|
+
// answers 400 invalid "The body arrived too slowly …" (Connection: close).
|
|
18
|
+
//
|
|
19
|
+
// Limit of the platform (Bun 1.3.5, measured with raw sockets): after a handler answered, Bun keeps reading — and
|
|
20
|
+
// discarding — whatever a client goes on sending on that socket, even with `Connection: close`, a cancelled body or
|
|
21
|
+
// server.timeout(); the handler and its memory are free, but the socket stays open while bytes flow. That holds for
|
|
22
|
+
// every route, including ones that never read a body. Only a front proxy with a request body limit ends it there.
|
|
23
|
+
// Bun 1.4.4 (the pinned runtime) closes the socket after a `Connection: close` answer instead, and enforces the
|
|
24
|
+
// server's maxRequestBodySize on chunked bodies (see its headroom in ../http.ts).
|
|
25
|
+
import { AgentError } from "./errors"
|
|
26
|
+
|
|
27
|
+
/** The default bound of an agent JSON body (messages, launches, grants, workspaces, …). */
|
|
28
|
+
export const AGENT_JSON_BODY_MAX = 1 << 20
|
|
29
|
+
/** Profiles carry versions of up to 512 KiB (PROFILE_LIMITS.versionBytes), JSON-escaped. */
|
|
30
|
+
export const PROFILE_JSON_BODY_MAX = 4 << 20
|
|
31
|
+
|
|
32
|
+
export interface BodyLimits {
|
|
33
|
+
/** At most this many bytes past the bound are read (and discarded) to keep the connection clean. */
|
|
34
|
+
readonly drainBytes: number
|
|
35
|
+
/** …within this long after the bound was crossed. */
|
|
36
|
+
readonly drainMs: number
|
|
37
|
+
/** Time every body gets before the rate counts. */
|
|
38
|
+
readonly graceMs: number
|
|
39
|
+
/** Each byte received earns 1 / minBytesPerSecond seconds more. */
|
|
40
|
+
readonly minBytesPerSecond: number
|
|
41
|
+
/** The longest gap without bytes. */
|
|
42
|
+
readonly idleMs: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const BODY_LIMITS: BodyLimits = { drainBytes: 8 << 20, drainMs: 2_000, graceMs: 30_000, minBytesPerSecond: 8 * 1024, idleMs: 30_000 }
|
|
46
|
+
|
|
47
|
+
/** Where a body's time budget ends (ms, same clock as the inputs): the rate budget or the idle limit, whichever first. */
|
|
48
|
+
export function bodyDeadline(progress: { readonly startedAt: number; readonly lastByteAt: number; readonly received: number }, limits: Pick<BodyLimits, "graceMs" | "minBytesPerSecond" | "idleMs">): number {
|
|
49
|
+
const earned = progress.startedAt + limits.graceMs + (progress.received * 1000) / limits.minBytesPerSecond
|
|
50
|
+
return Math.min(earned, progress.lastByteAt + limits.idleMs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type BoundedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly reason: "too_large" | "timeout"; readonly close: boolean }
|
|
54
|
+
|
|
55
|
+
const TIMEOUT = Symbol("timeout")
|
|
56
|
+
type Read = Awaited<ReturnType<ReadableStreamDefaultReader<Uint8Array>["read"]>>
|
|
57
|
+
|
|
58
|
+
/** reader.read(), or TIMEOUT once `deadline` (performance.now()) has passed. */
|
|
59
|
+
export async function readBefore(reader: ReadableStreamDefaultReader<Uint8Array>, deadline: number): Promise<Read | typeof TIMEOUT> {
|
|
60
|
+
const left = deadline - performance.now()
|
|
61
|
+
if (left <= 0) return TIMEOUT
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([reader.read(), new Promise<typeof TIMEOUT>((resolve) => (timer = setTimeout(() => resolve(TIMEOUT), left)))])
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const isTimeout = (value: unknown): value is typeof TIMEOUT => value === TIMEOUT
|
|
70
|
+
|
|
71
|
+
const cancel = (reader: ReadableStreamDefaultReader<Uint8Array>) => reader.cancel().catch(() => {})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discards what is left of a body within the byte and time budget: true when the body ended (the connection is
|
|
75
|
+
* clean), false when the budget ran out (the reader is cancelled: answer with Connection: close).
|
|
76
|
+
*/
|
|
77
|
+
export async function drainBounded(reader: ReadableStreamDefaultReader<Uint8Array>, limits: Partial<BodyLimits> = {}): Promise<boolean> {
|
|
78
|
+
const { drainBytes, drainMs } = { ...BODY_LIMITS, ...limits }
|
|
79
|
+
const deadline = performance.now() + drainMs
|
|
80
|
+
let drained = 0
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const next = await readBefore(reader, deadline)
|
|
84
|
+
if (next === TIMEOUT) break
|
|
85
|
+
if (next.done) return true
|
|
86
|
+
drained += next.value.byteLength
|
|
87
|
+
if (drained > drainBytes) break
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
return false // the client went away
|
|
91
|
+
}
|
|
92
|
+
await cancel(reader)
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The body's bytes, at most `max` of them, within the byte and time limits (see the file comment). */
|
|
97
|
+
export async function readBounded(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<BoundedBody> {
|
|
98
|
+
const declared = Number(request.headers.get("content-length") ?? "")
|
|
99
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
100
|
+
// Declared too large: nothing is kept. Bun 1.3.5 does not skip unread declared bytes either (a client reusing the
|
|
101
|
+
// socket got 431 for its next request), so a body within the drain budget is read and discarded; a bigger one
|
|
102
|
+
// is refused unread, with Connection: close.
|
|
103
|
+
if (!request.body) return { ok: false, reason: "too_large", close: false }
|
|
104
|
+
const reader = request.body.getReader()
|
|
105
|
+
const { drainBytes } = { ...BODY_LIMITS, ...limits }
|
|
106
|
+
if (declared - max > drainBytes) {
|
|
107
|
+
await cancel(reader)
|
|
108
|
+
return { ok: false, reason: "too_large", close: true }
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, reason: "too_large", close: !(await drainBounded(reader, { ...limits, drainBytes: declared })) }
|
|
111
|
+
}
|
|
112
|
+
if (!request.body) return { ok: true, bytes: new Uint8Array(0) }
|
|
113
|
+
const budget = { ...BODY_LIMITS, ...limits }
|
|
114
|
+
const startedAt = performance.now()
|
|
115
|
+
let lastByteAt = startedAt
|
|
116
|
+
const reader = request.body.getReader()
|
|
117
|
+
const chunks: Uint8Array[] = []
|
|
118
|
+
let size = 0
|
|
119
|
+
for (;;) {
|
|
120
|
+
let next: Read | typeof TIMEOUT
|
|
121
|
+
try {
|
|
122
|
+
next = await readBefore(reader, bodyDeadline({ startedAt, lastByteAt, received: size }, budget))
|
|
123
|
+
} catch {
|
|
124
|
+
return { ok: false, reason: "timeout", close: true } // the client went away mid-body
|
|
125
|
+
}
|
|
126
|
+
if (next === TIMEOUT) {
|
|
127
|
+
await cancel(reader)
|
|
128
|
+
return { ok: false, reason: "timeout", close: true }
|
|
129
|
+
}
|
|
130
|
+
if (next.done) break
|
|
131
|
+
lastByteAt = performance.now()
|
|
132
|
+
size += next.value.byteLength
|
|
133
|
+
// Past the bound (one oversized chunk included): nothing more is kept, this chunk neither.
|
|
134
|
+
if (size > max) return { ok: false, reason: "too_large", close: !(await drainBounded(reader, limits)) }
|
|
135
|
+
chunks.push(next.value)
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, bytes: Buffer.concat(chunks) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Why a body was cut off for time, with the limits in force. */
|
|
141
|
+
export function slowMessage(limits: Partial<BodyLimits> = {}): string {
|
|
142
|
+
const { graceMs, minBytesPerSecond, idleMs } = { ...BODY_LIMITS, ...limits }
|
|
143
|
+
const s = (ms: number) => `${Math.round(ms / 100) / 10} s`
|
|
144
|
+
return `The body arrived too slowly (no bytes for ${s(idleMs)}, or under ${Math.round(minBytesPerSecond / 102.4) / 10} KiB/s after ${s(graceMs)})`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The AgentError for a refused body (Connection: close when the socket is left dirty). */
|
|
148
|
+
export function bodyError(result: Extract<BoundedBody, { ok: false }>, max: number, limits: Partial<BodyLimits> = {}): AgentError {
|
|
149
|
+
if (result.reason === "timeout") return new AgentError("invalid", slowMessage(limits), { close: true })
|
|
150
|
+
return new AgentError("invalid", `The body is larger than ${max} bytes`, { extra: { limit: max }, ...(result.close ? { close: true } : {}) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The body as UTF-8 text of at most `max` bytes ("" without a body); AgentError invalid over the limits. */
|
|
154
|
+
export async function boundedText(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<string> {
|
|
155
|
+
const result = await readBounded(request, max, limits)
|
|
156
|
+
if (!result.ok) throw bodyError(result, max, limits)
|
|
157
|
+
return Buffer.from(result.bytes).toString("utf8")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** JSON.parse of a bounded body (a SyntaxError for invalid JSON, as request.json() threw). */
|
|
161
|
+
export async function boundedJson(request: Request, max = AGENT_JSON_BODY_MAX): Promise<unknown> {
|
|
162
|
+
return JSON.parse(await boundedText(request, max))
|
|
163
|
+
}
|