@yeaft/webchat-agent 1.0.569 → 1.0.571
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/index.js +2 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +33 -25
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/work-center/attachment-platform.js +16 -0
- package/yeaft/work-center/attachments.js +267 -78
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const SUPPORTED_WORK_ITEM_ATTACHMENT_PLATFORMS = new Set(['linux', 'darwin', 'win32']);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WorkItem attachment storage has a descriptor-anchored Linux implementation
|
|
5
|
+
* and a checked, exclusive-create implementation for macOS and Windows.
|
|
6
|
+
*/
|
|
7
|
+
export function supportsWorkItemAttachments(platform = process.platform) {
|
|
8
|
+
return SUPPORTED_WORK_ITEM_ATTACHMENT_PLATFORMS.has(platform);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertWorkItemAttachmentPlatform(platform = process.platform) {
|
|
12
|
+
if (!supportsWorkItemAttachments(platform)) {
|
|
13
|
+
throw new Error(`Secure WorkItem attachment storage is unavailable on ${platform || 'this platform'}`);
|
|
14
|
+
}
|
|
15
|
+
return platform;
|
|
16
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
openSync,
|
|
9
9
|
readFileSync,
|
|
10
10
|
realpathSync,
|
|
11
|
+
renameSync,
|
|
11
12
|
rmSync,
|
|
12
13
|
unlinkSync,
|
|
13
14
|
writeFileSync,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
MAX_WORK_ITEM_ATTACHMENT_BYTES,
|
|
22
23
|
MAX_WORK_ITEM_INLINE_BYTES,
|
|
23
24
|
} from './attachment-policy.js';
|
|
25
|
+
import { assertWorkItemAttachmentPlatform } from './attachment-platform.js';
|
|
24
26
|
|
|
25
27
|
function isInsideOrEqual(parent, child) {
|
|
26
28
|
const rel = relative(parent, child);
|
|
@@ -57,17 +59,79 @@ function digest(buffer) {
|
|
|
57
59
|
return createHash('sha256').update(buffer).digest('hex');
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
function samePath(left, right) {
|
|
63
|
+
return relative(resolve(left), resolve(right)) === '';
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
function assertStableDirectory(directory, label, expectedIdentity = null) {
|
|
61
67
|
const expected = resolve(directory);
|
|
62
68
|
const stat = lstatSync(expected);
|
|
63
69
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} must be a real directory`);
|
|
64
70
|
const actual = realpathSync(expected);
|
|
65
|
-
|
|
71
|
+
// realpath also rejects symlink/junction ancestors. path.relative preserves
|
|
72
|
+
// Windows' case-insensitive path semantics without weakening POSIX checks.
|
|
73
|
+
if (!samePath(actual, expected)
|
|
74
|
+
|| (expectedIdentity && !samePath(actual, expectedIdentity))) {
|
|
66
75
|
throw new Error(`${label} identity changed`);
|
|
67
76
|
}
|
|
68
77
|
return actual;
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
function directoryIdentity(directory, label) {
|
|
81
|
+
const path = assertStableDirectory(directory, label);
|
|
82
|
+
const stat = lstatSync(directory);
|
|
83
|
+
return { path, dev: stat.dev, ino: stat.ino };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameFileIdentity(stat, identity) {
|
|
87
|
+
return stat.dev === identity.dev && stat.ino === identity.ino;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertDirectoryIdentity(directory, label, identity, options = {}) {
|
|
91
|
+
const path = assertStableDirectory(directory, label, options.allowRename ? null : identity.path);
|
|
92
|
+
const stat = lstatSync(directory);
|
|
93
|
+
if (!sameFileIdentity(stat, identity)) throw new Error(`${label} identity changed`);
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertRegularFileIdentity(descriptor, filePath, label, expectedIdentity = null) {
|
|
98
|
+
const descriptorStat = fstatSync(descriptor);
|
|
99
|
+
const pathStat = lstatSync(filePath);
|
|
100
|
+
if (!descriptorStat.isFile() || !pathStat.isFile() || pathStat.isSymbolicLink()
|
|
101
|
+
|| !sameFileIdentity(pathStat, descriptorStat)
|
|
102
|
+
|| (expectedIdentity && !sameFileIdentity(descriptorStat, expectedIdentity))) {
|
|
103
|
+
throw new Error(`${label} identity changed`);
|
|
104
|
+
}
|
|
105
|
+
return descriptorStat;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isLinuxDescriptorState(state) {
|
|
109
|
+
return state?.rootDescriptor !== undefined && state?.itemDescriptor !== undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertPortableDirectoryState(state) {
|
|
113
|
+
const root = assertDirectoryIdentity(
|
|
114
|
+
state.attachmentRoot,
|
|
115
|
+
'WorkItem attachment root',
|
|
116
|
+
state.rootIdentity,
|
|
117
|
+
);
|
|
118
|
+
const item = assertDirectoryIdentity(
|
|
119
|
+
state.itemDirectory,
|
|
120
|
+
'WorkItem attachment owner directory',
|
|
121
|
+
state.itemIdentity,
|
|
122
|
+
);
|
|
123
|
+
if (!isInsideOrEqual(root, item)) throw new Error('WorkItem attachment owner identity changed');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertDirectoryState(state) {
|
|
127
|
+
if (isLinuxDescriptorState(state)) {
|
|
128
|
+
assertDescriptorMatchesPath(state.rootDescriptor, state.attachmentRoot, 'WorkItem attachment root');
|
|
129
|
+
assertDescriptorMatchesPath(state.itemDescriptor, state.itemDirectory, 'WorkItem attachment owner directory');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
assertPortableDirectoryState(state);
|
|
133
|
+
}
|
|
134
|
+
|
|
71
135
|
function assertDescriptorMatchesPath(descriptor, directory, label) {
|
|
72
136
|
const descriptorStat = fstatSync(descriptor);
|
|
73
137
|
const pathStat = lstatSync(directory);
|
|
@@ -96,14 +160,42 @@ function openDirectory(directory, label) {
|
|
|
96
160
|
}
|
|
97
161
|
|
|
98
162
|
function prepareAttachmentDirectory(root, workItemId) {
|
|
99
|
-
|
|
100
|
-
throw new Error('Secure WorkItem attachment persistence requires Linux');
|
|
101
|
-
}
|
|
163
|
+
assertWorkItemAttachmentPlatform();
|
|
102
164
|
const attachmentRoot = resolve(root);
|
|
103
165
|
const parent = resolve(attachmentRoot, '..');
|
|
104
166
|
const rootName = basename(attachmentRoot);
|
|
105
167
|
if (!rootName || rootName === '.' || rootName === '..') throw new Error('Invalid WorkItem attachment root');
|
|
106
|
-
|
|
168
|
+
const parentIdentity = directoryIdentity(parent, 'WorkItem attachment parent');
|
|
169
|
+
if (process.platform !== 'linux') {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(attachmentRoot, { mode: 0o700 });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
174
|
+
}
|
|
175
|
+
assertDirectoryIdentity(parent, 'WorkItem attachment parent', parentIdentity);
|
|
176
|
+
const rootIdentity = directoryIdentity(attachmentRoot, 'WorkItem attachment root');
|
|
177
|
+
if (!isInsideOrEqual(parentIdentity.path, rootIdentity.path)) throw new Error('WorkItem attachment root identity changed');
|
|
178
|
+
const ownerName = safeWorkItemId(workItemId);
|
|
179
|
+
const itemDirectory = join(attachmentRoot, ownerName);
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(itemDirectory, { mode: 0o700 });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error?.code === 'EEXIST') throw new Error('WorkItem attachment owner directory already exists');
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let itemIdentity;
|
|
187
|
+
try {
|
|
188
|
+
assertDirectoryIdentity(attachmentRoot, 'WorkItem attachment root', rootIdentity);
|
|
189
|
+
itemIdentity = directoryIdentity(itemDirectory, 'WorkItem attachment owner directory');
|
|
190
|
+
if (!isInsideOrEqual(rootIdentity.path, itemIdentity.path)) throw new Error('WorkItem attachment owner identity changed');
|
|
191
|
+
return { attachmentRoot, itemDirectory, ownerName, rootIdentity, itemIdentity };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (itemIdentity) {
|
|
194
|
+
removeCreatedDirectory({ attachmentRoot, itemDirectory, ownerName, rootIdentity, itemIdentity });
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
107
199
|
const parentDescriptor = openDirectory(parent, 'WorkItem attachment parent');
|
|
108
200
|
let rootDescriptor;
|
|
109
201
|
try {
|
|
@@ -148,6 +240,30 @@ function attachmentDirectory(root, workItemId) {
|
|
|
148
240
|
}
|
|
149
241
|
|
|
150
242
|
function writeAttachmentFile(directoryState, storageName, buffer) {
|
|
243
|
+
if (!isLinuxDescriptorState(directoryState)) {
|
|
244
|
+
assertPortableDirectoryState(directoryState);
|
|
245
|
+
const filePath = join(directoryState.itemDirectory, storageName);
|
|
246
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
247
|
+
| (constants.O_NOFOLLOW || 0);
|
|
248
|
+
// Windows maps missing write bits to a read-only attribute, which can block
|
|
249
|
+
// single-file rollback. Integrity is checked by size and SHA-256 on reads;
|
|
250
|
+
// POSIX platforms keep the existing read-only file mode.
|
|
251
|
+
const fileMode = process.platform === 'win32' ? 0o600 : 0o400;
|
|
252
|
+
const descriptor = openSync(filePath, flags, fileMode);
|
|
253
|
+
try {
|
|
254
|
+
assertRegularFileIdentity(descriptor, filePath, 'WorkItem attachment file');
|
|
255
|
+
const actualPath = realpathSync(filePath);
|
|
256
|
+
if (!isInsideOrEqual(directoryState.itemIdentity.path, actualPath)) {
|
|
257
|
+
throw new Error('WorkItem attachment path escapes its owner');
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(descriptor, buffer);
|
|
260
|
+
fchmodSync(descriptor, fileMode);
|
|
261
|
+
assertPortableDirectoryState(directoryState);
|
|
262
|
+
} finally {
|
|
263
|
+
closeSync(descriptor);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
151
267
|
assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
|
|
152
268
|
assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
|
|
153
269
|
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
@@ -167,6 +283,19 @@ function closeDirectoryState(state) {
|
|
|
167
283
|
}
|
|
168
284
|
|
|
169
285
|
function removeCreatedDirectory(state) {
|
|
286
|
+
if (!isLinuxDescriptorState(state)) {
|
|
287
|
+
try {
|
|
288
|
+
assertPortableDirectoryState(state);
|
|
289
|
+
const quarantine = join(state.attachmentRoot, `.remove-${randomUUID()}`);
|
|
290
|
+
renameSync(state.itemDirectory, quarantine);
|
|
291
|
+
assertDirectoryIdentity(quarantine, 'WorkItem attachment cleanup directory', state.itemIdentity, { allowRename: true });
|
|
292
|
+
assertDirectoryIdentity(state.attachmentRoot, 'WorkItem attachment root', state.rootIdentity);
|
|
293
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
294
|
+
} catch {
|
|
295
|
+
// Never follow or remove a replacement path while handling another failure.
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
170
299
|
try {
|
|
171
300
|
assertDescriptorMatchesPath(state.rootDescriptor, state.attachmentRoot, 'WorkItem attachment root');
|
|
172
301
|
assertDescriptorMatchesPath(state.itemDescriptor, state.itemDirectory, 'WorkItem attachment owner directory');
|
|
@@ -177,10 +306,18 @@ function removeCreatedDirectory(state) {
|
|
|
177
306
|
}
|
|
178
307
|
|
|
179
308
|
function openAttachmentDirectory(root, workItemId) {
|
|
309
|
+
assertWorkItemAttachmentPlatform();
|
|
310
|
+
const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
|
|
180
311
|
if (process.platform !== 'linux') {
|
|
181
|
-
|
|
312
|
+
const rootIdentity = directoryIdentity(attachmentRoot, 'WorkItem attachment root');
|
|
313
|
+
const itemIdentity = directoryIdentity(itemDirectory, 'WorkItem attachment owner directory');
|
|
314
|
+
if (!isInsideOrEqual(rootIdentity.path, itemIdentity.path)) {
|
|
315
|
+
throw new Error('WorkItem attachment owner identity changed');
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
attachmentRoot, itemDirectory, ownerName: safeWorkItemId(workItemId), rootIdentity, itemIdentity,
|
|
319
|
+
};
|
|
182
320
|
}
|
|
183
|
-
const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
|
|
184
321
|
const rootDescriptor = openDirectory(attachmentRoot, 'WorkItem attachment root');
|
|
185
322
|
try {
|
|
186
323
|
const ownerName = safeWorkItemId(workItemId);
|
|
@@ -204,6 +341,19 @@ function openAttachmentDirectory(root, workItemId) {
|
|
|
204
341
|
|
|
205
342
|
function removeAttachmentFile(directoryState, storageName) {
|
|
206
343
|
if (!/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) return;
|
|
344
|
+
if (!isLinuxDescriptorState(directoryState)) {
|
|
345
|
+
assertPortableDirectoryState(directoryState);
|
|
346
|
+
const filePath = join(directoryState.itemDirectory, storageName);
|
|
347
|
+
try {
|
|
348
|
+
const stat = lstatSync(filePath);
|
|
349
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('WorkItem attachment is not a regular file');
|
|
350
|
+
unlinkSync(filePath);
|
|
351
|
+
assertPortableDirectoryState(directoryState);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
207
357
|
assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
|
|
208
358
|
assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
|
|
209
359
|
try {
|
|
@@ -237,13 +387,11 @@ export function persistWorkItemAttachments(files, options = {}) {
|
|
|
237
387
|
if (totalBytes > MAX_WORK_ITEM_ATTACHMENT_BYTES) {
|
|
238
388
|
throw new Error(`WorkItem attachments exceed ${MAX_WORK_ITEM_ATTACHMENT_BYTES} bytes`);
|
|
239
389
|
}
|
|
240
|
-
|
|
241
|
-
assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
|
|
390
|
+
assertDirectoryState(directoryState);
|
|
242
391
|
const id = randomUUID();
|
|
243
392
|
const storageName = `${id}${safeExtension(name)}`;
|
|
244
393
|
writeAttachmentFile(directoryState, storageName, buffer);
|
|
245
|
-
|
|
246
|
-
assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
|
|
394
|
+
assertDirectoryState(directoryState);
|
|
247
395
|
attachments.push({
|
|
248
396
|
id,
|
|
249
397
|
name,
|
|
@@ -334,8 +482,22 @@ export function removeWorkItemAttachmentFiles(root, workItemId, attachments) {
|
|
|
334
482
|
|
|
335
483
|
export function removeWorkItemAttachments(root, workItemId, options = {}) {
|
|
336
484
|
if (!root || !workItemId) return;
|
|
485
|
+
assertWorkItemAttachmentPlatform();
|
|
337
486
|
if (process.platform !== 'linux') {
|
|
338
|
-
|
|
487
|
+
try {
|
|
488
|
+
const state = openAttachmentDirectory(root, workItemId);
|
|
489
|
+
options.beforeRemove?.();
|
|
490
|
+
assertPortableDirectoryState(state);
|
|
491
|
+
const quarantine = join(state.attachmentRoot, `.remove-${randomUUID()}`);
|
|
492
|
+
renameSync(state.itemDirectory, quarantine);
|
|
493
|
+
assertDirectoryIdentity(quarantine, 'WorkItem attachment removal directory', state.itemIdentity, { allowRename: true });
|
|
494
|
+
assertDirectoryIdentity(state.attachmentRoot, 'WorkItem attachment root', state.rootIdentity);
|
|
495
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error?.code === 'ENOENT') return;
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
339
501
|
}
|
|
340
502
|
|
|
341
503
|
const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
|
|
@@ -375,21 +537,51 @@ export function removeWorkItemAttachments(root, workItemId, options = {}) {
|
|
|
375
537
|
}
|
|
376
538
|
}
|
|
377
539
|
|
|
378
|
-
function
|
|
379
|
-
const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
|
|
380
|
-
assertStableDirectory(attachmentRoot, 'WorkItem attachment root');
|
|
381
|
-
const itemRoot = assertStableDirectory(itemDirectory, 'WorkItem attachment owner directory');
|
|
540
|
+
function safeStorageName(attachment) {
|
|
382
541
|
const storageName = typeof attachment?.storageName === 'string' ? attachment.storageName : '';
|
|
383
542
|
if (!/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) {
|
|
384
543
|
throw new Error('WorkItem attachment metadata is invalid');
|
|
385
544
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
545
|
+
return storageName;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function readAttachmentBuffer(state, attachment) {
|
|
549
|
+
const storageName = safeStorageName(attachment);
|
|
550
|
+
assertDirectoryState(state);
|
|
551
|
+
const filePath = join(state.itemDirectory, storageName);
|
|
552
|
+
const openPath = isLinuxDescriptorState(state)
|
|
553
|
+
? `/proc/self/fd/${state.itemDescriptor}/${storageName}`
|
|
554
|
+
: filePath;
|
|
555
|
+
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
556
|
+
const descriptor = openSync(openPath, flags);
|
|
557
|
+
try {
|
|
558
|
+
const descriptorStat = isLinuxDescriptorState(state)
|
|
559
|
+
? fstatSync(descriptor)
|
|
560
|
+
: assertRegularFileIdentity(descriptor, filePath, 'WorkItem attachment file');
|
|
561
|
+
if (!descriptorStat.isFile()) throw new Error('WorkItem attachment is not a regular file');
|
|
562
|
+
assertWorkItemAttachmentSize(descriptorStat.size);
|
|
563
|
+
if (!isLinuxDescriptorState(state)) {
|
|
564
|
+
const actualPath = realpathSync(filePath);
|
|
565
|
+
if (!isInsideOrEqual(state.itemIdentity.path, actualPath)) {
|
|
566
|
+
throw new Error('WorkItem attachment path escapes its owner');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const buffer = readFileSync(descriptor);
|
|
570
|
+
const finalStat = isLinuxDescriptorState(state)
|
|
571
|
+
? fstatSync(descriptor)
|
|
572
|
+
: assertRegularFileIdentity(descriptor, filePath, 'WorkItem attachment file', descriptorStat);
|
|
573
|
+
if (finalStat.size !== descriptorStat.size || !sameFileIdentity(finalStat, descriptorStat)) {
|
|
574
|
+
throw new Error('WorkItem attachment changed while reading');
|
|
575
|
+
}
|
|
576
|
+
assertDirectoryState(state);
|
|
577
|
+
assertRegularFileIdentity(descriptor, filePath, 'WorkItem attachment file', descriptorStat);
|
|
578
|
+
const actualPath = realpathSync(filePath);
|
|
579
|
+
const itemRoot = state.itemIdentity?.path || realpathSync(state.itemDirectory);
|
|
580
|
+
if (!isInsideOrEqual(itemRoot, actualPath)) throw new Error('WorkItem attachment path escapes its owner');
|
|
581
|
+
return { buffer, size: finalStat.size, path: actualPath };
|
|
582
|
+
} finally {
|
|
583
|
+
closeSync(descriptor);
|
|
584
|
+
}
|
|
393
585
|
}
|
|
394
586
|
|
|
395
587
|
/** Copy verified bytes into a new owner directory; never share source paths. */
|
|
@@ -398,23 +590,12 @@ export function cloneWorkItemAttachments(workItem, workItemId, options = {}) {
|
|
|
398
590
|
const state = openAttachmentDirectory(options.root, workItem.id);
|
|
399
591
|
try {
|
|
400
592
|
const files = workItem.attachments.map(attachment => {
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
403
|
-
|
|
593
|
+
const { buffer, size } = readAttachmentBuffer(state, attachment);
|
|
594
|
+
if (buffer.length !== Number(attachment.size) || size !== Number(attachment.size)
|
|
595
|
+
|| digest(buffer) !== attachment.sha256) {
|
|
596
|
+
throw new Error('WorkItem attachment changed after creation');
|
|
404
597
|
}
|
|
405
|
-
|
|
406
|
-
assertDescriptorMatchesPath(state.itemDescriptor, state.itemDirectory, 'WorkItem attachment owner directory');
|
|
407
|
-
const fd = openSync(`/proc/self/fd/${state.itemDescriptor}/${storageName}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
408
|
-
try {
|
|
409
|
-
const stat = fstatSync(fd);
|
|
410
|
-
if (!stat.isFile()) throw new Error('WorkItem attachment is not a regular file');
|
|
411
|
-
assertWorkItemAttachmentSize(stat.size);
|
|
412
|
-
const buffer = readFileSync(fd);
|
|
413
|
-
if (buffer.length !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
|
|
414
|
-
throw new Error('WorkItem attachment changed after creation');
|
|
415
|
-
}
|
|
416
|
-
return { name: attachment.name, mimeType: attachment.mimeType, data: buffer.toString('base64') };
|
|
417
|
-
} finally { closeSync(fd); }
|
|
598
|
+
return { name: attachment.name, mimeType: attachment.mimeType, data: buffer.toString('base64') };
|
|
418
599
|
});
|
|
419
600
|
return persistWorkItemAttachments(files, { root: options.root, workItemId });
|
|
420
601
|
} finally { closeDirectoryState(state); }
|
|
@@ -425,19 +606,24 @@ export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
|
|
|
425
606
|
? workItem.attachments.find(item => item?.id === attachmentId)
|
|
426
607
|
: null;
|
|
427
608
|
if (!attachment) throw new Error('WorkItem attachment not found');
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
609
|
+
const state = openAttachmentDirectory(options.root, workItem.id);
|
|
610
|
+
try {
|
|
611
|
+
const { buffer, size } = readAttachmentBuffer(state, attachment);
|
|
612
|
+
if (buffer.length !== Number(attachment.size) || size !== Number(attachment.size)
|
|
613
|
+
|| digest(buffer) !== attachment.sha256) {
|
|
614
|
+
throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
id: attachment.id,
|
|
618
|
+
name: attachment.name,
|
|
619
|
+
mimeType: attachment.mimeType,
|
|
620
|
+
size,
|
|
621
|
+
isImage: attachment.isImage === true,
|
|
622
|
+
data: buffer.toString('base64'),
|
|
623
|
+
};
|
|
624
|
+
} finally {
|
|
625
|
+
closeDirectoryState(state);
|
|
432
626
|
}
|
|
433
|
-
return {
|
|
434
|
-
id: attachment.id,
|
|
435
|
-
name: attachment.name,
|
|
436
|
-
mimeType: attachment.mimeType,
|
|
437
|
-
size: resolved.size,
|
|
438
|
-
isImage: attachment.isImage === true,
|
|
439
|
-
data: buffer.toString('base64'),
|
|
440
|
-
};
|
|
441
627
|
}
|
|
442
628
|
|
|
443
629
|
function escapePromptText(value) {
|
|
@@ -519,33 +705,36 @@ export function buildWorkItemAttachmentContext(workItem, options = {}) {
|
|
|
519
705
|
const promptParts = [];
|
|
520
706
|
const files = [];
|
|
521
707
|
const promptByteBudget = Math.max(0, Number(options.inlineTextBytes) || 0);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
708
|
+
const state = openAttachmentDirectory(options.root, workItem.id);
|
|
709
|
+
try {
|
|
710
|
+
for (const attachment of attachments) {
|
|
711
|
+
const resolved = readAttachmentBuffer(state, attachment);
|
|
712
|
+
if (resolved.buffer.length !== Number(attachment.size) || resolved.size !== Number(attachment.size)
|
|
713
|
+
|| digest(resolved.buffer) !== attachment.sha256) {
|
|
714
|
+
throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
|
|
715
|
+
}
|
|
716
|
+
const kind = attachment.kind || assertSupportedWorkItemAttachment(attachment.name, attachment.mimeType);
|
|
717
|
+
const ref = `work-item-attachment://${encodeURIComponent(attachment.id)}/${encodeURIComponent(attachment.name)}`;
|
|
718
|
+
lines.push(`- ${escapePromptText(attachment.name)}: ${escapePromptText(ref)} (${escapePromptText(attachment.mimeType)}, ${resolved.size} bytes)`);
|
|
719
|
+
files.push({ ref, path: resolved.path, root: state.itemIdentity?.path || state.itemDirectory, id: attachment.id });
|
|
720
|
+
if (kind === 'text' && promptByteBudget > 0) {
|
|
721
|
+
textAttachments.push({ attachment, content: resolved.buffer.toString('utf8') });
|
|
722
|
+
}
|
|
723
|
+
if (kind === 'image' && resolved.size <= MAX_WORK_ITEM_INLINE_BYTES) {
|
|
724
|
+
promptParts.push({
|
|
725
|
+
type: 'image',
|
|
726
|
+
source: { type: 'base64', media_type: attachment.mimeType, data: resolved.buffer.toString('base64') },
|
|
727
|
+
});
|
|
728
|
+
} else if (kind === 'pdf' && resolved.size <= MAX_WORK_ITEM_INLINE_BYTES) {
|
|
729
|
+
promptParts.push({
|
|
730
|
+
type: 'document',
|
|
731
|
+
source: { type: 'base64', media_type: 'application/pdf', data: resolved.buffer.toString('base64') },
|
|
732
|
+
title: attachment.name,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
548
735
|
}
|
|
736
|
+
} finally {
|
|
737
|
+
closeDirectoryState(state);
|
|
549
738
|
}
|
|
550
739
|
|
|
551
740
|
let promptBlock = buildAttachmentMetadataBlock(lines, promptByteBudget);
|
|
@@ -557,6 +746,6 @@ export function buildWorkItemAttachmentContext(workItem, options = {}) {
|
|
|
557
746
|
promptBlock,
|
|
558
747
|
promptParts,
|
|
559
748
|
files,
|
|
560
|
-
readRoots:
|
|
749
|
+
readRoots: files.length > 0 ? [files[0].root] : [],
|
|
561
750
|
};
|
|
562
751
|
}
|