@huynle/hub 0.8.6 → 0.9.0

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 (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,81 @@
1
+ // (Session media §3, item 4b) The hub's side of the spoke op previewBundle (packages/protocol/src/preview-bundle.ts):
2
+ // the manifest first, then every file the spoke did not inline, chunk by chunk, each checked against the manifest
3
+ // (sizes, SHA-256) before the preview is stored. A bundle that changes on the spoke while it is read (`stale`) is read
4
+ // again from the start, a few times at most.
5
+ import { createHash } from "node:crypto"
6
+ import { PREVIEW_BUNDLE_LIMITS, previewBundleResultProblem, type PreviewBundleResult, type PreviewBundleSkip } from "../../protocol"
7
+ import type { PreviewBundleContent } from "./store"
8
+
9
+ /** One previewBundle request of the page (its message and path are the caller's): the manifest, or a chunk. */
10
+ export type BundleAsk = (chunk?: { readonly bundle: string; readonly file: number; readonly offset: number }) => Promise<PreviewBundleResult>
11
+
12
+ export type FetchedBundle =
13
+ | { readonly bundle: PreviewBundleContent; readonly skipped: readonly PreviewBundleSkip[]; readonly skippedMore?: number }
14
+ | { readonly error: "not_found" | "hidden" | "refused" | "changed" }
15
+ | { readonly error: "too_large"; readonly bytes: number; readonly max: number }
16
+
17
+ /** A spoke answer that breaks the protocol (the route answers 502). */
18
+ export class BundleProtocolError extends Error {}
19
+
20
+ /** Reads of a bundle that keeps changing before the hub gives up ("changed"). */
21
+ const ATTEMPTS = 3
22
+
23
+ const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex")
24
+
25
+ function checked(result: unknown): PreviewBundleResult {
26
+ const problem = previewBundleResultProblem(result)
27
+ if (problem) throw new BundleProtocolError(`The spoke's preview bundle is malformed: ${problem}`)
28
+ return result as PreviewBundleResult
29
+ }
30
+
31
+ /** The page's bundle from the spoke (see the file's header). */
32
+ export async function fetchPreviewBundle(ask: BundleAsk): Promise<FetchedBundle> {
33
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
34
+ const first = checked(await ask())
35
+ if (first.refused === "hidden") return { error: "hidden" }
36
+ if (first.refused !== undefined) return { error: "refused" }
37
+ if (first.tooLarge) return { error: "too_large", bytes: first.tooLarge.bytes, max: first.tooLarge.max }
38
+ if (first.bundle === undefined || first.files === undefined || first.entry === undefined) {
39
+ if (first.chunk !== undefined || first.stale !== undefined) throw new BundleProtocolError("The spoke answered a manifest request with a chunk")
40
+ return { error: "not_found" }
41
+ }
42
+ const total = first.files.reduce((n, f) => n + f.bytes, 0)
43
+ if (total > PREVIEW_BUNDLE_LIMITS.totalBytes) throw new BundleProtocolError("The spoke's preview bundle is over its total size cap")
44
+ const files: Array<{ path: string; mediaType: string; body: Uint8Array }> = []
45
+ let stale = false
46
+ for (const [index, file] of first.files.entries()) {
47
+ let body: Uint8Array
48
+ if (file.data !== undefined) body = Uint8Array.from(Buffer.from(file.data, "base64"))
49
+ else {
50
+ const read = await readChunks(ask, first.bundle, index, file.bytes)
51
+ if (read === "stale") {
52
+ stale = true
53
+ break
54
+ }
55
+ body = read
56
+ }
57
+ if (body.length !== file.bytes || sha256(body) !== file.sha256) throw new BundleProtocolError(`The spoke's file ${file.path} does not match its manifest`)
58
+ files.push({ path: file.path, mediaType: file.mediaType, body })
59
+ }
60
+ if (stale) continue
61
+ return { bundle: { entry: first.entry, files }, skipped: first.skipped ?? [], ...(first.skippedMore ? { skippedMore: first.skippedMore } : {}) }
62
+ }
63
+ return { error: "changed" }
64
+ }
65
+
66
+ /** A file's bytes in chunks from offset 0 (none for an empty file), or "stale". */
67
+ async function readChunks(ask: BundleAsk, bundle: string, file: number, bytes: number): Promise<Uint8Array | "stale"> {
68
+ const out = new Uint8Array(bytes)
69
+ let offset = 0
70
+ while (offset < bytes) {
71
+ const answer = checked(await ask({ bundle, file, offset }))
72
+ if (answer.stale) return "stale"
73
+ const chunk = answer.chunk
74
+ if (!chunk || chunk.offset !== offset) throw new BundleProtocolError("The spoke answered a chunk request without that chunk")
75
+ const data = Buffer.from(chunk.data, "base64")
76
+ if (data.length === 0 || offset + data.length > bytes || chunk.last !== (offset + data.length === bytes)) throw new BundleProtocolError("The spoke's chunk does not fit its file")
77
+ out.set(data, offset)
78
+ offset += data.length
79
+ }
80
+ return out
81
+ }
@@ -0,0 +1,94 @@
1
+ // HTML previews of code blocks in session messages (session media §3, item 4a). The hub reads the message itself,
2
+ // from the session's spoke (the timeline op, newest-first pages), and never takes content from the console:
3
+ // - messageTexts: the text of one message's assistant text parts, in order (what the console renders as Markdown);
4
+ // - htmlBlocks: the fenced ```html / ```htm blocks of one text, parsed by the console's own Markdown parser
5
+ // (packages/protocol/src/markdown.ts, which web/src/lib/markdown.ts re-exports).
6
+ // Block `n` counts html blocks across the message's text parts in order. The store keeps the block as a preview
7
+ // artifact (ArtifactStore.savePreview).
8
+ import type { TimelinePage } from "../../protocol"
9
+ import { type MdBlock, parseMarkdown } from "../../../packages/protocol/src/markdown.ts"
10
+
11
+ export const PREVIEW_LIMITS = {
12
+ /** Bytes of one previewed HTML block (413 above). */
13
+ htmlBytes: 2 * 1024 * 1024,
14
+ /**
15
+ * Timeline rows per page read (small, so a page of long replies stays under the spoke frame limit), and pages
16
+ * read before giving up on finding the message.
17
+ */
18
+ pageRows: 50,
19
+ pages: 40,
20
+ } as const
21
+
22
+ const HTML_LANGS = new Set(["html", "htm"])
23
+
24
+ /**
25
+ * The fenced html/htm blocks of one text in document order (depth first through quotes and lists), parsed by the
26
+ * console's own Markdown parser: exactly the code blocks the console shows, with the same contents. The whole text
27
+ * is parsed (block numbers count from its start, so it cannot stop early): that parser is linear-time on adversarial
28
+ * text (test/web-markdown-perf.test.ts) and keeps blocks nested deeper than its MAX_BLOCK_DEPTH flat, as the console
29
+ * does.
30
+ */
31
+ export function htmlBlocks(text: string): string[] {
32
+ const out: string[] = []
33
+ const walk = (blocks: readonly MdBlock[]): void => {
34
+ for (const b of blocks) {
35
+ if (b.type === "code" && HTML_LANGS.has(b.lang.toLowerCase())) out.push(b.text)
36
+ else if (b.type === "quote") walk(b.blocks)
37
+ else if (b.type === "list") for (const item of b.items) walk(item.blocks)
38
+ }
39
+ }
40
+ walk(parseMarkdown(text))
41
+ return out
42
+ }
43
+
44
+ /** A newest-first timeline page request (op "timeline", order desc). */
45
+ export type TimelineReader = (page: { readonly limit: number; readonly before?: string }) => Promise<TimelinePage>
46
+
47
+ const record = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {})
48
+
49
+ /**
50
+ * The texts of the message's assistant text rows in order. Pages are read newest first until the message's rows
51
+ * are complete, at most PREVIEW_LIMITS.pages of them: a message older than that is "too_old" (not looked up, so a
52
+ * Preview never makes the spoke read a whole long history), one missing from the whole timeline "not_found".
53
+ */
54
+ export async function messageTexts(read: TimelineReader, messageID: string): Promise<{ texts: string[] } | { error: "not_found" | "too_old" }> {
55
+ const rows: Array<{ text: string; order: number }> = []
56
+ let found = false
57
+ let exhausted = false
58
+ let complete = false
59
+ let before: string | undefined
60
+ for (let page = 0; page < PREVIEW_LIMITS.pages; page++) {
61
+ const result = await read({ limit: PREVIEW_LIMITS.pageRows, ...(before !== undefined ? { before } : {}) })
62
+ const items = Array.isArray(result?.items) ? result.items.map(record) : []
63
+ let mine = 0
64
+ items.forEach((item, index) => {
65
+ if (item.messageID !== messageID || item.kind === "message") return
66
+ mine++
67
+ if (item.kind === "assistant" && typeof item.text === "string") rows.push({ text: item.text, order: typeof item.pos === "number" ? item.pos : page * -1e6 + index })
68
+ })
69
+ if (mine > 0) found = true
70
+ // The message's rows are contiguous: done once a page holds rows before them, or there is nothing older.
71
+ const first = items[0]
72
+ if (typeof result?.older !== "string" || !result.older) {
73
+ exhausted = true
74
+ break
75
+ }
76
+ if (found && first?.messageID !== messageID) {
77
+ complete = true
78
+ break
79
+ }
80
+ before = result.older
81
+ }
82
+ if (!found) return { error: exhausted ? "not_found" : "too_old" }
83
+ // The cap cut the message's own rows: its block numbering would be wrong.
84
+ if (!complete && !exhausted) return { error: "too_old" }
85
+ return { texts: rows.sort((a, b) => a.order - b.order).map((r) => r.text) }
86
+ }
87
+
88
+ /** The `block`-th html block across the texts: its bytes, or why not (no such block, larger than `maxBytes`). */
89
+ export function pickHtmlBlock(texts: readonly string[], block: number, maxBytes: number = PREVIEW_LIMITS.htmlBytes): { html: Uint8Array } | { error: "not_found" | "too_large" } {
90
+ const html = texts.flatMap(htmlBlocks)[block]
91
+ if (html === undefined) return { error: "not_found" }
92
+ const bytes = new TextEncoder().encode(html)
93
+ return bytes.byteLength > maxBytes ? { error: "too_large" } : { html: bytes }
94
+ }
@@ -1,7 +1,7 @@
1
1
  // The artifact transfer API (HWA-21; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts),
