@openchambery/web 1.19.13-beta.1 → 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-CiQIIM0d.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-CeKHYyKZ.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-B-odd2Gz.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-BT5Dwgzu.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-DfEGfyr8.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-CIZ-shMD.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-DGz4_2hs.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-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-BeeU2o4I.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-CxhTsmRq.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-BYuzpcI5.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-Bn7cwEzv.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-BBLFgl85.js → runtimeEndpointReset-CSub_ecV.js} +6 -6
- package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-C8VZn0gJ.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-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-BIFw36SA.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-DJSYYkVp.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-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-Bopgc5Eo.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/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +39 -7
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- 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 +494 -25
- package/server/lib/assistants/contact-store.test.js +403 -1
- package/server/lib/assistants/contact-tools.js +6 -5
- package/server/lib/assistants/contact-tools.test.js +24 -6
- package/server/lib/assistants/contracts.js +41 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +258 -66
- package/server/lib/assistants/service.test.js +400 -30
- 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/opencode/core-routes.js +2 -1
- package/dist/assets/AssistantView-YmeyxehG.js +0 -2
- package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
- package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
- package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
- package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
- package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
- package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
- package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
- package/dist/assets/TerminalView-8402vpcO.js +0 -8
- package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
- package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
- package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
- package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
- package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
- package/dist/assets/main-CLj_WS1o.js +0 -2
- package/dist/assets/main-CnSZ89pD.js +0 -169
- package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
- package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
- package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
- package/dist/assets/miniChat-B9c3y2pD.js +0 -2
- package/dist/assets/mobile-DvbXUfNS.js +0 -2
- package/dist/assets/persistence-CVTWFaqe.js +0 -3
- package/dist/assets/platform-BY_Gtsqr.js +0 -2
- package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
- package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
- package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
- package/dist/assets/src-BpHDMgud.css +0 -2
- package/dist/assets/terminalApi-C54_rKEK.js +0 -1
- package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
- package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
- package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
- package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
- package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.js +0 -7
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 fault-injection tests for contact attachments (not happy-path only).
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import fsp from 'node:fs/promises';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
bumpContactGeneration,
|
|
13
|
+
contactPartsFingerprint,
|
|
14
|
+
ensureContactSchema,
|
|
15
|
+
insertContactMessage,
|
|
16
|
+
nextContactOrdinal,
|
|
17
|
+
} from './contact-store.js';
|
|
18
|
+
import {
|
|
19
|
+
ensureContactAttachmentSchema,
|
|
20
|
+
putContactAttachment,
|
|
21
|
+
migrateContactDataUrlParts,
|
|
22
|
+
materializeContactParts,
|
|
23
|
+
assertContactAttachmentReadable,
|
|
24
|
+
canonicalDescriptorFromRow,
|
|
25
|
+
getContactAttachmentRow,
|
|
26
|
+
} from './contact-attachments.js';
|
|
27
|
+
import { storePromptAttachmentBytes } from '../fs/prompt-attachment-store.js';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
const realPng = Buffer.from([
|
|
32
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
33
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
34
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
35
|
+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
36
|
+
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
|
37
|
+
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
38
|
+
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
|
|
39
|
+
0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
|
40
|
+
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const open = () => {
|
|
44
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'p1-att-'));
|
|
45
|
+
const Database = require('better-sqlite3');
|
|
46
|
+
const db = new Database(path.join(directory, 'a.sqlite'));
|
|
47
|
+
ensureContactSchema(db);
|
|
48
|
+
ensureContactAttachmentSchema(db);
|
|
49
|
+
return { db, dataDir: directory, directory };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const streamOf = (buffer) => ({
|
|
53
|
+
async *[Symbol.asyncIterator]() { yield buffer; },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('P1 contact attachment faults', () => {
|
|
57
|
+
it('fingerprint is content-identity; random attachmentID does not break same-content replay', () => {
|
|
58
|
+
const digest = createHash('sha256').update(realPng).digest('hex');
|
|
59
|
+
const a = contactPartsFingerprint([
|
|
60
|
+
{ type: 'file', mime: 'image/png', attachmentID: 'att_1', sha256: digest, size: realPng.length, filename: 'a.png' },
|
|
61
|
+
]);
|
|
62
|
+
const b = contactPartsFingerprint([
|
|
63
|
+
{ type: 'file', mime: 'image/png', attachmentID: 'att_RANDOM_OTHER', sha256: digest, size: realPng.length, filename: 'a.png' },
|
|
64
|
+
]);
|
|
65
|
+
const c = contactPartsFingerprint([
|
|
66
|
+
{ type: 'file', mime: 'image/png', url: `data:image/png;base64,${realPng.toString('base64')}`, filename: 'a.png' },
|
|
67
|
+
]);
|
|
68
|
+
// Same content + mime + filename → identical fingerprint regardless of attachmentID / url form.
|
|
69
|
+
expect(a).toBe(b);
|
|
70
|
+
expect(a).toBe(c);
|
|
71
|
+
const different = contactPartsFingerprint([
|
|
72
|
+
{ type: 'file', mime: 'image/png', attachmentID: 'att_2', sha256: 'b'.repeat(64), size: 9, filename: 'a.png' },
|
|
73
|
+
]);
|
|
74
|
+
expect(different).not.toBe(a);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forged client metadata is replaced by storage row; reset during upload conflicts', async () => {
|
|
78
|
+
const { db, dataDir, directory } = open();
|
|
79
|
+
const digest = createHash('sha256').update(realPng).digest('hex');
|
|
80
|
+
const headers = {
|
|
81
|
+
'x-content-size': String(realPng.length),
|
|
82
|
+
'x-content-sha256': digest,
|
|
83
|
+
'content-type': 'image/png',
|
|
84
|
+
'x-attachment-filename': 'real.png',
|
|
85
|
+
};
|
|
86
|
+
const desc = await putContactAttachment({
|
|
87
|
+
db, dataDir, assistantID: 'asst_a', uploadID: 'up_ok', stream: streamOf(realPng), headers,
|
|
88
|
+
});
|
|
89
|
+
// Client lies about size/mime — admission rebuilds from row via readable assert.
|
|
90
|
+
const row = assertContactAttachmentReadable(db, { attachmentID: desc.attachmentID, assistantID: 'asst_a' });
|
|
91
|
+
const canonical = canonicalDescriptorFromRow(row);
|
|
92
|
+
expect(canonical).toEqual(desc);
|
|
93
|
+
expect(canonical.size).toBe(realPng.length);
|
|
94
|
+
expect(canonical.sha256).toBe(digest);
|
|
95
|
+
|
|
96
|
+
// Different content same uploadID after success → conflict
|
|
97
|
+
const other = Buffer.concat([realPng, Buffer.from([1])]);
|
|
98
|
+
// pad to invalid png but different hash
|
|
99
|
+
await expect(putContactAttachment({
|
|
100
|
+
db,
|
|
101
|
+
dataDir,
|
|
102
|
+
assistantID: 'asst_a',
|
|
103
|
+
uploadID: 'up_ok',
|
|
104
|
+
stream: streamOf(other),
|
|
105
|
+
headers: {
|
|
106
|
+
'x-content-size': String(other.length),
|
|
107
|
+
'x-content-sha256': createHash('sha256').update(other).digest('hex'),
|
|
108
|
+
'content-type': 'image/png',
|
|
109
|
+
},
|
|
110
|
+
})).rejects.toMatchObject({ code: 'idempotency_conflict' });
|
|
111
|
+
|
|
112
|
+
// Reset then external readable fails
|
|
113
|
+
bumpContactGeneration(db, 'asst_a');
|
|
114
|
+
expect(() => assertContactAttachmentReadable(db, {
|
|
115
|
+
attachmentID: desc.attachmentID,
|
|
116
|
+
assistantID: 'asst_a',
|
|
117
|
+
})).toThrowError(expect.objectContaining({ code: 'not_found' }));
|
|
118
|
+
|
|
119
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects parent symlink escape and corrupt existing object hash', async () => {
|
|
123
|
+
const { dataDir, directory } = open();
|
|
124
|
+
const outside = path.join(directory, 'outside-secret');
|
|
125
|
+
fs.writeFileSync(outside, 'secret');
|
|
126
|
+
const storeRoot = path.join(dataDir, 'prompt-attachments');
|
|
127
|
+
fs.mkdirSync(storeRoot, { recursive: true });
|
|
128
|
+
// Plant a symlink object that would escape if followed.
|
|
129
|
+
const digest = createHash('sha256').update(realPng).digest('hex');
|
|
130
|
+
const bucket = path.join(storeRoot, digest.slice(0, 2));
|
|
131
|
+
fs.mkdirSync(bucket, { recursive: true });
|
|
132
|
+
const target = path.join(bucket, `${digest}.png`);
|
|
133
|
+
try {
|
|
134
|
+
fs.symlinkSync(outside, target);
|
|
135
|
+
} catch {
|
|
136
|
+
// platforms without symlink support skip
|
|
137
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await expect(storePromptAttachmentBytes({
|
|
141
|
+
dataDir,
|
|
142
|
+
buffer: realPng,
|
|
143
|
+
expectedSha256: digest,
|
|
144
|
+
mime: 'image/png',
|
|
145
|
+
validateImage: true,
|
|
146
|
+
})).rejects.toSatisfy((error) => (
|
|
147
|
+
error?.code === 'attachment_unsafe_path'
|
|
148
|
+
|| error?.code === 'ELOOP'
|
|
149
|
+
|| /symlink|ELOOP|Unsafe/i.test(String(error?.message || error?.code || ''))
|
|
150
|
+
));
|
|
151
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('migration CAS fails closed when generation bumps mid-flight; continues other rows', async () => {
|
|
155
|
+
const { db, dataDir, directory } = open();
|
|
156
|
+
const url = `data:image/png;base64,${realPng.toString('base64')}`;
|
|
157
|
+
for (let i = 0; i < 3; i += 1) {
|
|
158
|
+
const messageID = `msg_m_${i}`;
|
|
159
|
+
insertContactMessage(db, {
|
|
160
|
+
messageID,
|
|
161
|
+
assistantID: 'asst_a',
|
|
162
|
+
role: 'user',
|
|
163
|
+
turnID: messageID,
|
|
164
|
+
bubbleIndex: 0,
|
|
165
|
+
createdAt: Date.now(),
|
|
166
|
+
ordinal: nextContactOrdinal(db, 'asst_a'),
|
|
167
|
+
status: 'complete',
|
|
168
|
+
parts: [{ type: 'file', mime: 'image/png', url, filename: `f${i}.png` }],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// After first part migrates, bump generation mid-run via custom yield.
|
|
172
|
+
let bumped = false;
|
|
173
|
+
const result = await migrateContactDataUrlParts({
|
|
174
|
+
db,
|
|
175
|
+
dataDir,
|
|
176
|
+
assistantID: 'asst_a',
|
|
177
|
+
yieldFn: async () => {
|
|
178
|
+
if (!bumped) {
|
|
179
|
+
bumpContactGeneration(db, 'asst_a');
|
|
180
|
+
bumped = true;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
batchSize: 1,
|
|
184
|
+
batchBytes: 1,
|
|
185
|
+
});
|
|
186
|
+
expect(result.scanned).toBeGreaterThan(0);
|
|
187
|
+
// At least one failure or success recorded; failures array is explicit.
|
|
188
|
+
expect(Array.isArray(result.failures)).toBe(true);
|
|
189
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('materialize uses canonical size: exact fit completes; over budget errors (no silent drop)', async () => {
|
|
193
|
+
const { db, dataDir, directory } = open();
|
|
194
|
+
// Synthetic 1-byte bodies via data URL — canonical size field drives budget.
|
|
195
|
+
const oneA = { type: 'file', mime: 'application/octet-stream', url: 'data:application/octet-stream;base64,QQ==', size: 1, filename: 'a.bin' };
|
|
196
|
+
const oneB = { type: 'file', mime: 'application/octet-stream', url: 'data:application/octet-stream;base64,Qg==', size: 1, filename: 'b.bin' };
|
|
197
|
+
const twoFit = await materializeContactParts(db, dataDir, 'asst_a', [
|
|
198
|
+
{ type: 'text', text: 'hi' },
|
|
199
|
+
oneA,
|
|
200
|
+
oneB,
|
|
201
|
+
], { budgetBytes: 2 });
|
|
202
|
+
expect(twoFit.filter((p) => p.type === 'file')).toHaveLength(2);
|
|
203
|
+
expect(twoFit[0]).toEqual({ type: 'text', text: 'hi' });
|
|
204
|
+
|
|
205
|
+
await expect(materializeContactParts(db, dataDir, 'asst_a', [
|
|
206
|
+
oneA, oneB, { ...oneA, filename: 'c.bin' },
|
|
207
|
+
], { budgetBytes: 2 })).rejects.toMatchObject({ code: 'attachment_budget_exceeded' });
|
|
208
|
+
|
|
209
|
+
// Two 25MiB under 50MiB turn budget must complete (pre-sum + per-file gate, no silent continue).
|
|
210
|
+
const twentyFive = 25 * 1024 * 1024;
|
|
211
|
+
const bigA = { type: 'file', mime: 'application/octet-stream', url: 'data:application/octet-stream;base64,QQ==', size: twentyFive, filename: 'big-a.bin' };
|
|
212
|
+
const bigB = { type: 'file', mime: 'application/octet-stream', url: 'data:application/octet-stream;base64,Qg==', size: twentyFive, filename: 'big-b.bin' };
|
|
213
|
+
const fiftyFit = await materializeContactParts(db, dataDir, 'asst_a', [bigA, bigB], {
|
|
214
|
+
budgetBytes: 50 * 1024 * 1024,
|
|
215
|
+
});
|
|
216
|
+
expect(fiftyFit.filter((p) => p.type === 'file')).toHaveLength(2);
|
|
217
|
+
|
|
218
|
+
await expect(materializeContactParts(db, dataDir, 'asst_a', [
|
|
219
|
+
bigA, bigB, { ...bigA, size: 1, filename: 'extra.bin' },
|
|
220
|
+
], { budgetBytes: 50 * 1024 * 1024 })).rejects.toMatchObject({ code: 'attachment_budget_exceeded' });
|
|
221
|
+
|
|
222
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('migration storeBytes failure preserves row and continues siblings', async () => {
|
|
226
|
+
const { db, dataDir, directory } = open();
|
|
227
|
+
const goodUrl = `data:image/png;base64,${realPng.toString('base64')}`;
|
|
228
|
+
insertContactMessage(db, {
|
|
229
|
+
messageID: 'msg_disk_fail',
|
|
230
|
+
assistantID: 'asst_a',
|
|
231
|
+
role: 'user',
|
|
232
|
+
turnID: 'msg_disk_fail',
|
|
233
|
+
bubbleIndex: 0,
|
|
234
|
+
createdAt: Date.now(),
|
|
235
|
+
ordinal: nextContactOrdinal(db, 'asst_a'),
|
|
236
|
+
status: 'complete',
|
|
237
|
+
parts: [{ type: 'file', mime: 'image/png', url: goodUrl, filename: 'fail.png' }],
|
|
238
|
+
});
|
|
239
|
+
insertContactMessage(db, {
|
|
240
|
+
messageID: 'msg_disk_ok',
|
|
241
|
+
assistantID: 'asst_a',
|
|
242
|
+
role: 'user',
|
|
243
|
+
turnID: 'msg_disk_ok',
|
|
244
|
+
bubbleIndex: 0,
|
|
245
|
+
createdAt: Date.now(),
|
|
246
|
+
ordinal: nextContactOrdinal(db, 'asst_a'),
|
|
247
|
+
status: 'complete',
|
|
248
|
+
parts: [{ type: 'file', mime: 'image/png', url: goodUrl, filename: 'ok.png' }],
|
|
249
|
+
});
|
|
250
|
+
const beforeFail = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_fail').part_json;
|
|
251
|
+
let calls = 0;
|
|
252
|
+
// Inject only on the first write failure; sibling uses real store.
|
|
253
|
+
const result = await migrateContactDataUrlParts({
|
|
254
|
+
db,
|
|
255
|
+
dataDir,
|
|
256
|
+
assistantID: 'asst_a',
|
|
257
|
+
storeBytes: async (args) => {
|
|
258
|
+
calls += 1;
|
|
259
|
+
if (calls === 1) {
|
|
260
|
+
const err = new Error('ENOSPC');
|
|
261
|
+
err.code = 'ENOSPC';
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
return storePromptAttachmentBytes(args);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
268
|
+
expect(result.failures.some((f) => f.error === 'ENOSPC' || /ENOSPC/.test(String(f.error)))).toBe(true);
|
|
269
|
+
const afterFail = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_fail').part_json;
|
|
270
|
+
expect(afterFail).toBe(beforeFail);
|
|
271
|
+
expect(afterFail).toContain('data:image/png;base64,');
|
|
272
|
+
expect(result.migrated).toBeGreaterThanOrEqual(1);
|
|
273
|
+
const afterOk = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_ok').part_json;
|
|
274
|
+
expect(afterOk).toContain('attachmentID');
|
|
275
|
+
expect(afterOk).not.toContain('data:image/png;base64,');
|
|
276
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('migration yields before decode when estimated bytes would exceed batch budget', async () => {
|
|
280
|
+
const { db, dataDir, directory } = open();
|
|
281
|
+
const url = `data:image/png;base64,${realPng.toString('base64')}`;
|
|
282
|
+
for (let i = 0; i < 3; i += 1) {
|
|
283
|
+
insertContactMessage(db, {
|
|
284
|
+
messageID: `msg_y_${i}`,
|
|
285
|
+
assistantID: 'asst_a',
|
|
286
|
+
role: 'user',
|
|
287
|
+
turnID: `msg_y_${i}`,
|
|
288
|
+
bubbleIndex: 0,
|
|
289
|
+
createdAt: Date.now(),
|
|
290
|
+
ordinal: nextContactOrdinal(db, 'asst_a'),
|
|
291
|
+
status: 'complete',
|
|
292
|
+
parts: [{ type: 'file', mime: 'image/png', url, filename: `y${i}.png` }],
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
let yields = 0;
|
|
296
|
+
await migrateContactDataUrlParts({
|
|
297
|
+
db,
|
|
298
|
+
dataDir,
|
|
299
|
+
assistantID: 'asst_a',
|
|
300
|
+
batchSize: 100,
|
|
301
|
+
batchBytes: realPng.length, // one file fills batch
|
|
302
|
+
yieldFn: async () => { yields += 1; },
|
|
303
|
+
});
|
|
304
|
+
expect(yields).toBeGreaterThanOrEqual(1);
|
|
305
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
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 { createRequire } from 'node:module';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { ensureContactSchema, bumpContactGeneration, insertContactMessage, nextContactOrdinal } from './contact-store.js';
|
|
9
|
+
import {
|
|
10
|
+
ensureContactAttachmentSchema,
|
|
11
|
+
putContactAttachment,
|
|
12
|
+
readContactAttachmentBytes,
|
|
13
|
+
materializeContactFilePart,
|
|
14
|
+
migrateContactDataUrlParts,
|
|
15
|
+
assertContactAttachmentOwned,
|
|
16
|
+
} from './contact-attachments.js';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
const png = Buffer.from([
|
|
21
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
22
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
23
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
24
|
+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
25
|
+
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
|
26
|
+
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
27
|
+
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
|
|
28
|
+
0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
|
29
|
+
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const open = () => {
|
|
33
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'contact-att-'));
|
|
34
|
+
const Database = require('better-sqlite3');
|
|
35
|
+
const db = new Database(path.join(directory, 'a.sqlite'));
|
|
36
|
+
ensureContactSchema(db);
|
|
37
|
+
ensureContactAttachmentSchema(db);
|
|
38
|
+
return { db, dataDir: directory, directory };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
const streamOf = (buffer) => ({
|
|
43
|
+
async *[Symbol.asyncIterator]() {
|
|
44
|
+
yield buffer;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('contact attachments', () => {
|
|
49
|
+
it('PUT is idempotent on same uploadID+content and conflicts on different content', async () => {
|
|
50
|
+
const { db, dataDir, directory } = open();
|
|
51
|
+
const digest = createHash('sha256').update(png).digest('hex');
|
|
52
|
+
const headers = {
|
|
53
|
+
'x-content-size': String(png.length),
|
|
54
|
+
'x-content-sha256': digest,
|
|
55
|
+
'content-type': 'image/png',
|
|
56
|
+
'x-attachment-filename': encodeURIComponent('shot.png'),
|
|
57
|
+
};
|
|
58
|
+
const first = await putContactAttachment({
|
|
59
|
+
db,
|
|
60
|
+
dataDir,
|
|
61
|
+
assistantID: 'asst_a',
|
|
62
|
+
uploadID: 'up1',
|
|
63
|
+
stream: streamOf(png),
|
|
64
|
+
headers,
|
|
65
|
+
});
|
|
66
|
+
expect(first).toMatchObject({
|
|
67
|
+
type: 'file',
|
|
68
|
+
attachmentID: expect.stringMatching(/^att_/),
|
|
69
|
+
sha256: digest,
|
|
70
|
+
size: png.length,
|
|
71
|
+
mime: 'image/png',
|
|
72
|
+
filename: 'shot.png',
|
|
73
|
+
});
|
|
74
|
+
expect(first).not.toHaveProperty('url');
|
|
75
|
+
expect(JSON.stringify(first)).not.toContain(dataDir);
|
|
76
|
+
|
|
77
|
+
const second = await putContactAttachment({
|
|
78
|
+
db,
|
|
79
|
+
dataDir,
|
|
80
|
+
assistantID: 'asst_a',
|
|
81
|
+
uploadID: 'up1',
|
|
82
|
+
stream: streamOf(png),
|
|
83
|
+
headers,
|
|
84
|
+
});
|
|
85
|
+
expect(second.attachmentID).toBe(first.attachmentID);
|
|
86
|
+
|
|
87
|
+
await expect(putContactAttachment({
|
|
88
|
+
db,
|
|
89
|
+
dataDir,
|
|
90
|
+
assistantID: 'asst_a',
|
|
91
|
+
uploadID: 'up1',
|
|
92
|
+
stream: streamOf(png),
|
|
93
|
+
headers: { ...headers, 'x-content-sha256': 'a'.repeat(64) },
|
|
94
|
+
})).rejects.toMatchObject({ code: 'idempotency_conflict' });
|
|
95
|
+
|
|
96
|
+
// Cross-assistant cannot read.
|
|
97
|
+
await expect(readContactAttachmentBytes({
|
|
98
|
+
db,
|
|
99
|
+
dataDir,
|
|
100
|
+
assistantID: 'asst_other',
|
|
101
|
+
attachmentID: first.attachmentID,
|
|
102
|
+
external: true,
|
|
103
|
+
})).rejects.toMatchObject({ code: 'not_found' });
|
|
104
|
+
|
|
105
|
+
const bytes = await readContactAttachmentBytes({
|
|
106
|
+
db,
|
|
107
|
+
dataDir,
|
|
108
|
+
assistantID: 'asst_a',
|
|
109
|
+
attachmentID: first.attachmentID,
|
|
110
|
+
external: true,
|
|
111
|
+
});
|
|
112
|
+
expect(bytes.buffer.equals(png)).toBe(true);
|
|
113
|
+
expect(bytes.etag).toContain(digest);
|
|
114
|
+
|
|
115
|
+
// Generation bump invalidates external GET.
|
|
116
|
+
bumpContactGeneration(db, 'asst_a');
|
|
117
|
+
await expect(readContactAttachmentBytes({
|
|
118
|
+
db,
|
|
119
|
+
dataDir,
|
|
120
|
+
assistantID: 'asst_a',
|
|
121
|
+
attachmentID: first.attachmentID,
|
|
122
|
+
external: true,
|
|
123
|
+
})).rejects.toMatchObject({ code: 'not_found' });
|
|
124
|
+
// Owned (in-flight turn) still allowed.
|
|
125
|
+
expect(assertContactAttachmentOwned(db, {
|
|
126
|
+
attachmentID: first.attachmentID,
|
|
127
|
+
assistantID: 'asst_a',
|
|
128
|
+
}).attachment_id).toBe(first.attachmentID);
|
|
129
|
+
|
|
130
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects size/hash/mime forgery and path-like filenames', async () => {
|
|
134
|
+
const { db, dataDir, directory } = open();
|
|
135
|
+
const digest = createHash('sha256').update(png).digest('hex');
|
|
136
|
+
await expect(putContactAttachment({
|
|
137
|
+
db,
|
|
138
|
+
dataDir,
|
|
139
|
+
assistantID: 'asst_a',
|
|
140
|
+
uploadID: 'badsize',
|
|
141
|
+
stream: streamOf(png),
|
|
142
|
+
headers: {
|
|
143
|
+
'x-content-size': String(png.length + 1),
|
|
144
|
+
'x-content-sha256': digest,
|
|
145
|
+
'content-type': 'image/png',
|
|
146
|
+
},
|
|
147
|
+
})).rejects.toMatchObject({ code: 'PROMPT_ATTACHMENT_SIZE_MISMATCH' });
|
|
148
|
+
|
|
149
|
+
await expect(putContactAttachment({
|
|
150
|
+
db,
|
|
151
|
+
dataDir,
|
|
152
|
+
assistantID: 'asst_a',
|
|
153
|
+
uploadID: 'badhash',
|
|
154
|
+
stream: streamOf(png),
|
|
155
|
+
headers: {
|
|
156
|
+
'x-content-size': String(png.length),
|
|
157
|
+
'x-content-sha256': 'b'.repeat(64),
|
|
158
|
+
'content-type': 'image/png',
|
|
159
|
+
},
|
|
160
|
+
})).rejects.toMatchObject({ code: 'attachment_hash_mismatch' });
|
|
161
|
+
|
|
162
|
+
await expect(putContactAttachment({
|
|
163
|
+
db,
|
|
164
|
+
dataDir,
|
|
165
|
+
assistantID: 'asst_a',
|
|
166
|
+
uploadID: 'badmime',
|
|
167
|
+
stream: streamOf(png),
|
|
168
|
+
headers: {
|
|
169
|
+
'x-content-size': String(png.length),
|
|
170
|
+
'x-content-sha256': digest,
|
|
171
|
+
'content-type': 'image/jpeg',
|
|
172
|
+
},
|
|
173
|
+
})).rejects.toMatchObject({ code: 'attachment_mime_mismatch' });
|
|
174
|
+
|
|
175
|
+
await expect(putContactAttachment({
|
|
176
|
+
db,
|
|
177
|
+
dataDir,
|
|
178
|
+
assistantID: 'asst_a',
|
|
179
|
+
uploadID: '../evil',
|
|
180
|
+
stream: streamOf(png),
|
|
181
|
+
headers: {
|
|
182
|
+
'x-content-size': String(png.length),
|
|
183
|
+
'x-content-sha256': digest,
|
|
184
|
+
'content-type': 'image/png',
|
|
185
|
+
},
|
|
186
|
+
})).rejects.toMatchObject({ code: 'validation_error' });
|
|
187
|
+
|
|
188
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('materializes descriptors to data URLs for model execution without storing url in DB', async () => {
|
|
192
|
+
const { db, dataDir, directory } = open();
|
|
193
|
+
const digest = createHash('sha256').update(png).digest('hex');
|
|
194
|
+
const desc = await putContactAttachment({
|
|
195
|
+
db,
|
|
196
|
+
dataDir,
|
|
197
|
+
assistantID: 'asst_a',
|
|
198
|
+
uploadID: 'mat1',
|
|
199
|
+
stream: streamOf(png),
|
|
200
|
+
headers: {
|
|
201
|
+
'x-content-size': String(png.length),
|
|
202
|
+
'x-content-sha256': digest,
|
|
203
|
+
'content-type': 'image/png',
|
|
204
|
+
'x-attachment-filename': 'x.png',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const materialized = await materializeContactFilePart(db, dataDir, 'asst_a', desc);
|
|
208
|
+
expect(materialized.url.startsWith('data:image/png;base64,')).toBe(true);
|
|
209
|
+
expect(materialized.mime).toBe('image/png');
|
|
210
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('migrates data-URL parts with CAS; failure keeps old JSON; reset race leaves row', async () => {
|
|
214
|
+
const { db, dataDir, directory } = open();
|
|
215
|
+
const dataUrl = `data:image/png;base64,${png.toString('base64')}`;
|
|
216
|
+
const messageID = 'msg_legacy';
|
|
217
|
+
insertContactMessage(db, {
|
|
218
|
+
messageID,
|
|
219
|
+
assistantID: 'asst_a',
|
|
220
|
+
role: 'user',
|
|
221
|
+
turnID: messageID,
|
|
222
|
+
bubbleIndex: 0,
|
|
223
|
+
createdAt: Date.now(),
|
|
224
|
+
ordinal: nextContactOrdinal(db, 'asst_a'),
|
|
225
|
+
status: 'complete',
|
|
226
|
+
parts: [{ type: 'file', mime: 'image/png', url: dataUrl, filename: 'old.png' }],
|
|
227
|
+
});
|
|
228
|
+
const before = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get(messageID).part_json;
|
|
229
|
+
expect(before).toContain('data:image/png');
|
|
230
|
+
|
|
231
|
+
const result = await migrateContactDataUrlParts({
|
|
232
|
+
db,
|
|
233
|
+
dataDir,
|
|
234
|
+
assistantID: 'asst_a',
|
|
235
|
+
});
|
|
236
|
+
expect(result.migrated).toBe(1);
|
|
237
|
+
const after = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get(messageID).part_json;
|
|
238
|
+
expect(after).toContain('attachmentID');
|
|
239
|
+
expect(after).not.toContain('data:image/png');
|
|
240
|
+
const parsed = JSON.parse(after);
|
|
241
|
+
expect(parsed.attachmentID).toMatch(/^att_/);
|
|
242
|
+
|
|
243
|
+
// Second pass is no-op.
|
|
244
|
+
const again = await migrateContactDataUrlParts({ db, dataDir, assistantID: 'asst_a' });
|
|
245
|
+
expect(again.migrated).toBe(0);
|
|
246
|
+
|
|
247
|
+
await fsp.rm(directory, { recursive: true, force: true });
|
|
248
|
+
});
|
|
249
|
+
});
|