@yeaft/webchat-agent 1.0.570 → 1.0.572

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.
@@ -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
- if (actual !== expected || (expectedIdentity && actual !== expectedIdentity)) {
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
- if (process.platform !== 'linux') {
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
- assertStableDirectory(parent, 'WorkItem attachment parent');
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
- throw new Error('Secure WorkItem attachment access requires Linux');
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
- assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
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
- assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
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
- throw new Error('Secure WorkItem attachment removal requires Linux');
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 resolveAttachmentPath(root, workItemId, attachment) {
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
- const filePath = resolve(itemDirectory, storageName);
387
- if (!isInsideOrEqual(itemRoot, filePath)) throw new Error('WorkItem attachment path escapes its owner');
388
- const stat = lstatSync(filePath);
389
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('WorkItem attachment is not a regular file');
390
- const actualPath = realpathSync(filePath);
391
- if (!isInsideOrEqual(itemRoot, actualPath)) throw new Error('WorkItem attachment path escapes its owner');
392
- return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
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 storageName = attachment.storageName;
402
- if (typeof storageName !== 'string' || !/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) {
403
- throw new Error('WorkItem attachment metadata is invalid');
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
- assertDescriptorMatchesPath(state.rootDescriptor, state.attachmentRoot, 'WorkItem attachment root');
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 resolved = resolveAttachmentPath(options.root, workItem.id, attachment);
429
- const buffer = readFileSync(resolved.filePath);
430
- if (resolved.size !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
431
- throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
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
- let itemDirectory = null;
523
- for (const attachment of attachments) {
524
- const resolved = resolveAttachmentPath(options.root, workItem.id, attachment);
525
- itemDirectory ||= resolved.itemDirectory;
526
- const buffer = readFileSync(resolved.filePath);
527
- if (resolved.size !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
528
- throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
529
- }
530
- const kind = attachment.kind || assertSupportedWorkItemAttachment(attachment.name, attachment.mimeType);
531
- const ref = `work-item-attachment://${encodeURIComponent(attachment.id)}/${encodeURIComponent(attachment.name)}`;
532
- lines.push(`- ${escapePromptText(attachment.name)}: ${escapePromptText(ref)} (${escapePromptText(attachment.mimeType)}, ${resolved.size} bytes)`);
533
- files.push({ ref, path: resolved.filePath, root: resolved.itemDirectory, id: attachment.id });
534
- if (kind === 'text' && promptByteBudget > 0) {
535
- textAttachments.push({ attachment, content: buffer.toString('utf8') });
536
- }
537
- if (kind === 'image' && resolved.size <= MAX_WORK_ITEM_INLINE_BYTES) {
538
- promptParts.push({
539
- type: 'image',
540
- source: { type: 'base64', media_type: attachment.mimeType, data: buffer.toString('base64') },
541
- });
542
- } else if (kind === 'pdf' && resolved.size <= MAX_WORK_ITEM_INLINE_BYTES) {
543
- promptParts.push({
544
- type: 'document',
545
- source: { type: 'base64', media_type: 'application/pdf', data: buffer.toString('base64') },
546
- title: attachment.name,
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: itemDirectory ? [itemDirectory] : [],
749
+ readRoots: files.length > 0 ? [files[0].root] : [],
561
750
  };
562
751
  }