@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.
- package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
- package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +56 -11
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +552 -19
- package/server/lib/assistants/contact-store.test.js +415 -1
- package/server/lib/assistants/contact-tools.js +114 -14
- package/server/lib/assistants/contact-tools.test.js +135 -6
- package/server/lib/assistants/contracts.js +42 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +569 -84
- package/server/lib/assistants/service.test.js +936 -37
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/core-routes.js +2 -1
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
- package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
- package/dist/assets/TerminalView-BiKCueth.js +0 -8
- package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
- package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
- package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
- package/dist/assets/main-C_PUoT3E.js +0 -169
- package/dist/assets/main-Dkg31fRY.js +0 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
- package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/mobile-Z_X7CbzB.js +0 -2
- package/dist/assets/persistence-DdUac14I.js +0 -3
- package/dist/assets/platform-B4KKLnZ5.js +0 -2
- package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
- package/dist/assets/src-B-P5qIBc.css +0 -2
- package/dist/assets/terminalApi-947l7Ra3.js +0 -1
- package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
- package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
- package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
- package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
- 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
|
+
});
|
package/server/lib/fs/routes.js
CHANGED
|
@@ -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
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
327
|
-
|
|
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 = '
|
|
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).
|
|
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(
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
395
|
-
expect(
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
}
|