@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,263 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ assertImageContentMatchesMime,
9
+ decodeDataUrlStrict,
10
+ relativeObjectPath,
11
+ storePromptAttachmentBytes,
12
+ readPromptAttachmentBytes,
13
+ promptAttachmentsRoot,
14
+ } from './prompt-attachment-store.js';
15
+
16
+ const png = Buffer.from([
17
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
18
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
19
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
20
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
21
+ 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
22
+ 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
23
+ 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
24
+ 0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
25
+ 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
26
+ ]);
27
+
28
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
29
+
30
+ const makeDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pas-'));
31
+
32
+ describe('prompt-attachment-store', () => {
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ it('stores content-addressed bytes and rejects path traversal relative paths', async () => {
38
+ const dataDir = makeDataDir();
39
+ const digest = sha256(png);
40
+ const stored = await storePromptAttachmentBytes({
41
+ dataDir,
42
+ buffer: png,
43
+ expectedSha256: digest,
44
+ mime: 'image/png',
45
+ filename: 'x.png',
46
+ });
47
+ expect(stored.relativePath).toBe(relativeObjectPath(digest, 'image/png'));
48
+ expect(stored.sha256).toBe(digest);
49
+ const again = await storePromptAttachmentBytes({
50
+ dataDir,
51
+ buffer: png,
52
+ expectedSha256: digest,
53
+ mime: 'image/png',
54
+ filename: 'x.png',
55
+ });
56
+ expect(again.absolutePath).toBe(stored.absolutePath);
57
+ const read = await readPromptAttachmentBytes(dataDir, stored.relativePath);
58
+ expect(read.buffer.equals(png)).toBe(true);
59
+ await expect(readPromptAttachmentBytes(dataDir, '../etc/passwd')).rejects.toMatchObject({
60
+ code: 'attachment_unsafe_path',
61
+ });
62
+ await fsp.rm(dataDir, { recursive: true, force: true });
63
+ });
64
+
65
+ it('rejects image mime/signature mismatch', () => {
66
+ expect(() => assertImageContentMatchesMime(Buffer.from('not-an-image'), 'image/png')).toThrowError(
67
+ expect.objectContaining({ code: 'attachment_mime_mismatch' }),
68
+ );
69
+ expect(() => assertImageContentMatchesMime(png, 'image/png')).not.toThrow();
70
+ });
71
+
72
+ describe('decodeDataUrlStrict', () => {
73
+ it('preserves literal plus in non-base64 data URLs (not form-urlencoded spaces)', () => {
74
+ const decoded = decodeDataUrlStrict('data:text/plain,a+b');
75
+ expect(decoded.buffer.toString('utf8')).toBe('a+b');
76
+ expect(decoded.mime).toBe('text/plain');
77
+ });
78
+
79
+ it('percent-decodes %2B to plus and rejects malformed percent sequences', () => {
80
+ expect(decodeDataUrlStrict('data:text/plain,a%2Bb').buffer.toString('utf8')).toBe('a+b');
81
+ expect(() => decodeDataUrlStrict('data:text/plain,a%zz')).toThrowError(
82
+ expect.objectContaining({ code: 'validation_error' }),
83
+ );
84
+ expect(() => decodeDataUrlStrict('data:text/plain,a%2')).toThrowError(
85
+ expect.objectContaining({ code: 'validation_error' }),
86
+ );
87
+ });
88
+
89
+ it('rejects invalid base64 alphabet and broken padding', () => {
90
+ expect(() => decodeDataUrlStrict('data:text/plain;base64,!!!')).toThrowError(
91
+ expect.objectContaining({ code: 'validation_error' }),
92
+ );
93
+ expect(() => decodeDataUrlStrict('data:text/plain;base64,YWJ')).toThrowError(
94
+ expect.objectContaining({ code: 'validation_error' }),
95
+ );
96
+ expect(() => decodeDataUrlStrict('data:text/plain;base64,YWJj====')).toThrowError(
97
+ expect.objectContaining({ code: 'validation_error' }),
98
+ );
99
+ });
100
+
101
+ it('allows html and svg data URLs for ordinary chat source uploads', () => {
102
+ const html = decodeDataUrlStrict('data:text/html,<h1>x</h1>');
103
+ expect(html.buffer.toString('utf8')).toBe('<h1>x</h1>');
104
+ expect(html.mime).toBe('text/html');
105
+ const svg = decodeDataUrlStrict('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"></svg>');
106
+ expect(svg.mime).toBe('image/svg+xml');
107
+ expect(svg.buffer.toString('utf8')).toContain('<svg');
108
+ });
109
+ });
110
+
111
+ it('allows HTML and SVG file storage (display safety is not this module)', async () => {
112
+ const dataDir = makeDataDir();
113
+ const html = Buffer.from('<!doctype html><title>t</title>', 'utf8');
114
+ const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>', 'utf8');
115
+ const htmlStored = await storePromptAttachmentBytes({
116
+ dataDir,
117
+ buffer: html,
118
+ mime: 'text/html',
119
+ validateImage: false,
120
+ });
121
+ const svgStored = await storePromptAttachmentBytes({
122
+ dataDir,
123
+ buffer: svg,
124
+ mime: 'image/svg+xml',
125
+ validateImage: false,
126
+ });
127
+ expect(htmlStored.sha256).toBe(sha256(html));
128
+ expect(svgStored.sha256).toBe(sha256(svg));
129
+ const htmlRead = await readPromptAttachmentBytes(dataDir, htmlStored.relativePath);
130
+ const svgRead = await readPromptAttachmentBytes(dataDir, svgStored.relativePath);
131
+ expect(htmlRead.buffer.equals(html)).toBe(true);
132
+ expect(svgRead.buffer.equals(svg)).toBe(true);
133
+ await fsp.rm(dataDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it('completes short writes instead of leaving truncated objects', async () => {
137
+ const dataDir = makeDataDir();
138
+ const body = Buffer.from('0123456789abcdefghijklmnopqrstuvwxyz', 'utf8');
139
+ const digest = sha256(body);
140
+ const openOrig = fsp.open.bind(fsp);
141
+ let shortWrote = false;
142
+ vi.spyOn(fsp, 'open').mockImplementation(async (filePath, flags, mode) => {
143
+ const handle = await openOrig(filePath, flags, mode);
144
+ if (typeof filePath === 'string' && filePath.includes('.tmp-')) {
145
+ const writeOrig = handle.write.bind(handle);
146
+ handle.write = async (buffer, offset = 0, length = buffer.length - offset, position = null) => {
147
+ if (!shortWrote && length > 1) {
148
+ shortWrote = true;
149
+ return writeOrig(buffer, offset, 1, position);
150
+ }
151
+ return writeOrig(buffer, offset, length, position);
152
+ };
153
+ }
154
+ return handle;
155
+ });
156
+
157
+ const stored = await storePromptAttachmentBytes({
158
+ dataDir,
159
+ buffer: body,
160
+ expectedSha256: digest,
161
+ mime: 'text/plain',
162
+ validateImage: false,
163
+ });
164
+ expect(shortWrote).toBe(true);
165
+ expect(stored.size).toBe(body.length);
166
+ const onDisk = await fsp.readFile(stored.absolutePath);
167
+ expect(onDisk.equals(body)).toBe(true);
168
+ expect(sha256(onDisk)).toBe(digest);
169
+ await fsp.rm(dataDir, { recursive: true, force: true });
170
+ });
171
+
172
+ it('rejects store-root and hash-prefix symlink escape (NOFOLLOW alone is not enough)', async () => {
173
+ const base = makeDataDir();
174
+ const dataDir = path.join(base, 'data');
175
+ const outside = path.join(base, 'outside-secret');
176
+ fs.mkdirSync(dataDir, { recursive: true });
177
+ fs.writeFileSync(outside, 'secret-leak');
178
+
179
+ const digest = sha256(png);
180
+ const prefix = digest.slice(0, 2);
181
+ const storeRoot = promptAttachmentsRoot(dataDir);
182
+ fs.mkdirSync(storeRoot, { recursive: true });
183
+ const prefixPath = path.join(storeRoot, prefix);
184
+ try {
185
+ fs.symlinkSync(path.dirname(outside), prefixPath);
186
+ } catch {
187
+ await fsp.rm(base, { recursive: true, force: true });
188
+ return;
189
+ }
190
+
191
+ await expect(storePromptAttachmentBytes({
192
+ dataDir,
193
+ buffer: png,
194
+ expectedSha256: digest,
195
+ mime: 'image/png',
196
+ validateImage: true,
197
+ })).rejects.toMatchObject({ code: 'attachment_unsafe_path' });
198
+
199
+ // Must not have written the object through the escaped prefix.
200
+ const escapedCandidate = path.join(path.dirname(outside), `${digest}.png`);
201
+ expect(fs.existsSync(escapedCandidate)).toBe(false);
202
+
203
+ await expect(readPromptAttachmentBytes(
204
+ dataDir,
205
+ relativeObjectPath(digest, 'image/png'),
206
+ )).rejects.toMatchObject({
207
+ code: expect.stringMatching(/attachment_unsafe_path|attachment_not_found/),
208
+ });
209
+
210
+ await fsp.rm(base, { recursive: true, force: true });
211
+ });
212
+
213
+ it('rejects corrupt existing object whose bytes do not match addressing hash', async () => {
214
+ const dataDir = makeDataDir();
215
+ const digest = sha256(png);
216
+ const relativePath = relativeObjectPath(digest, 'image/png');
217
+ const storeRoot = promptAttachmentsRoot(dataDir);
218
+ const absolutePath = path.join(storeRoot, relativePath);
219
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
220
+ fs.writeFileSync(absolutePath, Buffer.from('not-the-png-bytes'));
221
+
222
+ await expect(storePromptAttachmentBytes({
223
+ dataDir,
224
+ buffer: png,
225
+ expectedSha256: digest,
226
+ mime: 'image/png',
227
+ validateImage: true,
228
+ })).rejects.toMatchObject({ code: 'attachment_hash_mismatch' });
229
+
230
+ await fsp.rm(dataDir, { recursive: true, force: true });
231
+ });
232
+
233
+ it('rejects read when on-disk bytes diverge from path addressing hash or expected hash/size', async () => {
234
+ const dataDir = makeDataDir();
235
+ const digest = sha256(png);
236
+ const relativePath = relativeObjectPath(digest, 'image/png');
237
+ const storeRoot = promptAttachmentsRoot(dataDir);
238
+ const absolutePath = path.join(storeRoot, relativePath);
239
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
240
+ fs.writeFileSync(absolutePath, Buffer.from('corrupt-payload'));
241
+
242
+ await expect(readPromptAttachmentBytes(dataDir, relativePath)).rejects.toMatchObject({
243
+ code: 'attachment_hash_mismatch',
244
+ });
245
+
246
+ // Plant correct bytes then fail optional caller expectations.
247
+ fs.writeFileSync(absolutePath, png);
248
+ await expect(readPromptAttachmentBytes(dataDir, relativePath, {
249
+ expectedSha256: 'a'.repeat(64),
250
+ })).rejects.toMatchObject({ code: 'attachment_hash_mismatch' });
251
+ await expect(readPromptAttachmentBytes(dataDir, relativePath, {
252
+ expectedSize: png.length + 1,
253
+ })).rejects.toMatchObject({ code: 'PROMPT_ATTACHMENT_SIZE_MISMATCH' });
254
+
255
+ const ok = await readPromptAttachmentBytes(dataDir, relativePath, {
256
+ expectedSha256: digest,
257
+ expectedSize: png.length,
258
+ });
259
+ expect(ok.buffer.equals(png)).toBe(true);
260
+
261
+ await fsp.rm(dataDir, { recursive: true, force: true });
262
+ });
263
+ });
@@ -2,6 +2,15 @@ import { createHash } from 'node:crypto';
2
2
  import { createRealpathCache } from '../path-realpath-cache.js';
3
3
  import nodeFsPromises from 'node:fs/promises';
4
4
  import nodePath from 'node:path';
5
+ import {
6
+ MAX_PROMPT_ATTACHMENT_BYTES,
7
+ PROMPT_ATTACHMENT_ID,
8
+ PROMPT_ATTACHMENT_MIME,
9
+ PROMPT_ATTACHMENT_SHA256,
10
+ collectRequestBytes,
11
+ promptAttachmentExtension,
12
+ storePromptAttachmentBytes,
13
+ } from './prompt-attachment-store.js';
5
14
 
6
15
  const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
7
16
  const OUTSIDE_FILE_GRANT_TTL_MS = 10 * 60 * 1000;
@@ -363,58 +372,11 @@ const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn,
363
372
  });
364
373
  };
