@tangle-network/agent-app 0.45.58 → 0.45.60

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.
@@ -1,3 +1,6 @@
1
+ import {
2
+ redactErrorMessage
3
+ } from "../chunk-MH74DY2I.js";
1
4
  import {
2
5
  createSandboxFileIndexRoute
3
6
  } from "../chunk-QY4BRKRJ.js";
@@ -2262,6 +2265,23 @@ function createUploadRoute(options) {
2262
2265
  };
2263
2266
  }
2264
2267
 
2268
+ // src/chat-routes/attachment-store.ts
2269
+ var ATTACHMENT_STORAGE_FAILURE_MESSAGE = "Attachment storage is temporarily unavailable. Please try again.";
2270
+ var ATTACHMENT_ROLLBACK_FAILURE_CODE = "rollback_failed";
2271
+ var ATTACHMENT_ROLLBACK_FAILURE_MESSAGE = "Attachment cleanup failed. Please try again.";
2272
+ function immutableAttachmentPath(logicalPath, ownershipId) {
2273
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(ownershipId)) {
2274
+ throw new Error("attachment ownership id must be a path-safe identifier");
2275
+ }
2276
+ return `${logicalPath}--${ownershipId}`;
2277
+ }
2278
+ function createAtomicAttachmentWriter(input) {
2279
+ return {
2280
+ write: input.write.bind(input),
2281
+ abort: input.abort.bind(input)
2282
+ };
2283
+ }
2284
+
2265
2285
  // src/chat-routes/resolve-attachments.ts
2266
2286
  var MAX_ATTACHMENT_NAME_LENGTH = 256;
