@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,315 @@
1
+ // The isolated artifact host (HWA-26; contract: Cross-cutting "Trust boundary"; threat model in the Brain ADR and
2
+ // docs/artifact-host.md). Interactive artifacts (ISOLATED_KINDS: html, app) run only here, never in the console
3
+ // bundle and never in central: the hub serves the static bytes it stored and nothing else.
4
+ //
5
+ // GET /artifact-host/v1/<token>/<path> one file of the token's revision (no cookie or bearer used)
6
+ // GET /artifact-host/v1/<token>/…/__hub/bridge.js the first-party bridge client (window.hubArtifact)
7
+ // POST /api/agent/artifacts/:id/revisions/:rev/host {ttlSeconds?} → {capability, base, url, files}
8
+ // DELETE /api/agent/artifact-host/:cap revoke (the minting viewer, or the user)
9
+ //
10
+ // Isolation, out of the box (no new hostname, DNS or TLS): every host response carries
11
+ // `Content-Security-Policy: sandbox allow-scripts; …` and the console frames it with `sandbox="allow-scripts"`
12
+ // (never allow-same-origin), so the document always gets a fresh *opaque* origin, even opened top-level. It can't
13
+ // read the hub's cookies or the console's storage, and the browser treats its requests as cross-site (the hub's
14
+ // SameSite=Lax cookie is not attached). No popups, top navigation, forms, modals or downloads (not allowed by the
15
+ // sandbox), no plugins, workers or nested frames, and no network except this capability's own path prefix
16
+ // (connect/script/style/img/font/media-src are path-scoped to `<origin>/artifact-host/v1/<token>/`). Only the
17
+ // console origins may frame it (frame-ancestors). Bytes are served as declared (never sniffed: nosniff).
18
+ // Authority: the token only (./host-caps.ts), re-checked as the minting viewer on every request, and rate limited
19
+ // per capability. CORS answers `*` without credentials so module scripts load from the opaque origin.
20
+ import { boundedText } from "../agent/body"
21
+ import { type ArtifactKind, ISOLATED_KINDS, type BlobRef } from "../../protocol"
22
+ import type { AgentActor } from "../agent/actor"
23
+ import { AgentError } from "../agent/errors"
24
+ // Types only: ../agent/routes imports the artifact modules (a value import would be circular).
25
+ import type { AgentRoute } from "../agent/routes"
26
+ import type { ArtifactPrincipal } from "./access"
27
+ import { ARTIFACT_HOST_LIMITS, type ArtifactHostCaps } from "./host-caps"
28
+ import type { ArtifactStore } from "./store"
29
+
30
+ export const ARTIFACT_HOST_PATH = "/artifact-host/v1/"
31
+ /** The bridge client, served at this path under any directory of a capability. Bundle files there are shadowed. */
32
+ export const BRIDGE_CLIENT_PATH = "__hub/bridge.js"
33
+ /** Must equal BRIDGE_PROTOCOL / BRIDGE_VERSION in web/src/core/artifact-bridge.ts (a test checks). */
34
+ export const BRIDGE_PROTOCOL = "hub.artifact-bridge"
35
+ export const BRIDGE_VERSION = 1
36
+
37
+ export const ARTIFACT_HOST_LIMITS_HTTP = {
38
+ ...ARTIFACT_HOST_LIMITS,
39
+ /** Host requests one capability may make per minute (per hub process). */
40
+ requestsPerMinute: 600,
41
+ /** Capabilities tracked by the rate limiter before old windows are dropped. */
42
+ trackedCaps: 10_000,
43
+ /** A mint request body. */
44
+ mintBodyBytes: 1024,
45
+ } as const
46
+
47
+ export const isArtifactHostPath = (pathname: string): boolean => pathname === "/artifact-host" || pathname.startsWith("/artifact-host/")
48
+
49
+ const FILE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\/\/)[A-Za-z0-9._/-]{1,200}$/
50
+ const BRIDGE_AT = /^(?:[A-Za-z0-9._-]+\/)*__hub\/bridge\.js$/
51
+ const TOKEN = /^[A-Za-z0-9._-]{1,1024}$/
52
+ const PERMISSIONS =
53
+ "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()"
54
+
55
+ const unique = (values: readonly string[]): string[] => [...new Set(values)]
56
+
57
+ /** The host's Content-Security-Policy: sandboxed, nothing by default, sources only under the capability's path. */
58
+ export function hostCsp(bases: readonly string[], ancestors: readonly string[]): string {
59
+ const b = unique(bases).join(" ")
60
+ return [
61
+ "sandbox allow-scripts",
62
+ "default-src 'none'",
63
+ `script-src ${b} 'unsafe-inline'`,
64
+ `style-src ${b} 'unsafe-inline'`,
65
+ `img-src ${b} data: blob:`,
66
+ `font-src ${b} data:`,
67
+ `media-src ${b} data: blob:`,
68
+ `connect-src ${b}`,
69
+ "frame-src 'none'",
70
+ "worker-src 'none'",
71
+ "object-src 'none'",
72
+ "manifest-src 'none'",
73
+ "form-action 'none'",
74
+ "base-uri 'none'",
75
+ `frame-ancestors ${ancestors.length ? unique(ancestors).join(" ") : "'none'"}`,
76
+ ].join("; ")
77
+ }
78
+
79
+ /** For a response without a capability (errors): nothing at all may load or run. */
80
+ const LOCKED_CSP = "sandbox; default-src 'none'; frame-ancestors 'none'"
81
+
82
+ /**
83
+ * The bridge client the artifact loads from `__hub/bridge.js`: window.hubArtifact. It accepts the init message only
84
+ * from its parent window (source check) on a console origin (origin check), then talks only on that channel to
85
+ * that origin. Replies are matched by request ID. First-party code; the artifact may also speak the protocol itself.
86
+ */
87
+ export function bridgeClientScript(parents: readonly string[]): string {
88
+ return `// Hub artifact bridge client (HWA-26). Protocol ${BRIDGE_PROTOCOL} v${BRIDGE_VERSION}.
89
+ (() => {
90
+ "use strict";
91
+ if (window.hubArtifact) return;
92
+ const P = ${JSON.stringify(BRIDGE_PROTOCOL)}, V = ${BRIDGE_VERSION}, PARENTS = ${JSON.stringify(unique(parents))};
93
+ const up = window.parent;
94
+ let channel, origin, info, seq = 0;
95
+ const pending = new Map(), waiting = [];
96
+ addEventListener("message", (e) => {
97
+ if (up === window || e.source !== up || !PARENTS.includes(e.origin)) return;
98
+ const m = e.data;
99
+ if (!m || typeof m !== "object" || m.p !== P || m.v !== V) return;
100
+ if (m.type === "init") {
101
+ if (channel !== undefined || typeof m.channel !== "string" || !/^[0-9a-f]{32}$/.test(m.channel)) return;
102
+ channel = m.channel;
103
+ origin = e.origin;
104
+ info = Object.freeze({ artifactID: String(m.artifactID), revisionID: String(m.revisionID), files: Object.freeze((Array.isArray(m.files) ? m.files : []).map((f) => Object.freeze({ ...f }))), capabilities: Object.freeze([...(m.capabilities || [])]) });
105
+ for (const resolve of waiting.splice(0)) resolve(info);
106
+ return;
107
+ }
108
+ if (m.channel !== channel || m.type !== "reply" || typeof m.id !== "string") return;
109
+ const p = pending.get(m.id);
110
+ if (!p) return;
111
+ pending.delete(m.id);
112
+ if (m.ok) p.resolve(m.result);
113
+ else p.reject(Object.assign(new Error(String((m.error && m.error.message) || "refused")), { code: m.error && m.error.code }));
114
+ });
115
+ const post = (message) => up.postMessage(Object.assign({ p: P, v: V, channel }, message), origin);
116
+ const ready = () => (info ? Promise.resolve(info) : new Promise((resolve) => waiting.push(resolve)));
117
+ const request = async (type, body) => {
118
+ await ready();
119
+ const id = "r" + ++seq;
120
+ return new Promise((resolve, reject) => {
121
+ pending.set(id, { resolve, reject });
122
+ post(Object.assign({ type, id }, body));
123
+ });
124
+ };
125
+ const resize = async (height) => {
126
+ await ready();
127
+ post({ type: "resize", height: Math.ceil(Number(height) || 0) });
128
+ };
129
+ const autoResize = () => {
130
+ const report = () => resize(document.documentElement.scrollHeight);
131
+ new ResizeObserver(report).observe(document.documentElement);
132
+ report();
133
+ };
134
+ window.hubArtifact = Object.freeze({ version: V, ready, resize, autoResize, askAI: (instruction) => request("askAI", { instruction: String(instruction) }) });
135
+ })();
136
+ `
137
+ }
138
+
139
+ export interface ArtifactHostDeps {
140
+ readonly caps: ArtifactHostCaps
141
+ /** Absent: this hub keeps no artifacts (every host path answers 404). */
142
+ readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision" | "readBlob">
143
+ /** Origins the console is served from: the only frame ancestors and the bridge's accepted parents. */
144
+ readonly consoleOrigins: () => readonly string[]
145
+ /** False when a capability's minting device no longer exists (unpaired): the capability stops working. */
146
+ readonly deviceActive?: (userID: string, deviceID: string) => boolean
147
+ readonly now?: () => number
148
+ }
149
+
150
+ const isolated = (kind: ArtifactKind): boolean => ISOLATED_KINDS.includes(kind)
151
+
152
+ /** The file a host path names in a revision: its bundle path, or "" for the one unnamed file. */
153
+ function fileAt(blobs: readonly BlobRef[], rest: string): BlobRef | undefined {
154
+ if (rest === "") return blobs.length === 1 && blobs[0]!.path === undefined ? blobs[0] : undefined
155
+ return blobs.find((b) => b.path === rest)
156
+ }
157
+
158
+ const contentType = (mediaType: string): string => (mediaType.startsWith("text/") ? `${mediaType}; charset=utf-8` : mediaType)
159
+
160
+ export function artifactHost(deps: ArtifactHostDeps): { handle(request: Request, url: URL): Promise<Response> } {
161
+ const now = deps.now ?? Date.now
162
+ const windows = new Map<string, { start: number; count: number }>()
163
+
164
+ const refuse = (status: number, text: string, extra: Record<string, string> = {}) =>
165
+ new Response(text, {
166
+ status,
167
+ headers: { "content-type": "text/plain; charset=utf-8", "content-security-policy": LOCKED_CSP, "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", "cache-control": "no-store", ...extra },
168
+ })
169
+ const notFound = () => refuse(404, "Not found (the link may have expired or been revoked)")
170
+
171
+ /** False when the capability spent its requests for this minute. */
172
+ const spend = (capID: string): boolean => {
173
+ const t = now()
174
+ let w = windows.get(capID)
175
+ if (!w || t - w.start >= 60_000) {
176
+ if (!w && windows.size >= ARTIFACT_HOST_LIMITS_HTTP.trackedCaps) {
177
+ for (const [id, old] of windows) if (t - old.start >= 60_000) windows.delete(id)
178
+ if (windows.size >= ARTIFACT_HOST_LIMITS_HTTP.trackedCaps) windows.delete(windows.keys().next().value!)
179
+ }
180
+ w = { start: t, count: 0 }
181
+ windows.set(capID, w)
182
+ }
183
+ return ++w.count <= ARTIFACT_HOST_LIMITS_HTTP.requestsPerMinute
184
+ }
185
+
186
+ return {
187
+ async handle(request, url) {
188
+ if (request.method !== "GET" && request.method !== "HEAD") return refuse(405, "Method not allowed", { allow: "GET, HEAD" })
189
+ if (!url.pathname.startsWith(ARTIFACT_HOST_PATH)) return notFound()
190
+ const tail = url.pathname.slice(ARTIFACT_HOST_PATH.length)
191
+ const slash = tail.indexOf("/")
192
+ if (slash <= 0) return notFound()
193
+ const token = tail.slice(0, slash)
194
+ const rest = tail.slice(slash + 1)
195
+ if (!TOKEN.test(token)) return notFound()
196
+ const isBridge = BRIDGE_AT.test(rest)
197
+ if (rest !== "" && !isBridge && !FILE_PATH.test(rest)) return notFound()
198
+ const verified = await deps.caps.verify(token)
199
+ if (!verified || !deps.artifacts) return notFound()
200
+ const { userID, cap } = verified
201
+ if (!spend(cap.id)) return refuse(429, "Too many requests", { "retry-after": "60" })
202
+ if (cap.deviceID !== undefined && deps.deviceActive && !deps.deviceActive(userID, cap.deviceID)) return notFound()
203
+ const origins = deps.consoleOrigins()
204
+ const ancestors = unique([url.origin, ...origins])
205
+ const base = `${ARTIFACT_HOST_PATH}${token}/`
206
+ const headers: Record<string, string> = {
207
+ "content-security-policy": hostCsp(ancestors.map((o) => `${o}${base}`), ancestors),
208
+ "x-content-type-options": "nosniff",
209
+ "referrer-policy": "no-referrer",
210
+ "cache-control": "private, no-store",
211
+ "permissions-policy": PERMISSIONS,
212
+ "access-control-allow-origin": "*",
213
+ "cross-origin-opener-policy": "same-origin",
214
+ }
215
+ try {
216
+ // Re-checked as the minting viewer on every request: a revoked read ends the capability at once.
217
+ const artifact = await deps.artifacts.get(userID, cap.viewer, cap.artifactID)
218
+ if (!isolated(artifact.kind)) return notFound()
219
+ if (isBridge) {
220
+ const body = request.method === "HEAD" ? null : bridgeClientScript(ancestors)
221
+ return new Response(body, { status: 200, headers: { ...headers, "content-type": "text/javascript; charset=utf-8" } })
222
+ }
223
+ const revision = await deps.artifacts.getRevision(userID, cap.viewer, cap.artifactID, cap.revisionID)
224
+ const ref = fileAt(revision.blobs, rest)
225
+ if (!ref) return notFound()
226
+ const { blob } = await deps.artifacts.readBlob(userID, cap.viewer, cap.artifactID, cap.revisionID, ref.hash)
227
+ if (request.method === "HEAD") await blob.body.cancel().catch(() => {})
228
+ return new Response(request.method === "HEAD" ? null : blob.body, {
229
+ status: 200,
230
+ headers: { ...headers, "content-type": contentType(ref.mediaType), "content-length": String(blob.bytes), etag: `"${ref.hash}"` },
231
+ })
232
+ } catch (error) {
233
+ if (error instanceof AgentError && (error.code === "not_found" || error.code === "forbidden")) return notFound()
234
+ throw error
235
+ }
236
+ },
237
+ }
238
+ }
239
+
240
+ // ---- agent routes: mint and revoke -----------------------------------------------------------------------
241
+
242
+ export interface ArtifactHostRouteDeps {
243
+ readonly caps: ArtifactHostCaps
244
+ /** Absent: this hub keeps no artifacts (422 unsupported). */
245
+ readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision">
246
+ }
247
+
248
+ const READ = { session: true, scope: "artifacts:read" } as const
249
+ const NO_STORE = { "cache-control": "no-store" }
250
+ const invalid = (message: string) => new AgentError("invalid", message)
251
+ const principalOf = (actor: AgentActor): ArtifactPrincipal => (actor.kind === "session" ? { kind: "session", sessionID: actor.sessionID } : { kind: "user" })
252
+
253
+ async function mintBody(request: Request): Promise<{ ttlMs?: number }> {
254
+ const max = ARTIFACT_HOST_LIMITS_HTTP.mintBodyBytes
255
+ // Bounded in bytes and time, the drain too (agent/body.ts): "The body is larger than <max> bytes".
256
+ const text = await boundedText(request, max)
257
+ if (text.trim() === "") return {}
258
+ let body: unknown
259
+ try {
260
+ body = JSON.parse(text)
261
+ } catch {
262
+ throw invalid("Invalid JSON")
263
+ }
264
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalid("Expected a JSON object")
265
+ const extra = Object.keys(body).find((k) => k !== "ttlSeconds")
266
+ if (extra !== undefined) throw invalid(`The body has an unknown field ${JSON.stringify(extra.slice(0, 50))}`)
267
+ const ttl = (body as { ttlSeconds?: unknown }).ttlSeconds
268
+ if (ttl === undefined) return {}
269
+ const maxSeconds = ARTIFACT_HOST_LIMITS.maxTtlMs / 1000
270
+ if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl < 1 || ttl > maxSeconds) throw invalid(`ttlSeconds must be a whole number 1–${maxSeconds}`)
271
+ return { ttlMs: ttl * 1000 }
272
+ }
273
+
274
+ export function artifactHostRoutes(deps: ArtifactHostRouteDeps): AgentRoute[] {
275
+ const store = () => {
276
+ if (!deps.artifacts) throw new AgentError("unsupported", "This hub does not store artifacts", { extra: { feature: "artifacts" } })
277
+ return deps.artifacts
278
+ }
279
+ return [
280
+ {
281
+ method: "POST",
282
+ path: "/api/agent/artifacts/:id/revisions/:rev/host",
283
+ allow: READ,
284
+ async handle({ user, actor, params, request }) {
285
+ const artifacts = store()
286
+ const { ttlMs } = await mintBody(request)
287
+ const principal = principalOf(actor)
288
+ const artifact = await artifacts.get(user.id, principal, params.id!)
289
+ if (!isolated(artifact.kind)) throw invalid(`Only ${ISOLATED_KINDS.join(" and ")} artifacts run in the artifact host; ${artifact.kind} renders in the console`)
290
+ const revision = await artifacts.getRevision(user.id, principal, artifact.id, params.rev!)
291
+ const entry = revision.entry ?? ""
292
+ const entryFile = fileAt(revision.blobs, entry)
293
+ if (!entryFile || entryFile.mediaType !== "text/html") throw invalid("The revision's entry must be an HTML file")
294
+ const { token, capability } = await deps.caps.mint(user.id, principal, {
295
+ artifactID: artifact.id,
296
+ revisionID: revision.id,
297
+ ...(ttlMs !== undefined ? { ttlMs } : {}),
298
+ ...(actor.kind === "user" ? { deviceID: actor.deviceID } : {}),
299
+ })
300
+ const base = `${ARTIFACT_HOST_PATH}${token}/`
301
+ const files = revision.blobs.map((b) => ({ ...(b.path !== undefined ? { path: b.path } : {}), mediaType: b.mediaType, bytes: b.bytes }))
302
+ return Response.json({ capability, base, url: `${base}${entry}`, files }, { status: 201, headers: NO_STORE })
303
+ },
304
+ },
305
+ {
306
+ method: "DELETE",
307
+ path: "/api/agent/artifact-host/:cap",
308
+ allow: READ,
309
+ async handle({ user, actor, params }) {
310
+ if (!(await deps.caps.revoke(user.id, principalOf(actor), params.cap!))) throw new AgentError("not_found", "No such artifact view")
311
+ return new Response(null, { status: 204, headers: NO_STORE })
312
+ },
313
+ },
314
+ ]
315
+ }
@@ -0,0 +1,109 @@
1
+ // Idempotency-Key for the artifact creating routes (HWA-21; contract: Cross-cutting rules, Idempotency): per user and
2
+ // per route, IDEMPOTENCY_TTL_MS. The same key and body answer the original result (created false); another body under
3
+ // the same key is 409 conflict. Stored in the StateStore collection "artifact-idempotency":
4
+ // `<route>|<key>` → { v, digest, at, state: "pending" | "done", artifactID?, revisionID? }
5
+ // A key is claimed (create-only write) before the work runs and released when the work fails, so two concurrent
6
+ // requests with one key never both publish: the second sees a pending claim and answers 429 rate_limited
7
+ // (retryAfter). A pending claim older than PENDING_MS belongs to a writer that crashed and is taken over.
8
+ import { createHash } from "node:crypto"
9
+ import { IDEMPOTENCY_KEY, IDEMPOTENCY_TTL_MS } from "../../protocol"
10
+ import { AgentError } from "../agent/errors"
11
+ import type { Clock, StateStore } from "../backend/types"
12
+
13
+ export const ARTIFACT_IDEMPOTENCY = "artifact-idempotency"
14
+ /** How long a claim may stay pending before another request takes it over. */
15
+ export const PENDING_MS = 5 * 60_000
16
+ /** Keys kept per user beyond expiry pruning (oldest dropped first). */
17
+ export const IDEMPOTENCY_KEYS_MAX = 2_000
18
+ const ATTEMPTS = 8
19
+
20
+ export interface IdempotentResult {
21
+ readonly artifactID: string
22
+ readonly revisionID: string
23
+ }
24
+
25
+ interface Claim {
26
+ readonly v: 1
27
+ readonly digest: string
28
+ readonly at: number
29
+ readonly state: "pending" | "done"
30
+ readonly artifactID?: string
31
+ readonly revisionID?: string
32
+ }
33
+
34
+ /** A stable digest of a JSON body (object keys sorted). */
35
+ export function bodyDigest(body: unknown): string {
36
+ return createHash("sha256").update(canonical(body)).digest("hex")
37
+ }
38
+
39
+ function canonical(value: unknown): string {
40
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
41
+ if (value && typeof value === "object") {
42
+ const entries = Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined)
43
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
44
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`
45
+ }
46
+ return JSON.stringify(value) ?? "null"
47
+ }
48
+
49
+ export class ArtifactIdempotency {
50
+ constructor(
51
+ private readonly state: StateStore,
52
+ private readonly now: Clock = Date.now,
53
+ ) {}
54
+
55
+ /**
56
+ * Runs `work` once per (user, route, key, body): a repeat answers `replay(original)`. Without a key the work just
57
+ * runs. `work` must resolve with the IDs that identify its result.
58
+ */
59
+ async run<T>(
60
+ userID: string,
61
+ route: string,
62
+ key: string | null | undefined,
63
+ body: unknown,
64
+ work: () => Promise<{ readonly ids: IdempotentResult; readonly value: T }>,
65
+ replay: (ids: IdempotentResult) => Promise<T>,
66
+ ): Promise<{ value: T; created: boolean }> {
67
+ if (key === null || key === undefined) return { value: (await work()).value, created: true }
68
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
69
+ const digest = bodyDigest(body)
70
+ const id = `${route}|${key}`
71
+ await this.prune(userID)
72
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
73
+ const held = await this.state.get<Claim>(userID, ARTIFACT_IDEMPOTENCY, id)
74
+ const live = held && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
75
+ if (live) {
76
+ if (live.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used with a different body`, { hint: "Use a new key, or resend the original body" })
77
+ if (live.state === "done" && live.artifactID && live.revisionID) return { value: await replay({ artifactID: live.artifactID, revisionID: live.revisionID }), created: false }
78
+ if (this.now() - live.at < PENDING_MS) throw new AgentError("rate_limited", `The request with Idempotency-Key ${key} is still running`, { extra: { retryAfter: 1 } })
79
+ // Its writer crashed: take the key over.
80
+ }
81
+ const claim: Claim = { v: 1, digest, at: this.now(), state: "pending" }
82
+ const claimed = await this.state.put<Claim>(userID, ARTIFACT_IDEMPOTENCY, id, claim, { ifVersion: held?.version ?? 0 })
83
+ if (!claimed.ok) continue
84
+ let result: { ids: IdempotentResult; value: T }
85
+ try {
86
+ result = await work()
87
+ } catch (error) {
88
+ await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, id, { ifVersion: claimed.version }).catch(() => {})
89
+ throw error
90
+ }
91
+ await this.state
92
+ .put<Claim>(userID, ARTIFACT_IDEMPOTENCY, id, { ...claim, state: "done", ...result.ids }, { ifVersion: claimed.version })
93
+ .catch(() => {})
94
+ return { value: result.value, created: true }
95
+ }
96
+ throw new AgentError("rate_limited", `Idempotency-Key ${key} is contended; retry`, { extra: { retryAfter: 1 } })
97
+ }
98
+
99
+ /** Drops expired keys, and the oldest ones past IDEMPOTENCY_KEYS_MAX. */
100
+ private async prune(userID: string): Promise<void> {
101
+ const entries = await this.state.list<Claim>(userID, ARTIFACT_IDEMPOTENCY)
102
+ const cutoff = this.now() - IDEMPOTENCY_TTL_MS
103
+ const live = entries.filter((e) => e.value.at >= cutoff)
104
+ for (const e of entries) if (e.value.at < cutoff) await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, e.key, { ifVersion: e.version })
105
+ if (live.length < IDEMPOTENCY_KEYS_MAX) return
106
+ const oldest = live.filter((e) => e.value.state === "done").sort((a, b) => a.value.at - b.value.at)
107
+ for (const e of oldest.slice(0, live.length - IDEMPOTENCY_KEYS_MAX + 1)) await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, e.key, { ifVersion: e.version })
108
+ }
109
+ }