@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.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. 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
- const result = await plan.run(grant)
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 grant that covers the request, after every never rule; NeedsUser when none does. */
489
- private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean): Grant {
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") return grant
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.json()))
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
+ }