@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.
- package/dist/assistant/index.js +1 -1
- package/dist/chat-routes/attachment-store.d.ts +69 -26
- package/dist/chat-routes/attachment-upload.d.ts +59 -38
- package/dist/chat-routes/attachment-write-safety.d.ts +11 -0
- package/dist/chat-routes/index.js +429 -35
- package/dist/chat-routes/index.js.map +1 -1
- package/dist/chat-routes/promote-file-part.d.ts +30 -13
- package/dist/chat-store/index.js +1 -1
- package/dist/{chunk-UPXDBHEE.js → chunk-FOXGPGXF.js} +16 -3
- package/dist/chunk-FOXGPGXF.js.map +1 -0
- package/dist/chunk-MH74DY2I.js +175 -0
- package/dist/chunk-MH74DY2I.js.map +1 -0
- package/dist/{chunk-LRHVCVEW.js → chunk-Q4ZER4HI.js} +39 -1
- package/dist/chunk-Q4ZER4HI.js.map +1 -0
- package/dist/redact/index.d.ts +1 -0
- package/dist/redact/index.js +10 -116
- package/dist/redact/index.js.map +1 -1
- package/dist/store/index.d.ts +35 -0
- package/dist/store/index.js +3 -1
- package/dist/web-react/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-LRHVCVEW.js.map +0 -1
- package/dist/chunk-UPXDBHEE.js.map +0 -1
|
@@ -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]*)
|
|
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:([^;,]+)[;,]
|
|
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 ||
|
|
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 =
|
|
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: ${
|
|
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: ${
|
|
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 (
|
|
2464
|
-
const isSandboxPath =
|
|
2465
|
-
if (!isSandboxPath)
|
|
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
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: ${
|
|
2920
|
+
`attachments must not repeat a path within one upload: ${duplicatePath}`,
|
|
2626
2921
|
path
|
|
2627
2922
|
);
|
|
2628
2923
|
}
|
|
2629
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
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,
|