@tangle-network/agent-app 0.60.194 → 0.60.196

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.
@@ -173,61 +173,76 @@ function asciiEntryName(bytes, offset, length) {
173
173
  }
174
174
  return name;
175
175
  }
176
- function findZipEndOfCentralDirectory(bytes) {
177
- const lowest = Math.max(0, bytes.length - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES - ZIP_MAX_COMMENT_BYTES);
178
- for (let offset = bytes.length - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES; offset >= lowest; offset--) {
179
- if (readU32Le(bytes, offset) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
180
- if (offset + ZIP_END_OF_CENTRAL_DIRECTORY_BYTES + readU16Le(bytes, offset + 20) === bytes.length) return offset;
176
+ function wholeFileWindow(bytes) {
177
+ return {
178
+ size: bytes.length,
179
+ read: (offset, length) => offset >= 0 && length >= 0 && offset + length <= bytes.length ? bytes.subarray(offset, offset + length) : null
180
+ };
181
+ }
182
+ function findZipEndOfCentralDirectory(source) {
183
+ const lowest = Math.max(0, source.size - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES - ZIP_MAX_COMMENT_BYTES);
184
+ const region = source.read(lowest, source.size - lowest);
185
+ if (!region) return null;
186
+ for (let offset = source.size - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES; offset >= lowest; offset--) {
187
+ if (readU32Le(region, offset - lowest) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
188
+ if (offset + ZIP_END_OF_CENTRAL_DIRECTORY_BYTES + readU16Le(region, offset - lowest + 20) === source.size) return offset;
181
189
  }
182
190
  return null;
183
191
  }
184
- function readZipCentralDirectory(bytes) {
185
- const end = findZipEndOfCentralDirectory(bytes);
192
+ function readZipCentralDirectory(source) {
193
+ const end = findZipEndOfCentralDirectory(source);
186
194
  if (end === null) return null;
187
- const entryCount = readU16Le(bytes, end + 10);
188
- const directoryBytes = readU32Le(bytes, end + 12);
189
- const directoryOffset = readU32Le(bytes, end + 16);
195
+ const record = source.read(end, ZIP_END_OF_CENTRAL_DIRECTORY_BYTES);
196
+ if (!record) return null;
197
+ const entryCount = readU16Le(record, 10);
198
+ const directoryBytes = readU32Le(record, 12);
199
+ const directoryOffset = readU32Le(record, 16);
190
200
  if (entryCount === ZIP64_SENTINEL_U16) return null;
191
201
  if (directoryBytes === ZIP64_SENTINEL_U32 || directoryOffset === ZIP64_SENTINEL_U32) return null;
192
- if (directoryOffset + directoryBytes > bytes.length) return null;
202
+ if (directoryOffset + directoryBytes > source.size) return null;
203
+ const directory = source.read(directoryOffset, source.size - directoryOffset);
204
+ if (!directory) return null;
193
205
  const entries = [];
194
- let cursor = directoryOffset;
206
+ let cursor = 0;
195
207
  for (let i = 0; i < entryCount; i++) {
196
- if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES > bytes.length) return null;
197
- if (readU32Le(bytes, cursor) !== ZIP_CENTRAL_FILE_HEADER_SIGNATURE) return null;
198
- const nameLength = readU16Le(bytes, cursor + 28);
199
- const extraLength = readU16Le(bytes, cursor + 30);
200
- const commentLength = readU16Le(bytes, cursor + 32);
201
- if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES + nameLength > bytes.length) return null;
208
+ if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES > directory.length) return null;
209
+ if (readU32Le(directory, cursor) !== ZIP_CENTRAL_FILE_HEADER_SIGNATURE) return null;
210
+ const nameLength = readU16Le(directory, cursor + 28);
211
+ const extraLength = readU16Le(directory, cursor + 30);
212
+ const commentLength = readU16Le(directory, cursor + 32);
213
+ if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES + nameLength > directory.length) return null;
202
214
  entries.push({
203
- name: asciiEntryName(bytes, cursor + ZIP_CENTRAL_FILE_HEADER_BYTES, nameLength),
204
- compressionMethod: readU16Le(bytes, cursor + 10),
205
- compressedBytes: readU32Le(bytes, cursor + 20),
206
- localHeaderOffset: readU32Le(bytes, cursor + 42)
215
+ name: asciiEntryName(directory, cursor + ZIP_CENTRAL_FILE_HEADER_BYTES, nameLength),
216
+ compressionMethod: readU16Le(directory, cursor + 10),
217
+ compressedBytes: readU32Le(directory, cursor + 20),
218
+ localHeaderOffset: readU32Le(directory, cursor + 42)
207
219
  });
208
220
  cursor += ZIP_CENTRAL_FILE_HEADER_BYTES + nameLength + extraLength + commentLength;
209
221
  }
210
222
  return entries;
211
223
  }
212
- function storedZipEntryText(bytes, entry) {
224
+ function storedZipEntryText(source, entry) {
213
225
  if (entry.compressionMethod !== ZIP_METHOD_STORED) return null;
214
226
  const header = entry.localHeaderOffset;
215
- if (header + ZIP_LOCAL_FILE_HEADER_BYTES > bytes.length) return null;
216
- if (readU32Le(bytes, header) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) return null;
217
- const start = header + ZIP_LOCAL_FILE_HEADER_BYTES + readU16Le(bytes, header + 26) + readU16Le(bytes, header + 28);
227
+ const local = source.read(header, ZIP_LOCAL_FILE_HEADER_BYTES);
228
+ if (!local) return null;
229
+ if (readU32Le(local, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) return null;
230
+ const start = header + ZIP_LOCAL_FILE_HEADER_BYTES + readU16Le(local, 26) + readU16Le(local, 28);
218
231
  const length = Math.min(entry.compressedBytes, OPC_CONTENT_TYPES_SCAN_BYTES);
219
- if (length === 0 || start + length > bytes.length) return null;
220
- return new TextDecoder("utf-8").decode(bytes.subarray(start, start + length));
232
+ if (length === 0) return null;
233
+ const text = source.read(start, length);
234
+ if (!text) return null;
235
+ return new TextDecoder("utf-8").decode(text);
221
236
  }
222
- function sniffOoxml(bytes) {
223
- const entries = readZipCentralDirectory(bytes);
237
+ function sniffOoxml(source) {
238
+ const entries = readZipCentralDirectory(source);
224
239
  if (!entries) return null;
225
240
  const contentTypes = entries.find((entry) => entry.name === OPC_CONTENT_TYPES_PART);
226
241
  if (!contentTypes) return null;
227
242
  const names = entries.map((entry) => entry.name);
228
243
  if (!names.includes(OPC_PACKAGE_RELATIONSHIPS_PART)) return null;
229
244
  const macroEnabled = names.some((name) => name.endsWith(OOXML_VBA_PROJECT_PART_SUFFIX));
230
- const declared = storedZipEntryText(bytes, contentTypes);
245
+ const declared = storedZipEntryText(source, contentTypes);
231
246
  if (declared !== null) {
232
247
  if (!declared.includes(OPC_CONTENT_TYPES_NAMESPACE)) return null;
233
248
  const matches = OOXML_DECLARED_MAIN_PART_TYPES.filter((row2) => declared.includes(row2.declaredType));
@@ -239,7 +254,7 @@ function sniffOoxml(bytes) {
239
254
  if (!format) return null;
240
255
  return macroEnabled ? format.macroEnabledMime : format.mime;
241
256
  }
242
- function sniffMagicBytes(bytes) {
257
+ function sniffMagicBytes(bytes, zip = wholeFileWindow(bytes)) {
243
258
  if (bytesStartWith(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
244
259
  if (bytesStartWith(bytes, 0, [255, 216, 255])) return "image/jpeg";
245
260
  if (asciiAt(bytes, 0, "GIF87a") || asciiAt(bytes, 0, "GIF89a")) return "image/gif";
@@ -248,7 +263,7 @@ function sniffMagicBytes(bytes) {
248
263
  if (bytesStartWith(bytes, 0, [77, 77, 0, 42])) return "image/tiff";
249
264
  if (bytesStartWith(bytes, 0, [0, 0, 1, 0])) return "image/x-icon";
250
265
  if (asciiAt(bytes, 0, "%PDF-")) return "application/pdf";
251
- if (bytesStartWith(bytes, 0, [80, 75, 3, 4])) return sniffOoxml(bytes) ?? "application/zip";
266
+ if (bytesStartWith(bytes, 0, [80, 75, 3, 4])) return sniffOoxml(zip) ?? "application/zip";
252
267
  if (bytesStartWith(bytes, 0, [31, 139])) return "application/gzip";
253
268
  if (sniffId3(bytes) || bytesStartWith(bytes, 0, [255, 251])) return "audio/mpeg";
254
269
  if (asciiAt(bytes, 0, "OggS")) return "audio/ogg";
@@ -281,6 +296,87 @@ function sniffBinary(bytes) {
281
296
  if (sniffSvgText(decoded)) return { binary: true, mime: "image/svg+xml" };
282
297
  return { binary: false, mime: null };
283
298
  }
299
+ var STREAM_SNIFF_HEAD_BYTES = 64 * 1024;
300
+ var STREAM_SNIFF_TAIL_BYTES = 1024 * 1024;
301
+ function concatChunks(chunks, length) {
302
+ if (chunks.length === 1) return chunks[0];
303
+ const joined = new Uint8Array(length);
304
+ let offset = 0;
305
+ for (const chunk of chunks) {
306
+ joined.set(chunk, offset);
307
+ offset += chunk.length;
308
+ }
309
+ return joined;
310
+ }
311
+ function createStreamingSniffer() {
312
+ const head = new Uint8Array(STREAM_SNIFF_HEAD_BYTES);
313
+ let headLength = 0;
314
+ const tailChunks = [];
315
+ let tailLength = 0;
316
+ let size = 0;
317
+ let sawNul = false;
318
+ let validUtf8 = true;
319
+ const decoder = new TextDecoder("utf-8", { fatal: true });
320
+ let finished = false;
321
+ return {
322
+ get size() {
323
+ return size;
324
+ },
325
+ head: () => head.subarray(0, headLength),
326
+ push(chunk) {
327
+ if (finished) throw new Error("streaming sniffer already finished");
328
+ if (chunk.length === 0) return;
329
+ size += chunk.length;
330
+ if (headLength < head.length) {
331
+ const take = Math.min(head.length - headLength, chunk.length);
332
+ head.set(chunk.subarray(0, take), headLength);
333
+ headLength += take;
334
+ }
335
+ tailChunks.push(chunk);
336
+ tailLength += chunk.length;
337
+ while (tailChunks.length > 1 && tailLength - tailChunks[0].length >= STREAM_SNIFF_TAIL_BYTES) {
338
+ tailLength -= tailChunks.shift().length;
339
+ }
340
+ if (!sawNul && chunk.includes(0)) sawNul = true;
341
+ if (validUtf8 && !sawNul) {
342
+ try {
343
+ decoder.decode(chunk, { stream: true });
344
+ } catch {
345
+ validUtf8 = false;
346
+ }
347
+ }
348
+ },
349
+ finish() {
350
+ finished = true;
351
+ if (size <= head.length) return sniffBinary(head.subarray(0, size));
352
+ if (validUtf8 && !sawNul) {
353
+ try {
354
+ decoder.decode();
355
+ } catch {
356
+ validUtf8 = false;
357
+ }
358
+ }
359
+ const start = head.subarray(0, headLength);
360
+ const joined = concatChunks(tailChunks, tailLength);
361
+ const tail = joined.subarray(Math.max(0, joined.length - STREAM_SNIFF_TAIL_BYTES));
362
+ const tailStart = size - tail.length;
363
+ const window = {
364
+ size,
365
+ read(offset, length) {
366
+ if (offset < 0 || length < 0 || offset + length > size) return null;
367
+ if (offset + length <= start.length) return start.subarray(offset, offset + length);
368
+ if (offset >= tailStart) return tail.subarray(offset - tailStart, offset - tailStart + length);
369
+ return null;
370
+ }
371
+ };
372
+ const mime = sniffMagicBytes(start, window);
373
+ if (mime) return { binary: true, mime };
374
+ if (sawNul || !validUtf8) return { binary: true, mime: null };
375
+ if (sniffSvgText(new TextDecoder("utf-8").decode(start))) return { binary: true, mime: "image/svg+xml" };
376
+ return { binary: false, mime: null };
377
+ }
378
+ };
379
+ }
284
380
 
285
381
  // src/chat-routes/attachment-validation.ts
286
382
  var MAX_BINARY_ATTACHMENT_BYTES = 10 * 1024 * 1024;
@@ -334,6 +430,10 @@ var EXTENSION_IMPLIES_SNIFFED_MIME = {
334
430
  webm: "video/webm",
335
431
  mkv: "video/x-matroska"
336
432
  };
433
+ function sniffedMimeImpliedByName(fileName) {
434
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
435
+ return EXTENSION_IMPLIES_SNIFFED_MIME[extension];
436
+ }
337
437
  function checkAttachmentType(fileName, sniff, allowed = ALLOWED_ATTACHMENT_SNIFFED_MIMES) {
338
438
  if (sniff.binary === false) return { succeeded: true };
339
439
  const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
@@ -451,6 +551,9 @@ export {
451
551
  OOXML_SPREADSHEET_MACRO_ENABLED_MIME,
452
552
  OOXML_PRESENTATION_MACRO_ENABLED_MIME,
453
553
  sniffBinary,
554
+ STREAM_SNIFF_HEAD_BYTES,
555
+ STREAM_SNIFF_TAIL_BYTES,
556
+ createStreamingSniffer,
454
557
  MAX_BINARY_ATTACHMENT_BYTES,
455
558
  MAX_TEXT_ATTACHMENT_BYTES,
456
559
  ATTACHMENT_MAX_COUNT,
@@ -459,6 +562,7 @@ export {
459
562
  OOXML_SNIFFED_MIMES,
460
563
  MACRO_ENABLED_OOXML_SNIFFED_MIMES,
461
564
  ALLOWED_ATTACHMENT_SNIFFED_MIMES,
565
+ sniffedMimeImpliedByName,
462
566
  checkAttachmentType,
463
567
  sanitizeAttachmentFileName,
464
568
  attachmentSizeErrorMessage,
@@ -466,4 +570,4 @@ export {
466
570
  defaultValidateAttachmentPath,
467
571
  parseChatAttachmentInputs
468
572
  };
469
- //# sourceMappingURL=chunk-PBQYVAB6.js.map
573
+ //# sourceMappingURL=chunk-2ZRXJ3NO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/chat-routes/binary-sniff.ts","../src/chat-routes/attachment-validation.ts"],"sourcesContent":["/**\n * Content-based binary/text classification, shared by the attachment upload\n * route (server) and the composer's client-side pre-validation (browser) —\n * both sides must agree on what counts as binary before a byte ever leaves\n * the client. Extension-based allowlists lie (a renamed `.docx`, a PNG saved\n * as `.txt`), so classification reads the actual bytes: a magic-byte table\n * for common binary formats first, then a UTF-8 decode attempt for\n * everything else. Three families need more than a fixed-offset signature and\n * get a reader of their own — ISO-BMFF brands (`sniffFtyp`), EBML containers\n * (`sniffEbml`), and OOXML Office packages, whose PKZIP container is shared\n * with every other `.zip` (`sniffOoxml`).\n *\n * Lifted near-verbatim from gtm-agent's `src/lib/binary-sniff.ts` (the\n * source PRs hardened this against real corruption/gate bugs: gtm#584,\n * gtm#592). Import-free by design — `/web-react` re-exports `/chat-routes`\n * modules into browser bundles (`tests/browser-safe-subpaths.test.ts` walks\n * the graph), so nothing here may reach a Node builtin or an engine package.\n */\n\nexport interface SniffResult {\n binary: boolean\n mime: string | null\n}\n\n/** Sniffed mime for a Word OOXML package (`.docx`). */\nexport const OOXML_WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n\n/** Sniffed mime for an Excel OOXML package (`.xlsx`). */\nexport const OOXML_SPREADSHEET_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n\n/** Sniffed mime for a PowerPoint OOXML package (`.pptx`). */\nexport const OOXML_PRESENTATION_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'\n\n/** Sniffed mime for a macro-enabled Word package (`.docm`). Reported\n * separately from {@link OOXML_WORD_MIME} so a package carrying a VBA project\n * can never be admitted under the plain-document mime: a route that wants\n * macro-enabled files opts in explicitly through its allowed-mime seam. */\nexport const OOXML_WORD_MACRO_ENABLED_MIME = 'application/vnd.ms-word.document.macroEnabled.12'\n\n/** Sniffed mime for a macro-enabled Excel package (`.xlsm`). */\nexport const OOXML_SPREADSHEET_MACRO_ENABLED_MIME = 'application/vnd.ms-excel.sheet.macroEnabled.12'\n\n/** Sniffed mime for a macro-enabled PowerPoint package (`.pptm`). */\nexport const OOXML_PRESENTATION_MACRO_ENABLED_MIME = 'application/vnd.ms-powerpoint.presentation.macroEnabled.12'\n\nfunction bytesStartWith(bytes: Uint8Array, offset: number, signature: number[]): boolean {\n if (bytes.length < offset + signature.length) return false\n for (let i = 0; i < signature.length; i++) {\n if (bytes[offset + i] !== signature[i]) return false\n }\n return true\n}\n\nfunction asciiAt(bytes: Uint8Array, offset: number, text: string): boolean {\n if (bytes.length < offset + text.length) return false\n for (let i = 0; i < text.length; i++) {\n if (bytes[offset + i] !== text.charCodeAt(i)) return false\n }\n return true\n}\n\n/** RIFF containers (WebP, WAV) share the `RIFF....<TYPE>` header; the type\n * tag at byte offset 8 distinguishes them. */\nfunction sniffRiff(bytes: Uint8Array): string | null {\n if (!asciiAt(bytes, 0, 'RIFF')) return null\n if (asciiAt(bytes, 8, 'WEBP')) return 'image/webp'\n if (asciiAt(bytes, 8, 'WAVE')) return 'audio/wav'\n return null\n}\n\n/** MP4/MOV/AVIF/HEIC containers share an `ftyp` box at byte offset 4; the\n * major brand at offset 8 tells them apart within the shared ISO-BMFF\n * family. Brands outside this table (mp4, m4a, m4v, etc) fall back to\n * `video/mp4`, the family's most common member. */\nfunction sniffFtyp(bytes: Uint8Array): string | null {\n if (!asciiAt(bytes, 4, 'ftyp')) return null\n if (asciiAt(bytes, 8, 'qt ')) return 'video/quicktime'\n if (asciiAt(bytes, 8, 'avif') || asciiAt(bytes, 8, 'avis')) return 'image/avif'\n if (asciiAt(bytes, 8, 'heic') || asciiAt(bytes, 8, 'heix') || asciiAt(bytes, 8, 'hevc') || asciiAt(bytes, 8, 'hevx')) return 'image/heic'\n if (asciiAt(bytes, 8, 'mif1') || asciiAt(bytes, 8, 'msf1')) return 'image/heif'\n return 'video/mp4'\n}\n\nconst EBML_HEADER_ID = 0x1a45dfa3\nconst EBML_DOC_TYPE_ID = 0x4282\n/** A normal EBML header is only a few dozen bytes. This cap keeps a crafted\n * header from choosing how much untrusted input the DocType reader scans. */\nconst EBML_HEADER_SCAN_BYTES = 4 * 1024\n\ninterface EbmlVint {\n length: number\n value: number\n}\n\n/** Read an EBML variable-width integer. Element IDs retain their marker bit;\n * element sizes remove it. Values too large for exact JS integer arithmetic,\n * unknown-size sentinels, and truncated encodings are rejected. */\nfunction readEbmlVint(\n bytes: Uint8Array,\n offset: number,\n maxLength: number,\n stripMarker: boolean,\n): EbmlVint | null {\n const first = bytes[offset]\n if (first === undefined || first === 0) return null\n\n let marker = 0x80\n let length = 1\n while ((first & marker) === 0) {\n marker >>= 1\n length++\n }\n if (length > maxLength || offset + length > bytes.length) return null\n\n if (stripMarker) {\n let unknown = (first & (marker - 1)) === marker - 1\n for (let index = 1; index < length; index++) unknown &&= bytes[offset + index] === 0xff\n if (unknown) return null\n }\n\n let value = stripMarker ? first & (marker - 1) : first\n for (let index = 1; index < length; index++) {\n value = value * 0x100 + bytes[offset + index]!\n if (!Number.isSafeInteger(value)) return null\n }\n return { length, value }\n}\n\n/** WebM and Matroska share the EBML magic. Their header's bounded DocType\n * element is the distinguishing signal; other or malformed EBML containers\n * remain recognized only as unknown binary. */\nfunction sniffEbml(bytes: Uint8Array): string | null {\n const headerId = readEbmlVint(bytes, 0, 4, false)\n if (headerId?.value !== EBML_HEADER_ID) return null\n\n const headerSize = readEbmlVint(bytes, headerId.length, 8, true)\n if (!headerSize) return null\n const headerStart = headerId.length + headerSize.length\n const headerEnd = headerStart + headerSize.value\n if (!Number.isSafeInteger(headerEnd) || headerEnd > bytes.length) return null\n const scanEnd = Math.min(headerEnd, headerStart + EBML_HEADER_SCAN_BYTES)\n\n let cursor = headerStart\n while (cursor < scanEnd) {\n const id = readEbmlVint(bytes, cursor, 4, false)\n if (!id) return null\n const size = readEbmlVint(bytes, cursor + id.length, 8, true)\n if (!size) return null\n const valueStart = cursor + id.length + size.length\n const valueEnd = valueStart + size.value\n if (!Number.isSafeInteger(valueEnd) || valueEnd > headerEnd || valueEnd > scanEnd) return null\n\n if (id.value === EBML_DOC_TYPE_ID) {\n let docType = ''\n for (let index = valueStart; index < valueEnd; index++) {\n const byte = bytes[index]!\n if (byte > 0x7f) return null\n docType += String.fromCharCode(byte)\n }\n if (docType === 'webm') return 'video/webm'\n if (docType === 'matroska') return 'video/x-matroska'\n return null\n }\n cursor = valueEnd\n }\n return null\n}\n\n/** `BM` alone matches ordinary prose (\"BMW…\"), so require the BMP header's\n * reserved bytes (offsets 6-9), which the format mandates to be zero. */\nfunction sniffBmp(bytes: Uint8Array): boolean {\n return asciiAt(bytes, 0, 'BM')\n && bytes.length >= 10\n && bytes[6] === 0 && bytes[7] === 0 && bytes[8] === 0 && bytes[9] === 0\n}\n\n/** `ID3` alone matches ordinary prose (\"ID3 tags…\"), so require the ID3v2\n * header shape: a plausible version byte and sync-safe size bytes. */\nfunction sniffId3(bytes: Uint8Array): boolean {\n return asciiAt(bytes, 0, 'ID3')\n && bytes.length >= 10\n && bytes[3]! < 0x10\n && bytes[6]! < 0x80 && bytes[7]! < 0x80 && bytes[8]! < 0x80 && bytes[9]! < 0x80\n}\n\nconst ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50\nconst ZIP_CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50\nconst ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50\n/** Fixed part of an end-of-central-directory record, before its comment. */\nconst ZIP_END_OF_CENTRAL_DIRECTORY_BYTES = 22\n/** Fixed part of a central-directory file header, before name/extra/comment. */\nconst ZIP_CENTRAL_FILE_HEADER_BYTES = 46\n/** Fixed part of a local file header, before name/extra. */\nconst ZIP_LOCAL_FILE_HEADER_BYTES = 30\n/** A zip's trailing comment is a u16 length, so the end-of-central-directory\n * record sits within this distance of the last byte. */\nconst ZIP_MAX_COMMENT_BYTES = 0xffff\n/** ZIP64 stores sentinels in the 16/32-bit fields and the real values in a\n * separate record. Nothing that large rides an attachment cap, so a sentinel\n * leaves the archive unclassified instead of half-parsed. */\nconst ZIP64_SENTINEL_U16 = 0xffff\nconst ZIP64_SENTINEL_U32 = 0xffffffff\n/** Deflate is the usual method; only method 0 (stored) can be read without an\n * inflater, and this module has no decompression (it runs in the browser\n * bundle and on the upload path, on untrusted bytes). */\nconst ZIP_METHOD_STORED = 0\n\n/** The OPC content-types part every OOXML package carries. */\nconst OPC_CONTENT_TYPES_PART = '[Content_Types].xml'\n\n/** The OPC package-relationships part. ECMA-376 Part 2 requires it in every\n * conforming package, so an archive that borrowed the Office part NAMES\n * without also carrying this one is not a package. Structural, like the\n * content-types part: both are read from the zip's own uncompressed metadata,\n * which is the only evidence available without an inflater. */\nconst OPC_PACKAGE_RELATIONSHIPS_PART = '_rels/.rels'\n\n/** Namespace a genuine content-types part declares. Only checkable when that\n * part is STORED uncompressed — Word, Excel and PowerPoint all deflate it —\n * so this narrows the crafted-archive surface where it applies and is silent\n * where it does not. */\nconst OPC_CONTENT_TYPES_NAMESPACE = 'http://schemas.openxmlformats.org/package/2006/content-types'\n\n/** Ceiling on how much of the content-types part is read when it happens to\n * be stored uncompressed — the genuine part is a few hundred bytes, and a\n * crafted archive must not be able to dictate how much text is scanned. */\nconst OPC_CONTENT_TYPES_SCAN_BYTES = 64 * 1024\n\ninterface OoxmlDeclaredMainPartType {\n /** The content type as it appears in the content-types part. */\n readonly declaredType: string\n /** Reported mime when the package carries no VBA project. */\n readonly mime: string\n /** Reported mime for the same family once a VBA project is present. */\n readonly macroEnabledMime: string\n /** True when `declaredType` is itself one of the macro-enabled types. */\n readonly macroEnabled: boolean\n}\n\n/** Content types a package DECLARES for its main part, in the content-types\n * part itself — the authoritative signal when it is readable, because it\n * distinguishes a document from a template or a macro-enabled sibling that\n * share the same part layout. Each row carries its macro-enabled sibling\n * because a declared type is only half the answer: the VBA-project scan can\n * override it, and a package that carries one is macro-enabled whatever it\n * declares. */\nconst OOXML_DECLARED_MAIN_PART_TYPES: ReadonlyArray<OoxmlDeclaredMainPartType> = [\n {\n declaredType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml',\n mime: OOXML_WORD_MIME,\n macroEnabledMime: OOXML_WORD_MACRO_ENABLED_MIME,\n macroEnabled: false,\n },\n {\n declaredType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',\n mime: OOXML_SPREADSHEET_MIME,\n macroEnabledMime: OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n macroEnabled: false,\n },\n {\n declaredType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml',\n mime: OOXML_PRESENTATION_MIME,\n macroEnabledMime: OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n macroEnabled: false,\n },\n {\n declaredType: 'application/vnd.ms-word.document.macroEnabled.main+xml',\n mime: OOXML_WORD_MACRO_ENABLED_MIME,\n macroEnabledMime: OOXML_WORD_MACRO_ENABLED_MIME,\n macroEnabled: true,\n },\n {\n declaredType: 'application/vnd.ms-excel.sheet.macroEnabled.main+xml',\n mime: OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n macroEnabledMime: OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n macroEnabled: true,\n },\n {\n declaredType: 'application/vnd.ms-powerpoint.presentation.macroEnabled.main+xml',\n mime: OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n macroEnabledMime: OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n macroEnabled: true,\n },\n]\n\n/** Main part of each Office format, as it appears in the package's entry\n * names — the structural signal used when the declared content types are\n * compressed (the common case: Word, Excel and PowerPoint all deflate the\n * content-types part). Entry names live in the zip's own uncompressed\n * metadata, so reading them needs no inflater.\n *\n * `directory` is the fallback for the packages that do not use the canonical\n * main-part name — a repaired or converted Word file declares\n * `word/document2.xml` — and it is only consulted for a package already\n * confirmed to carry the OPC content-types part, where a `word/` part means\n * a Word document and nothing else. The three directories never mix: an\n * embedded workbook inside a `.docx` lives under `word/embeddings/`. */\nconst OOXML_MAIN_PARTS: ReadonlyArray<{ part: string; directory: string; mime: string; macroEnabledMime: string }> = [\n { part: 'word/document.xml', directory: 'word/', mime: OOXML_WORD_MIME, macroEnabledMime: OOXML_WORD_MACRO_ENABLED_MIME },\n { part: 'xl/workbook.xml', directory: 'xl/', mime: OOXML_SPREADSHEET_MIME, macroEnabledMime: OOXML_SPREADSHEET_MACRO_ENABLED_MIME },\n { part: 'ppt/presentation.xml', directory: 'ppt/', mime: OOXML_PRESENTATION_MIME, macroEnabledMime: OOXML_PRESENTATION_MACRO_ENABLED_MIME },\n]\n\n/** A VBA project part means the package is macro-enabled whatever its\n * extension claims. Matched by suffix rather than by exact path: naming the\n * macro-enabled mime is the fail-closed answer (the default allow-list\n * refuses it), so a part this misses is the expensive direction. */\nconst OOXML_VBA_PROJECT_PART_SUFFIX = 'vbaProject.bin'\n\ninterface ZipEntry {\n name: string\n compressionMethod: number\n compressedBytes: number\n localHeaderOffset: number\n}\n\nfunction readU16Le(bytes: Uint8Array, offset: number): number {\n return bytes[offset]! | (bytes[offset + 1]! << 8)\n}\n\n/** Little-endian u32 as an unsigned JS number — the high byte is multiplied\n * rather than shifted, because `<< 24` would produce a negative value. */\nfunction readU32Le(bytes: Uint8Array, offset: number): number {\n return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)) + bytes[offset + 3]! * 0x1000000\n}\n\n/** Entry names are raw bytes; only exact ASCII part names are compared here,\n * so a non-ASCII byte becomes a character that can never match one. */\nfunction asciiEntryName(bytes: Uint8Array, offset: number, length: number): string {\n let name = ''\n for (let i = 0; i < length; i++) {\n const byte = bytes[offset + i]!\n name += byte < 0x80 ? String.fromCharCode(byte) : '\\uFFFD'\n }\n return name\n}\n\n/** The bytes a zip reader may consult: the whole file, or — for a file\n * classified while it streams — its first and last bytes. `read` answers a\n * range only when every byte of it is held, so a reader that needs a part\n * the window dropped gets `null` and leaves the archive unclassified. */\ninterface ByteWindow {\n /** Total byte length of the file. */\n readonly size: number\n read(offset: number, length: number): Uint8Array | null\n}\n\nfunction wholeFileWindow(bytes: Uint8Array): ByteWindow {\n return {\n size: bytes.length,\n read: (offset, length) => (offset >= 0 && length >= 0 && offset + length <= bytes.length\n ? bytes.subarray(offset, offset + length)\n : null),\n }\n}\n\n/** Scan back from the tail for the end-of-central-directory record. The\n * comment length must account for exactly the bytes that follow, or the\n * \"signature\" is compressed data that happens to spell `PK\\x05\\x06`. */\nfunction findZipEndOfCentralDirectory(source: ByteWindow): number | null {\n const lowest = Math.max(0, source.size - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES - ZIP_MAX_COMMENT_BYTES)\n const region = source.read(lowest, source.size - lowest)\n if (!region) return null\n for (let offset = source.size - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES; offset >= lowest; offset--) {\n if (readU32Le(region, offset - lowest) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue\n if (offset + ZIP_END_OF_CENTRAL_DIRECTORY_BYTES + readU16Le(region, offset - lowest + 20) === source.size) return offset\n }\n return null\n}\n\n/** Read the archive's central directory — the authoritative index a zip\n * reader uses. Returns `null` for anything that is not a complete, in-bounds,\n * non-ZIP64 directory: a truncated or crafted archive is left unclassified\n * rather than guessed at from its first local header. */\nfunction readZipCentralDirectory(source: ByteWindow): ZipEntry[] | null {\n const end = findZipEndOfCentralDirectory(source)\n if (end === null) return null\n const record = source.read(end, ZIP_END_OF_CENTRAL_DIRECTORY_BYTES)\n if (!record) return null\n\n const entryCount = readU16Le(record, 10)\n const directoryBytes = readU32Le(record, 12)\n const directoryOffset = readU32Le(record, 16)\n if (entryCount === ZIP64_SENTINEL_U16) return null\n if (directoryBytes === ZIP64_SENTINEL_U32 || directoryOffset === ZIP64_SENTINEL_U32) return null\n if (directoryOffset + directoryBytes > source.size) return null\n // Offsets below are relative to the directory's start; the bounds checks\n // are the same as against the whole file, which ends where `directory` ends.\n const directory = source.read(directoryOffset, source.size - directoryOffset)\n if (!directory) return null\n\n const entries: ZipEntry[] = []\n let cursor = 0\n for (let i = 0; i < entryCount; i++) {\n if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES > directory.length) return null\n if (readU32Le(directory, cursor) !== ZIP_CENTRAL_FILE_HEADER_SIGNATURE) return null\n const nameLength = readU16Le(directory, cursor + 28)\n const extraLength = readU16Le(directory, cursor + 30)\n const commentLength = readU16Le(directory, cursor + 32)\n if (cursor + ZIP_CENTRAL_FILE_HEADER_BYTES + nameLength > directory.length) return null\n entries.push({\n name: asciiEntryName(directory, cursor + ZIP_CENTRAL_FILE_HEADER_BYTES, nameLength),\n compressionMethod: readU16Le(directory, cursor + 10),\n compressedBytes: readU32Le(directory, cursor + 20),\n localHeaderOffset: readU32Le(directory, cursor + 42),\n })\n cursor += ZIP_CENTRAL_FILE_HEADER_BYTES + nameLength + extraLength + commentLength\n }\n return entries\n}\n\n/** Text of an entry stored WITHOUT compression, or `null` for anything else.\n * The data offset is computed from the local header (its extra field may be\n * a different length than the central directory's copy). */\nfunction storedZipEntryText(source: ByteWindow, entry: ZipEntry): string | null {\n if (entry.compressionMethod !== ZIP_METHOD_STORED) return null\n const header = entry.localHeaderOffset\n const local = source.read(header, ZIP_LOCAL_FILE_HEADER_BYTES)\n if (!local) return null\n if (readU32Le(local, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) return null\n const start = header + ZIP_LOCAL_FILE_HEADER_BYTES + readU16Le(local, 26) + readU16Le(local, 28)\n const length = Math.min(entry.compressedBytes, OPC_CONTENT_TYPES_SCAN_BYTES)\n if (length === 0) return null\n const text = source.read(start, length)\n if (!text) return null\n return new TextDecoder('utf-8').decode(text)\n}\n\n/**\n * Identify an OOXML (Office Open XML) package inside a PKZIP container, or\n * `null` for an archive that is not one.\n *\n * Every `.docx`/`.xlsx`/`.pptx` is a zip, so the zip signature alone says\n * nothing — matching on it would admit every `.zip` a user can produce. What\n * this reads is the archive's own uncompressed metadata: the central\n * directory's entry names, plus the content-types part's text in the one case\n * where a producer stored it uncompressed. Everything below follows from that\n * one constraint — the module ships into browser bundles and runs on untrusted\n * bytes, so it has no inflater and cannot read a deflated part.\n *\n * Structural preconditions (both required): the OPC content-types part\n * `[Content_Types].xml` AND the OPC package-relationships part `_rels/.rels`.\n * Which Office format it is comes from the content types the first part\n * DECLARES when it is readable, and otherwise from which main part the package\n * contains (`word/document.xml`, `xl/workbook.xml`, `ppt/presentation.xml`).\n * A package carrying a VBA project reports the macro-enabled mime — computed\n * BEFORE the declared lookup and applied to its answer, so a package that\n * declares the plain type and ships `vbaProject.bin` reports macro-enabled,\n * and a decoy `Override` cannot select the plainer of two declared types.\n *\n * **What this does NOT prove, and no name-only reader can.** A deflated part's\n * CONTENT is unreadable here, so an archive that names its entries like a\n * package and fills them with arbitrary bytes reports the Office mime. That is\n * the same standing as every other magic-byte family this module reports —\n * `%PDF-` followed by arbitrary bytes reports `application/pdf` — because a\n * content sniffer answers \"what format does this claim to be\", not \"is this\n * safe\". The attachment gate is a FORMAT gate; a product that needs the bytes\n * to be a real, parseable document gets that from `/documents`' extractor,\n * which fails loud on a package it cannot read.\n */\nfunction sniffOoxml(source: ByteWindow): string | null {\n const entries = readZipCentralDirectory(source)\n if (!entries) return null\n\n const contentTypes = entries.find((entry) => entry.name === OPC_CONTENT_TYPES_PART)\n if (!contentTypes) return null\n const names = entries.map((entry) => entry.name)\n if (!names.includes(OPC_PACKAGE_RELATIONSHIPS_PART)) return null\n\n // Order is load-bearing: a VBA project part makes the package macro-enabled\n // whatever its content-types part declares, and the declared lookup below\n // RETURNS, so this has to be known before it runs.\n const macroEnabled = names.some((name) => name.endsWith(OOXML_VBA_PROJECT_PART_SUFFIX))\n\n const declared = storedZipEntryText(source, contentTypes)\n if (declared !== null) {\n if (!declared.includes(OPC_CONTENT_TYPES_NAMESPACE)) return null\n // Every match, not the first: `includes` over the whole part means a decoy\n // `Override` declaring a second main-part type is indistinguishable from\n // the real one, so the macro-enabled reading wins whenever one is present.\n const matches = OOXML_DECLARED_MAIN_PART_TYPES.filter((row) => declared.includes(row.declaredType))\n const row = matches.find((candidate) => candidate.macroEnabled) ?? matches[0]\n if (row) return macroEnabled || row.macroEnabled ? row.macroEnabledMime : row.mime\n }\n\n const canonical = OOXML_MAIN_PARTS.find((main) => names.includes(main.part))\n const format = canonical ?? OOXML_MAIN_PARTS.find((main) => names.some((name) => name.startsWith(main.directory)))\n if (!format) return null\n return macroEnabled ? format.macroEnabledMime : format.mime\n}\n\n/** `bytes` is the start of the file; `zip` is what the archive reader may\n * consult, which is the same bytes unless the file was classified while it\n * streamed. Every other family is decided by its first few KB. */\nfunction sniffMagicBytes(bytes: Uint8Array, zip: ByteWindow = wholeFileWindow(bytes)): string | null {\n if (bytesStartWith(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'\n if (bytesStartWith(bytes, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg'\n if (asciiAt(bytes, 0, 'GIF87a') || asciiAt(bytes, 0, 'GIF89a')) return 'image/gif'\n if (sniffBmp(bytes)) return 'image/bmp'\n if (bytesStartWith(bytes, 0, [0x49, 0x49, 0x2a, 0x00])) return 'image/tiff' // little-endian\n if (bytesStartWith(bytes, 0, [0x4d, 0x4d, 0x00, 0x2a])) return 'image/tiff' // big-endian\n if (bytesStartWith(bytes, 0, [0x00, 0x00, 0x01, 0x00])) return 'image/x-icon'\n if (asciiAt(bytes, 0, '%PDF-')) return 'application/pdf'\n // OOXML (.docx/.xlsx/.pptx) is a PKZIP container, so the container is\n // inspected before an archive is reported as a generic one — see\n // `sniffOoxml`. An archive that is not an Office package stays\n // `application/zip`, which no default allow-list admits.\n if (bytesStartWith(bytes, 0, [0x50, 0x4b, 0x03, 0x04])) return sniffOoxml(zip) ?? 'application/zip'\n if (bytesStartWith(bytes, 0, [0x1f, 0x8b])) return 'application/gzip'\n if (sniffId3(bytes) || bytesStartWith(bytes, 0, [0xff, 0xfb])) return 'audio/mpeg'\n if (asciiAt(bytes, 0, 'OggS')) return 'audio/ogg'\n\n const ebml = sniffEbml(bytes)\n if (ebml) return ebml\n\n const riff = sniffRiff(bytes)\n if (riff) return riff\n\n const ftyp = sniffFtyp(bytes)\n if (ftyp) return ftyp\n\n return null\n}\n\n/** SVG is valid UTF-8 but must round-trip byte-identical (image tools read\n * it from the box as a file), so it is classified binary. Conservative\n * match: the document's first element is `<svg`, or an `<?xml` prolog is\n * followed by an `<svg` element within the first ~1KB (comments/doctype may\n * sit between). Plain XML without an svg root, or prose that merely\n * mentions \"<svg\", stays text. */\nfunction sniffSvgText(decoded: string): boolean {\n let text = decoded\n if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)\n text = text.trimStart()\n if (/^<svg[\\s>/]/.test(text)) return true\n if (!text.startsWith('<?xml')) return false\n return /<svg[\\s>/]/.test(text.slice(0, 1024))\n}\n\n/** Decide whether uploaded bytes are binary or text, and identify the mime\n * type when it can be determined from content. Magic bytes are checked\n * first; anything unmatched falls back to a fatal UTF-8 decode. A NUL byte\n * or a decode failure means binary. Valid UTF-8 that is an SVG document is\n * binary (byte-identity matters for image tooling). Content that matches\n * nothing and does not decode as text is binary with an unknown mime —\n * extension-based guessing happens at the call site, not here. */\nexport function sniffBinary(bytes: Uint8Array): SniffResult {\n const mime = sniffMagicBytes(bytes)\n if (mime) return { binary: true, mime }\n\n if (bytes.includes(0x00)) return { binary: true, mime: null }\n\n let decoded: string\n try {\n decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n } catch {\n return { binary: true, mime: null }\n }\n if (sniffSvgText(decoded)) return { binary: true, mime: 'image/svg+xml' }\n return { binary: false, mime: null }\n}\n\n/** Leading bytes a streamed classification keeps. Every magic-byte family\n * except PKZIP is decided within them (the EBML header scan reads 4 KiB),\n * and a file no longer than this is classified exactly as\n * {@link sniffBinary} classifies it. */\nexport const STREAM_SNIFF_HEAD_BYTES = 64 * 1024\n\n/** Trailing bytes a streamed classification keeps for the zip reader. The\n * end-of-central-directory record sits within 64 KiB + 22 bytes of the end,\n * and an Office package's central directory is a few KB. An archive whose\n * directory starts earlier than this window is reported as a plain zip. */\nexport const STREAM_SNIFF_TAIL_BYTES = 1024 * 1024\n\n/** Classifies a file from its bytes as they stream past, holding at most\n * {@link STREAM_SNIFF_HEAD_BYTES} + {@link STREAM_SNIFF_TAIL_BYTES} of it. */\nexport interface StreamingSniffer {\n /** Feed the next bytes in order. The sniffer keeps references to recent\n * chunks, so a caller must not reuse a chunk's buffer afterwards. */\n push(chunk: Uint8Array): void\n /** Bytes pushed so far. */\n readonly size: number\n /** The start of the file, up to {@link STREAM_SNIFF_HEAD_BYTES}. */\n head(): Uint8Array\n /** Classify everything pushed. The answer is {@link sniffBinary}'s over the\n * whole file, except that a zip whose central directory lies outside the\n * tail window reads as `application/zip`, and the SVG check sees only the\n * head. Text validity (NUL bytes, UTF-8) is checked over every byte. */\n finish(): SniffResult\n}\n\nfunction concatChunks(chunks: readonly Uint8Array[], length: number): Uint8Array {\n if (chunks.length === 1) return chunks[0]!\n const joined = new Uint8Array(length)\n let offset = 0\n for (const chunk of chunks) {\n joined.set(chunk, offset)\n offset += chunk.length\n }\n return joined\n}\n\n/** Start a {@link StreamingSniffer} for one file. */\nexport function createStreamingSniffer(): StreamingSniffer {\n const head = new Uint8Array(STREAM_SNIFF_HEAD_BYTES)\n let headLength = 0\n const tailChunks: Uint8Array[] = []\n let tailLength = 0\n let size = 0\n let sawNul = false\n let validUtf8 = true\n const decoder = new TextDecoder('utf-8', { fatal: true })\n let finished = false\n\n return {\n get size() {\n return size\n },\n head: () => head.subarray(0, headLength),\n push(chunk) {\n if (finished) throw new Error('streaming sniffer already finished')\n if (chunk.length === 0) return\n size += chunk.length\n if (headLength < head.length) {\n const take = Math.min(head.length - headLength, chunk.length)\n head.set(chunk.subarray(0, take), headLength)\n headLength += take\n }\n tailChunks.push(chunk)\n tailLength += chunk.length\n while (tailChunks.length > 1 && tailLength - tailChunks[0]!.length >= STREAM_SNIFF_TAIL_BYTES) {\n tailLength -= tailChunks.shift()!.length\n }\n if (!sawNul && chunk.includes(0x00)) sawNul = true\n if (validUtf8 && !sawNul) {\n try {\n decoder.decode(chunk, { stream: true })\n } catch {\n validUtf8 = false\n }\n }\n },\n finish() {\n finished = true\n if (size <= head.length) return sniffBinary(head.subarray(0, size))\n if (validUtf8 && !sawNul) {\n try {\n decoder.decode()\n } catch {\n validUtf8 = false\n }\n }\n const start = head.subarray(0, headLength)\n const joined = concatChunks(tailChunks, tailLength)\n const tail = joined.subarray(Math.max(0, joined.length - STREAM_SNIFF_TAIL_BYTES))\n const tailStart = size - tail.length\n const window: ByteWindow = {\n size,\n read(offset, length) {\n if (offset < 0 || length < 0 || offset + length > size) return null\n if (offset + length <= start.length) return start.subarray(offset, offset + length)\n if (offset >= tailStart) return tail.subarray(offset - tailStart, offset - tailStart + length)\n return null\n },\n }\n const mime = sniffMagicBytes(start, window)\n if (mime) return { binary: true, mime }\n if (sawNul || !validUtf8) return { binary: true, mime: null }\n if (sniffSvgText(new TextDecoder('utf-8').decode(start))) return { binary: true, mime: 'image/svg+xml' }\n return { binary: false, mime: null }\n },\n }\n}\n","/**\n * Shared attachment validation core — constants, type-gate, and filename\n * sanitization used by BOTH the (server) attachment upload route and the\n * (browser) composer's client-side pre-validation, so a rejection never\n * differs depending on which side classified the bytes first. The structural\n * `attachments` parser lives here too, so the chat route and the operator API\n * refuse the same turn bodies without either pulling in the chat store.\n *\n * ≈ gtm-agent's `src/lib/attachment-limits.ts`, minus what agent-app already\n * has (`ATTACHMENT_MAX_COUNT`/`MAX_ATTACHMENT_TOTAL_BYTES`/\n * `attachmentTotalSizeErrorMessage` lived in `./resolve-attachments` and are\n * re-homed here so the whole validation vocabulary — count cap, size caps,\n * and type gate — has one address). Import-free besides `./wire`\n * (`formatBytes`) and `./binary-sniff` (`SniffResult`): `/web-react`\n * re-exports `/chat-routes` modules into browser bundles\n * (`tests/browser-safe-subpaths.test.ts` walks the graph), so nothing here\n * may reach a Node builtin or an engine package.\n */\n\nimport { formatBytes, type ChatAttachmentInput, type ChatAttachmentKind } from './wire'\nimport {\n OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n OOXML_PRESENTATION_MIME,\n OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n OOXML_SPREADSHEET_MIME,\n OOXML_WORD_MACRO_ENABLED_MIME,\n OOXML_WORD_MIME,\n} from './binary-sniff'\nimport type { SniffResult } from './binary-sniff'\n\n/** Ceiling on a binary attachment's raw (pre-encoding) byte size. */\nexport const MAX_BINARY_ATTACHMENT_BYTES = 10 * 1024 * 1024\n\n/** Ceiling on a text attachment's raw byte size. Text hydrates through\n * inline prompt parts, a separate path that remains proxy-capped (see\n * `INLINE_PARTS_MAX_BYTES` in `./wire`). */\nexport const MAX_TEXT_ATTACHMENT_BYTES = 950 * 1024\n\n/** Most files a single request may carry: the composer staging cap, the\n * upload route's per-request cap, and the chat body's `attachments` cap. */\nexport const ATTACHMENT_MAX_COUNT = 10\n\n/** Aggregate raw-byte ceiling across one message's attachments. */\nexport const MAX_ATTACHMENT_TOTAL_BYTES = 25 * 1024 * 1024\n\n/**\n * Accept list for the composer file picker + type validation, same grammar as\n * the native `<input accept>` attribute. Images plus the text/doc types a\n * product's store actually reads.\n *\n * Every extension here is one the default {@link ALLOWED_ATTACHMENT_SNIFFED_MIMES}\n * actually admits — a picker that offers a format the gate then rejects is a\n * defect, so `tests/chat-routes/attachment-validation.test.ts` walks this\n * string and proves each entry uploads.\n */\nexport const ATTACHMENT_ACCEPT =\n 'image/*,.pdf,.docx,.xlsx,.pptx,.txt,.md,.csv,.json,.yaml,.yml,.html'\n\n/** The Office (OOXML) package mimes the default allow-list admits — the\n * formats professionals actually send: Word contracts, Excel workpapers,\n * PowerPoint decks. Admitting the format is a gate decision only; whether a\n * product can EXTRACT text from one is that product's concern. */\nexport const OOXML_SNIFFED_MIMES: ReadonlySet<string> = new Set([\n OOXML_WORD_MIME,\n OOXML_SPREADSHEET_MIME,\n OOXML_PRESENTATION_MIME,\n])\n\n/** Macro-enabled Office package mimes (`.docm`/`.xlsm`/`.pptm`), reported by\n * `sniffBinary` but deliberately NOT in the default allow-list: a package\n * carrying a VBA project is a different risk decision than a plain document,\n * and it is the product's to make. Opt in by widening the allow-list at the\n * route's `allowedSniffedMimes` seam:\n * `new Set([...ALLOWED_ATTACHMENT_SNIFFED_MIMES, ...MACRO_ENABLED_OOXML_SNIFFED_MIMES])`. */\nexport const MACRO_ENABLED_OOXML_SNIFFED_MIMES: ReadonlySet<string> = new Set([\n OOXML_WORD_MACRO_ENABLED_MIME,\n OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n])\n\n/** Sniffed-mime counterpart of `ATTACHMENT_ACCEPT`: the binary formats\n * `sniffBinary` can identify from content among the accepted types.\n * Values must match `sniffBinary`'s output strings verbatim, or every\n * upload of that format fails the type gate — locked by the round-trip test\n * that sniffs real bytes of every mime listed here. */\nexport const ALLOWED_ATTACHMENT_SNIFFED_MIMES: ReadonlySet<string> = new Set([\n 'image/png',\n 'image/jpeg',\n 'image/gif',\n 'image/bmp',\n 'image/tiff',\n 'image/x-icon',\n 'image/webp',\n 'image/svg+xml',\n 'image/avif',\n 'image/heic',\n 'image/heif',\n 'application/pdf',\n ...OOXML_SNIFFED_MIMES,\n])\n\n/** Extensions whose magic-byte family is unambiguous, mapped to the mime\n * `sniffBinary` emits for genuine content of that format. Keep in sync with\n * `ATTACHMENT_ACCEPT`: an extension only belongs here if its format has a\n * detectable magic-byte signature. Text extensions (.txt/.md/.csv/.json/\n * .yaml/.yml/.html) are deliberately absent — they have no magic bytes to\n * compare against, so they ride the plain UTF-8 gate instead. AVIF/HEIC/HEIF\n * extensions are also deliberately absent: the ISO-BMFF brand-to-extension\n * mapping in that family isn't one-to-one (a legitimate `.heic` can carry a\n * `mif1` brand), so an extension-implies-mime entry would reject genuine\n * files. They still ride the allowlist gate below, which is what catches an\n * mp4 renamed `.avif` (its content sniffs `video/mp4`, not an allowed mime).\n * The Office extensions ARE here: a `.zip` renamed `.docx` sniffs\n * `application/zip` (its container carries neither of the two OPC parts an\n * Office package must have), so the entry turns a generic 415 into a mismatch\n * that names what the file actually is. Sniffing answers what format the\n * bytes CLAIM to be, not whether they are a readable document — see\n * `sniffOoxml`'s note on what a reader with no inflater can and cannot prove.\n * The macro-enabled and EBML-video extensions are listed for the same reason\n * even though the default allow-list refuses them — a product that widens\n * the allow-list gets the renamed-file check with it. */\nconst EXTENSION_IMPLIES_SNIFFED_MIME: Readonly<Record<string, string>> = {\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n gif: 'image/gif',\n bmp: 'image/bmp',\n tif: 'image/tiff',\n tiff: 'image/tiff',\n ico: 'image/x-icon',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n pdf: 'application/pdf',\n docx: OOXML_WORD_MIME,\n xlsx: OOXML_SPREADSHEET_MIME,\n pptx: OOXML_PRESENTATION_MIME,\n docm: OOXML_WORD_MACRO_ENABLED_MIME,\n xlsm: OOXML_SPREADSHEET_MACRO_ENABLED_MIME,\n pptm: OOXML_PRESENTATION_MACRO_ENABLED_MIME,\n webm: 'video/webm',\n mkv: 'video/x-matroska',\n}\n\n/** The sniffed mime a filename's extension implies, when its format has an\n * unambiguous magic-byte family (`.xlsx` → the spreadsheet mime). */\nexport function sniffedMimeImpliedByName(fileName: string): string | undefined {\n const extension = fileName.split('.').pop()?.toLowerCase() ?? ''\n return EXTENSION_IMPLIES_SNIFFED_MIME[extension]\n}\n\n/** Represent the result of checking an attachment's type with success or specific failure details */\nexport type AttachmentTypeCheckResult =\n | { succeeded: true }\n | { succeeded: false; code: 'attachment_type_mismatch' | 'attachment_type_not_allowed'; message: string }\n\n/**\n * Cross-check a filename's extension against its sniffed content.\n *\n * Text content (`sniff.binary === false`) always passes here — it has no\n * magic bytes to compare, so it rides the existing UTF-8 gate instead. For\n * binary content: an extension with an unambiguous magic-byte family (e.g.\n * `.pdf`) must match the sniffed mime, or the upload is a mismatch (a\n * renamed file). Otherwise the sniffed mime must be one of `allowed`\n * (default {@link ALLOWED_ATTACHMENT_SNIFFED_MIMES}), or the upload is\n * rejected outright. The `allowed` param feeds a route's override seam (a\n * product accepting a narrower or wider set than the default).\n */\nexport function checkAttachmentType(\n fileName: string,\n sniff: SniffResult,\n allowed: ReadonlySet<string> = ALLOWED_ATTACHMENT_SNIFFED_MIMES,\n): AttachmentTypeCheckResult {\n if (sniff.binary === false) return { succeeded: true }\n\n const extension = fileName.split('.').pop()?.toLowerCase() ?? ''\n const impliedMime = EXTENSION_IMPLIES_SNIFFED_MIME[extension]\n if (impliedMime && sniff.mime && sniff.mime !== impliedMime) {\n return {\n succeeded: false,\n code: 'attachment_type_mismatch',\n message: `${fileName} has a .${extension} extension, but its content is ${sniff.mime}`,\n }\n }\n\n if (!sniff.mime || !allowed.has(sniff.mime)) {\n return {\n succeeded: false,\n code: 'attachment_type_not_allowed',\n message: sniff.mime\n ? `${fileName}'s content (${sniff.mime}) is not an allowed attachment type`\n : `${fileName}'s content is not a recognized attachment type`,\n }\n }\n\n return { succeeded: true }\n}\n\n/**\n * Rewrite a filename into the store-path charset (`A-Za-z0-9._-` per\n * segment) — attachment paths double as store keys, sandbox file paths, and\n * in-message path references, none of which tolerate spaces or punctuation.\n * Runs of unsupported characters collapse to one `-`; leading dots/dashes are\n * stripped so the name can't read as a hidden segment. The original name is\n * preserved separately (the returned `ChatAttachmentInput.name`), so\n * sanitization loses nothing.\n */\nexport function sanitizeAttachmentFileName(name: string): string {\n const sanitized = name\n .trim()\n .replace(/[^A-Za-z0-9._-]+/g, '-')\n .replace(/^[.-]+/, '')\n return sanitized || 'file'\n}\n\n/** Human-readable error naming both the actual size and the limit that was\n * exceeded. Shared so the server route and the composer pre-check report\n * the same message shape. */\nexport function attachmentSizeErrorMessage(name: string, actualBytes: number, limitBytes: number): string {\n return `${name} is ${formatBytes(actualBytes)}; attachments are limited to ${formatBytes(limitBytes)}`\n}\n\n/** Human-readable error for a chat message whose combined attachments exceed\n * the aggregate raw-byte ceiling. */\nexport function attachmentTotalSizeErrorMessage(totalBytes: number, limitBytes: number): string {\n return `Attachments total ${formatBytes(totalBytes)}; each message is limited to ${formatBytes(limitBytes)}`\n}\n\n/** Verdict of a path check: OK, or a rejection naming why. Mirrors\n * `SandboxMentionPathCheck` in `./wire`. */\nexport type AttachmentPathCheck =\n | { succeeded: true }\n | { succeeded: false; error: string }\n\n/** Longest attachment display name accepted — bounds what gets echoed into the\n * prompt block and rendered as a chip label. */\nconst MAX_ATTACHMENT_NAME_LENGTH = 256\n\nfunction isAttachmentKind(value: unknown): value is ChatAttachmentKind {\n return value === 'image' || value === 'file'\n}\n\n/** C0 control characters (0x00–0x1F) plus DEL (0x7F) — covers `\\n`/`\\r`/`\\t`.\n * A `name` or `path` carrying one of these has no legitimate use here and\n * everything to gain from an attacker: {@link buildAttachmentPromptBlock} in\n * `/chat-store` renders both fields verbatim into the dispatched agent\n * prompt, so an embedded newline fabricates new prompt lines (a\n * prompt-injection vector) rather than naming a file. Rejected at this\n * boundary — not merely neutralized downstream — so the wire never accepts\n * the input in the first place; a legitimate name/path never contains one. */\nconst CONTROL_CHARS = /[\\x00-\\x1F\\x7F]/\n\n/**\n * Default path validator when a caller supplies none. Rejects the ways a path\n * picked in a client can escape the store root — traversal (`..` segment),\n * absolute (leading `/`), backslashes, null bytes, control characters (see\n * {@link CONTROL_CHARS} — a path also feeds {@link buildAttachmentPromptBlock}'s\n * `(vault: ${path})` pointer, so it is exposed to the same injection surface as\n * `name`) — plus a dotfile/hidden segment (a leading `.` on any segment).\n * Generalized from gtm's `validateVaultFilePath`, in the spirit of\n * `validateSandboxMentionPath` (`/chat-routes`'s wire mention-path validator) —\n * but the dotfile rejection here is INTENTIONALLY stricter than that sibling:\n * an uploaded attachment path is sanitized store-relative storage the product\n * itself assigned, whereas a mention path points at a file that already exists\n * in the sandbox and may legitimately live under a dotfile segment. A caller\n * that needs gtm's exact (looser) rule can supply `validatePath` to override\n * this default entirely.\n */\nexport function defaultValidateAttachmentPath(path: string): AttachmentPathCheck {\n if (path.includes('\\0')) return { succeeded: false, error: 'attachment path must not contain null bytes' }\n if (path.includes('\\\\')) return { succeeded: false, error: 'attachment path must not contain backslashes' }\n if (CONTROL_CHARS.test(path)) return { succeeded: false, error: 'attachment path must not contain control characters' }\n if (path.startsWith('/')) return { succeeded: false, error: 'attachment path must be store-relative, not absolute' }\n const segments = path.split('/')\n if (segments.some((segment) => segment === '..')) {\n return { succeeded: false, error: 'attachment path must not contain \"..\" segments' }\n }\n if (segments.some((segment) => segment.startsWith('.'))) {\n return { succeeded: false, error: 'attachment path must not contain a hidden (dotfile) segment' }\n }\n return { succeeded: true }\n}\n\nfunction parseAttachmentInput(\n value: unknown,\n index: number,\n validatePath: (path: string) => AttachmentPathCheck,\n): { succeeded: true; value: ChatAttachmentInput } | { succeeded: false; error: string } {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n return { succeeded: false, error: `attachments[${index}] must be an object` }\n }\n const record = value as Record<string, unknown>\n\n const path = record.path\n if (typeof path !== 'string' || !path) {\n return { succeeded: false, error: `attachments[${index}].path must be a non-empty string` }\n }\n const name = record.name\n if (typeof name !== 'string' || !name.trim()) {\n return { succeeded: false, error: `attachments[${index}].name must be a non-empty string` }\n }\n if (name.length > MAX_ATTACHMENT_NAME_LENGTH) {\n return { succeeded: false, error: `attachments[${index}].name must not exceed ${MAX_ATTACHMENT_NAME_LENGTH} characters` }\n }\n if (CONTROL_CHARS.test(name)) {\n return { succeeded: false, error: `attachments[${index}].name must not contain control characters` }\n }\n const size = record.size\n if (typeof size !== 'number' || !Number.isFinite(size)) {\n return { succeeded: false, error: `attachments[${index}].size must be a finite number` }\n }\n if (size < 0) {\n return { succeeded: false, error: `attachments[${index}].size must not be negative` }\n }\n const mediaType = record.mediaType\n if (typeof mediaType !== 'string') {\n return { succeeded: false, error: `attachments[${index}].mediaType must be a string` }\n }\n const kind = record.kind\n if (!isAttachmentKind(kind)) {\n return { succeeded: false, error: `attachments[${index}].kind must be \"image\" or \"file\"` }\n }\n\n const pathCheck = validatePath(path)\n if (!pathCheck.succeeded) return { succeeded: false, error: pathCheck.error }\n\n return { succeeded: true, value: { path, name, size, mediaType, kind } }\n}\n\n/** Structural limits for {@link parseChatAttachmentInputs}. */\nexport interface ParseChatAttachmentInputsOptions {\n /** Most attachments one request may carry. Default {@link ATTACHMENT_MAX_COUNT}. */\n maxCount?: number\n /** Path validator override. Default {@link defaultValidateAttachmentPath}. */\n validatePath?: (path: string) => AttachmentPathCheck\n}\n\n/**\n * Validate an `attachments` value's shape without reading the store: an\n * array of at most `maxCount` {@link ChatAttachmentInput}s with valid,\n * distinct paths. Absent (`undefined`/`null`) is an empty list. Sizes are\n * still client-reported here; {@link resolveChatAttachments} re-derives them\n * from the store.\n */\nexport function parseChatAttachmentInputs(\n value: unknown,\n options: ParseChatAttachmentInputsOptions = {},\n): { succeeded: true; value: ChatAttachmentInput[] } | { succeeded: false; error: string } {\n const maxCount = options.maxCount ?? ATTACHMENT_MAX_COUNT\n const validatePath = options.validatePath ?? defaultValidateAttachmentPath\n\n if (value === undefined || value === null) return { succeeded: true, value: [] }\n if (!Array.isArray(value)) return { succeeded: false, error: 'attachments must be an array' }\n if (value.length > maxCount) {\n return { succeeded: false, error: `attachments must not exceed ${maxCount} entries` }\n }\n\n const inputs: ChatAttachmentInput[] = []\n const seenPaths = new Set<string>()\n for (let index = 0; index < value.length; index += 1) {\n const parsed = parseAttachmentInput(value[index], index, validatePath)\n if (!parsed.succeeded) return parsed\n if (seenPaths.has(parsed.value.path)) {\n return { succeeded: false, error: `attachments must not repeat a path: ${parsed.value.path}` }\n }\n seenPaths.add(parsed.value.path)\n inputs.push(parsed.value)\n }\n return { succeeded: true, value: inputs }\n}\n"],"mappings":"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","names":["row"]}
@@ -65,6 +65,8 @@ export interface ChatOperatorAdapterOptions<Identity> {
65
65
  * `resolveChatAttachments`. Without it, turns that carry attachments are refused.
66
66
  */
67
67
  acceptsAttachments?: boolean;
68
+ /** Most attachments one turn may carry; set it to the chat route's own count cap. Default `ATTACHMENT_MAX_COUNT`. */
69
+ maxAttachments?: number;
68
70
  /** The app's public origin, the base of every browser link. */
69
71
  origin: string;
70
72
  /** Browser path of a conversation; defaults to `/app/<workspace>/chat/<thread>`. */
@@ -43,6 +43,16 @@ export interface OperatorClient {
43
43
  waitSeconds?: number;
44
44
  signal?: AbortSignal;
45
45
  }): Promise<OperatorResult<OperatorTurn>>;
46
+ /**
47
+ * Stop a turn. A settled turn comes back as it settled; otherwise the app
48
+ * has been asked to stop it, and the server holds up to `waitSeconds` for
49
+ * the turn to settle `failed` with `OPERATOR_TURN_CANCELLED_CODE`. Read it
50
+ * with `getTurn` or `waitForTurn` until it settles.
51
+ */
52
+ cancelTurn(workspaceId: string, threadId: string, turnId: string, options?: {
53
+ waitSeconds?: number;
54
+ signal?: AbortSignal;
55
+ }): Promise<OperatorResult<OperatorTurn>>;
46
56
  /** Follow until the turn settles, waits on a decision, or the timeout passes; the last read is returned. */
47
57
  waitForTurn(workspaceId: string, threadId: string, turnId: string, options: WaitForTurnOptions): Promise<OperatorResult<OperatorTurn>>;
48
58
  listThreads(workspaceId: string, query?: {
@@ -43,7 +43,7 @@ export interface OperatorAppInfo {
43
43
  /** Optional operations this app implements; absent ones answer 501. */
44
44
  capabilities: OperatorCapability[];
45
45
  }
46
- export type OperatorCapability = 'turns.attachments' | 'workspaces.create' | 'threads.read' | 'files.read' | 'journal.read' | 'assets.read' | 'scorecard.read';
46
+ export type OperatorCapability = 'turns.attachments' | 'turns.cancel' | 'workspaces.create' | 'threads.read' | 'files.read' | 'journal.read' | 'assets.read' | 'scorecard.read';
47
47
  export interface OperatorPrincipalInfo {
48
48
  keyId: string;
49
49
  scopes: string[];
@@ -71,6 +71,8 @@ export interface OperatorThread {
71
71
  */
72
72
  export type OperatorTurnState = 'queued' | 'working' | 'input-required' | 'succeeded' | 'failed' | 'unknown';
73
73
  export declare const OPERATOR_SETTLED_STATES: readonly OperatorTurnState[];
74
+ /** `failure.code` of a turn that settled because it was cancelled before it finished. */
75
+ export declare const OPERATOR_TURN_CANCELLED_CODE = "turn.cancelled";
74
76
  export interface OperatorAssetRef {
75
77
  id: string;
76
78
  /** Root-relative operator route that returns the bytes. */
@@ -179,9 +181,10 @@ export interface StartTurnInput {
179
181
  * Files uploaded to this workspace ahead of the turn, as the app's
180
182
  * attachment upload route returned them in `{ files }`. They reach the
181
183
  * agent exactly as browser-composer attachments do; the app re-checks each
182
- * path against the workspace's own store. At most `ATTACHMENT_MAX_COUNT`
183
- * (`/chat-routes`) entries. Apps without the `turns.attachments`
184
- * capability refuse them.
184
+ * path against the workspace's own store. At most the app's
185
+ * `maxAttachments` entries, `ATTACHMENT_MAX_COUNT` (`/chat-routes`) unless
186
+ * the app sets one. Apps without the `turns.attachments` capability refuse
187
+ * them.
185
188
  */
186
189
  attachments?: ChatAttachmentInput[];
187
190
  }
@@ -226,6 +229,11 @@ export declare const OPERATOR_ROUTES: readonly [{
226
229
  readonly method: 'GET';
227
230
  readonly path: '/workspaces/:workspaceId/threads/:threadId/turns/:turnId';
228
231
  readonly scopes: readonly ["operator:read"];
232
+ }, {
233
+ readonly id: 'turns.cancel';
234
+ readonly method: 'POST';
235
+ readonly path: '/workspaces/:workspaceId/threads/:threadId/turns/:turnId/cancel';
236
+ readonly scopes: readonly ["operator:read", "operator:run"];
229
237
  }, {
230
238
  readonly id: 'approvals.list';
231
239
  readonly method: 'GET';
@@ -5,7 +5,7 @@ import {
5
5
  } from "../chunk-XB4KZVUV.js";
6
6
  import {
7
7
  parseChatAttachmentInputs
8
- } from "../chunk-PBQYVAB6.js";
8
+ } from "../chunk-2ZRXJ3NO.js";
9
9
  import {
10
10
  createApiKeyFetch
11
11
  } from "../chunk-RN4WPTBH.js";
@@ -52,6 +52,7 @@ function operatorKeyWorkspaces(scopes) {
52
52
  return restricted.map((scope) => scope.slice(OPERATOR_WORKSPACE_SCOPE_PREFIX.length)).filter((id) => WORKSPACE_ID.test(id));
53
53
  }
54
54
  var OPERATOR_SETTLED_STATES = ["succeeded", "failed"];
55
+ var OPERATOR_TURN_CANCELLED_CODE = "turn.cancelled";
55
56
  var OPERATOR_ROUTES = [
56
57
  { id: "describe", method: "GET", path: "", scopes: ["operator:read"] },
57
58
  { id: "workspaces.list", method: "GET", path: "/workspaces", scopes: ["operator:read"] },
@@ -61,6 +62,7 @@ var OPERATOR_ROUTES = [
61
62
  { id: "threads.list", method: "GET", path: "/workspaces/:workspaceId/threads", scopes: ["operator:read"] },
62
63
  { id: "threads.get", method: "GET", path: "/workspaces/:workspaceId/threads/:threadId", scopes: ["operator:read"] },
63
64
  { id: "turns.get", method: "GET", path: "/workspaces/:workspaceId/threads/:threadId/turns/:turnId", scopes: ["operator:read"] },
65
+ { id: "turns.cancel", method: "POST", path: "/workspaces/:workspaceId/threads/:threadId/turns/:turnId/cancel", scopes: ["operator:read", "operator:run"] },
64
66
  { id: "approvals.list", method: "GET", path: "/workspaces/:workspaceId/approvals", scopes: ["operator:read"] },
65
67
  { id: "journal.list", method: "GET", path: "/workspaces/:workspaceId/journal", scopes: ["operator:read"] },
66
68
  { id: "files.list", method: "GET", path: "/workspaces/:workspaceId/files", scopes: ["operator:read"] },
@@ -179,7 +181,7 @@ function optionalString(body, name, max) {
179
181
  }
180
182
  return value.trim();
181
183
  }
182
- function parseStartTurn(body) {
184
+ function parseStartTurn(body, maxAttachments) {
183
185
  const unknown = Object.keys(body).filter((field) => !START_FIELDS.has(field));
184
186
  if (unknown.length) throw new OperatorError("operator.invalid_input", 400, `Unknown fields: ${unknown.join(", ")}`);
185
187
  if (typeof body.turnId !== "string" || !TURN_ID.test(body.turnId)) {
@@ -197,7 +199,7 @@ function parseStartTurn(body) {
197
199
  }
198
200
  const title = optionalString(body, "title", 200);
199
201
  const model = optionalString(body, "model", 200);
200
- const attachments = parseChatAttachmentInputs(body.attachments);
202
+ const attachments = parseChatAttachmentInputs(body.attachments, maxAttachments === void 0 ? {} : { maxCount: maxAttachments });
201
203
  if (!attachments.succeeded) throw new OperatorError("operator.invalid_input", 400, attachments.error);
202
204
  return {
203
205
  turnId: body.turnId.toLowerCase(),
@@ -210,6 +212,7 @@ function parseStartTurn(body) {
210
212
  }
211
213
  function capabilities(adapter) {
212
214
  const present = [
215
+ ["turns.cancel", adapter.cancelTurn],
213
216
  ["workspaces.create", adapter.createWorkspace],
214
217
  ["threads.read", adapter.getThread ?? adapter.listThreads],
215
218
  ["files.read", adapter.readFile ?? adapter.listFiles],
@@ -223,8 +226,11 @@ function capabilities(adapter) {
223
226
  function unsupported() {
224
227
  throw new OperatorError("operator.unsupported", 501, "This app does not implement this operation");
225
228
  }
229
+ function settled(turn) {
230
+ return OPERATOR_SETTLED_STATES.includes(turn.state);
231
+ }
226
232
  function needsAttention(turn) {
227
- return turn.state === "succeeded" || turn.state === "failed" || turn.state === "input-required" || turn.approvals.length > 0;
233
+ return settled(turn) || turn.state === "input-required" || turn.approvals.length > 0;
228
234
  }
229
235
  var sleep = (ms, signal) => new Promise((resolve) => {
230
236
  if (signal.aborted) return resolve();
@@ -308,13 +314,24 @@ function createOperatorApi(options) {
308
314
  if (restriction && !restriction.includes(workspaceId)) {
309
315
  throw new OperatorError("operator.workspace_not_found", 404, "Workspace not found");
310
316
  }
311
- const workspace = await adapter.authorizeWorkspace(ctx, workspaceId, route.id === "turns.start" ? "run" : "read");
317
+ const runs = route.id === "turns.start" || route.id === "turns.cancel";
318
+ const workspace = await adapter.authorizeWorkspace(ctx, workspaceId, runs ? "run" : "read");
312
319
  if (!workspace) throw new OperatorError("operator.workspace_not_found", 404, "Workspace not found");
320
+ const hold = async (first, target, wait, done) => {
321
+ const deadline = Date.now() + wait * 1e3;
322
+ let turn = first;
323
+ while (turn && !done(turn) && Date.now() + pollIntervalMs <= deadline && !request.signal.aborted) {
324
+ await sleep(pollIntervalMs, request.signal);
325
+ turn = await adapter.getTurn(ctx, workspace, target);
326
+ }
327
+ if (!turn) throw new OperatorError("operator.turn_not_found", 404, "Turn not found");
328
+ return turn;
329
+ };
313
330
  switch (route.id) {
314
331
  case "workspaces.get":
315
332
  return json({ workspace });
316
333
  case "turns.start": {
317
- const input = parseStartTurn(await readJsonBody(request));
334
+ const input = parseStartTurn(await readJsonBody(request), adapter.maxAttachments);
318
335
  if (input.attachments && adapter.acceptsAttachments !== true) {
319
336
  throw new OperatorError("operator.unsupported", 501, "This app does not accept attachments on operator turns");
320
337
  }
@@ -338,14 +355,17 @@ function createOperatorApi(options) {
338
355
  case "turns.get": {
339
356
  const target = { threadId: params.threadId, turnId: params.turnId };
340
357
  const wait = boundedInteger(url.searchParams.get("wait"), 0, 0, maxWaitSeconds, "wait");
341
- const deadline = Date.now() + wait * 1e3;
342
- let turn = await adapter.getTurn(ctx, workspace, target);
343
- while (turn && !needsAttention(turn) && Date.now() + pollIntervalMs <= deadline && !request.signal.aborted) {
344
- await sleep(pollIntervalMs, request.signal);
345
- turn = await adapter.getTurn(ctx, workspace, target);
346
- }
347
- if (!turn) throw new OperatorError("operator.turn_not_found", 404, "Turn not found");
348
- return json({ turn });
358
+ return json({ turn: await hold(await adapter.getTurn(ctx, workspace, target), target, wait, needsAttention) });
359
+ }
360
+ case "turns.cancel": {
361
+ if (!adapter.cancelTurn) unsupported();
362
+ const target = { threadId: params.threadId, turnId: params.turnId };
363
+ const wait = boundedInteger(url.searchParams.get("wait"), 0, 0, maxWaitSeconds, "wait");
364
+ const current = await adapter.getTurn(ctx, workspace, target);
365
+ if (!current) throw new OperatorError("operator.turn_not_found", 404, "Turn not found");
366
+ if (settled(current)) return json({ turn: current });
367
+ const turn = await hold(await adapter.cancelTurn(ctx, workspace, target), target, wait, settled);
368
+ return json({ turn }, settled(turn) ? 200 : 202);
349
369
  }
350
370
  case "approvals.list":
351
371
  return json({ approvals: await adapter.listApprovals(ctx, workspace) });
@@ -470,6 +490,11 @@ function createOperatorClient(options) {
470
490
  (body) => body.turn,
471
491
  opts.signal ? { signal: opts.signal } : {}
472
492
  ),
493
+ cancelTurn: (workspaceId, threadId, turnId, opts = {}) => call(
494
+ `/workspaces/${segment(workspaceId)}/threads/${segment(threadId)}/turns/${segment(turnId)}/cancel${query({ wait: opts.waitSeconds })}`,
495
+ (body) => body.turn,
496
+ { method: "POST", ...opts.signal ? { signal: opts.signal } : {} }
497
+ ),
473
498
  async waitForTurn(workspaceId, threadId, turnId, opts) {
474
499
  const deadline = Date.now() + opts.timeoutMs;
475
500
  const waitSeconds = opts.waitSeconds ?? 25;
@@ -695,6 +720,7 @@ function createChatOperatorAdapter(options) {
695
720
  }
696
721
  const adapter = {
697
722
  ...options.acceptsAttachments === true ? { acceptsAttachments: true } : {},
723
+ ...options.maxAttachments === void 0 ? {} : { maxAttachments: options.maxAttachments },
698
724
  async authorizeWorkspace(ctx, workspaceId, access) {
699
725
  const workspace = await options.workspaces.authorize(ctx, workspaceId, access);
700
726
  return workspace ? workspaceView(workspace) : null;
@@ -882,6 +908,7 @@ export {
882
908
  OPERATOR_ROUTES,
883
909
  OPERATOR_SCOPE_DEPENDENCIES,
884
910
  OPERATOR_SETTLED_STATES,
911
+ OPERATOR_TURN_CANCELLED_CODE,
885
912
  OPERATOR_WORKSPACE_SCOPE_PREFIX,
886
913
  OperatorError,
887
914
  createChatOperatorAdapter,