2267
2287
  function isAttachmentKind(value) {
@@ -2357,6 +2377,47 @@ async function resolveChatAttachments(value, options) {
2357
2377
  return { succeeded: true, value: inputs.map(attachmentInputToPart) };
2358
2378
  }
2359
2379
 
2380
+ // src/chat-routes/attachment-write-safety.ts
2381
+ function objectLike(value) {
2382
+ return value !== null && (typeof value === "object" || typeof value === "function");
2383
+ }
2384
+ function inspectLegacyAttachmentWriteResult(value) {
2385
+ try {
2386
+ if (!objectLike(value)) return void 0;
2387
+ const ok = value.ok;
2388
+ if (ok === true) return { ok: true };
2389
+ if (ok === false && typeof value.reason === "string") {
2390
+ return { ok: false, reason: value.reason };
2391
+ }
2392
+ } catch {
2393
+ }
2394
+ return void 0;
2395
+ }
2396
+ function inspectAtomicAttachmentWriteResult(value, ownership) {
2397
+ try {
2398
+ if (!objectLike(value)) return void 0;
2399
+ const receiptValue = value.receipt;
2400
+ if (!objectLike(receiptValue)) return void 0;
2401
+ const receiptOwnership = receiptValue.ownership;
2402
+ if (!objectLike(receiptOwnership)) return void 0;
2403
+ if (receiptOwnership.id !== ownership.id || receiptOwnership.path !== ownership.path || typeof receiptOwnership.id !== "string" || typeof receiptOwnership.path !== "string" || typeof receiptValue.rollback !== "function") {
2404
+ return void 0;
2405
+ }
2406
+ if (value.ok === true) {
2407
+ return { ok: true, receipt: receiptValue };
2408
+ }
2409
+ if (value.ok === false && typeof value.reason === "string") {
2410
+ return {
2411
+ ok: false,
2412
+ reason: value.reason,
2413
+ receipt: receiptValue
2414
+ };
2415
+ }
2416
+ } catch {
2417
+ }
2418
+ return void 0;
2419
+ }
2420
+
2360
2421
  // src/chat-routes/promote-file-part.ts
2361
2422
  var PROMOTE_MAX_FILE_BYTES = 10 * 1024 * 1024;
2362
2423
  var EXT_TO_MIME = {
@@ -2403,27 +2464,27 @@ function base64ToBytes(base64) {
2403
2464
  return bytes;
2404
2465
  }
2405
2466
  function parseDataUrl(url) {
2406
- const match = /^data:[^,]*,([\s\S]*)$/.exec(url);
2467
+ const match = /^data:[^,]*,([\s\S]*)$/i.exec(url);
2407
2468
  if (!match) return null;
2408
2469
  return { base64: /;base64,/i.test(url), data: match[1] ?? "" };
2409
2470
  }
2410
2471
  function dataUrlMime(url) {
2411
2472
  if (!url) return void 0;
2412
- const match = /^data:([^;,]+)[;,]/.exec(url);
2473
+ const match = /^data:([^;,]+)[;,]/i.exec(url);
2413
2474
  return match ? match[1] : void 0;
2414
2475
  }
2415
2476
  function basenameFromUrl(url) {
2416
- if (!url || url.startsWith("data:")) return void 0;
2477
+ if (!url || /^data:/i.test(url)) return void 0;
2417
2478
  const withoutQuery = url.split(/[?#]/)[0] ?? url;
2418
2479
  const segments = withoutQuery.split("/").filter(Boolean);
2419
2480
  return segments[segments.length - 1] || void 0;
2420
2481
  }
2421
2482
  function resolveFileUrlPath(url) {
2422
- const withoutScheme = url.startsWith("file://") ? url.slice("file://".length) : url;
2483
+ const withoutScheme = /^file:\/\//i.test(url) ? url.slice("file://".length) : url;
2423
2484
  try {
2424
2485
  return { succeeded: true, path: decodeURIComponent(withoutScheme) };
2425
2486
  } catch (err) {
2426
- return { succeeded: false, reason: `malformed file path: ${err instanceof Error ? err.message : String(err)}` };
2487
+ return { succeeded: false, reason: `malformed file path: ${redactErrorMessage(err)}` };
2427
2488
  }
2428
2489
  }
2429
2490
  function oversizeReason(filename, actual, limit) {
@@ -2436,7 +2497,7 @@ function resolveDataUrlBytes(url, filename, maxBytes) {
2436
2497
  try {
2437
2498
  bytes = parsed.base64 ? base64ToBytes(parsed.data) : new TextEncoder().encode(decodeURIComponent(parsed.data));
2438
2499
  } catch (err) {
2439
- return { succeeded: false, reason: `failed to decode data URI: ${err instanceof Error ? err.message : String(err)}` };
2500
+ return { succeeded: false, reason: `failed to decode data URI: ${redactErrorMessage(err)}` };
2440
2501
  }
2441
2502
  if (bytes.byteLength > maxBytes) {
2442
2503
  return { succeeded: false, reason: oversizeReason(filename, bytes.byteLength, maxBytes) };
@@ -2446,23 +2507,26 @@ function resolveDataUrlBytes(url, filename, maxBytes) {
2446
2507
  async function resolveSandboxFileBytes(input) {
2447
2508
  const stat = await statSandboxFileSize(input.box, input.path, { sessionId: input.sessionId });
2448
2509
  if (!stat.succeeded) {
2449
- return { succeeded: false, reason: `could not stat agent file: ${stat.error}` };
2510
+ return { succeeded: false, reason: `could not stat agent file: ${redactErrorMessage(stat.error)}` };
2450
2511
  }
2451
2512
  if (stat.value > input.maxBytes) {
2452
2513
  return { succeeded: false, reason: oversizeReason(input.filename, stat.value, input.maxBytes) };
2453
2514
  }
2454
2515
  const read = await readSandboxBinaryBytes(input.box, input.path, stat.value, { sessionId: input.sessionId });
2455
2516
  if (!read.succeeded) {
2456
- return { succeeded: false, reason: `could not read agent file: ${read.error}` };
2517
+ return { succeeded: false, reason: `could not read agent file: ${redactErrorMessage(read.error)}` };
2457
2518
  }
2458
2519
  return { succeeded: true, bytes: read.value.bytes };
2459
2520
  }
2460
2521
  async function resolveBytes(input) {
2461
2522
  const url = input.raw.url;
2462
2523
  if (!url) return { succeeded: false, reason: "the file part carries no url" };
2463
- if (url.startsWith("data:")) return resolveDataUrlBytes(url, input.filename, input.maxBytes);
2464
- const isSandboxPath = url.startsWith("file://") || url.startsWith("/");
2465
- if (!isSandboxPath) return { succeeded: false, reason: `unsupported file URL scheme: ${url}` };
2524
+ if (/^data:/i.test(url)) return resolveDataUrlBytes(url, input.filename, input.maxBytes);
2525
+ const isSandboxPath = /^file:\/\//i.test(url) || url.startsWith("/");
2526
+ if (!isSandboxPath) {
2527
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url)?.[1]?.toLowerCase() ?? "unknown";
2528
+ return { succeeded: false, reason: `unsupported file URL scheme: ${scheme}` };
2529
+ }
2466
2530
  if (!input.box) return { succeeded: false, reason: "no sandbox to read agent file" };
2467
2531
  const resolvedPath = resolveFileUrlPath(url);
2468
2532
  if (!resolvedPath.succeeded) return resolvedPath;
@@ -2484,11 +2548,47 @@ function defaultBuildAttachmentPath(args) {
2484
2548
  const base = extension ? args.filename.slice(0, -extension.length) : args.filename;
2485
2549
  return `uploads/agent/${args.date}/${base}-${args.hash8}${extension}`;
2486
2550
  }
2551
+ function logPromotionStorageError(logger, message, fields) {
2552
+ try {
2553
+ const safeFields = Object.fromEntries(
2554
+ Object.entries(fields).map(([key, value]) => [
2555
+ key,
2556
+ typeof value === "string" ? redactErrorMessage(value) : value
2557
+ ])
2558
+ );
2559
+ logger.error(message, safeFields);
2560
+ } catch {
2561
+ }
2562
+ }
2563
+ async function compensatePromotionWrite(writer, scopeId, ownership, receipt) {
2564
+ try {
2565
+ await receipt.rollback();
2566
+ return {};
2567
+ } catch (rollbackError) {
2568
+ try {
2569
+ await writer.abort(scopeId, ownership);
2570
+ return { rollbackError };
2571
+ } catch (abortError) {
2572
+ return { rollbackError, abortError };
2573
+ }
2574
+ }
2575
+ }
2576
+ function isAtomicPromotionOptions(options) {
2577
+ return "attachmentWriter" in options;
2578
+ }
2487
2579
  async function promoteAgentFilePart(options) {
2580
+ const atomic = isAtomicPromotionOptions(options);
2488
2581
  const maxBytes = options.maxBytes ?? PROMOTE_MAX_FILE_BYTES;
2489
2582
  const sniffMime = options.sniffMime ?? sniffMimeFromName;
2490
2583
  const buildAttachmentPath = options.buildAttachmentPath ?? defaultBuildAttachmentPath;
2491
2584
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
2585
+ const logger = options.logger ?? console;
2586
+ const createWriteId = atomic ? options.createWriteId ?? (() => {
2587
+ if (typeof crypto === "undefined" || typeof crypto.randomUUID !== "function") {
2588
+ throw new Error("attachment promotion requires crypto.randomUUID");
2589
+ }
2590
+ return crypto.randomUUID();
2591
+ }) : void 0;
2492
2592
  const filename = sanitizeAttachmentFileName(
2493
2593
  options.raw.filename ?? basenameFromUrl(options.raw.url) ?? "agent-file"
2494
2594
  );
@@ -2500,23 +2600,132 @@ async function promoteAgentFilePart(options) {
2500
2600
  maxBytes
2501
2601
  });
2502
2602
  if (!resolved.succeeded) return { succeeded: false, filename, reason: resolved.reason };
2503
- const mediaType = options.raw.mediaType ?? options.raw.mime ?? dataUrlMime(options.raw.url) ?? sniffMime(filename);
2504
- const kind = attachmentKindForMime(mediaType);
2505
- const digest = await hash8(options.raw.id ?? options.raw.url ?? filename);
2506
- const date = now().toISOString().split("T")[0] ?? "";
2507
- const path = buildAttachmentPath({ filename, hash8: digest, date, mediaType, kind });
2603
+ let mediaType;
2604
+ let kind;
2605
+ let logicalPath;
2606
+ try {
2607
+ mediaType = (options.raw.mediaType ?? options.raw.mime ?? dataUrlMime(options.raw.url) ?? sniffMime(filename)).toLowerCase();
2608
+ kind = attachmentKindForMime(mediaType);
2609
+ const digest = await hash8(options.raw.id ?? options.raw.url ?? filename);
2610
+ const date = now().toISOString().split("T")[0] ?? "";
2611
+ logicalPath = buildAttachmentPath({ filename, hash8: digest, date, mediaType, kind });
2612
+ } catch (error) {
2613
+ logPromotionStorageError(logger, "[promote-file-part] path planning failed", {
2614
+ error: redactErrorMessage(error)
2615
+ });
2616
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2617
+ }
2618
+ if (!atomic) {
2619
+ let written2;
2620
+ try {
2621
+ written2 = await options.writeAttachment(options.scopeId, logicalPath, resolved.bytes, {
2622
+ mediaType,
2623
+ name: filename,
2624
+ originalName: options.raw.filename ?? filename,
2625
+ size: resolved.bytes.byteLength
2626
+ });
2627
+ } catch (error) {
2628
+ const reason = redactErrorMessage(error);
2629
+ logPromotionStorageError(logger, "[promote-file-part] legacy writer failed", {
2630
+ path: logicalPath,
2631
+ error: reason
2632
+ });
2633
+ return { succeeded: false, filename, reason };
2634
+ }
2635
+ const result2 = inspectLegacyAttachmentWriteResult(written2);
2636
+ if (!result2) {
2637
+ logPromotionStorageError(logger, "[promote-file-part] legacy writer returned an invalid result", {
2638
+ path: logicalPath
2639
+ });
2640
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2641
+ }
2642
+ if (!result2.ok) {
2643
+ const reason = redactErrorMessage(result2.reason);
2644
+ logPromotionStorageError(logger, "[promote-file-part] legacy write rejected", {
2645
+ path: logicalPath,
2646
+ error: reason
2647
+ });
2648
+ return { succeeded: false, filename, reason };
2649
+ }
2650
+ return {
2651
+ succeeded: true,
2652
+ part: {
2653
+ type: kind,
2654
+ path: logicalPath,
2655
+ name: filename,
2656
+ size: resolved.bytes.byteLength,
2657
+ mediaType
2658
+ }
2659
+ };
2660
+ }
2661
+ let ownershipId;
2662
+ let path;
2663
+ try {
2664
+ ownershipId = createWriteId();
2665
+ path = immutableAttachmentPath(logicalPath, ownershipId);
2666
+ } catch (error) {
2667
+ logPromotionStorageError(logger, "[promote-file-part] could not allocate ownership key", {
2668
+ path: logicalPath,
2669
+ error: redactErrorMessage(error)
2670
+ });
2671
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2672
+ }
2673
+ const ownership = Object.freeze({ id: ownershipId, path });
2508
2674
  let written;
2509
2675
  try {
2510
- written = await options.writeAttachment(options.scopeId, path, resolved.bytes, {
2676
+ written = await options.attachmentWriter.write(options.scopeId, path, resolved.bytes, {
2511
2677
  mediaType,
2512
2678
  name: filename,
2513
2679
  originalName: options.raw.filename ?? filename,
2514
- size: resolved.bytes.byteLength
2680
+ size: resolved.bytes.byteLength,
2681
+ ownership
2515
2682
  });
2516
2683
  } catch (err) {
2517
- return { succeeded: false, filename, reason: err instanceof Error ? err.message : String(err) };
2684
+ let abortError;
2685
+ try {
2686
+ await options.attachmentWriter.abort(options.scopeId, ownership);
2687
+ } catch (error) {
2688
+ abortError = error;
2689
+ }
2690
+ logPromotionStorageError(logger, "[promote-file-part] write failed", {
2691
+ path,
2692
+ ownershipId: ownership.id,
2693
+ error: redactErrorMessage(err),
2694
+ ...abortError === void 0 ? {} : { abortError: redactErrorMessage(abortError) }
2695
+ });
2696
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2697
+ }
2698
+ const result = inspectAtomicAttachmentWriteResult(written, ownership);
2699
+ if (!result) {
2700
+ let abortError;
2701
+ try {
2702
+ await options.attachmentWriter.abort(options.scopeId, ownership);
2703
+ } catch (error) {
2704
+ abortError = error;
2705
+ }
2706
+ logPromotionStorageError(logger, "[promote-file-part] writer returned a mismatched ownership receipt", {
2707
+ path,
2708
+ ownershipId: ownership.id,
2709
+ ...abortError === void 0 ? {} : { abortError: redactErrorMessage(abortError) }
2710
+ });
2711
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2712
+ }
2713
+ if (!result.ok) {
2714
+ const compensation = await compensatePromotionWrite(
2715
+ options.attachmentWriter,
2716
+ options.scopeId,
2717
+ ownership,
2718
+ result.receipt
2719
+ );
2720
+ logPromotionStorageError(logger, "[promote-file-part] write rejected", {
2721
+ path,
2722
+ ownershipId: ownership.id,
2723
+ error: redactErrorMessage(result.reason),
2724
+ ...compensation.rollbackError === void 0 ? {} : { rollbackError: redactErrorMessage(compensation.rollbackError) },
2725
+ ...compensation.abortError === void 0 ? {} : { abortError: redactErrorMessage(compensation.abortError) }
2726
+ });
2727
+ return { succeeded: false, filename, reason: ATTACHMENT_STORAGE_FAILURE_MESSAGE };
2518
2728
  }
2519
- if (!written.ok) return { succeeded: false, filename, reason: written.reason };
2520
2729
  return {
2521
2730
  succeeded: true,
2522
2731
  part: {
@@ -2530,13 +2739,68 @@ async function promoteAgentFilePart(options) {
2530
2739
  }
2531
2740
 
2532
2741
  // src/chat-routes/attachment-upload.ts
2742
+ function logAttachmentUploadError(logger, message, fields) {
2743
+ try {
2744
+ const safeFields = Object.fromEntries(
2745
+ Object.entries(fields).map(([key, value]) => [
2746
+ key,
2747
+ typeof value === "string" ? redactErrorMessage(value) : value
2748
+ ])
2749
+ );
2750
+ logger.error(message, safeFields);
2751
+ } catch {
2752
+ }
2753
+ }
2533
2754
  function attachmentUploadError(status, code, message, path) {
2534
2755
  return Response.json(
2535
2756
  { error: path === void 0 ? { code, message } : { code, message, path } },
2536
2757
  { status }
2537
2758
  );
2538
2759
  }
2760
+ function atomicAttachmentFailure(path, cleanupFailures = 0) {
2761
+ return attachmentUploadError(
2762
+ 503,
2763
+ cleanupFailures > 0 ? ATTACHMENT_ROLLBACK_FAILURE_CODE : "attachment_store_unavailable",
2764
+ cleanupFailures > 0 ? ATTACHMENT_ROLLBACK_FAILURE_MESSAGE : ATTACHMENT_STORAGE_FAILURE_MESSAGE,
2765
+ path
2766
+ );
2767
+ }
2768
+ async function rollbackSuccessfulWrites(writes, scopeId, writer, logger) {
2769
+ let failures = 0;
2770
+ for (let index = writes.length - 1; index >= 0; index -= 1) {
2771
+ const write = writes[index];
2772
+ try {
2773
+ await write.receipt.rollback();
2774
+ } catch (error) {
2775
+ failures += 1;
2776
+ logAttachmentUploadError(logger, "[attachment-upload] cleanup failed", {
2777
+ path: write.path,
2778
+ ownershipId: write.ownership.id,
2779
+ error: redactErrorMessage(error)
2780
+ });
2781
+ failures += await abortOwnership(writer, scopeId, write.ownership, logger);
2782
+ }
2783
+ }
2784
+ return failures;
2785
+ }
2786
+ async function abortOwnership(writer, scopeId, ownership, logger) {
2787
+ try {
2788
+ await writer.abort(scopeId, ownership);
2789
+ return 0;
2790
+ } catch (error) {
2791
+ logAttachmentUploadError(logger, "[attachment-upload] ambiguous write cleanup failed", {
2792
+ path: ownership.path,
2793
+ ownershipId: ownership.id,
2794
+ error: redactErrorMessage(error)
2795
+ });
2796
+ return 1;
2797
+ }
2798
+ }
2799
+ function isAtomicRouteOptions(options) {
2800
+ return "attachmentWriter" in options;
2801
+ }
2539
2802
  function createAttachmentUploadRoute(options) {
2803
+ const atomic = isAtomicRouteOptions(options);
2540
2804
  const maxCount = options.limits?.maxCount ?? ATTACHMENT_MAX_COUNT;
2541
2805
  const maxBinaryBytes = options.limits?.maxBinaryBytes ?? MAX_BINARY_ATTACHMENT_BYTES;
2542
2806
  const maxBytesBySniffedMime = options.limits?.maxBytesBySniffedMime;
@@ -2547,10 +2811,21 @@ function createAttachmentUploadRoute(options) {
2547
2811
  const pathFor = options.pathFor ?? ((name) => name);
2548
2812
  const validatePath = options.validatePath ?? defaultValidateAttachmentPath;
2549
2813
  const sniffMime = options.sniffMime ?? sniffMimeFromName;
2814
+ const createWriteId = atomic ? options.createWriteId ?? (() => {
2815
+ if (typeof crypto === "undefined" || typeof crypto.randomUUID !== "function") {
2816
+ throw new Error("attachment upload requires crypto.randomUUID");
2817
+ }
2818
+ return crypto.randomUUID();
2819
+ }) : void 0;
2820
+ const logger = options.logger ?? console;
2550
2821
  return async function attachmentUpload(request) {
2551
2822
  const auth = await options.authorize({ request });
2552
2823
  if (!auth.ok) return auth.response;
2553
- const write = auth.writeAttachment ?? options.writeAttachment;
2824
+ const atomicWriter = atomic ? auth.attachmentWriter ?? options.attachmentWriter : void 0;
2825
+ const legacyWriter = atomic ? void 0 : auth.writeAttachment ?? options.writeAttachment;
2826
+ if (atomic && !atomicWriter) {
2827
+ return attachmentUploadError(503, "attachment_store_unavailable", ATTACHMENT_STORAGE_FAILURE_MESSAGE);
2828
+ }
2554
2829
  let form;
2555
2830
  try {
2556
2831
  form = await request.formData();
@@ -2580,7 +2855,7 @@ function createAttachmentUploadRoute(options) {
2580
2855
  );
2581
2856
  }
2582
2857
  const prepared = [];
2583
- const seenPaths = /* @__PURE__ */ new Set();
2858
+ const seenLogicalPaths = /* @__PURE__ */ new Set();
2584
2859
  let totalBytes = 0;
2585
2860
  for (const file of files) {
2586
2861
  const bytes = new Uint8Array(await file.arrayBuffer());
@@ -2613,20 +2888,40 @@ function createAttachmentUploadRoute(options) {
2613
2888
  attachmentSizeErrorMessage(name, bytes.length, limit)
2614
2889
  );
2615
2890
  }
2616
- const path = pathFor(name);
2891
+ const logicalPath = pathFor(name);
2892
+ let ownershipId;
2893
+ let path = logicalPath;
2894
+ if (atomic) {
2895
+ try {
2896
+ ownershipId = createWriteId();
2897
+ path = immutableAttachmentPath(logicalPath, ownershipId);
2898
+ } catch (error) {
2899
+ logAttachmentUploadError(logger, "[attachment-upload] could not allocate ownership key", {
2900
+ path: logicalPath,
2901
+ error: redactErrorMessage(error)
2902
+ });
2903
+ return attachmentUploadError(
2904
+ 503,
2905
+ "attachment_store_unavailable",
2906
+ ATTACHMENT_STORAGE_FAILURE_MESSAGE,
2907
+ logicalPath
2908
+ );
2909
+ }
2910
+ }
2617
2911
  const pathCheck = validatePath(path);
2618
2912
  if (!pathCheck.succeeded) {
2619
2913
  return attachmentUploadError(400, "invalid_attachment_path", pathCheck.error, path);
2620
2914
  }
2621
- if (seenPaths.has(path)) {
2915
+ const duplicatePath = atomic ? logicalPath : path;
2916
+ if (seenLogicalPaths.has(duplicatePath)) {
2622
2917
  return attachmentUploadError(
2623
2918
  400,
2624
2919
  "attachment_duplicate_path",
2625
- `attachments must not repeat a path within one upload: ${path}`,
2920
+ `attachments must not repeat a path within one upload: ${duplicatePath}`,
2626
2921
  path
2627
2922
  );
2628
2923
  }
2629
- seenPaths.add(path);
2924
+ seenLogicalPaths.add(duplicatePath);
2630
2925
  totalBytes += bytes.length;
2631
2926
  if (totalBytes > maxTotalBytes) {
2632
2927
  return attachmentUploadError(
@@ -2636,19 +2931,113 @@ function createAttachmentUploadRoute(options) {
2636
2931
  );
2637
2932
  }
2638
2933
  const mediaType = sniff.mime ?? sniffMime(name);
2639
- prepared.push({ path, name, bytes, originalName: file.name, size: bytes.length, mediaType, kind });
2934
+ prepared.push({ path, ownershipId, name, bytes, originalName: file.name, size: bytes.length, mediaType, kind });
2640
2935
  }
2641
2936
  const uploaded = [];
2937
+ const successfulWrites = [];
2642
2938
  for (const input of prepared) {
2643
- const written = await write(auth.scopeId, input.path, input.bytes, {
2644
- mediaType: input.mediaType,
2645
- name: input.name,
2646
- originalName: input.originalName,
2647
- size: input.size
2648
- });
2649
- if (!written.ok) {
2650
- return attachmentUploadError(413, "attachment_write_failed", written.reason, input.path);
2939
+ if (!atomic) {
2940
+ let written2;
2941
+ try {
2942
+ written2 = await legacyWriter(auth.scopeId, input.path, input.bytes, {
2943
+ mediaType: input.mediaType,
2944
+ name: input.name,
2945
+ originalName: input.originalName,
2946
+ size: input.size
2947
+ });
2948
+ } catch (error) {
2949
+ logAttachmentUploadError(logger, "[attachment-upload] legacy writer failed", {
2950
+ path: input.path,
2951
+ error: redactErrorMessage(error)
2952
+ });
2953
+ return attachmentUploadError(
2954
+ 503,
2955
+ "attachment_store_unavailable",
2956
+ ATTACHMENT_STORAGE_FAILURE_MESSAGE,
2957
+ input.path
2958
+ );
2959
+ }
2960
+ const result2 = inspectLegacyAttachmentWriteResult(written2);
2961
+ if (!result2) {
2962
+ logAttachmentUploadError(logger, "[attachment-upload] legacy writer returned an invalid result", {
2963
+ path: input.path
2964
+ });
2965
+ return attachmentUploadError(
2966
+ 503,
2967
+ "attachment_store_unavailable",
2968
+ ATTACHMENT_STORAGE_FAILURE_MESSAGE,
2969
+ input.path
2970
+ );
2971
+ }
2972
+ if (!result2.ok) {
2973
+ const reason = redactErrorMessage(result2.reason);
2974
+ logAttachmentUploadError(logger, "[attachment-upload] legacy write rejected", {
2975
+ path: input.path,
2976
+ error: reason
2977
+ });
2978
+ return attachmentUploadError(413, "attachment_write_failed", reason, input.path);
2979
+ }
2980
+ uploaded.push({
2981
+ path: input.path,
2982
+ name: input.name,
2983
+ size: input.size,
2984
+ mediaType: input.mediaType,
2985
+ kind: input.kind
2986
+ });
2987
+ continue;
2988
+ }
2989
+ const ownershipId = input.ownershipId;
2990
+ if (!ownershipId) {
2991
+ return attachmentUploadError(503, "attachment_store_unavailable", ATTACHMENT_STORAGE_FAILURE_MESSAGE, input.path);
2992
+ }
2993
+ const ownership = Object.freeze({ id: ownershipId, path: input.path });
2994
+ let written;
2995
+ try {
2996
+ written = await atomicWriter.write(auth.scopeId, input.path, input.bytes, {
2997
+ mediaType: input.mediaType,
2998
+ name: input.name,
2999
+ originalName: input.originalName,
3000
+ size: input.size,
3001
+ ownership
3002
+ });
3003
+ } catch (error) {
3004
+ const cleanupFailures = await abortOwnership(atomicWriter, auth.scopeId, ownership, logger) + await rollbackSuccessfulWrites(successfulWrites, auth.scopeId, atomicWriter, logger);
3005
+ logAttachmentUploadError(logger, "[attachment-upload] write failed", {
3006
+ path: input.path,
3007
+ ownershipId: ownership.id,
3008
+ cleanupFailures,
3009
+ error: redactErrorMessage(error)
3010
+ });
3011
+ return atomicAttachmentFailure(input.path, cleanupFailures);
3012
+ }
3013
+ const result = inspectAtomicAttachmentWriteResult(written, ownership);
3014
+ if (!result) {
3015
+ const cleanupFailures = await abortOwnership(atomicWriter, auth.scopeId, ownership, logger) + await rollbackSuccessfulWrites(successfulWrites, auth.scopeId, atomicWriter, logger);
3016
+ logAttachmentUploadError(logger, "[attachment-upload] writer returned a mismatched ownership receipt", {
3017
+ path: input.path,
3018
+ ownershipId: ownership.id,
3019
+ cleanupFailures
3020
+ });
3021
+ return atomicAttachmentFailure(input.path, cleanupFailures);
3022
+ }
3023
+ if (!result.ok) {
3024
+ const cleanupFailures = await rollbackSuccessfulWrites([
3025
+ { path: input.path, ownership, receipt: result.receipt }
3026
+ ], auth.scopeId, atomicWriter, logger) + await rollbackSuccessfulWrites(
3027
+ successfulWrites,
3028
+ auth.scopeId,
3029
+ atomicWriter,
3030
+ logger
3031
+ );
3032
+ logAttachmentUploadError(logger, "[attachment-upload] write rejected", {
3033
+ path: input.path,
3034
+ ownershipId: ownership.id,
3035
+ cleanupFailures,
3036
+ error: redactErrorMessage(result.reason)
3037
+ });
3038
+ return atomicAttachmentFailure(input.path, cleanupFailures);
2651
3039
  }
3040
+ successfulWrites.push({ path: input.path, ownership, receipt: result.receipt });
2652
3041
  uploaded.push({
2653
3042
  path: input.path,
2654
3043
  name: input.name,
@@ -2881,6 +3270,9 @@ export {
2881
3270
  ALLOWED_ATTACHMENT_SNIFFED_MIMES,
2882
3271
  ATTACHMENT_ACCEPT,
2883
3272
  ATTACHMENT_MAX_COUNT,
3273
+ ATTACHMENT_ROLLBACK_FAILURE_CODE,
3274
+ ATTACHMENT_ROLLBACK_FAILURE_MESSAGE,
3275
+ ATTACHMENT_STORAGE_FAILURE_MESSAGE,
2884
3276
  ChatTurnInputError,
2885
3277
  DEFAULT_MODEL_FIRST_RESPONSE_TIMEOUT_MS,
2886
3278
  DEFAULT_MODEL_STREAM_OPEN_TIMEOUT_MS,
@@ -2920,6 +3312,7 @@ export {
2920
3312
  checkAttachmentType,
2921
3313
  classifyTerminalFailure,
2922
3314
  createAssistantDraftWriter,
3315
+ createAtomicAttachmentWriter,
2923
3316
  createAttachmentUploadRoute,
2924
3317
  createChatTurnRoutes,
2925
3318
  createSandboxChatProducer,
@@ -2928,6 +3321,7 @@ export {
2928
3321
  defaultValidateAttachmentPath,
2929
3322
  fileMentionsToParts,
2930
3323
  formatBytes,
3324
+ immutableAttachmentPath,
2931
3325
  isCommittingSandboxEvent,
2932
3326
  isDraftContentEvent,
2933
3327
  mediaTypeForMentionPath,