2
2
  // under /api/agent with the agent error envelope:
3
3
  //
4
- // GET /artifacts the caller's library (limit, cursor, state, workspace)
4
+ // GET /artifacts the caller's library (limit, cursor, state, workspace, origin)
5
5
  // POST /artifacts create with the first revision (Idempotency-Key)
6
6
  // POST /artifacts/blobs stage raw bytes; answers a BlobRef to reference
7
7
  // GET /artifacts/:id one artifact
@@ -9,6 +9,7 @@
9
9
  // DELETE /artifacts/:id irreversible {expectedRevision}
10
10
  // PUT /artifacts/:id/visibility {expectedRevision, visibility, workspaceID?}
11
11
  // POST /artifacts/:id/archive | /unarchive {expectedRevision}
12
+ // POST /artifacts/:id/keep {expectedRevision}: a preview becomes a normal artifact
12
13
  // GET /artifacts/:id/revisions newest first (limit, cursor)
13
14
  // POST /artifacts/:id/revisions {baseRevisionID, blobs|upload, entry?} (Idempotency-Key)
14
15
  // GET /artifacts/:id/revisions/:rev one revision
@@ -227,11 +228,15 @@ export function artifactRoutes(deps: ArtifactRouteDeps): AgentRoute[] {
227
228
  const limit = intParam(url, "limit")
228
229
  const cursor = url.searchParams.get("cursor") ?? undefined
229
230
  const workspaceID = url.searchParams.get("workspace") ?? undefined
231
+ // Previews (session media §3) are listed only when asked for: origin=preview (only them) or origin=all.
232
+ const origin = url.searchParams.get("origin") ?? undefined
233
+ if (origin !== undefined && origin !== "preview" && origin !== "all") throw invalid("origin must be preview or all")
230
234
  const page = await store().list(user.id, principalOf(actor), {
231
235
  ...(limit !== undefined ? { limit } : {}),
232
236
  ...(cursor ? { cursor } : {}),
233
237
  ...(state ? { state: state as RecordState } : {}),
234
238
  ...(workspaceID ? { workspaceID } : {}),
239
+ ...(origin ? { origin: origin as "preview" | "all" } : {}),
235
240
  })
236
241
  return json(page)
237
242
  }),
