@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,171 @@
1
+ // (S4 M2) What an image's bytes are: magic-byte sniffing (PNG, JPEG, WebP, GIF, SVG) and the width and height in
2
+ // a raster image's header, read without decoding it. The spoke serves an image only when its bytes are the type its
3
+ // source claims (an attachment's or tool item's mime, a path's extension) and its dimensions fit MEDIA_LIMITS.
4
+ import { MEDIA_LIMITS, MEDIA_MIMES, type MediaMime } from "../protocol"
5
+
6
+ export interface Dimensions {
7
+ readonly width: number
8
+ readonly height: number
9
+ }
10
+
11
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
12
+ const ascii = (b: Uint8Array, at: number, text: string) => b.length >= at + text.length && [...text].every((c, i) => b[at + i] === c.charCodeAt(0))
13
+ const be16 = (b: Uint8Array, at: number) => (b[at]! << 8) | b[at + 1]!
14
+ const be32 = (b: Uint8Array, at: number) => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0
15
+ const le16 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8)
16
+ const le24 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16)
17
+ const le32 = (b: Uint8Array, at: number) => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0
18
+
19
+ /** An SVG document: optional BOM, XML declaration, comments and doctype, then the root `<svg`. */
20
+ const SVG_START = /^\uFEFF?\s*(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE\s+svg[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>/]/i
21
+ const SVG_SNIFF_BYTES = 4096
22
+
23
+ /** The image type of `bytes` by their magic bytes, else undefined. */
24
+ export function sniffMime(bytes: Uint8Array): MediaMime | undefined {
25
+ if (bytes.length >= 8 && PNG_SIGNATURE.every((v, i) => bytes[i] === v)) return "image/png"
26
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"
27
+ if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif"
28
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp"
29
+ const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, SVG_SNIFF_BYTES))
30
+ if (SVG_START.test(head)) return "image/svg+xml"
31
+ return undefined
32
+ }
33
+
34
+ /** A raster image's width and height from its (complete) header; undefined when it is truncated, malformed or SVG. */
35
+ export function imageDimensions(bytes: Uint8Array, mime: string): Dimensions | undefined {
36
+ switch (mime) {
37
+ case "image/png":
38
+ // Signature, then the IHDR chunk (length, type, 13 bytes of data, CRC).
39
+ if (bytes.length < 33 || !ascii(bytes, 12, "IHDR")) return undefined
40
+ return { width: be32(bytes, 16), height: be32(bytes, 20) }
41
+ case "image/gif":
42
+ // Header and the whole logical screen descriptor.
43
+ if (bytes.length < 13) return undefined
44
+ return { width: le16(bytes, 6), height: le16(bytes, 8) }
45
+ case "image/webp":
46
+ return webpDimensions(bytes)
47
+ case "image/jpeg":
48
+ return jpegDimensions(bytes)
49
+ default:
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ function webpDimensions(b: Uint8Array): Dimensions | undefined {
55
+ if (b.length < 20) return undefined
56
+ const size = le32(b, 16)
57
+ if (ascii(b, 12, "VP8 ")) {
58
+ // Frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height.
59
+ if (size < 10 || b.length < 30 || b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return undefined
60
+ return { width: le16(b, 26) & 0x3fff, height: le16(b, 28) & 0x3fff }
61
+ }
62
+ if (ascii(b, 12, "VP8L")) {
63
+ if (size < 5 || b.length < 25 || b[20] !== 0x2f) return undefined
64
+ const bits = le32(b, 21)
65
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
66
+ }
67
+ if (ascii(b, 12, "VP8X")) {
68
+ if (size < 10 || b.length < 30) return undefined
69
+ return { width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ /** Start-of-frame markers (every SOFn except DHT C4, JPG C8 and DAC CC). */
75
+ const isSOF = (marker: number) => marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
76
+
77
+ function jpegDimensions(b: Uint8Array): Dimensions | undefined {
78
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return undefined
79
+ let at = 2
80
+ while (at < b.length) {
81
+ if (b[at] !== 0xff) return undefined
82
+ while (at < b.length && b[at] === 0xff) at++
83
+ if (at >= b.length) return undefined
84
+ const marker = b[at]!
85
+ at++
86
+ // Markers without a length: TEM, RSTn, SOI. EOI or SOS before a frame: no dimensions.
87
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue
88
+ if (marker === 0xd9 || marker === 0xda) return undefined
89
+ if (at + 2 > b.length) return undefined
90
+ const length = be16(b, at)
91
+ if (length < 2) return undefined
92
+ if (isSOF(marker)) {
93
+ // Length, precision, height, width.
94
+ if (length < 7 || at + 7 > b.length) return undefined
95
+ return { width: be16(b, at + 5), height: be16(b, at + 3) }
96
+ }
97
+ at += length
98
+ }
99
+ return undefined
100
+ }
101
+
102
+ const EXTENSIONS: Readonly<Record<string, MediaMime>> = {
103
+ png: "image/png",
104
+ jpg: "image/jpeg",
105
+ jpeg: "image/jpeg",
106
+ webp: "image/webp",
107
+ gif: "image/gif",
108
+ svg: "image/svg+xml",
109
+ }
110
+
111
+ /** The image type a path's extension claims, else undefined. */
112
+ export function mimeOfPath(path: string): MediaMime | undefined {
113
+ const match = /\.([A-Za-z0-9]+)$/.exec(path)
114
+ return match ? EXTENSIONS[match[1]!.toLowerCase()] : undefined
115
+ }
116
+
117
+ /** A claimed mime as one of MEDIA_MIMES (image/jpg is image/jpeg), else undefined. */
118
+ export function mediaMime(claim: string | undefined): MediaMime | undefined {
119
+ const mime = (claim ?? "").trim().toLowerCase().split(";")[0]!.trim()
120
+ const normal = mime === "image/jpg" ? "image/jpeg" : mime
121
+ return (MEDIA_MIMES as readonly string[]).includes(normal) ? (normal as MediaMime) : undefined
122
+ }
123
+
124
+ export type ImageCheck = { readonly ok: { readonly mime: MediaMime } & Partial<Dimensions> } | { readonly refused: "type" | "dimensions" }
125
+
126
+ /**
127
+ * Whether `bytes` may be served as `claim`: the claim is an allowed type and the magic bytes match it ("type"
128
+ * otherwise, also for a raster image whose header has no readable dimensions), and a raster image fits
129
+ * MEDIA_LIMITS' sides and pixels ("dimensions" otherwise).
130
+ */
131
+ export function checkImage(bytes: Uint8Array, claim: string): ImageCheck {
132
+ const mime = mediaMime(claim)
133
+ if (!mime || sniffMime(bytes) !== mime) return { refused: "type" }
134
+ if (mime === "image/svg+xml") return { ok: { mime } }
135
+ const dims = imageDimensions(bytes, mime)
136
+ if (!dims || dims.width < 1 || dims.height < 1) return { refused: "type" }
137
+ if (dims.width > MEDIA_LIMITS.maxSide || dims.height > MEDIA_LIMITS.maxSide || dims.width * dims.height > MEDIA_LIMITS.maxPixels) return { refused: "dimensions" }
138
+ return { ok: { mime, ...dims } }
139
+ }
140
+
141
+ const DATA_URI = /^data:([^;,]*)((?:;[^;,]*)*);base64,([A-Za-z0-9+/=_-]*)$/i
142
+
143
+ /** The parts of a base64 `data:` URI, else undefined. */
144
+ export function parseDataURI(uri: string): { mime: string; base64: string } | undefined {
145
+ const match = DATA_URI.exec(uri)
146
+ return match ? { mime: match[1]!.toLowerCase(), base64: match[3]! } : undefined
147
+ }
148
+
149
+ /** The decoded size of base64 text (padding and URL-safe alphabet allowed). */
150
+ export function base64Bytes(base64: string): number {
151
+ const body = base64.replace(/=+$/, "")
152
+ return Math.floor((body.length * 3) / 4)
153
+ }
154
+
155
+ /** The first bytes of base64 text (at most `max`), for header sniffing without decoding all of it. */
156
+ export function base64Head(base64: string, max = HEAD_BYTES): Uint8Array {
157
+ const chars = Math.ceil(max / 3) * 4
158
+ return Uint8Array.from(Buffer.from(base64.slice(0, chars), base64.includes("-") || base64.includes("_") ? "base64url" : "base64"))
159
+ }
160
+
161
+ /** Bytes read to find a header's dimensions (a JPEG's frame may follow large EXIF segments). */
162
+ const HEAD_BYTES = 96 * 1024
163
+
164
+ /** A raster image's header dimensions from the start of its base64, when the bytes are the claimed type. */
165
+ export function base64Dimensions(base64: string, claim: string): Partial<Dimensions> {
166
+ const mime = mediaMime(claim)
167
+ if (!mime || mime === "image/svg+xml") return {}
168
+ const head = base64Head(base64)
169
+ if (sniffMime(head) !== mime) return {}
170
+ return imageDimensions(head, mime) ?? {}
171
+ }
@@ -0,0 +1,394 @@
1
+ // (S4 M2, design §1) The path policy of `p:` media refs: which local image files an assistant message may show.
2
+ //
3
+ // 1. The path must be the source of a markdown image in that message's own text (re-parsed here, so the console
4
+ // cannot name an arbitrary path). `~` is never expanded; remote and other URL schemes are not paths.
5
+ // 2. After realpath (symlinks resolved), the file must be under the session's directory (its `.worktrees/` too),
6
+ // or in the session's touched set: the paths of its own read/write/edit calls and the names of tool file outputs.
7
+ // 3. A path with a segment starting with "." (`.git/`, `.env`, …) below the directory is refused unless the file is
8
+ // in the touched set. 4. Regular files only, and the open file must be what the checked realpath names
9
+ // (openChecked: on macOS the real path is opened with no symlink anywhere in it; elsewhere the path is opened first
10
+ // without following a final symlink, then checked). The bytes are checked in media-bytes.ts.
11
+ import { constants, type Stats } from "node:fs"
12
+ import { lstat, open, readlink, realpath, stat, type FileHandle } from "node:fs/promises"
13
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
14
+ import { fileURLToPath } from "node:url"
15
+ import { parseMarkdown, type Inline, type MdBlock } from "../../packages/protocol/src/markdown.ts"
16
+ import { MEDIA_LIMITS } from "../protocol"
17
+
18
+ const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
19
+
20
+ /**
21
+ * Text with indented code blocks (4+ spaces or a tab after a blank line, outside fences) blanked: parseMarkdown keeps
22
+ * them as paragraphs, but such a line is code to CommonMark and to whoever wrote it, so its images never count.
23
+ */
24
+ export function withoutIndentedCode(text: string): string {
25
+ const lines: string[] = []
26
+ let fence: string | undefined
27
+ let blank = true
28
+ let indented = false
29
+ for (const line of text.split("\n")) {
30
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)
31
+ const isBlank = line.trim() === ""
32
+ if (!fence && !isBlank && /^(?: {4}|\t)/.test(line) && (blank || indented)) {
33
+ indented = true
34
+ blank = false
35
+ lines.push("")
36
+ continue
37
+ }
38
+ if (!isBlank) indented = false
39
+ blank = isBlank
40
+ if (fence) {
41
+ if (open && open[1]![0] === fence[0] && open[1]!.length >= fence.length && line.trim() === open[1]) fence = undefined
42
+ } else if (open) fence = open[1]
43
+ lines.push(line)
44
+ }
45
+ return lines.join("\n")
46
+ }
47
+
48
+ /** The sources of the image nodes of a parsed tree (link labels, lists, quotes and tables too), in order. */
49
+ function imagesOf(blocks: readonly MdBlock[], out: string[] = []): string[] {
50
+ const inline = (nodes: readonly Inline[]) => {
51
+ for (const node of nodes) {
52
+ if (node.type === "image") out.push(node.src)
53
+ else if ("children" in node) inline(node.children)
54
+ }
55
+ }
56
+ for (const block of blocks) {
57
+ if (block.type === "paragraph" || block.type === "heading") inline(block.children)
58
+ else if (block.type === "quote") imagesOf(block.blocks, out)
59
+ else if (block.type === "list") for (const item of block.items) imagesOf(item.blocks, out)
60
+ else if (block.type === "table") for (const row of [block.head, ...block.rows]) for (const cell of row) inline(cell)
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * Backstops (the parser is linear, but it runs synchronously in the harness's process on every timeline build): only
67
+ * the first 64 KiB characters of a text, up to its last blank line there (scannedPart), are read for images, and at
68
+ * most MEDIA_LIMITS.perRow sources are kept (a row lists no more). An image past either limit is neither listed nor
69
+ * served.
70
+ */
71
+ const IMAGE_SCAN_CHARS = 64 * 1024
72
+
73
+ /** A blank line (only spaces, tabs or a CR on it), as the parser sees one. */
74
+ const BLANK_LINE = /\n[ \t\r]*\n/g
75
+
76
+ /**
77
+ * The part of a text read for images: all of it up to IMAGE_SCAN_CHARS, else what comes before its last blank line
78
+ * within the first IMAGE_SCAN_CHARS (nothing when there is none), so the cut never splits a paragraph and the code
79
+ * spans in it. A fence still open at the cut makes the rest code: fewer images, never more.
80
+ */
81
+ export function scannedPart(text: string): string {
82
+ if (text.length <= IMAGE_SCAN_CHARS) return text
83
+ const head = text.slice(0, IMAGE_SCAN_CHARS)
84
+ let cut = 0
85
+ BLANK_LINE.lastIndex = 0
86
+ for (let m = BLANK_LINE.exec(head); m; m = BLANK_LINE.exec(head)) {
87
+ cut = m.index
88
+ BLANK_LINE.lastIndex = m.index + 1
89
+ }
90
+ return head.slice(0, cut)
91
+ }
92
+
93
+ /**
94
+ * The image sources (`![alt](src)`) of markdown text, in order, without duplicates, as the console's parser
95
+ * (parseMarkdown, packages/protocol) reads them: never in fenced code, code spans or after a backslash escape. The
96
+ * console does not render reference images (`![alt][ref]`), so they are not sources either. Indented code is left
97
+ * out as well: a source counts only when the text without indented code (withoutIndentedCode) still has it.
98
+ */
99
+ export function markdownImageSources(markdown: string): string[] {
100
+ const text = scannedPart(markdown)
101
+ if (!text.includes("![")) return []
102
+ const outsideIndented = new Set(imagesOf(parseMarkdown(withoutIndentedCode(text))))
103
+ const out: string[] = []
104
+ for (const src of imagesOf(parseMarkdown(text))) {
105
+ if (src && outsideIndented.has(src) && !out.includes(src)) out.push(src)
106
+ if (out.length >= MEDIA_LIMITS.perRow) break
107
+ }
108
+ return out
109
+ }
110
+
111
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/
112
+
113
+ /**
114
+ * The absolute (not yet realpath'd) path an image source names, relative ones against `directory`: plain paths and
115
+ * local `file:` URLs. Undefined for `~` paths (never expanded), remote and other URLs, and empty or NUL sources.
116
+ */
117
+ export function sourcePath(src: string, directory: string): string | undefined {
118
+ if (!src || src.includes("\u0000") || src.startsWith("~")) return undefined
119
+ if (SCHEME.test(src)) {
120
+ if (!/^file:/i.test(src)) return undefined
121
+ try {
122
+ const url = new URL(src)
123
+ if (url.hostname !== "" && url.hostname !== "localhost") return undefined
124
+ const path = fileURLToPath(url)
125
+ return path.includes("\u0000") ? undefined : path
126
+ } catch {
127
+ return undefined
128
+ }
129
+ }
130
+ return resolve(directory, src)
131
+ }
132
+
133
+ const TOUCHING_TOOLS = new Set(["read", "write", "edit"])
134
+ const PATH_KEYS = ["filePath", "path", "file_path"] as const
135
+
136
+ /**
137
+ * The session's touched set: the paths of its own completed read/write/edit calls and the names of those calls' file
138
+ * outputs (the file actually read). A denied, failed or unfinished call touches nothing; other tools (bash, MCP)
139
+ * never widen disk access (their own file outputs are served by their t<part>.<item> refs).
140
+ */
141
+ export function touchedPaths(messages: readonly unknown[], directory: string): string[] {
142
+ const out = new Set<string>()
143
+ const add = (value: unknown) => {
144
+ const path = typeof value === "string" ? sourcePath(value, directory) : undefined
145
+ if (path) out.add(path)
146
+ }
147
+ for (const raw of messages) {
148
+ const message = record(raw)
149
+ if (message.type !== "assistant" || !Array.isArray(message.content)) continue
150
+ for (const part of message.content) {
151
+ const state = record(record(part).state)
152
+ if (part.type !== "tool" || state.status !== "completed" || !TOUCHING_TOOLS.has(String(part.name ?? "").toLowerCase())) continue
153
+ const input = record(state.input)
154
+ for (const key of PATH_KEYS) add(input[key])
155
+ if (Array.isArray(state.content)) for (const item of state.content) if (record(item).type === "file") add(record(item).name)
156
+ }
157
+ }
158
+ return [...out]
159
+ }
160
+
161
+ /** A served path: its real path and the open file's device and inode. */
162
+ export type PathCheck = { readonly path: string; readonly dev: number; readonly ino: number } | { readonly refused: "path" }
163
+
164
+ const REFUSED = { refused: "path" } as const
165
+
166
+ /** Never follow a final symlink, never block (a FIFO opens at once and is refused as not a regular file). */
167
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
168
+
169
+ /**
170
+ * macOS 11+ (<sys/fcntl.h>): open(2) fails with ELOOP when any component of the path is a symlink, not only the last.
171
+ * Node does not export it. Older macOS refuses it with EINVAL (then the Linux order below is used, without /proc).
172
+ */
173
+ const O_NOFOLLOW_ANY = 0x20000000
174
+
175
+ /** The file system calls and platform openChecked uses; tests replace them to change the tree between lookups. */
176
+ export interface MediaFs {
177
+ readonly realpath?: typeof realpath
178
+ readonly open?: (path: string, flags: number) => Promise<FileHandle>
179
+ readonly readlink?: (path: string) => Promise<string>
180
+ readonly platform?: NodeJS.Platform
181
+ }
182
+
183
+ type Fs = Required<MediaFs>
184
+
185
+ export interface MediaPathInput {
186
+ readonly src: string
187
+ readonly text: string
188
+ readonly directory: string
189
+ readonly touched: readonly string[]
190
+ /** For tests: the file system calls and platform to use (defaults: node:fs/promises, process.platform). */
191
+ readonly fs?: MediaFs
192
+ }
193
+
194
+ /** Whether a real path is under the real directory with no dot segment (but a leading .worktrees). */
195
+ function insideClean(real: string, realDir: string): boolean {
196
+ const rel = relative(realDir, real)
197
+ const inside = rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
198
+ return inside && !rel.split(sep).some((segment, index) => segment.startsWith(".") && !(index === 0 && segment === ".worktrees"))
199
+ }
200
+
201
+ /** Whether a real path may be served: under the directory (no dot segment but a leading .worktrees), or touched. */
202
+ async function allowed(real: string, input: PolicyContext, resolveReal: typeof realpath): Promise<boolean> {
203
+ const realDir = await resolveReal(input.directory).catch(() => undefined)
204
+ if (!realDir) return false
205
+ if (insideClean(real, realDir)) return true
206
+ if (input.touchedReal) return input.touchedReal.has(real)
207
+ const reals = await Promise.all(input.touched.map((p) => resolveReal(resolve(input.directory, p)).catch(() => undefined)))
208
+ return reals.includes(real)
209
+ }
210
+
211
+ /**
212
+ * The context with the touched set's real paths resolved once (and the directory's), for callers that check many
213
+ * paths (a preview bundle walk): allowed() then looks them up instead of resolving every touched path per check.
214
+ */
215
+ export async function resolvedPolicy(input: PolicyContext): Promise<PolicyContext> {
216
+ const resolveReal = input.fs?.realpath ?? realpath
217
+ const reals = await Promise.all(input.touched.map((p) => resolveReal(resolve(input.directory, p)).catch(() => undefined)))
218
+ const realDir = await resolveReal(input.directory).catch(() => undefined)
219
+ return { ...input, touchedReal: new Set(reals.filter((r): r is string => r !== undefined)), ...(realDir ? { realDir } : {}) }
220
+ }
221
+
222
+ /**
223
+ * Whether an absolute path that cannot be opened is missing where the policy would allow it: nothing is at `path`,
224
+ * and its nearest existing ancestor's real path plus the rest of `path` lies under the directory with no dot segment.
225
+ * Anything else (outside, a dot segment, a symlinked directory leaving the workspace, a missing touched file) is not
226
+ * told apart from a refusal, so a preview never reveals whether a file outside the policy exists. At most `maxSteps`
227
+ * ancestors are looked up.
228
+ */
229
+ export async function missingAllowed(path: string, input: PolicyContext, maxSteps = 64): Promise<boolean> {
230
+ const resolveReal = input.fs?.realpath ?? realpath
231
+ const realDir = input.realDir ?? (await resolveReal(input.directory).catch(() => undefined))
232
+ if (!realDir) return false
233
+ const gone = (e: NodeJS.ErrnoException) => e?.code === "ENOENT"
234
+ if (!(await lstat(path).then(() => false, gone))) return false
235
+ let ancestor = dirname(path)
236
+ for (let step = 0; step < maxSteps; step++) {
237
+ const info = await lstat(ancestor).catch((e: NodeJS.ErrnoException) => (gone(e) ? undefined : null))
238
+ if (info === null) return false
239
+ if (info) {
240
+ if (!info.isDirectory() && !info.isSymbolicLink()) return false
241
+ const real = await resolveReal(ancestor).catch(() => undefined)
242
+ return real !== undefined && insideClean(join(real, relative(ancestor, path)), realDir)
243
+ }
244
+ const up = dirname(ancestor)
245
+ if (up === ancestor) return false
246
+ ancestor = up
247
+ }
248
+ return false
249
+ }
250
+
251
+ type Opened = { path: string; handle: FileHandle; info: Stats }
252
+
253
+ /** Where a policy check runs: the session's directory and touched set (and, for tests, the file system calls). */
254
+ export type PolicyContext = Pick<MediaPathInput, "directory" | "touched" | "fs"> & {
255
+ /** The touched set's real paths, resolved once (resolvedPolicy). */
256
+ readonly touchedReal?: ReadonlySet<string>
257
+ /** The directory's real path, resolved once (resolvedPolicy; missingAllowed only, allowed() resolves it per check). */
258
+ readonly realDir?: string
259
+ }
260
+
261
+ /**
262
+ * Opens the image source `src` of a message whose text is `text` when it may be served. A process in the workspace
263
+ * may swap files and directories between lookups, so the open file itself is checked (see openAllowed).
264
+ * The caller reads only from the returned handle and closes it.
265
+ */
266
+ async function openChecked(input: MediaPathInput): Promise<Opened | typeof REFUSED> {
267
+ if (!markdownImageSources(input.text).includes(input.src)) return REFUSED
268
+ const path = sourcePath(input.src, input.directory)
269
+ if (!path) return REFUSED
270
+ return openAllowed(path, input)
271
+ }
272
+
273
+ /**
274
+ * Opens the absolute `path` when the path policy allows it (rules 2–4 above; whoever calls checks rule 1, that the
275
+ * message names it), in the platform's order below. The caller reads only from the returned handle and closes it.
276
+ */
277
+ export async function openAllowed(path: string, input: PolicyContext): Promise<Opened | typeof REFUSED> {
278
+ const fs: Fs = {
279
+ realpath: input.fs?.realpath ?? realpath,
280
+ open: input.fs?.open ?? ((p, flags) => open(p, flags)),
281
+ readlink: input.fs?.readlink ?? ((p) => readlink(p)),
282
+ platform: input.fs?.platform ?? process.platform,
283
+ }
284
+ if (fs.platform === "darwin") {
285
+ const opened = await openRealNoSymlinks(path, input, fs)
286
+ if (opened !== "unsupported") return opened
287
+ }
288
+ return openThenCheck(path, input, fs)
289
+ }
290
+
291
+ /**
292
+ * macOS: realpath first, the policy on the real path, then open exactly that path with O_NOFOLLOW_ANY (no symlink
293
+ * anywhere in it, so no swapped parent directory can redirect the open), a regular file with the real path's device
294
+ * and inode. "unsupported" when the kernel does not know the flag.
295
+ */
296
+ async function openRealNoSymlinks(path: string, input: PolicyContext, fs: Fs): Promise<Opened | typeof REFUSED | "unsupported"> {
297
+ // A final symlink is refused as on the other platforms (O_NOFOLLOW there); not for safety, the real path is checked.
298
+ if ((await lstat(path).catch(() => undefined))?.isSymbolicLink()) return REFUSED
299
+ const real = await fs.realpath(path).catch(() => undefined)
300
+ if (!real || !(await allowed(real, input, fs.realpath))) return REFUSED
301
+ let handle: FileHandle
302
+ try {
303
+ handle = await fs.open(real, OPEN_FLAGS | O_NOFOLLOW_ANY)
304
+ } catch (error) {
305
+ return (error as NodeJS.ErrnoException)?.code === "EINVAL" ? "unsupported" : REFUSED
306
+ }
307
+ return identified(real, handle)
308
+ }
309
+
310
+ /**
311
+ * Linux, Windows and the rest: the requested path is opened first, without following a final symlink and without
312
+ * blocking, and must be a regular file; only then is its realpath checked against the policy, and the realpath's
313
+ * device and inode must be the open file's. On Linux the kernel's path of the open file (/proc/self/fd) must also be
314
+ * the realpath, and a failing readlink refuses. Elsewhere there is no such path: on Windows (no O_NOFOLLOW, no
315
+ * O_NOFOLLOW_ANY) a parent directory swapped three times (a symlink to a file outside when opened, the directory
316
+ * during realpath, the symlink again for the identity check) can still serve a file outside the workspace; so can
317
+ * macOS before 11. Bun itself needs macOS 13+, where the O_NOFOLLOW_ANY order above applies.
318
+ */
319
+ async function openThenCheck(path: string, input: PolicyContext, fs: Fs): Promise<Opened | typeof REFUSED> {
320
+ const handle = await fs.open(path, OPEN_FLAGS).catch(() => undefined)
321
+ if (!handle) return REFUSED
322
+ const real = await checkedReal(path, handle, input, fs).catch(() => undefined)
323
+ if (!real) {
324
+ await handle.close().catch(() => {})
325
+ return REFUSED
326
+ }
327
+ return identified(real, handle)
328
+ }
329
+
330
+ /** The realpath of the opened `path` when the policy allows it (and, on Linux, /proc names it for the open file). */
331
+ async function checkedReal(path: string, handle: FileHandle, input: PolicyContext, fs: Fs): Promise<string | undefined> {
332
+ if (!(await handle.stat()).isFile()) return undefined
333
+ const real = await fs.realpath(path).catch(() => undefined)
334
+ if (!real || !(await allowed(real, input, fs.realpath))) return undefined
335
+ if (fs.platform === "linux" && (await fs.readlink(`/proc/self/fd/${handle.fd}`).catch(() => undefined)) !== real) return undefined
336
+ return real
337
+ }
338
+
339
+ /** The open file when it is regular and `real` names it now (same device and inode); else closed and refused. */
340
+ async function identified(real: string, handle: FileHandle): Promise<Opened | typeof REFUSED> {
341
+ try {
342
+ const info = await handle.stat()
343
+ const named = await stat(real).catch(() => undefined)
344
+ if (info.isFile() && named && named.dev === info.dev && named.ino === info.ino) return { path: real, handle, info }
345
+ } catch {}
346
+ await handle.close().catch(() => {})
347
+ return REFUSED
348
+ }
349
+
350
+ /** Whether the image source `src` may be served (its real path and the file's identity), else refused. */
351
+ export async function checkMediaPath(input: MediaPathInput): Promise<PathCheck> {
352
+ const opened = await openChecked(input)
353
+ if ("refused" in opened) return opened
354
+ await opened.handle.close()
355
+ return { path: opened.path, dev: opened.info.dev, ino: opened.info.ino }
356
+ }
357
+
358
+ /**
359
+ * The bytes of the image source `src` (openChecked), read only from the checked handle and never more than `max`:
360
+ * `tooLarge` with the size seen when it is (or grows) larger; refused when it changes size while it is read.
361
+ */
362
+ export async function readMediaPath(input: MediaPathInput & { readonly max: number }): Promise<ReadResult> {
363
+ const opened = await openChecked(input)
364
+ return "refused" in opened ? opened : readOpened(opened, input.max)
365
+ }
366
+
367
+ export type ReadResult = { path: string; bytes: Uint8Array } | { tooLarge: number } | typeof REFUSED
368
+
369
+ /** The bytes of the absolute `path` when the path policy allows it (openAllowed), as readMediaPath reads them. */
370
+ export async function readAllowedPath(path: string, input: PolicyContext & { readonly max: number }): Promise<ReadResult> {
371
+ const opened = await openAllowed(path, input)
372
+ return "refused" in opened ? opened : readOpened(opened, input.max)
373
+ }
374
+
375
+ async function readOpened({ handle, info, path }: Opened, max: number): Promise<ReadResult> {
376
+ try {
377
+ if (info.size > max) return { tooLarge: info.size }
378
+ // One byte more than the file had: reading it means the file grew while it was read.
379
+ const buffer = new Uint8Array(info.size + 1)
380
+ let length = 0
381
+ while (length < buffer.length) {
382
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
383
+ if (bytesRead === 0) break
384
+ length += bytesRead
385
+ }
386
+ if (length !== info.size) {
387
+ const now = (await handle.stat()).size
388
+ return now > max ? { tooLarge: now } : REFUSED
389
+ }
390
+ return { path, bytes: buffer.subarray(0, length) }
391
+ } finally {
392
+ await handle.close()
393
+ }
394
+ }