@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
@@ -0,0 +1,113 @@
1
+ // The BlobStore contract (HWA-20; docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts): artifact
2
+ // bytes, content-addressed by sha256 and immutable, stored per user (the tenant key). Implementations: memory
3
+ // (src/central/backend/memory-blobs.ts, tests and the single-process default), files under the hub data directory
4
+ // (HWA-21) and a chunked Postgres table (HWA-22, decision tctkrik8). Every implementation passes the shared suite in
5
+ // test/blob-store-conformance.ts.
6
+ //
7
+ // Rules every implementation keeps:
8
+ // - Tenancy: a blob belongs to one user. Another user's hash answers like a missing one (no existence oracle), and
9
+ // usage is per user. The same bytes stored by two users are two blobs.
10
+ // - put streams the body: it hashes as it reads, stops reading (and cancels the source) as soon as the body passes
11
+ // `maxBytes` (default and ceiling ARTIFACT_LIMITS.blobBytes), and stores nothing when the body is too large, its
12
+ // hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (never sniffed), or a new blob would
13
+ // take the user past `quotaBytes` (default ARTIFACT_LIMITS.userBlobBytes). Storing bytes that are already there
14
+ // stores nothing new, is never refused for quota, and refreshes `touchedAt`.
15
+ // - A blob becomes visible to get/has/stat/list only once all its bytes are durably stored and verified
16
+ // (write-temp-then-rename, a committed transaction): readers never see a partial blob.
17
+ // - Reference counting lives with the revisions (src/central/artifacts/store.ts), not here: the artifact store
18
+ // decides a blob is unreferenced and then calls delete with `touchedBefore`. delete refuses (false) a blob touched
19
+ // at or after that time, atomically with the removal, so a writer that touched a blob after the deleter read the
20
+ // references keeps it (see the publish protocol in store.ts).
21
+ import type { BlobRef, Page } from "../../protocol"
22
+
23
+ /** A blob body: whole bytes, a web stream, or any async iterable of chunks (read once). */
24
+ export type BlobBody = Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
25
+
26
+ export interface BlobPutOptions {
27
+ /** `sha256:<hex>` the body must hash to; a mismatch stores nothing (BlobHashMismatchError). */
28
+ readonly expectedHash?: string
29
+ /** Largest body accepted (default and ceiling ARTIFACT_LIMITS.blobBytes). */
30
+ readonly maxBytes?: number
31
+ /** The user's total stored bytes a new blob may not exceed (default ARTIFACT_LIMITS.userBlobBytes). */
32
+ readonly quotaBytes?: number
33
+ }
34
+
35
+ /** A stored blob's facts (no media type: that belongs to the revision's BlobRef). */
36
+ export interface BlobInfo {
37
+ readonly hash: string
38
+ readonly bytes: number
39
+ /** When the bytes were first stored. */
40
+ readonly storedAt: number
41
+ /** The latest put or touch of these bytes. */
42
+ readonly touchedAt: number
43
+ }
44
+
45
+ /** A blob being read: its facts and its bytes as a stream (read once). */
46
+ export interface BlobRead extends BlobInfo {
47
+ readonly body: ReadableStream<Uint8Array>
48
+ }
49
+
50
+ export interface BlobUsage {
51
+ readonly blobs: number
52
+ readonly bytes: number
53
+ }
54
+
55
+ export interface BlobDeleteOptions {
56
+ /** Delete only when the blob's touchedAt is before this time (checked atomically with the delete). */
57
+ readonly touchedBefore?: number
58
+ }
59
+
60
+ export interface BlobListOptions {
61
+ /** 1–LIST_LIMIT_MAX (default LIST_LIMIT_DEFAULT). */
62
+ readonly limit?: number
63
+ /** The cursor of the previous page. */
64
+ readonly cursor?: string
65
+ }
66
+
67
+ export interface BlobStore {
68
+ /** Stores the body (see the rules above) and answers its reference with the given media type. */
69
+ put(userID: string, body: BlobBody, mediaType: string, options?: BlobPutOptions): Promise<BlobRef>
70
+ /** The blob's bytes; undefined when missing (or another user's, or a malformed hash). */
71
+ get(userID: string, hash: string): Promise<BlobRead | undefined>
72
+ stat(userID: string, hash: string): Promise<BlobInfo | undefined>
73
+ has(userID: string, hash: string): Promise<boolean>
74
+ /** Refreshes touchedAt; false when the blob is missing. */
75
+ touch(userID: string, hash: string): Promise<boolean>
76
+ /** Removes the blob; false when missing or (touchedBefore) touched at or after that time. */
77
+ delete(userID: string, hash: string, options?: BlobDeleteOptions): Promise<boolean>
78
+ usage(userID: string): Promise<BlobUsage>
79
+ /** The user's blobs by hash, paged (orphan recovery walks it). */
80
+ list(userID: string, options?: BlobListOptions): Promise<Page<BlobInfo>>
81
+ }
82
+
83
+ /** A request the store refuses as malformed: a media type, maxBytes, limit or cursor. Maps to 400 invalid. */
84
+ export class BlobInputError extends Error {
85
+ constructor(message: string) {
86
+ super(message)
87
+ this.name = "BlobInputError"
88
+ }
89
+ }
90
+
91
+ /** The body passed maxBytes; nothing was stored. Maps to 400 invalid. */
92
+ export class BlobTooLargeError extends Error {
93
+ constructor(readonly maxBytes: number) {
94
+ super(`blob is larger than ${maxBytes} bytes`)
95
+ this.name = "BlobTooLargeError"
96
+ }
97
+ }
98
+
99
+ /** The body did not hash to expectedHash; nothing was stored. Maps to 400 invalid. */
100
+ export class BlobHashMismatchError extends Error {
101
+ constructor(readonly expected: string, readonly actual: string) {
102
+ super(`blob hashes to ${actual}, not ${expected}`)
103
+ this.name = "BlobHashMismatchError"
104
+ }
105
+ }
106
+
107
+ /** A new blob would take the user past their quota; nothing was stored. Maps to 429 rate_limited (a user cap). */
108
+ export class BlobQuotaError extends Error {
109
+ constructor(readonly quotaBytes: number, readonly usedBytes: number) {
110
+ super(`blob storage quota of ${quotaBytes} bytes reached`)
111
+ this.name = "BlobQuotaError"
112
+ }
113
+ }
@@ -0,0 +1,98 @@
1
+ // Helpers every BlobStore implementation shares (memory, files HWA-21, Postgres HWA-22): option checks, the
2
+ // bounded hashing body reader, and the list cursor. Keeping them here keeps the implementations alike.
3
+ import { ARTIFACT_LIMITS, ARTIFACT_MEDIA_TYPES, isBlobHash, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX } from "../../protocol"
4
+ import { type BlobBody, BlobHashMismatchError, BlobInputError, type BlobListOptions, type BlobPutOptions, BlobTooLargeError } from "./blob-store"
5
+
6
+ export interface PutLimits {
7
+ readonly maxBytes: number
8
+ readonly quotaBytes: number
9
+ readonly expectedHash?: string
10
+ }
11
+
12
+ /** Checks put's media type and options (BlobInputError) and answers the limits in force. */
13
+ export function putLimits(mediaType: string, options: BlobPutOptions = {}): PutLimits {
14
+ if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
15
+ const maxBytes = options.maxBytes ?? ARTIFACT_LIMITS.blobBytes
16
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > ARTIFACT_LIMITS.blobBytes) {
17
+ throw new BlobInputError(`maxBytes must be 0–${ARTIFACT_LIMITS.blobBytes}`)
18
+ }
19
+ const quotaBytes = options.quotaBytes ?? ARTIFACT_LIMITS.userBlobBytes
20
+ if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 0) throw new BlobInputError("quotaBytes must be a non-negative integer")
21
+ if (options.expectedHash !== undefined && !isBlobHash(options.expectedHash)) throw new BlobInputError("expectedHash must be sha256:<64 hex>")
22
+ return { maxBytes, quotaBytes, ...(options.expectedHash !== undefined ? { expectedHash: options.expectedHash } : {}) }
23
+ }
24
+
25
+ /**
26
+ * Reads the body once, handing each chunk to `sink` as it comes, hashing and counting it. Stops reading and
27
+ * cancels the source as soon as the body passes maxBytes (BlobTooLargeError), and checks expectedHash at the end
28
+ * (BlobHashMismatchError). The caller discards what the sink got when this throws.
29
+ */
30
+ export async function readBlobBody(
31
+ body: BlobBody,
32
+ limits: PutLimits,
33
+ sink: (chunk: Uint8Array) => void | Promise<void>,
34
+ ): Promise<{ hash: string; bytes: number }> {
35
+ const hasher = new Bun.CryptoHasher("sha256")
36
+ let bytes = 0
37
+ const take = async (chunk: Uint8Array): Promise<boolean> => {
38
+ if (!(chunk instanceof Uint8Array)) throw new BlobInputError("blob chunks must be bytes")
39
+ bytes += chunk.byteLength
40
+ if (bytes > limits.maxBytes) return false
41
+ hasher.update(chunk)
42
+ await sink(chunk)
43
+ return true
44
+ }
45
+ if (body instanceof Uint8Array) {
46
+ if (!(await take(body))) throw new BlobTooLargeError(limits.maxBytes)
47
+ } else if (body instanceof ReadableStream) {
48
+ const reader = body.getReader()
49
+ try {
50
+ for (;;) {
51
+ const { done, value } = await reader.read()
52
+ if (done) break
53
+ if (!(await take(value))) {
54
+ await reader.cancel().catch(() => {})
55
+ throw new BlobTooLargeError(limits.maxBytes)
56
+ }
57
+ }
58
+ } finally {
59
+ // Bun 1.3 can throw from releaseLock() on a finished body reader after other I/O (quirk tfxrxuta).
60
+ try {
61
+ reader.releaseLock()
62
+ } catch {
63
+ // best effort
64
+ }
65
+ }
66
+ } else {
67
+ const iterator = body[Symbol.asyncIterator]()
68
+ for (;;) {
69
+ const { done, value } = await iterator.next()
70
+ if (done) break
71
+ if (!(await take(value))) {
72
+ await iterator.return?.()
73
+ throw new BlobTooLargeError(limits.maxBytes)
74
+ }
75
+ }
76
+ }
77
+ const hash = `sha256:${hasher.digest("hex")}`
78
+ if (limits.expectedHash !== undefined && limits.expectedHash !== hash) throw new BlobHashMismatchError(limits.expectedHash, hash)
79
+ return { hash, bytes }
80
+ }
81
+
82
+ /** A list request's limit and the hash after which the page starts (BlobInputError on bad ones). */
83
+ export function listWindow(options: BlobListOptions = {}): { limit: number; after?: string } {
84
+ const limit = options.limit ?? LIST_LIMIT_DEFAULT
85
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new BlobInputError(`limit must be 1–${LIST_LIMIT_MAX}`)
86
+ if (options.cursor === undefined) return { limit }
87
+ let after: string
88
+ try {
89
+ after = Buffer.from(options.cursor, "base64url").toString("utf8")
90
+ } catch {
91
+ throw new BlobInputError("cursor is not valid")
92
+ }
93
+ if (!isBlobHash(after)) throw new BlobInputError("cursor is not valid")
94
+ return { limit, after }
95
+ }
96
+
97
+ /** The cursor that continues a list after `hash`. */
98
+ export const listCursor = (hash: string): string => Buffer.from(hash, "utf8").toString("base64url")
@@ -0,0 +1,27 @@
1
+ // Artifact events for the canonical agent journal (HWA-21; contract: Cross-cutting rules, Events). The store's
2
+ // onEvent hook gives compact events; this maps them to journal input in the `artifact` family (read with
3
+ // artifacts:read). Payloads are IDs, kinds, revisions and counts only: never titles, descriptions or content.
4
+ import type { ArtifactEvent } from "./store"
5
+
6
+ /** Journal input of an artifact event (an EventInput of family "artifact"). */
7
+ export interface ArtifactJournalInput {
8
+ readonly type: ArtifactEvent["type"]
9
+ readonly family: "artifact"
10
+ readonly subject: { readonly artifactID: string }
11
+ readonly data: Readonly<Record<string, string | number>>
12
+ }
13
+
14
+ export function artifactJournalEvent(event: ArtifactEvent): ArtifactJournalInput {
15
+ return {
16
+ type: event.type,
17
+ family: "artifact",
18
+ subject: { artifactID: event.artifactID },
19
+ data: {
20
+ kind: event.kind,
21
+ revision: event.revision,
22
+ ...(event.revisionID !== undefined ? { revisionID: event.revisionID } : {}),
23
+ ...(event.number !== undefined ? { number: event.number } : {}),
24
+ ...(event.state !== undefined ? { state: event.state } : {}),
25
+ },
26
+ }
27
+ }
@@ -0,0 +1,162 @@
1
+ // Capabilities of the isolated artifact host (HWA-26; contract: Cross-cutting "Trust boundary", ADR in Brain).
2
+ //
3
+ // The artifact host serves an html/app revision's files at /artifact-host/v1/<token>/<path> with no ambient
4
+ // credentials: no cookie, no bearer, only the token in the path. A token is
5
+ //
6
+ // ahc1.<kid>.<base64url(JSON {c: capID, u: userID, a: artifactID, r: revisionID, e: expiresAt})>.<base64url(sig)>
7
+ //
8
+ // where sig = HMAC-SHA256(secret of kid, "ahc1.<kid>.<payload>"), signed with the hub's token keys (the "ahc1."
9
+ // prefix separates it from every other token those keys sign). The payload names a record in the per-user
10
+ // collection ARTIFACT_HOST_CAPS, which holds what the token may never assert by itself: the viewer it was minted
11
+ // for (the user or a session, and the device) and whether it was revoked. So a capability is
12
+ // - short-lived: expiresAt is in the signed payload and in the record (min/max TTL below);
13
+ // - single-revision: one artifactID + revisionID, signed;
14
+ // - viewer-bound: the host re-checks the *viewer's* read access to that revision on every request (a session
15
+ // that leaves the workspace, an archived/deleted artifact or a removed device ends it at once);
16
+ // - revocable: revoke() marks the record; a missing record (deleted, pruned, another user's) fails closed.
17
+ // Records live in the StateStore (file-state standalone, Postgres cluster), so every pod verifies every token and
18
+ // they survive restarts. Secrets and tokens are never logged.
19
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
20
+ import { AgentError } from "../agent/errors"
21
+ import type { StateStore } from "../backend/types"
22
+ import type { ArtifactPrincipal } from "./access"
23
+
24
+ export const ARTIFACT_HOST_CAPS = "artifact-host-caps"
25
+
26
+ export const ARTIFACT_HOST_LIMITS = {
27
+ /** A capability's lifetime when the minter names none. */
28
+ defaultTtlMs: 15 * 60_000,
29
+ minTtlMs: 5_000,
30
+ maxTtlMs: 60 * 60_000,
31
+ /** Unexpired capabilities one user may hold (a soft cap: concurrent mints may overshoot by a few). */
32
+ liveCapsPerUser: 64,
33
+ /** Characters of a token (anything longer is refused unparsed). */
34
+ tokenChars: 1024,
35
+ } as const
36
+
37
+ const PREFIX = "ahc1"
38
+ const B64URL = /^[A-Za-z0-9_-]+$/
39
+
40
+ /** The hub's signing keys (TokenKeys implements it). */
41
+ export interface HostSigningKeys {
42
+ active(): { kid: string; secret: Buffer }
43
+ key(kid: string): Buffer | undefined
44
+ }
45
+
46
+ export interface StoredHostCap {
47
+ readonly v: 1
48
+ readonly id: string
49
+ readonly artifactID: string
50
+ readonly revisionID: string
51
+ /** Who minted it: the host reads the revision as this principal. */
52
+ readonly viewer: ArtifactPrincipal
53
+ /** The minting device (console users), re-checked on use when the hub can. */
54
+ readonly deviceID?: string
55
+ readonly createdAt: number
56
+ readonly expiresAt: number
57
+ readonly revokedAt?: number
58
+ }
59
+
60
+ export interface MintInput {
61
+ readonly artifactID: string
62
+ readonly revisionID: string
63
+ readonly ttlMs?: number
64
+ readonly deviceID?: string
65
+ }
66
+
67
+ export interface ArtifactHostCapsOptions {
68
+ readonly state: StateStore
69
+ readonly keys: HostSigningKeys
70
+ readonly now?: () => number
71
+ }
72
+
73
+ interface Payload {
74
+ c: string
75
+ u: string
76
+ a: string
77
+ r: string
78
+ e: number
79
+ }
80
+
81
+ const sign = (secret: Buffer, kid: string, payload: string): string => createHmac("sha256", secret).update(`${PREFIX}.${kid}.${payload}`).digest("base64url")
82
+
83
+ const sameViewer = (a: ArtifactPrincipal, b: ArtifactPrincipal): boolean => a.kind === b.kind && (a.kind === "user" || (b.kind === "session" && a.sessionID === b.sessionID))
84
+
85
+ export class ArtifactHostCaps {
86
+ private readonly state: StateStore
87
+ private readonly keys: HostSigningKeys
88
+ private readonly now: () => number
89
+
90
+ constructor(options: ArtifactHostCapsOptions) {
91
+ this.state = options.state
92
+ this.keys = options.keys
93
+ this.now = options.now ?? Date.now
94
+ }
95
+
96
+ /** A new capability for one revision as `viewer`; prunes the user's expired ones and enforces the live cap. */
97
+ async mint(userID: string, viewer: ArtifactPrincipal, input: MintInput): Promise<{ token: string; capability: { id: string; expiresAt: number } }> {
98
+ const now = this.now()
99
+ let live = 0
100
+ for (const entry of await this.state.list<StoredHostCap>(userID, ARTIFACT_HOST_CAPS)) {
101
+ if (entry.value.expiresAt <= now || entry.value.revokedAt !== undefined) await this.state.delete(userID, ARTIFACT_HOST_CAPS, entry.key)
102
+ else live++
103
+ }
104
+ if (live >= ARTIFACT_HOST_LIMITS.liveCapsPerUser) {
105
+ throw new AgentError("rate_limited", `At most ${ARTIFACT_HOST_LIMITS.liveCapsPerUser} open artifact views at once; close some first`, { extra: { limit: ARTIFACT_HOST_LIMITS.liveCapsPerUser } })
106
+ }
107
+ const ttl = Math.min(ARTIFACT_HOST_LIMITS.maxTtlMs, Math.max(ARTIFACT_HOST_LIMITS.minTtlMs, input.ttlMs ?? ARTIFACT_HOST_LIMITS.defaultTtlMs))
108
+ const id = `ahc_${randomBytes(12).toString("base64url")}`
109
+ const cap: StoredHostCap = {
110
+ v: 1,
111
+ id,
112
+ artifactID: input.artifactID,
113
+ revisionID: input.revisionID,
114
+ viewer: viewer.kind === "user" ? { kind: "user" } : { kind: "session", sessionID: viewer.sessionID },
115
+ ...(input.deviceID !== undefined ? { deviceID: input.deviceID } : {}),
116
+ createdAt: now,
117
+ expiresAt: now + ttl,
118
+ }
119
+ await this.state.put(userID, ARTIFACT_HOST_CAPS, id, cap)
120
+ const { kid, secret } = this.keys.active()
121
+ const payload = Buffer.from(JSON.stringify({ c: id, u: userID, a: cap.artifactID, r: cap.revisionID, e: cap.expiresAt } satisfies Payload)).toString("base64url")
122
+ return { token: `${PREFIX}.${kid}.${payload}.${sign(secret, kid, payload)}`, capability: { id, expiresAt: cap.expiresAt } }
123
+ }
124
+
125
+ /** The user and record of a valid token; undefined for anything else (malformed, forged, expired, revoked). */
126
+ async verify(token: string): Promise<{ userID: string; cap: StoredHostCap } | undefined> {
127
+ if (typeof token !== "string" || token.length > ARTIFACT_HOST_LIMITS.tokenChars) return undefined
128
+ const parts = token.split(".")
129
+ if (parts.length !== 4) return undefined
130
+ const [prefix, kid, payload, sig] = parts as [string, string, string, string]
131
+ if (prefix !== PREFIX || !B64URL.test(kid) || !B64URL.test(payload) || !B64URL.test(sig)) return undefined
132
+ const secret = this.keys.key(kid)
133
+ if (!secret) return undefined
134
+ const expected = Buffer.from(sign(secret, kid, payload))
135
+ const given = Buffer.from(sig)
136
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return undefined
137
+ let body: Payload
138
+ try {
139
+ body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Payload
140
+ } catch {
141
+ return undefined
142
+ }
143
+ if (typeof body?.c !== "string" || typeof body.u !== "string" || typeof body.a !== "string" || typeof body.r !== "string" || typeof body.e !== "number") return undefined
144
+ const now = this.now()
145
+ if (body.e <= now) return undefined
146
+ const stored = await this.state.get<StoredHostCap>(body.u, ARTIFACT_HOST_CAPS, body.c)
147
+ const cap = stored?.value
148
+ if (!cap || cap.id !== body.c || cap.artifactID !== body.a || cap.revisionID !== body.r || cap.expiresAt !== body.e) return undefined
149
+ if (cap.revokedAt !== undefined || cap.expiresAt <= now) return undefined
150
+ return { userID: body.u, cap }
151
+ }
152
+
153
+ /** Revokes one capability of `userID`: the user may revoke any, a session only its own. False when none. */
154
+ async revoke(userID: string, viewer: ArtifactPrincipal, id: string): Promise<boolean> {
155
+ if (typeof id !== "string" || !/^ahc_[A-Za-z0-9_-]{16}$/.test(id)) return false
156
+ const stored = await this.state.get<StoredHostCap>(userID, ARTIFACT_HOST_CAPS, id)
157
+ if (!stored || stored.value.revokedAt !== undefined) return false
158
+ if (viewer.kind === "session" && !sameViewer(stored.value.viewer, viewer)) return false
159
+ await this.state.put(userID, ARTIFACT_HOST_CAPS, id, { ...stored.value, revokedAt: this.now() })
160
+ return true
161
+ }
162
+ }