@@ -318,6 +323,12 @@ export function artifactRoutes(deps: ArtifactRouteDeps): AgentRoute[] {
318
323
  }),
319
324
  ),
320
325
 
326
+ route("POST", `${P}/:id/keep`, MANAGE, async ({ user, actor, params, request }) => {
327
+ const body = await readJson(request, 4096)
328
+ onlyKeys(body, new Set(["expectedRevision"]), "The body")
329
+ return json({ artifact: await store().keep(user.id, principalOf(actor), params.id!, expectedRevision(body)) })
330
+ }),
331
+
321
332
  route("GET", `${P}/:id/revisions`, READ, async ({ user, actor, params, url }) => {
322
333
  const limit = intParam(url, "limit")
323
334
  const cursor = url.searchParams.get("cursor") ?? undefined
@@ -30,8 +30,10 @@
30
30
  //
31
31
  // Lifecycle: archive is reversible and keeps everything; delete is irreversible (tombstone first, so a crash half
32
32
  // way is finished by recover()); archived artifacts are purged only when `archivedRetentionMs` is set (default:
33
- // kept until deleted).
34
- import { randomBytes } from "node:crypto"
33
+ // kept until deleted). Previews (origin "preview", session media §3) have their own retention in purgeArchived:
34
+ // archived `previewArchiveAfterMs` after their last view (viewedAt, set when a host capability is minted) or
35
+ // creation, purged `previewPurgeAfterMs` after that. Keep clears the origin, which ends it.
36
+ import { createHash, randomBytes } from "node:crypto"
35
37
  import {
36
38
  ARTIFACT_LIMITS,
37
39
  ARTIFACT_MEDIA_TYPES,
@@ -41,10 +43,13 @@ import {
41
43
  type IdKind,
42
44
  isContractID,
43
45
  isExpectedRevision,
46
+ provenanceProblem,
44
47
  LIST_LIMIT_DEFAULT,
45
48
  LIST_LIMIT_MAX,
46
49
  type Artifact,
47
50
  type ArtifactKind,
51
+ type ArtifactOrigin,
52
+ type ArtifactProvenance,
48
53
  type ArtifactRevision,
49
54
  type ArtifactVisibility,
50
55
  type BlobRef,
@@ -64,6 +69,11 @@ export const UPLOADS = "artifact-uploads"
64
69
  export const revisionsCollection = (artifactID: string): string => `artifact-revisions.${artifactID}`
65
70
  /** How long an upload may run, and an unreferenced blob stays, before recovery treats it as abandoned. */
66
71
  export const ARTIFACT_GRACE_MS = 15 * 60_000
72
+ /** Preview retention defaults: archived this long after the last view (or creation), then purged this long later. */
73
+ export const PREVIEW_ARCHIVE_AFTER_MS = 24 * 60 * 60_000
74
+ export const PREVIEW_PURGE_AFTER_MS = 7 * 24 * 60 * 60_000
75
+ /** The single file of a preview revision. */
76
+ export const PREVIEW_ENTRY = "index.html"
67
77
  /** CAS attempts on the artifact record when unrelated changes race a commit. */
68
78
  const ATTEMPTS = 16
69
79
 
@@ -98,8 +108,12 @@ export interface ArtifactStoreOptions {
98
108
  /** Tighter limits than ARTIFACT_LIMITS (never looser for blobBytes). */
99
109
  readonly limits?: Partial<ArtifactLimits>
100
110
  readonly graceMs?: number
101
- /** Purge archived artifacts this long after archiving (default: never). */
111
+ /** Purge archived artifacts this long after archiving (default: never). Previews follow their own retention. */
102
112
  readonly archivedRetentionMs?: number
113
+ /** Archive a preview this long after its last view, or its creation when never viewed (default 24 h). */
114
+ readonly previewArchiveAfterMs?: number
115
+ /** Purge an archived preview this long after it was archived (default 7 days). */
116
+ readonly previewPurgeAfterMs?: number
103
117
  readonly onEvent?: (userID: string, event: ArtifactEvent) => void
104
118
  readonly newID?: (kind: IdKind) => string
105
119
  }
@@ -123,6 +137,9 @@ export interface ArtifactInput {
123
137
  readonly workspaceID?: string
124
138
  readonly tags?: readonly string[]
125
139
  readonly metadata?: Readonly<Record<string, string>>
140
+ /** Internal (savePreview): the routes never pass these. */
141
+ readonly origin?: ArtifactOrigin
142
+ readonly provenance?: ArtifactProvenance
126
143
  }
127
144
 
128
145
  export interface ArtifactUpdate {
@@ -139,6 +156,32 @@ export interface ArtifactListOptions {
139
156
  /** Default "active". */
140
157
  readonly state?: RecordState
141
158
  readonly workspaceID?: string
159
+ /** Default: normal artifacts only. "preview": previews only; "all": both. */
160
+ readonly origin?: ArtifactOrigin | "all"
161
+ }
162
+
163
+ /** The files of a preview revision and its entry (one of their paths). */
164
+ export interface PreviewBundleContent {
165
+ readonly entry: string
166
+ readonly files: ReadonlyArray<{ readonly path: string; readonly mediaType: string; readonly body: Uint8Array }>
167
+ }
168
+
169
+ /** A stored copy of a provenance: only its known fields. */
170
+ const provenanceCopy = (p: ArtifactProvenance): ArtifactProvenance => ({
171
+ sessionID: p.sessionID,
172
+ messageID: p.messageID,
173
+ ...(p.block !== undefined ? { block: p.block } : {}),
174
+ ...(p.path !== undefined ? { path: p.path } : {}),
175
+ })
176
+
177
+ /** "Preview: <file name>" for a page path, cut to the title limit. */
178
+ const previewTitle = (path: string): string => `Preview: ${path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path}`.slice(0, 120)
179
+
180
+ export interface SavedPreview {
181
+ readonly artifact: Artifact
182
+ readonly revision: ArtifactRevision
183
+ /** The content was unchanged: the latest revision was reused. */
184
+ readonly reused: boolean
142
185
  }
143
186
 
144
187
  export interface RecoveryReport {
@@ -154,6 +197,8 @@ interface StoredArtifact {
154
197
  readonly head: number
155
198
  readonly archivedAt?: number
156
199
  readonly deletingAt?: number
200
+ /** Previews: the last time a host capability was minted for it (retention counts from it). */
201
+ readonly viewedAt?: number
157
202
  }
158
203
 
159
204
  interface Holder {
@@ -215,6 +260,10 @@ export class ArtifactStore {
215
260
  private readonly limits: ArtifactLimits
216
261
  private readonly graceMs: number
217
262
  private readonly archivedRetentionMs: number | undefined
263
+ private readonly previewArchiveAfterMs: number
264
+ private readonly previewPurgeAfterMs: number
265
+ /** savePreview calls in flight per user and block (one process): concurrent presses share one artifact. */
266
+ private readonly previewsInFlight = new Map<string, Promise<SavedPreview>>()
218
267
  private readonly onEvent: ((userID: string, event: ArtifactEvent) => void) | undefined
219
268
  private readonly newID: (kind: IdKind) => string
220
269
 
@@ -228,6 +277,8 @@ export class ArtifactStore {
228
277
  if (this.limits.blobBytes > ARTIFACT_LIMITS.blobBytes) throw new Error("limits.blobBytes exceeds ARTIFACT_LIMITS.blobBytes")
229
278
  this.graceMs = options.graceMs ?? ARTIFACT_GRACE_MS
230
279
  this.archivedRetentionMs = options.archivedRetentionMs
280
+ this.previewArchiveAfterMs = options.previewArchiveAfterMs ?? PREVIEW_ARCHIVE_AFTER_MS
281
+ this.previewPurgeAfterMs = options.previewPurgeAfterMs ?? PREVIEW_PURGE_AFTER_MS
231
282
  this.onEvent = options.onEvent
232
283
  this.newID = options.newID ?? randomID
233
284
  }
@@ -252,6 +303,7 @@ export class ArtifactStore {
252
303
  .map((entry) => entry.value)
253
304
  .filter((s) => s.deletingAt === undefined && s.artifact.state === state)
254
305
  .filter((s) => options.workspaceID === undefined || s.artifact.workspaceID === options.workspaceID)
306
+ .filter((s) => options.origin === "all" || s.artifact.origin === options.origin)
255
307
  .map((s) => s.artifact)
256
308
  .sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
257
309
  .filter((a) => !after || a.updatedAt < after.updatedAt || (a.updatedAt === after.updatedAt && a.id > after.id))
@@ -332,6 +384,8 @@ export class ArtifactStore {
332
384
  latestRevisionID: rev.id,
333
385
  ...(input.tags !== undefined ? { tags: [...input.tags] } : {}),
334
386
  ...(input.metadata !== undefined ? { metadata: { ...input.metadata } } : {}),
387
+ ...(input.origin !== undefined ? { origin: input.origin } : {}),
388
+ ...(input.provenance !== undefined ? { provenance: provenanceCopy(input.provenance) } : {}),
335
389
  createdAt: at,
336
390
  updatedAt: at,
337
391
  }
@@ -428,14 +482,20 @@ export class ArtifactStore {
428
482
  await this.finishDelete(userID, id)
429
483
  }
430
484
 
431
- /** Purges archived artifacts older than archivedRetentionMs (none when unset); resolves with their IDs. */
485
+ /**
486
+ * Retention, for one user: archives previews not viewed for previewArchiveAfterMs, then purges archived previews
487
+ * older than previewPurgeAfterMs and other archived artifacts older than archivedRetentionMs (never when unset);
488
+ * resolves with the purged IDs.
489
+ */
432
490
  async purgeArchived(userID: string): Promise<string[]> {
433
- if (this.archivedRetentionMs === undefined) return []
434
- const cutoff = this.now() - this.archivedRetentionMs
491
+ await this.archiveStalePreviews(userID)
492
+ const now = this.now()
493
+ const retention = (a: Artifact): number | undefined => (a.origin === "preview" ? this.previewPurgeAfterMs : this.archivedRetentionMs)
435
494
  const purged: string[] = []
436
495
  for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
437
496
  const s = entry.value
438
- if (s.deletingAt !== undefined || s.artifact.state !== "archived" || s.archivedAt === undefined || s.archivedAt > cutoff) continue
497
+ const keep = retention(s.artifact)
498
+ if (keep === undefined || s.deletingAt !== undefined || s.artifact.state !== "archived" || s.archivedAt === undefined || s.archivedAt > now - keep) continue
439
499
  const marked = await this.state.put<StoredArtifact>(userID, ARTIFACTS, entry.key, { ...s, deletingAt: this.now() }, { ifVersion: entry.version })
440
500
  if (!marked.ok) continue
441
501
  await this.finishDelete(userID, entry.key)
@@ -444,6 +504,134 @@ export class ArtifactStore {
444
504
  return purged
445
505
  }
446
506
 
507
+ // ---- previews (session media §3) ------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Stores an HTML preview for the user: a private html artifact with origin "preview" and its source as provenance.
511
+ * A code block (`block`) is one index.html file; a local page (`path`, session media §3 4b) is its bundle (files and
512
+ * entry). The same source again reuses the latest revision when the content is unchanged (the same files with the
513
+ * same hashes and types, in any order, and the same entry) and adds a revision otherwise; an archived preview is
514
+ * restored. A preview counts as viewed.
515
+ */
516
+ savePreview(userID: string, source: ArtifactProvenance, content: string | Uint8Array | PreviewBundleContent, options: { readonly title?: string } = {}): Promise<SavedPreview> {
517
+ const key = JSON.stringify([userID, source.sessionID, source.messageID, source.block ?? null, source.path ?? null])
518
+ const running = this.previewsInFlight.get(key)
519
+ const bundle: PreviewBundleContent =
520
+ typeof content === "string" || content instanceof Uint8Array
521
+ ? { entry: PREVIEW_ENTRY, files: [{ path: PREVIEW_ENTRY, mediaType: "text/html", body: typeof content === "string" ? new TextEncoder().encode(content) : content }] }
522
+ : content
523
+ const run = (running ?? Promise.resolve())
524
+ .catch(() => {})
525
+ .then(() => this.storePreview(userID, source, bundle, options.title))
526
+ const tracked = run.finally(() => {
527
+ if (this.previewsInFlight.get(key) === tracked) this.previewsInFlight.delete(key)
528
+ })
529
+ this.previewsInFlight.set(key, tracked)
530
+ return tracked
531
+ }
532
+
533
+ /**
534
+ * Records a view of a preview (a host capability was minted for it): its retention restarts, and an archived
535
+ * preview is restored, as pressing Preview again does. Anything else is left alone.
536
+ */
537
+ async markViewed(userID: string, artifactID: string): Promise<void> {
538
+ const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, artifactID)
539
+ if (!entry || entry.value.deletingAt !== undefined || entry.value.artifact.origin !== "preview") return
540
+ await this.mutateStored(userID, artifactID, (s) => this.viewed(s), (s) => s.artifact.state === "archived").catch((error: unknown) => {
541
+ if (!(error instanceof AgentError && error.code === "not_found")) throw error
542
+ })
543
+ }
544
+
545
+ /** Keep: a preview becomes a normal artifact (origin cleared; provenance stays). A normal artifact is unchanged. */
546
+ async keep(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
547
+ const { stored } = await this.load(userID, principal, id, "manage")
548
+ if (stored.artifact.origin === undefined) {
549
+ // Nothing to change, but a stale expectedRevision is a conflict as for every other change.
550
+ if (!isExpectedRevision(expectedRevision)) throw invalid("expectedRevision must be a non-negative integer")
551
+ if (stored.artifact.revision !== expectedRevision) throw conflict("expectedRevision is not the current revision", stored)
552
+ return stored.artifact
553
+ }
554
+ return this.mutate(userID, principal, id, expectedRevision, async (s) => {
555
+ const { origin: _, ...artifact } = s.artifact
556
+ const { viewedAt: _v, ...rest } = s
557
+ return { ...rest, artifact: artifact as Artifact }
558
+ })
559
+ }
560
+
561
+ private async storePreview(userID: string, source: ArtifactProvenance, bundle: PreviewBundleContent, title: string | undefined): Promise<SavedPreview> {
562
+ const defaultTitle = source.path !== undefined ? previewTitle(source.path) : `HTML preview ${(source.block ?? 0) + 1}`
563
+ const problem = provenanceProblem(source) ?? artifactInputProblem({ title: title ?? defaultTitle, kind: "html", origin: "preview", provenance: source })
564
+ if (problem) throw invalid(problem)
565
+ const files: RevisionFile[] = bundle.files.map((f) => ({ body: f.body, mediaType: f.mediaType, path: f.path }))
566
+ const hashes = new Map(bundle.files.map((f) => [f.path, `${f.mediaType} sha256:${createHash("sha256").update(f.body).digest("hex")}`]))
567
+ const found = (await this.state.list<StoredArtifact>(userID, ARTIFACTS)).find(
568
+ (e) =>
569
+ e.value.deletingAt === undefined &&
570
+ e.value.artifact.origin === "preview" &&
571
+ e.value.artifact.provenance?.sessionID === source.sessionID &&
572
+ e.value.artifact.provenance.messageID === source.messageID &&
573
+ e.value.artifact.provenance.block === source.block &&
574
+ e.value.artifact.provenance.path === source.path,
575
+ )
576
+ if (!found) {
577
+ const input: ArtifactInput = { title: title ?? defaultTitle, kind: "html", origin: "preview", provenance: source }
578
+ const created = await this.create(userID, { kind: "user" }, input, { files, entry: bundle.entry })
579
+ return { ...created, reused: false }
580
+ }
581
+ const id = found.key
582
+ // Viewed now (and restored when archived), so retention never takes it while the console opens it.
583
+ let artifact = await this.mutateStored(userID, id, (s) => this.viewed(s), (s) => s.artifact.state === "archived")
584
+ const latest = (await this.visibleRevisions(userID, (await this.state.get<StoredArtifact>(userID, ARTIFACTS, id))!.value))[0]
585
+ const same =
586
+ latest !== undefined &&
587
+ latest.entry === bundle.entry &&
588
+ latest.blobs.length === hashes.size &&
589
+ latest.blobs.every((b) => b.path !== undefined && hashes.get(b.path) === `${b.mediaType} ${b.hash}`)
590
+ if (same) return { artifact, revision: latest, reused: true }
591
+ const revised = await this.revise(userID, { kind: "user" }, id, { files, entry: bundle.entry, baseRevisionID: artifact.latestRevisionID })
592
+ artifact = revised.artifact
593
+ return { artifact, revision: revised.revision, reused: false }
594
+ }
595
+
596
+ /** A preview's record after a view: active again (when archived) with its retention clock restarted. */
597
+ private viewed(s: StoredArtifact): StoredArtifact {
598
+ if (s.artifact.origin !== "preview") return s
599
+ const { archivedAt: _, ...rest } = s
600
+ return { ...rest, viewedAt: this.now(), artifact: { ...s.artifact, state: "active" } }
601
+ }
602
+
603
+ /**
604
+ * A system change of a stored record (CAS-retried, no principal): `bump` says whether it is a visible change
605
+ * (revision and updatedAt move, as for a user's change; archive events are emitted by the caller).
606
+ */
607
+ private async mutateStored(userID: string, id: string, change: (s: StoredArtifact) => StoredArtifact, bump: (s: StoredArtifact) => boolean): Promise<Artifact> {
608
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
609
+ const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
610
+ if (!entry || entry.value.deletingAt !== undefined) throw notFound()
611
+ const visible = bump(entry.value)
612
+ const changed = change(entry.value)
613
+ const next: StoredArtifact = visible ? { ...changed, artifact: { ...changed.artifact, revision: entry.value.artifact.revision + 1, updatedAt: this.now() } } : changed
614
+ if ((await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, next, { ifVersion: entry.version })).ok) {
615
+ if (visible && next.artifact.state !== entry.value.artifact.state) {
616
+ this.emit(userID, { type: "artifact.archived", artifactID: id, kind: next.artifact.kind, revision: next.artifact.revision, state: next.artifact.state })
617
+ }
618
+ return next.artifact
619
+ }
620
+ }
621
+ throw conflict("The artifact kept changing; retry", undefined)
622
+ }
623
+
624
+ /** Archives active previews whose last view (or creation) is older than previewArchiveAfterMs. */
625
+ private async archiveStalePreviews(userID: string): Promise<void> {
626
+ const cutoff = this.now() - this.previewArchiveAfterMs
627
+ for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
628
+ const s = entry.value
629
+ if (s.deletingAt !== undefined || s.artifact.origin !== "preview" || s.artifact.state !== "active" || (s.viewedAt ?? s.artifact.createdAt) > cutoff) continue
630
+ const stale = (c: StoredArtifact) => c.artifact.origin === "preview" && c.artifact.state === "active" && (c.viewedAt ?? c.artifact.createdAt) <= cutoff
631
+ await this.mutateStored(userID, entry.key, (c) => (stale(c) ? { ...c, archivedAt: this.now(), artifact: { ...c.artifact, state: "archived" } } : c), stale).catch(() => {})
632
+ }
633
+ }
634
+
447
635
  // ---- recovery -------------------------------------------------------------------------------------------
448
636
 
449
637
  /**
@@ -705,7 +893,9 @@ export class ArtifactStore {
705
893
  private async setState(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number, to: RecordState): Promise<Artifact> {
706
894
  const artifact = await this.mutate(userID, principal, id, expectedRevision, async (s) => {
707
895
  const { archivedAt: _, ...rest } = s
708
- return { ...rest, ...(to === "archived" ? { archivedAt: this.now() } : {}), artifact: { ...s.artifact, state: to } }
896
+ // Unarchiving a preview restarts its retention, so the next maintenance pass does not archive it again.
897
+ const viewed = to === "active" && s.artifact.origin === "preview" ? { viewedAt: this.now() } : {}
898
+ return { ...rest, ...viewed, ...(to === "archived" ? { archivedAt: this.now() } : {}), artifact: { ...s.artifact, state: to } }
709
899
  })
710
900
  this.emit(userID, { type: "artifact.archived", artifactID: id, kind: artifact.kind, revision: artifact.revision, state: to })
711
901
  return artifact
@@ -11,6 +11,7 @@ import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolv
11
11
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
12
12
  import { checkReleaseBase } from "./install/render"
13
13
  import { cspMode, type CspMode } from "./csp"
14
+ import { parseDenyCidrs } from "./fetcher"
14
15
 
15
16
  export const GOOGLE_ISSUER = "https://accounts.google.com"
16
17
 
@@ -77,6 +78,10 @@ export interface CentralConfig {
77
78
  readonly releaseBase?: string
78
79
  /** Cluster mode (design §9): set only when HUB_DATABASE_URL and HUB_VALKEY_URL (or hub.json cluster) are. */
79
80
  readonly cluster?: ClusterConfig
81
+ /** HUB_REMOTE_FETCH=off (only then set, to false): turns off the hub fetching web URLs for the console (/api/media/remote, /api/media/unfurl). */
82
+ readonly remoteFetch?: boolean
83
+ /** HUB_FETCH_DENY_CIDRS: CIDRs the hub's outbound fetcher refuses on top of its built-in ranges (validated). */
84
+ readonly fetchDenyCidrs?: readonly string[]
80
85
  }
81
86
 
82
87
  /**
@@ -469,6 +474,28 @@ function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): Clust
469
474
  return cluster as ClusterConfig
470
475
  }
471
476
 
477
+ const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] as const
478
+
479
+ /**
480
+ * HUB_REMOTE_FETCH: "on", "off", or unset. Unset means on, unless a proxy is set in the environment (Bun's fetch
481
+ * honours HTTP(S)_PROXY with no per-request opt-out, and a proxy may re-resolve the name, undoing the fetcher's
482
+ * address pinning) and NO_PROXY is not "*": then off, with a startup warning. Throws on any other value.
483
+ */
484
+ export function remoteFetchSetting(rawEnv: Env): { enabled: boolean; warning?: string } {
485
+ const raw = rawEnv.HUB_REMOTE_FETCH
486
+ const value = raw?.trim().toLowerCase()
487
+ if (value === "on") return { enabled: true }
488
+ if (value === "off") return { enabled: false }
489
+ if (value) throw new Error(`HUB_REMOTE_FETCH must be "on" or "off", got ${JSON.stringify(raw)}`)
490
+ const proxy = PROXY_VARS.find((name) => rawEnv[name]?.trim())
491
+ const noProxy = (rawEnv.NO_PROXY ?? rawEnv.no_proxy)?.trim()
492
+ if (!proxy || noProxy === "*") return { enabled: true }
493
+ return {
494
+ enabled: false,
495
+ warning: `remote images are off (HUB_REMOTE_FETCH): ${proxy} is set, and a proxy could re-resolve hostnames and reach private addresses. Set NO_PROXY=* for the hub process to fetch directly, or HUB_REMOTE_FETCH=on to accept proxying knowingly`,
496
+ }
497
+ }
498
+
472
499
  export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
473
500
  const env = hubEnv(rawEnv)
474
501
  const host = env.HUB_HOST || file.host || "127.0.0.1"
@@ -486,6 +513,9 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
486
513
  }
487
514
  const auth = authConfig(env, file.auth)
488
515
  const cluster = clusterConfig(env, auth, file.cluster)
516
+ const remoteFetch = remoteFetchSetting(env).enabled ? "on" : "off"
517
+ parseDenyCidrs(env.HUB_FETCH_DENY_CIDRS) // throws on an invalid entry: a startup error
518
+ const fetchDenyCidrs = (env.HUB_FETCH_DENY_CIDRS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
489
519
  return {
490
520
  host,
491
521
  port,
@@ -508,6 +538,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
508
538
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
509
539
  ...(releaseBase ? { releaseBase } : {}),
510
540
  ...(cluster ? { cluster } : {}),
541
+ ...(remoteFetch === "off" ? { remoteFetch: false } : {}),
542
+ ...(fetchDenyCidrs.length ? { fetchDenyCidrs } : {}),
511
543
  }
512
544
  }
513
545
 
@@ -532,6 +564,8 @@ export function loadCentralConfig(
532
564
  if (!resolved.HUB_CONFIG) migrateFirst(legacyPaths(configBase(env, home), "hub.json"), file, migrate)
533
565
  const { file: hub, warnings } = readHubFile(file, home)
534
566
  const config = centralConfig(env, home, hub)
567
+ const remoteFetchWarning = remoteFetchSetting(hubEnv(env)).warning
568
+ if (remoteFetchWarning) warnings.push(remoteFetchWarning)
535
569
  if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
536
570
  // hub.pid is the spoke's auto-start lock for the legacy location, not state.
537
571
  migrateStateDir(stateLegacyPaths(env, home), config.stateDir, { ...migrate, skip: ["hub.pid"] })