365
374
 
366
- const MAX_PROMPT_ATTACHMENT_BYTES = 25 * 1024 * 1024;
367
- const PROMPT_ATTACHMENT_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
368
- const PROMPT_ATTACHMENT_SHA256 = /^[a-f0-9]{64}$/;
369
- const PROMPT_ATTACHMENT_MIME = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
370
-
371
375
  const headerValue = (value) => {
372
376
  if (Array.isArray(value)) return value[0];
373
377
  return typeof value === 'string' ? value : undefined;
374
378
  };
375
379
 
376
- const promptAttachmentExtension = (mime, filename) => {
377
- const fromName = typeof filename === 'string' ? nodePath.posix.extname(filename).toLowerCase() : '';
378
- if (fromName && fromName.length <= 16 && /^\.[a-z0-9.]+$/i.test(fromName)) return fromName;
379
- const lowerMime = typeof mime === 'string' ? mime.toLowerCase() : '';
380
- if (lowerMime === 'image/jpeg') return '.jpg';
381
- if (lowerMime === 'image/png') return '.png';
382
- if (lowerMime === 'image/gif') return '.gif';
383
- if (lowerMime === 'image/webp') return '.webp';
384
- if (lowerMime === 'image/heic') return '.heic';
385
- if (lowerMime === 'image/heif') return '.heif';
386
- if (lowerMime === 'image/avif') return '.avif';
387
- if (lowerMime === 'image/bmp') return '.bmp';
388
- if (lowerMime === 'image/svg+xml') return '.svg';
389
- return '.bin';
390
- };
391
-
392
- const collectRequestBytes = async (req, { expectedSize, maxBytes, signal } = {}) => {
393
- const chunks = [];
394
- let size = 0;
395
- for await (const chunk of req) {
396
- if (signal?.aborted) {
397
- const error = new Error('Upload aborted');
398
- error.code = 'PROMPT_ATTACHMENT_ABORTED';
399
- throw error;
400
- }
401
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
402
- size += bytes.byteLength;
403
- if (size > maxBytes) {
404
- const error = new Error('Attachment too large');
405
- error.code = 'PROMPT_ATTACHMENT_TOO_LARGE';
406
- throw error;
407
- }
408
- chunks.push(bytes);
409
- }
410
- if (expectedSize !== undefined && size !== expectedSize) {
411
- const error = new Error('Attachment size mismatch');
412
- error.code = 'PROMPT_ATTACHMENT_SIZE_MISMATCH';
413
- throw error;
414
- }
415
- return { buffer: Buffer.concat(chunks, size), size };
416
- };
417
-
418
380
  export const registerFsRoutes = (app, dependencies) => {
419
381
  const {
420
382
  os,
@@ -1109,38 +1071,29 @@ export const registerFsRoutes = (app, dependencies) => {
1109
1071
  const controller = new AbortController();
1110
1072
  req.once?.('aborted', () => controller.abort());
1111
1073
 
1112
- let tmpPath;
1113
1074
  try {
1114
1075
  const { buffer, size } = await collectRequestBytes(req, {
1115
1076
  expectedSize,
1116
1077
  maxBytes: MAX_PROMPT_ATTACHMENT_BYTES,
1117
1078
  signal: controller.signal,
1118
1079
  });
1119
- const digest = createHash('sha256').update(buffer).digest('hex');
1120
- if (digest !== expectedSha256) {
1121
- return res.status(400).json({ error: 'Attachment digest mismatch' });
1122
- }
1123
-
1124
- const storeRoot = path.resolve(dataDir, 'prompt-attachments');
1125
- const destinationDir = path.join(storeRoot, digest.slice(0, 2));
1126
- const destination = path.join(destinationDir, `${digest}${promptAttachmentExtension(mimeType, filename)}`);
1127
- await fsPromises.mkdir(destinationDir, { recursive: true, mode: 0o700 });
1128
- tmpPath = `${destination}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1129
- await fsPromises.writeFile(tmpPath, buffer, { mode: 0o600 });
1130
- try {
1131
- await fsPromises.rename(tmpPath, destination);
1132
- } catch (error) {
1133
- if (error && typeof error === 'object' && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
1134
- await fsPromises.unlink(tmpPath).catch(() => {});
1135
- tmpPath = undefined;
1136
- } else {
1137
- throw error;
1138
- }
1139
- }
1140
- tmpPath = undefined;
1141
- return res.json({ success: true, path: destination, size, mime: mimeType, sha256: digest });
1080
+ const stored = await storePromptAttachmentBytes({
1081
+ dataDir,
1082
+ buffer,
1083
+ expectedSha256,
1084
+ mime: mimeType,
1085
+ filename,
1086
+ maxBytes: MAX_PROMPT_ATTACHMENT_BYTES,
1087
+ validateImage: false,
1088
+ });
1089
+ return res.json({
1090
+ success: true,
1091
+ path: stored.absolutePath,
1092
+ size: stored.size,
1093
+ mime: stored.mime,
1094
+ sha256: stored.sha256,
1095
+ });
1142
1096
  } catch (error) {
1143
- if (tmpPath) await fsPromises.unlink(tmpPath).catch(() => {});
1144
1097
  const code = error && typeof error === 'object' ? error.code : undefined;
1145
1098
  if (code === 'PROMPT_ATTACHMENT_TOO_LARGE') {
1146
1099
  return res.status(413).json({ error: 'Attachment too large' });
@@ -1151,6 +1104,9 @@ export const registerFsRoutes = (app, dependencies) => {
1151
1104
  if (code === 'PROMPT_ATTACHMENT_ABORTED') {
1152
1105
  return res.status(499).json({ error: 'Upload aborted' });
1153
1106
  }
1107
+ if (code === 'attachment_hash_mismatch') {
1108
+ return res.status(400).json({ error: 'Attachment digest mismatch' });
1109
+ }
1154
1110
  console.error('Failed to store prompt attachment:', error);
1155
1111
  return res.status(500).json({ error: 'Failed to store prompt attachment' });
1156
1112
  }
@@ -1,4 +1,7 @@
1
1
  import { EventEmitter } from 'events';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
2
5
  import path from 'path';
3
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
7
 
@@ -323,15 +326,10 @@ describe('fs stat', () => {
323
326
 
324
327
  describe('fs prompt attachments', () => {
325
328
  it('stores binary bytes under the data-dir content-addressed path', async () => {
326
- const fsPromises = {
327
- mkdir: vi.fn(async () => undefined),
328
- writeFile: vi.fn(async () => undefined),
329
- rename: vi.fn(async () => undefined),
330
- unlink: vi.fn(async () => undefined),
331
- };
332
- const handler = registerPromptAttachment(fsPromises);
329
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-prompt-att-'));
330
+ const handler = registerPromptAttachment({}, { openchamberDataDir: dataDir });
333
331
  const body = Buffer.from('hello-image');
334
- const digest = 'a9a8fa077e26f69bcc4a7eeec6e1827a07ae07281952ae755766b9f06c8185e1';
332
+ const digest = createHash('sha256').update(body).digest('hex');
335
333
  const req = {
336
334
  params: { attachmentID: 'att-1' },
337
335
  headers: {
@@ -347,34 +345,20 @@ describe('fs prompt attachments', () => {
347
345
  const res = createMockResponse();
348
346
  await handler(req, res);
349
347
 
350
- expect(res.body).toEqual({
348
+ expect(res.body).toMatchObject({
351
349
  success: true,
352
- path: `/data/openchamber/prompt-attachments/${digest.slice(0, 2)}/${digest}.png`,
353
350
  size: body.length,
354
351
  mime: 'image/png',
355
352
  sha256: digest,
356
353
  });
357
- expect(fsPromises.mkdir).toHaveBeenCalledWith(
358
- `/data/openchamber/prompt-attachments/${digest.slice(0, 2)}`,
359
- { recursive: true, mode: 0o700 },
360
- );
361
- const tmp = fsPromises.writeFile.mock.calls[0][0];
362
- expect(tmp).toMatch(new RegExp(`^/data/openchamber/prompt-attachments/${digest.slice(0, 2)}/${digest}\\.png\\.tmp-`));
363
- expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, body, { mode: 0o600 });
364
- expect(fsPromises.rename).toHaveBeenCalledWith(
365
- tmp,
366
- `/data/openchamber/prompt-attachments/${digest.slice(0, 2)}/${digest}.png`,
367
- );
354
+ expect(fs.existsSync(res.body.path)).toBe(true);
355
+ expect(path.basename(res.body.path)).toBe(`${digest}.png`);
356
+ fs.rmSync(dataDir, { recursive: true, force: true });
368
357
  });
369
358
 
370
359
  it('rejects digest mismatches without writing a durable file', async () => {
371
- const fsPromises = {
372
- mkdir: vi.fn(async () => undefined),
373
- writeFile: vi.fn(async () => undefined),
374
- rename: vi.fn(async () => undefined),
375
- unlink: vi.fn(async () => undefined),
376
- };
377
- const handler = registerPromptAttachment(fsPromises);
360
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-prompt-att-bad-'));
361
+ const handler = registerPromptAttachment({}, { openchamberDataDir: dataDir });
378
362
  const body = Buffer.from('hello-image');
379
363
  const req = {
380
364
  params: { attachmentID: 'att-1' },
@@ -391,8 +375,9 @@ describe('fs prompt attachments', () => {
391
375
  await handler(req, res);
392
376
  expect(res.statusCode).toBe(400);
393
377
  expect(res.body).toEqual({ error: 'Attachment digest mismatch' });
394
- expect(fsPromises.writeFile).not.toHaveBeenCalled();
395
- expect(fsPromises.rename).not.toHaveBeenCalled();
378
+ const storeRoot = path.join(dataDir, 'prompt-attachments');
379
+ expect(fs.existsSync(storeRoot) ? fs.readdirSync(storeRoot).length : 0).toBe(0);
380
+ fs.rmSync(dataDir, { recursive: true, force: true });
396
381
  });
397
382
 
398
383
  it('reports unavailable storage when the data dir is missing', async () => {
@@ -5,8 +5,19 @@ const stringID = (value) => (typeof value === 'string' && value.trim() ? value.t
5
5
  const listIncludesImage = (value) => Array.isArray(value)
6
6
  && value.some((item) => String(item).toLowerCase() === 'image');
7
7
 
8
- function modelAcceptsImages(model) {
8
+ /**
9
+ * Vision capability projection.
10
+ * Prefer authoritative OpenCode SDK Model.capabilities (`input.image` / `attachment`),
11
+ * then fall back to legacy modalities/input/attachment shapes used by older fixtures.
12
+ */
13
+ export function modelAcceptsImages(model) {
9
14
  if (!isRecord(model)) return false;
15
+ if (isRecord(model.capabilities)) {
16
+ if (isRecord(model.capabilities.input) && typeof model.capabilities.input.image === 'boolean') {
17
+ return model.capabilities.input.image === true;
18
+ }
19
+ if (model.capabilities.attachment === true) return true;
20
+ }
10
21
  if (isRecord(model.modalities) && listIncludesImage(model.modalities.input)) return true;
11
22
  if (listIncludesImage(model.input)) return true;
12
23
  return model.attachment === true;
@@ -40,7 +51,12 @@ export function projectConnectedModels(source) {
40
51
  if (!isRecord(model)) continue;
41
52
  const modelID = stringID(model.id);
42
53
  if (!modelID) continue;
43
- const entry = { providerID, modelID, name: stringID(model.name) || modelID, acceptsImages: modelAcceptsImages(model) };
54
+ const entry = {
55
+ providerID,
56
+ modelID,
57
+ name: stringID(model.name) || modelID,
58
+ acceptsImages: modelAcceptsImages(model),
59
+ };
44
60
  models.push(entry);
45
61
  providerModels.push({ id: modelID, name: entry.name });
46
62
  }
@@ -76,25 +92,84 @@ export function isConnectedModel(catalog, providerID, modelID) {
76
92
  return catalog.models.some((entry) => entry.providerID === providerID && entry.modelID === modelID);
77
93
  }
78
94
 
95
+ /** Bound provider/config catalog loads so a pending SDK call cannot stall a contact lane. */
96
+ export const CONNECTED_CATALOG_TIMEOUT_MS = 8_000;
97
+
98
+ const isAbortError = (error) => (
99
+ error?.name === 'AbortError'
100
+ || error?.code === 'ABORT_ERR'
101
+ || (typeof error?.message === 'string' && /aborted|timed out/i.test(error.message))
102
+ );
103
+
79
104
  /**
80
105
  * Load the connected catalog from an OpenCode SDK client.
81
106
  * Failure is distinct from a successful empty catalog.
107
+ * Uses a bounded AbortSignal (default 8s) on provider.list + config.providers.
108
+ *
109
+ * @param {object} client
110
+ * @param {{ signal?: AbortSignal, timeoutMs?: number }} [options]
82
111
  */
83
- export async function loadConnectedCatalog(client) {
84
- const listed = await client.provider.list({});
85
- if (listed?.error || !isRecord(listed?.data)) {
86
- const error = new Error('OpenCode provider list is unavailable');
87
- error.code = 'upstream_error';
88
- throw error;
112
+ export async function loadConnectedCatalog(client, options = {}) {
113
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
114
+ ? Math.trunc(options.timeoutMs)
115
+ : CONNECTED_CATALOG_TIMEOUT_MS;
116
+ const parentSignal = options.signal;
117
+ const controller = new AbortController();
118
+ const onParentAbort = () => {
119
+ try {
120
+ controller.abort(parentSignal?.reason instanceof Error
121
+ ? parentSignal.reason
122
+ : new Error('Connected catalog aborted'));
123
+ } catch {
124
+ // ignore
125
+ }
126
+ };
127
+ if (parentSignal) {
128
+ if (parentSignal.aborted) onParentAbort();
129
+ else parentSignal.addEventListener('abort', onParentAbort, { once: true });
89
130
  }
90
- const configured = await client.config.providers({});
91
- if (configured?.error || !isRecord(configured?.data)) {
92
- const error = new Error('OpenCode provider catalog is unavailable');
93
- error.code = 'upstream_error';
131
+ const timer = setTimeout(() => {
132
+ try {
133
+ controller.abort(new Error(`Connected catalog timed out after ${timeoutMs}ms`));
134
+ } catch {
135
+ // ignore
136
+ }
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+
140
+ const requestOptions = { signal: controller.signal };
141
+ try {
142
+ const listed = await client.provider.list({}, requestOptions);
143
+ if (listed?.error || !isRecord(listed?.data)) {
144
+ const error = new Error('OpenCode provider list is unavailable');
145
+ error.code = 'upstream_error';
146
+ throw error;
147
+ }
148
+ const configured = await client.config.providers({}, requestOptions);
149
+ if (configured?.error || !isRecord(configured?.data)) {
150
+ const error = new Error('OpenCode provider catalog is unavailable');
151
+ error.code = 'upstream_error';
152
+ throw error;
153
+ }
154
+ return projectConnectedModels({
155
+ connected: listed.data.connected,
156
+ providers: configured.data.providers,
157
+ });
158
+ } catch (error) {
159
+ if (controller.signal.aborted || isAbortError(error)) {
160
+ const timedOut = typeof error?.message === 'string' && /timed out/i.test(error.message)
161
+ || (controller.signal.reason instanceof Error && /timed out/i.test(controller.signal.reason.message));
162
+ const next = new Error(
163
+ timedOut
164
+ ? `Connected catalog timed out after ${timeoutMs}ms`
165
+ : 'Connected catalog request was aborted',
166
+ );
167
+ next.code = 'upstream_error';
168
+ throw next;
169
+ }
94
170
  throw error;
171
+ } finally {
172
+ clearTimeout(timer);
173
+ parentSignal?.removeEventListener?.('abort', onParentAbort);
95
174
  }
96
- return projectConnectedModels({
97
- connected: listed.data.connected,
98
- providers: configured.data.providers,
99
- });
100
175
  }