@openchambery/web 1.19.12 → 1.19.13-beta.2

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 (168) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-D5c557Oe.js +2 -0
  3. package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
  4. package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
  10. package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
  11. package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
  17. package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
  18. package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
  25. package/dist/assets/desktopHosts-DILvrAok.js +1 -0
  26. package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
  27. package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
  39. package/dist/assets/main-CqcB6tKc.js +169 -0
  40. package/dist/assets/main-DG-k0j8A.js +2 -0
  41. package/dist/assets/markdown-worker-CptoICmq.js +2 -0
  42. package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
  43. package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
  44. package/dist/assets/miniChat-BQtYQrsh.js +2 -0
  45. package/dist/assets/mobile-8iyqtXkr.js +2 -0
  46. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
  49. package/dist/assets/platform-DzsGqlGW.js +2 -0
  50. package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
  51. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
  52. package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
  53. package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
  54. package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
  55. package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
  56. package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
  57. package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
  58. package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
  60. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
  62. package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
  63. package/dist/assets/src-AA-5nXsF.css +2 -0
  64. package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
  79. package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
  82. package/dist/index.html +12 -9
  83. package/dist/mini-chat.html +12 -9
  84. package/dist/mobile.html +12 -9
  85. package/package.json +1 -1
  86. package/server/index.js +19 -0
  87. package/server/lib/assistant-delivery-parts.js +19 -1
  88. package/server/lib/assistants/DOCUMENTATION.md +56 -11
  89. package/server/lib/assistants/assign.js +455 -30
  90. package/server/lib/assistants/assign.test.js +351 -0
  91. package/server/lib/assistants/cards.js +16 -2
  92. package/server/lib/assistants/cards.test.js +16 -1
  93. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  94. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  95. package/server/lib/assistants/contact-attachments.js +814 -0
  96. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  97. package/server/lib/assistants/contact-attachments.test.js +249 -0
  98. package/server/lib/assistants/contact-store.js +552 -19
  99. package/server/lib/assistants/contact-store.test.js +415 -1
  100. package/server/lib/assistants/contact-tools.js +114 -14
  101. package/server/lib/assistants/contact-tools.test.js +135 -6
  102. package/server/lib/assistants/contracts.js +42 -2
  103. package/server/lib/assistants/harness.js +4 -1
  104. package/server/lib/assistants/harness.test.js +124 -0
  105. package/server/lib/assistants/routes.js +46 -1
  106. package/server/lib/assistants/service.js +569 -84
  107. package/server/lib/assistants/service.test.js +936 -37
  108. package/server/lib/fs/prompt-attachment-store.js +663 -0
  109. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  110. package/server/lib/fs/routes.js +28 -72
  111. package/server/lib/fs/routes.test.js +15 -30
  112. package/server/lib/llm/catalog.js +91 -16
  113. package/server/lib/llm/catalog.test.js +152 -11
  114. package/server/lib/notifications/APNS.md +20 -4
  115. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  116. package/server/lib/notifications/apns-runtime.js +136 -18
  117. package/server/lib/notifications/apns-runtime.test.js +128 -0
  118. package/server/lib/notifications/apns-titles.js +21 -1
  119. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  120. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  121. package/server/lib/notifications/runtime.js +93 -2
  122. package/server/lib/notifications/runtime.test.js +167 -4
  123. package/server/lib/opencode/core-routes.js +2 -1
  124. package/server/lib/opencode/settings-helpers.js +12 -0
  125. package/server/lib/opencode/settings-runtime.js +16 -0
  126. package/server/lib/opencode/settings-runtime.test.js +21 -0
  127. package/server/lib/scheduled-tasks/runtime.js +21 -0
  128. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  129. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  130. package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
  131. package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
  132. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  133. package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
  134. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  135. package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
  136. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  137. package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
  138. package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
  139. package/dist/assets/TerminalView-BiKCueth.js +0 -8
  140. package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
  141. package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
  142. package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
  143. package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
  144. package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
  145. package/dist/assets/main-C_PUoT3E.js +0 -169
  146. package/dist/assets/main-Dkg31fRY.js +0 -2
  147. package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
  148. package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
  149. package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
  150. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  151. package/dist/assets/mobile-Z_X7CbzB.js +0 -2
  152. package/dist/assets/persistence-DdUac14I.js +0 -3
  153. package/dist/assets/platform-B4KKLnZ5.js +0 -2
  154. package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
  155. package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
  156. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  157. package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
  158. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  159. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
  160. package/dist/assets/src-B-P5qIBc.css +0 -2
  161. package/dist/assets/terminalApi-947l7Ra3.js +0 -1
  162. package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
  163. package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
  164. package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
  165. package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
  166. package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
  167. package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
  168. package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Content-addressed prompt attachment storage under dataDir/prompt-attachments.
3
+ * Shared by Chat prompt uploads and Assistant contact attachments.
4
+ * Never treats client-supplied filesystem paths as authorization.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import fsp from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { constants as fsConstants } from 'node:fs';
11
+
12
+ export const MAX_PROMPT_ATTACHMENT_BYTES = 25 * 1024 * 1024;
13
+ export const PROMPT_ATTACHMENT_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
14
+ export const PROMPT_ATTACHMENT_SHA256 = /^[a-f0-9]{64}$/;
15
+ export const PROMPT_ATTACHMENT_MIME = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
16
+
17
+ /** Passiveive image types safe for Content-Disposition: inline. */
18
+ export const SAFE_INLINE_IMAGE_MIME = new Set([
19
+ 'image/png',
20
+ 'image/jpeg',
21
+ 'image/jpg',
22
+ 'image/gif',
23
+ 'image/webp',
24
+ 'image/bmp',
25
+ 'image/avif',
26
+ 'image/heic',
27
+ 'image/heif',
28
+ ]);
29
+
30
+ const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW || 0;
31
+
32
+ const fail = (code, message) => {
33
+ const error = new Error(message || code);
34
+ error.code = code;
35
+ throw error;
36
+ };
37
+
38
+ const isInsideRoot = (root, candidate) => {
39
+ const base = path.resolve(root);
40
+ const target = path.resolve(candidate);
41
+ return target === base || target.startsWith(`${base}${path.sep}`);
42
+ };
43
+
44
+ /** Extension from MIME only — never trust client filename for object path. */
45
+ export const promptAttachmentExtension = (mime) => {
46
+ const lowerMime = typeof mime === 'string' ? mime.toLowerCase().split(';', 1)[0].trim() : '';
47
+ if (lowerMime === 'image/jpeg' || lowerMime === 'image/jpg') return '.jpg';
48
+ if (lowerMime === 'image/png') return '.png';
49
+ if (lowerMime === 'image/gif') return '.gif';
50
+ if (lowerMime === 'image/webp') return '.webp';
51
+ if (lowerMime === 'image/heic') return '.heic';
52
+ if (lowerMime === 'image/heif') return '.heif';
53
+ if (lowerMime === 'image/avif') return '.avif';
54
+ if (lowerMime === 'image/bmp') return '.bmp';
55
+ if (lowerMime === 'text/plain') return '.txt';
56
+ if (lowerMime === 'application/pdf') return '.pdf';
57
+ // SVG/HTML never get a "document" extension that browsers might execute inline by path alone.
58
+ return '.bin';
59
+ };
60
+
61
+ export function isSafeInlineImageMime(mime) {
62
+ const lower = typeof mime === 'string' ? mime.toLowerCase().split(';', 1)[0].trim() : '';
63
+ return SAFE_INLINE_IMAGE_MIME.has(lower);
64
+ }
65
+
66
+ /** Magic-byte checks for common image types; non-image MIME skips. */
67
+ export function assertImageContentMatchesMime(buffer, mime) {
68
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
69
+ fail('attachment_empty', 'Attachment body is empty');
70
+ }
71
+ const lower = typeof mime === 'string' ? mime.toLowerCase().split(';', 1)[0].trim() : '';
72
+ if (!lower.startsWith('image/')) return;
73
+ // SVG/HTML storage is allowed at the shared writer; raster magic checks only.
74
+ if (lower === 'image/svg+xml' || lower.includes('html')) return;
75
+ const b = buffer;
76
+ const isPng = b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47;
77
+ const isJpeg = b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
78
+ const isGif = b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46;
79
+ const isWebp = b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46
80
+ && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50;
81
+ const isBmp = b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d;
82
+ if (lower === 'image/png' && !isPng) fail('attachment_mime_mismatch', 'PNG signature mismatch');
83
+ if ((lower === 'image/jpeg' || lower === 'image/jpg') && !isJpeg) fail('attachment_mime_mismatch', 'JPEG signature mismatch');
84
+ if (lower === 'image/gif' && !isGif) fail('attachment_mime_mismatch', 'GIF signature mismatch');
85
+ if (lower === 'image/webp' && !isWebp) fail('attachment_mime_mismatch', 'WEBP signature mismatch');
86
+ if (lower === 'image/bmp' && !isBmp) fail('attachment_mime_mismatch', 'BMP signature mismatch');
87
+ }
88
+
89
+ export async function collectRequestBytes(req, { expectedSize, maxBytes = MAX_PROMPT_ATTACHMENT_BYTES, signal } = {}) {
90
+ // Prefer raw Buffer body when express already buffered (should not happen for exempt PUTs).
91
+ if (Buffer.isBuffer(req?.body)) {
92
+ const size = req.body.length;
93
+ if (size > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
94
+ if (expectedSize !== undefined && size !== expectedSize) {
95
+ fail('PROMPT_ATTACHMENT_SIZE_MISMATCH', 'Attachment size mismatch');
96
+ }
97
+ return { buffer: req.body, size };
98
+ }
99
+ if (typeof req?.[Symbol.asyncIterator] !== 'function') {
100
+ fail('attachment_invalid_body', 'Attachment stream required');
101
+ }
102
+ const chunks = [];
103
+ let size = 0;
104
+ for await (const chunk of req) {
105
+ if (signal?.aborted) fail('PROMPT_ATTACHMENT_ABORTED', 'Upload aborted');
106
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
107
+ size += bytes.byteLength;
108
+ if (size > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
109
+ chunks.push(bytes);
110
+ }
111
+ if (expectedSize !== undefined && size !== expectedSize) {
112
+ fail('PROMPT_ATTACHMENT_SIZE_MISMATCH', 'Attachment size mismatch');
113
+ }
114
+ return { buffer: Buffer.concat(chunks, size), size };
115
+ }
116
+
117
+ export function promptAttachmentsRoot(dataDir) {
118
+ if (typeof dataDir !== 'string' || !dataDir.trim()) {
119
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
120
+ }
121
+ return path.resolve(dataDir.trim(), 'prompt-attachments');
122
+ }
123
+
124
+ /**
125
+ * Ensure store root exists and return canonical real path (no symlink escape).
126
+ * Trust anchor for all subsequent object path checks.
127
+ */
128
+ export async function ensureCanonicalStoreRoot(dataDir) {
129
+ const root = promptAttachmentsRoot(dataDir);
130
+ await fsp.mkdir(root, { recursive: true, mode: 0o700 });
131
+
132
+ let rootLstat;
133
+ try {
134
+ rootLstat = await fsp.lstat(root);
135
+ } catch (error) {
136
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
137
+ }
138
+ if (rootLstat.isSymbolicLink()) {
139
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
140
+ }
141
+ if (!rootLstat.isDirectory()) {
142
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
143
+ }
144
+
145
+ let realRoot;
146
+ try {
147
+ realRoot = await fsp.realpath(root);
148
+ } catch {
149
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
150
+ }
151
+
152
+ // Trust anchor is always realpath(root). Intermediate path components may legally
153
+ // realpath-differ on some platforms (e.g. macOS /var → /private/var). The leaf
154
+ // "prompt-attachments" entry itself must not be a symlink (checked above); all
155
+ // later object IO must use this realRoot and reject prefix/leaf symlink escapes.
156
+ if (typeof realRoot !== 'string' || !realRoot) {
157
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
158
+ }
159
+
160
+ const realStat = await fsp.stat(realRoot);
161
+ if (!realStat.isDirectory()) {
162
+ fail('attachment_store_unavailable', 'Prompt attachment storage is unavailable');
163
+ }
164
+ return realRoot;
165
+ }
166
+
167
+ /** Relative path under store root: aa/aaa…sha.ext — MIME-driven extension only. */
168
+ export function relativeObjectPath(sha256, mime) {
169
+ if (!PROMPT_ATTACHMENT_SHA256.test(sha256)) fail('attachment_invalid_hash', 'Invalid attachment digest');
170
+ const ext = promptAttachmentExtension(mime);
171
+ return path.posix.join(sha256.slice(0, 2), `${sha256}${ext}`);
172
+ }
173
+
174
+ export function absoluteObjectPath(dataDir, relativePath, { canonicalRoot = null } = {}) {
175
+ const root = canonicalRoot || promptAttachmentsRoot(dataDir);
176
+ const rel = typeof relativePath === 'string' ? relativePath.replace(/\\/g, '/').replace(/^\/+/, '') : '';
177
+ if (!rel || rel.includes('..') || path.isAbsolute(rel) || rel.includes('\0')) {
178
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
179
+ }
180
+ if (!/^[a-f0-9]{2}\/[a-f0-9]{64}\.[a-z0-9.]+$/i.test(rel)) {
181
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
182
+ }
183
+ const absolute = path.resolve(root, rel);
184
+ if (!isInsideRoot(root, absolute)) {
185
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
186
+ }
187
+ return absolute;
188
+ }
189
+
190
+ /** Addressing digest embedded in a validated relative object path. */
191
+ function addressingHashFromRelativePath(relativePath) {
192
+ const rel = typeof relativePath === 'string' ? relativePath.replace(/\\/g, '/') : '';
193
+ const match = /^([a-f0-9]{2})\/([a-f0-9]{64})\.[a-z0-9.]+$/i.exec(rel);
194
+ if (!match || match[1].toLowerCase() !== match[2].slice(0, 2).toLowerCase()) {
195
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
196
+ }
197
+ return match[2].toLowerCase();
198
+ }
199
+
200
+ /**
201
+ * Ensure hash-prefix directory exists under canonical root without following symlinks.
202
+ * O_NOFOLLOW only protects the final open; prefix dirs must be checked explicitly.
203
+ */
204
+ async function ensureTrustedPrefixDir(canonicalRoot, sha256) {
205
+ const prefix = sha256.slice(0, 2).toLowerCase();
206
+ const dir = path.join(canonicalRoot, prefix);
207
+
208
+ let st;
209
+ try {
210
+ st = await fsp.lstat(dir);
211
+ } catch (error) {
212
+ if (error?.code !== 'ENOENT') throw error;
213
+ await fsp.mkdir(dir, { mode: 0o700 });
214
+ st = await fsp.lstat(dir);
215
+ }
216
+
217
+ if (st.isSymbolicLink()) {
218
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
219
+ }
220
+ if (!st.isDirectory()) {
221
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
222
+ }
223
+
224
+ let realDir;
225
+ try {
226
+ realDir = await fsp.realpath(dir);
227
+ } catch {
228
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
229
+ }
230
+ // canonicalRoot is already realpath'd; prefix realpath must stay inside it.
231
+ // Do not require realDir === dir: platform path aliases (macOS /var vs /private/var) differ.
232
+ if (!isInsideRoot(canonicalRoot, realDir) || path.basename(realDir).toLowerCase() !== prefix) {
233
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
234
+ }
235
+ return realDir;
236
+ }
237
+
238
+ /**
239
+ * Resolve a trusted final object path: validate relative form, trusted prefix, no symlink parents.
240
+ */
241
+ async function resolveTrustedObjectPath(canonicalRoot, relativePath) {
242
+ const rel = typeof relativePath === 'string' ? relativePath.replace(/\\/g, '/').replace(/^\/+/, '') : '';
243
+ if (!rel || rel.includes('..') || path.isAbsolute(rel) || rel.includes('\0')) {
244
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
245
+ }
246
+ if (!/^[a-f0-9]{2}\/[a-f0-9]{64}\.[a-z0-9.]+$/i.test(rel)) {
247
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
248
+ }
249
+ const addressingHash = addressingHashFromRelativePath(rel);
250
+ const realPrefix = await ensureTrustedPrefixDir(canonicalRoot, addressingHash);
251
+ const baseName = path.posix.basename(rel);
252
+ if (!baseName || baseName !== rel.split('/').pop() || baseName.includes('/') || baseName.includes('\\')) {
253
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
254
+ }
255
+ const absolutePath = path.join(realPrefix, baseName);
256
+ if (!isInsideRoot(canonicalRoot, absolutePath)) {
257
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
258
+ }
259
+
260
+ // Final leaf must not be a symlink (when present).
261
+ try {
262
+ const leaf = await fsp.lstat(absolutePath);
263
+ if (leaf.isSymbolicLink()) {
264
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
265
+ }
266
+ } catch (error) {
267
+ if (error?.code !== 'ENOENT') throw error;
268
+ }
269
+
270
+ return { absolutePath, addressingHash, prefixDir: realPrefix };
271
+ }
272
+
273
+ async function hashFileLimited(fd, maxBytes) {
274
+ const hash = createHash('sha256');
275
+ const buf = Buffer.alloc(64 * 1024);
276
+ let total = 0;
277
+ for (;;) {
278
+ const { bytesRead } = await fd.read(buf, 0, buf.length, total);
279
+ if (bytesRead === 0) break;
280
+ total += bytesRead;
281
+ if (total > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
282
+ hash.update(buf.subarray(0, bytesRead));
283
+ }
284
+ return { digest: hash.digest('hex'), size: total };
285
+ }
286
+
287
+ /** Write the full buffer; a single fd.write may short-write. */
288
+ async function writeAllBytes(fd, buffer) {
289
+ let offset = 0;
290
+ while (offset < buffer.length) {
291
+ const { bytesWritten } = await fd.write(buffer, offset, buffer.length - offset, offset);
292
+ if (!Number.isInteger(bytesWritten) || bytesWritten <= 0) {
293
+ fail('attachment_write_failed', 'Short write while storing attachment');
294
+ }
295
+ offset += bytesWritten;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Directory fsync after rename for durability. Platforms that reject dir sync are skipped explicitly.
301
+ */
302
+ async function fsyncDirectory(dirPath) {
303
+ let dirFd;
304
+ try {
305
+ dirFd = await fsp.open(dirPath, fsConstants.O_RDONLY);
306
+ } catch (error) {
307
+ // Windows and some sandbox environments cannot open directories for fsync.
308
+ if (error?.code === 'EISDIR' || error?.code === 'EPERM' || error?.code === 'EINVAL' || error?.code === 'ENOTDIR') {
309
+ return;
310
+ }
311
+ throw error;
312
+ }
313
+ try {
314
+ await dirFd.sync();
315
+ } catch (error) {
316
+ if (error?.code === 'EINVAL' || error?.code === 'ENOTSUP' || error?.code === 'EPERM' || error?.code === 'EISDIR') {
317
+ return;
318
+ }
319
+ throw error;
320
+ } finally {
321
+ await dirFd.close().catch(() => {});
322
+ }
323
+ }
324
+
325
+ async function openReadonlyNoFollow(absolutePath) {
326
+ try {
327
+ return await fsp.open(absolutePath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
328
+ } catch (error) {
329
+ if (error?.code === 'ELOOP' || error?.code === 'EPERM') {
330
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Write buffer to content-addressed path. Idempotent on same digest after hash verify.
338
+ * Durable: complete write loop, post-write size/hash verify, fsync temp, rename, parent dir fsync.
339
+ * Existing files are re-hashed before reuse; content must match addressing hash.
340
+ */
341
+ export async function storePromptAttachmentBytes({
342
+ dataDir,
343
+ buffer,
344
+ expectedSha256,
345
+ mime,
346
+ filename,
347
+ maxBytes = MAX_PROMPT_ATTACHMENT_BYTES,
348
+ validateImage = true,
349
+ } = {}) {
350
+ if (!Buffer.isBuffer(buffer)) fail('attachment_invalid_body', 'Attachment body required');
351
+ if (buffer.length > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
352
+ if (buffer.length === 0) fail('attachment_empty', 'Attachment body is empty');
353
+ const mimeType = typeof mime === 'string' && mime.trim()
354
+ ? mime.split(';', 1)[0].trim()
355
+ : 'application/octet-stream';
356
+ if (!PROMPT_ATTACHMENT_MIME.test(mimeType)) fail('attachment_invalid_mime', 'Attachment MIME type is invalid');
357
+ // HTML/SVG are ordinary chat source uploads; HTTP display safety is owned elsewhere.
358
+ if (validateImage) assertImageContentMatchesMime(buffer, mimeType);
359
+
360
+ const digest = createHash('sha256').update(buffer).digest('hex');
361
+ if (expectedSha256 && !PROMPT_ATTACHMENT_SHA256.test(expectedSha256)) {
362
+ fail('attachment_invalid_hash', 'Attachment digest is required');
363
+ }
364
+ if (expectedSha256 && expectedSha256 !== digest) {
365
+ fail('attachment_hash_mismatch', 'Attachment digest mismatch');
366
+ }
367
+
368
+ const root = await ensureCanonicalStoreRoot(dataDir);
369
+ const relativePath = relativeObjectPath(digest, mimeType);
370
+ const { absolutePath, prefixDir } = await resolveTrustedObjectPath(root, relativePath);
371
+
372
+ // Existing object: open nofollow, fstat, re-hash, only reuse if digest+size match addressing hash.
373
+ try {
374
+ const existingFd = await openReadonlyNoFollow(absolutePath);
375
+ try {
376
+ const st = await existingFd.stat();
377
+ if (!st.isFile()) fail('attachment_unsafe_path', 'Unsafe attachment path');
378
+ if (st.size !== buffer.length) {
379
+ fail('attachment_hash_mismatch', 'Existing attachment object digest mismatch');
380
+ }
381
+ if (st.size > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
382
+ const { digest: existingDigest, size: existingSize } = await hashFileLimited(existingFd, maxBytes);
383
+ if (existingDigest !== digest || existingSize !== buffer.length || existingDigest !== addressingHashFromRelativePath(relativePath)) {
384
+ fail('attachment_hash_mismatch', 'Existing attachment object digest mismatch');
385
+ }
386
+ return {
387
+ sha256: digest,
388
+ size: buffer.length,
389
+ mime: mimeType,
390
+ relativePath,
391
+ absolutePath,
392
+ filename: typeof filename === 'string' && filename.trim() ? filename.trim().slice(0, 512) : undefined,
393
+ };
394
+ } finally {
395
+ await existingFd.close().catch(() => {});
396
+ }
397
+ } catch (error) {
398
+ if (error?.code === 'ENOENT') {
399
+ // fall through to write
400
+ } else if (error?.code === 'ELOOP' || error?.code === 'EPERM') {
401
+ fail('attachment_unsafe_path', 'Unsafe attachment path');
402
+ } else if (typeof error?.code === 'string' && (
403
+ error.code.startsWith('attachment')
404
+ || error.code === 'PROMPT_ATTACHMENT_TOO_LARGE'
405
+ || error.code === 'PROMPT_ATTACHMENT_SIZE_MISMATCH'
406
+ )) {
407
+ throw error;
408
+ } else {
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ const tmpPath = path.join(
414
+ prefixDir,
415
+ `.tmp-${path.basename(absolutePath)}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
416
+ );
417
+ try {
418
+ // O_RDWR so we can re-read for size/hash verify after write; O_NOFOLLOW rejects symlink tmp.
419
+ const fd = await fsp.open(
420
+ tmpPath,
421
+ fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_TRUNC | OPEN_NOFOLLOW,
422
+ 0o600,
423
+ );
424
+ try {
425
+ await writeAllBytes(fd, buffer);
426
+ await fd.sync();
427
+ const st = await fd.stat();
428
+ if (st.size !== buffer.length) {
429
+ fail('attachment_write_failed', 'Attachment size mismatch after write');
430
+ }
431
+ const { digest: writtenDigest, size: writtenSize } = await hashFileLimited(fd, maxBytes);
432
+ if (writtenDigest !== digest || writtenSize !== buffer.length) {
433
+ fail('attachment_hash_mismatch', 'Attachment digest mismatch after write');
434
+ }
435
+ } finally {
436
+ await fd.close().catch(() => {});
437
+ }
438
+ await fsp.rename(tmpPath, absolutePath);
439
+ await fsyncDirectory(prefixDir);
440
+ } catch (error) {
441
+ await fsp.unlink(tmpPath).catch(() => {});
442
+ if (error && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
443
+ // Lost race to another writer — verify winner.
444
+ return storePromptAttachmentBytes({
445
+ dataDir,
446
+ buffer,
447
+ expectedSha256: digest,
448
+ mime: mimeType,
449
+ filename,
450
+ maxBytes,
451
+ validateImage: false,
452
+ });
453
+ }
454
+ throw error;
455
+ }
456
+
457
+ // Post-rename verify through trusted open (size + addressing hash).
458
+ const verifyFd = await openReadonlyNoFollow(absolutePath);
459
+ try {
460
+ const st = await verifyFd.stat();
461
+ if (!st.isFile() || st.size !== buffer.length) {
462
+ fail('attachment_write_failed', 'Attachment size mismatch after rename');
463
+ }
464
+ const { digest: finalDigest, size: finalSize } = await hashFileLimited(verifyFd, maxBytes);
465
+ if (finalDigest !== digest || finalSize !== buffer.length) {
466
+ fail('attachment_hash_mismatch', 'Attachment digest mismatch after rename');
467
+ }
468
+ } finally {
469
+ await verifyFd.close().catch(() => {});
470
+ }
471
+
472
+ return {
473
+ sha256: digest,
474
+ size: buffer.length,
475
+ mime: mimeType,
476
+ relativePath,
477
+ absolutePath,
478
+ filename: typeof filename === 'string' && filename.trim() ? filename.trim().slice(0, 512) : undefined,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Read attachment bytes with NOFOLLOW + bounded read.
484
+ * Optional expectedSize / expectedSha256 for callers; on-disk content must always match path addressing hash.
485
+ */
486
+ export async function readPromptAttachmentBytes(
487
+ dataDir,
488
+ relativePath,
489
+ {
490
+ maxBytes = MAX_PROMPT_ATTACHMENT_BYTES,
491
+ expectedSize,
492
+ expectedSha256,
493
+ } = {},
494
+ ) {
495
+ const root = await ensureCanonicalStoreRoot(dataDir);
496
+ const { absolutePath, addressingHash } = await resolveTrustedObjectPath(root, relativePath);
497
+
498
+ if (expectedSha256 !== undefined) {
499
+ if (typeof expectedSha256 !== 'string' || !PROMPT_ATTACHMENT_SHA256.test(expectedSha256)) {
500
+ fail('attachment_invalid_hash', 'Invalid attachment digest');
501
+ }
502
+ }
503
+
504
+ let fd;
505
+ try {
506
+ fd = await openReadonlyNoFollow(absolutePath);
507
+ } catch (error) {
508
+ if (error?.code === 'ENOENT') fail('attachment_not_found', 'Attachment not found');
509
+ if (error?.code === 'ELOOP') fail('attachment_unsafe_path', 'Unsafe attachment path');
510
+ throw error;
511
+ }
512
+ try {
513
+ const st = await fd.stat();
514
+ if (!st.isFile()) fail('attachment_unsafe_path', 'Unsafe attachment path');
515
+ if (st.size > maxBytes) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
516
+ if (expectedSize !== undefined && st.size !== expectedSize) {
517
+ fail('PROMPT_ATTACHMENT_SIZE_MISMATCH', 'Attachment size mismatch');
518
+ }
519
+
520
+ const buffer = Buffer.alloc(st.size);
521
+ let offset = 0;
522
+ while (offset < st.size) {
523
+ const { bytesRead } = await fd.read(buffer, offset, Math.min(64 * 1024, st.size - offset), offset);
524
+ if (bytesRead === 0) break;
525
+ offset += bytesRead;
526
+ }
527
+ if (offset !== st.size) {
528
+ fail('attachment_read_failed', 'Attachment read truncated');
529
+ }
530
+
531
+ const digest = createHash('sha256').update(buffer).digest('hex');
532
+ if (digest !== addressingHash) {
533
+ fail('attachment_hash_mismatch', 'Attachment digest mismatch');
534
+ }
535
+ if (expectedSha256 && expectedSha256 !== digest) {
536
+ fail('attachment_hash_mismatch', 'Attachment digest mismatch');
537
+ }
538
+ if (expectedSize !== undefined && offset !== expectedSize) {
539
+ fail('PROMPT_ATTACHMENT_SIZE_MISMATCH', 'Attachment size mismatch');
540
+ }
541
+
542
+ return { buffer, size: offset, absolutePath };
543
+ } finally {
544
+ await fd.close().catch(() => {});
545
+ }
546
+ }
547
+
548
+ export function sha256Hex(buffer) {
549
+ return createHash('sha256').update(buffer).digest('hex');
550
+ }
551
+
552
+ /** Sync exists check for migration dry probes. */
553
+ export function promptAttachmentFileExistsSync(dataDir, relativePath) {
554
+ try {
555
+ const root = promptAttachmentsRoot(dataDir);
556
+ const absolutePath = absoluteObjectPath(dataDir, relativePath, { canonicalRoot: root });
557
+ // Walk components with lstat so prefix symlinks cannot report a false positive.
558
+ const rel = path.relative(root, absolutePath);
559
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
560
+ let current = root;
561
+ const parts = rel.split(path.sep);
562
+ for (let i = 0; i < parts.length; i += 1) {
563
+ current = path.join(current, parts[i]);
564
+ const st = fs.lstatSync(current);
565
+ if (st.isSymbolicLink()) return false;
566
+ if (i < parts.length - 1) {
567
+ if (!st.isDirectory()) return false;
568
+ } else if (!st.isFile()) {
569
+ return false;
570
+ }
571
+ }
572
+ return true;
573
+ } catch {
574
+ return false;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Strict percent-decode for data-URL payload (non-base64).
580
+ * Literal '+' is preserved (data URLs are not form-urlencoded).
581
+ */
582
+ function decodeDataUrlPercentPayload(payload) {
583
+ if (typeof payload !== 'string') fail('validation_error', 'Invalid data URL payload');
584
+ // Reject bare % that is not a valid %HH sequence before decodeURIComponent.
585
+ if (/%(?![0-9A-Fa-f]{2})/.test(payload)) {
586
+ fail('validation_error', 'Invalid data URL payload');
587
+ }
588
+ try {
589
+ return Buffer.from(decodeURIComponent(payload), 'utf8');
590
+ } catch {
591
+ fail('validation_error', 'Invalid data URL payload');
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Strict base64 decode: alphabet + padding length, round-trip check.
597
+ */
598
+ function decodeDataUrlBase64Payload(payload) {
599
+ const cleaned = payload.replace(/\s+/g, '');
600
+ if (!cleaned) fail('validation_error', 'Invalid data URL base64');
601
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
602
+ fail('validation_error', 'Invalid data URL base64');
603
+ }
604
+ if (cleaned.length % 4 === 1) {
605
+ fail('validation_error', 'Invalid data URL base64');
606
+ }
607
+ const padMatch = cleaned.match(/=+$/);
608
+ if (padMatch && padMatch[0].length > 2) {
609
+ fail('validation_error', 'Invalid data URL base64');
610
+ }
611
+ if (/=/.test(cleaned.replace(/=+$/, ''))) {
612
+ fail('validation_error', 'Invalid data URL base64');
613
+ }
614
+ // Normalize missing padding for decode, then round-trip against canonical form.
615
+ const padded = cleaned + '='.repeat((4 - (cleaned.length % 4)) % 4);
616
+ let buffer;
617
+ try {
618
+ buffer = Buffer.from(padded, 'base64');
619
+ } catch {
620
+ fail('validation_error', 'Invalid data URL base64');
621
+ }
622
+ if (cleaned && buffer.length === 0) fail('validation_error', 'Invalid data URL base64');
623
+ const roundTrip = buffer.toString('base64');
624
+ const normalizedInput = padded;
625
+ if (roundTrip !== normalizedInput) {
626
+ // Allow optional padding on input: compare without trailing '='.
627
+ if (roundTrip.replace(/=+$/, '') !== cleaned.replace(/=+$/, '')) {
628
+ fail('validation_error', 'Invalid data URL base64');
629
+ }
630
+ // If input had padding, it must match canonical padding exactly.
631
+ if (cleaned.includes('=') && roundTrip !== cleaned && roundTrip !== padded) {
632
+ fail('validation_error', 'Invalid data URL base64');
633
+ }
634
+ }
635
+ return buffer;
636
+ }
637
+
638
+ /**
639
+ * Strict data-URL decode.
640
+ * @param {'admission'|'migration'} mode — admission enforces 25MiB; migration allows larger historical.
641
+ */
642
+ export function decodeDataUrlStrict(url, { mode = 'admission', maxBytes = MAX_PROMPT_ATTACHMENT_BYTES } = {}) {
643
+ if (typeof url !== 'string' || !url.startsWith('data:')) {
644
+ fail('validation_error', 'Invalid data URL');
645
+ }
646
+ const match = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]*)$/i.exec(url);
647
+ if (!match) fail('validation_error', 'Invalid data URL');
648
+ const mime = (match[1] || 'application/octet-stream').trim().toLowerCase();
649
+ if (!PROMPT_ATTACHMENT_MIME.test(mime)) fail('attachment_invalid_mime', 'Attachment MIME type is invalid');
650
+ // HTML/SVG allowed for ordinary chat source uploads; display safety is elsewhere.
651
+ const isBase64 = Boolean(match[2]);
652
+ const payload = match[3] || '';
653
+ let buffer;
654
+ if (isBase64) {
655
+ buffer = decodeDataUrlBase64Payload(payload);
656
+ } else {
657
+ buffer = decodeDataUrlPercentPayload(payload);
658
+ }
659
+ const limit = mode === 'migration' ? Math.max(maxBytes, buffer.length) : maxBytes;
660
+ if (buffer.length > limit) fail('PROMPT_ATTACHMENT_TOO_LARGE', 'Attachment too large');
661
+ if (buffer.length === 0) fail('attachment_empty', 'Attachment body is empty');
662
+ return { buffer, mime, size: buffer.length };
663
+ }