@parall/agent-core 1.22.0 → 1.24.0

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.
@@ -0,0 +1,687 @@
1
+ import { execSync } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import * as fsSync from "node:fs";
4
+ import * as fs from "node:fs/promises";
5
+ import * as path from "node:path";
6
+ import { formatBytes, renderLocalAttachmentSection } from "../prompt-fragments.js";
7
+ /**
8
+ * Per-dispatch cap on image bytes a runtime turn receives. Counts declared
9
+ * attachment sizes plus fresh download bytes; cache hits are NOT recharged
10
+ * against this budget — those are already on disk and bounded separately by
11
+ * `DEFAULT_ATTACHMENT_CACHE_MAX_BYTES` below. The cap exists to keep any one
12
+ * dispatch from saturating the bridge↔runtime hop with a single oversized
13
+ * batch; the cache cap exists to keep the workspace from growing unbounded.
14
+ */
15
+ export const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
16
+ /**
17
+ * Workspace cache ceiling. The default is generous for typical operator setups,
18
+ * but on small PVCs (e.g. 1 GiB) it can claim a meaningful fraction of disk.
19
+ * Operators on constrained storage should override via `LocalAttachmentOptions.maxCacheBytes`.
20
+ */
21
+ export const DEFAULT_ATTACHMENT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
22
+ export const DEFAULT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 30_000;
23
+ export const DEFAULT_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
24
+ export const DEFAULT_MAINTENANCE_COOLDOWN_MS = 60 * 1000;
25
+ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
26
+ "image/png",
27
+ "image/jpeg",
28
+ "image/jpg",
29
+ "image/webp",
30
+ "image/gif",
31
+ ]);
32
+ // Process-global state — correct under the current "one gateway per agent"
33
+ // architecture (each runtime bridge is a single-tenant subprocess, so all
34
+ // in-flight dispatches share one logical agent identity). If the gateway
35
+ // ever multiplexes multiple agent identities in a single process, both
36
+ // `activeAttachmentDirs` and `maintenanceStateByRoot` need to be scoped per
37
+ // tenant — otherwise they leak active-dispatch and cooldown state across
38
+ // agents that should not share cache visibility.
39
+ const activeAttachmentDirs = new Set();
40
+ export async function prepareLocalImageAttachments(event, context, opts) {
41
+ const attachments = event.attachments ?? [];
42
+ const imageAttachments = attachments.filter((att) => SUPPORTED_IMAGE_MIME_TYPES.has(att.mimeType.toLowerCase()));
43
+ if (imageAttachments.length === 0)
44
+ return { images: [], notes: [] };
45
+ const maxTotalImageBytes = opts.maxTotalImageBytes ?? DEFAULT_MAX_TOTAL_IMAGE_BYTES;
46
+ const totalDeclaredBytes = imageAttachments.reduce((sum, att) => sum + Math.max(0, att.fileSize), 0);
47
+ if (totalDeclaredBytes > maxTotalImageBytes) {
48
+ return {
49
+ images: [],
50
+ notes: [
51
+ `[Image attachments not downloaded: total size ${formatBytes(totalDeclaredBytes)} exceeds bridge limit ${formatBytes(maxTotalImageBytes)}]`,
52
+ ],
53
+ };
54
+ }
55
+ const rootDir = await ensureAttachmentRootDir(opts.workspaceDir);
56
+ // Pin this dispatch's message directory in `activeAttachmentDirs` BEFORE
57
+ // scheduling cache maintenance. The maintenance pass snapshots active dirs
58
+ // when it starts; if we scheduled first, a concurrent prune could observe
59
+ // the snapshot without our entry and delete a same-`messageId` cache from a
60
+ // prior dispatch (retry / replay) right under us.
61
+ const messageDir = path.join(rootDir, sanitizePathSegment(event.messageId));
62
+ await ensurePathIsNotSymlink(messageDir);
63
+ await fs.mkdir(messageDir, { recursive: true });
64
+ await ensurePathIsNotSymlink(messageDir);
65
+ const activeMessageDir = path.resolve(messageDir);
66
+ activeAttachmentDirs.add(activeMessageDir);
67
+ // Cache maintenance (TTL cleanup + LRU prune) walks every cached message
68
+ // directory and stats every file. Doing it synchronously per-dispatch turns
69
+ // dispatch latency into O(cache_dirs) and burns CPU under high concurrency
70
+ // (1000 in-flight dispatches → 1000 walks). Instead we throttle by root
71
+ // (one pass per cooldown window) and run it off the dispatch path. The
72
+ // cache cap is best-effort, not a hard ceiling — cooldown drift is fine.
73
+ const maintenanceCooldownMs = opts.maintenanceCooldownMs ?? DEFAULT_MAINTENANCE_COOLDOWN_MS;
74
+ const maintenancePromise = scheduleAttachmentMaintenance(rootDir, {
75
+ ttlMs: opts.attachmentTtlMs ?? DEFAULT_ATTACHMENT_TTL_MS,
76
+ maxBytes: opts.maxCacheBytes ?? DEFAULT_ATTACHMENT_CACHE_MAX_BYTES,
77
+ cooldownMs: maintenanceCooldownMs,
78
+ log: opts.log,
79
+ });
80
+ if (maintenanceCooldownMs === 0 && maintenancePromise) {
81
+ // Sync-mode for tests that need deterministic prune ordering relative to
82
+ // active-dispatch tracking. Production paths leave this on the background.
83
+ await maintenancePromise;
84
+ }
85
+ try {
86
+ const images = [];
87
+ const notes = [];
88
+ let downloadedBytes = 0;
89
+ for (const att of imageAttachments) {
90
+ const localPath = path.join(messageDir, localFileName(att.id, att.fileName, att.mimeType));
91
+ const downloadTimeoutMs = opts.downloadTimeoutMs ?? DEFAULT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS;
92
+ const fetchFresh = async () => {
93
+ const fileInfo = await withTimeout(context.client.getFileUrl(att.id), downloadTimeoutMs, `file URL lookup timed out after ${downloadTimeoutMs}ms`);
94
+ downloadedBytes += await downloadAttachmentToFile(fileInfo.url, localPath, maxTotalImageBytes - downloadedBytes, downloadTimeoutMs, rootDir);
95
+ };
96
+ const cameFromCache = await existingUsableFile(localPath, att.fileSize, rootDir);
97
+ if (!cameFromCache) {
98
+ try {
99
+ await fetchFresh();
100
+ }
101
+ catch (err) {
102
+ opts.log?.warn?.(`agent-core: failed to prepare local image attachment ${att.id}: ${String(err)}`);
103
+ notes.push(`[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)}]`);
104
+ continue;
105
+ }
106
+ }
107
+ let mimeOk = await downloadedFileMatchesImageMime(localPath, att.mimeType, rootDir);
108
+ if (!mimeOk && cameFromCache) {
109
+ // Cache hit produced a file whose magic bytes don't match the declared
110
+ // MIME — the cached copy is corrupt (partial write, agent edit,
111
+ // bit-rot). Drop it and try once with a fresh download before giving
112
+ // up; a genuinely broken upstream will fail the second MIME check
113
+ // too and fall through to the unavailable note.
114
+ await removeLocalFileIfInside(localPath, rootDir);
115
+ try {
116
+ await fetchFresh();
117
+ }
118
+ catch (err) {
119
+ opts.log?.warn?.(`agent-core: failed to re-download corrupted cache for ${att.id}: ${String(err)}`);
120
+ notes.push(`[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)}]`);
121
+ continue;
122
+ }
123
+ mimeOk = await downloadedFileMatchesImageMime(localPath, att.mimeType, rootDir);
124
+ }
125
+ if (!mimeOk) {
126
+ await removeLocalFileIfInside(localPath, rootDir);
127
+ notes.push(`[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)} (downloaded content does not match ${sanitizeMeta(att.mimeType)})]`);
128
+ continue;
129
+ }
130
+ images.push({
131
+ attachmentId: att.id,
132
+ fileName: att.fileName,
133
+ mimeType: att.mimeType,
134
+ fileSize: att.fileSize,
135
+ localPath,
136
+ });
137
+ }
138
+ return { images, notes };
139
+ }
140
+ finally {
141
+ activeAttachmentDirs.delete(activeMessageDir);
142
+ }
143
+ }
144
+ export function appendLocalAttachmentRefs(body, result) {
145
+ const section = renderLocalAttachmentSection(result);
146
+ return section ? `${body}\n\n${section}` : body;
147
+ }
148
+ export async function appendPreparedLocalAttachmentRefs(body, event, context, opts) {
149
+ const attachments = await prepareLocalImageAttachments(event, context, opts);
150
+ return { body: appendLocalAttachmentRefs(body, attachments), attachments };
151
+ }
152
+ export function pinLocalAttachmentPaths(images) {
153
+ const dirs = new Set(images.map((image) => path.resolve(path.dirname(image.localPath))));
154
+ for (const dir of dirs) {
155
+ activeAttachmentDirs.add(dir);
156
+ }
157
+ let released = false;
158
+ return () => {
159
+ if (released)
160
+ return;
161
+ released = true;
162
+ for (const dir of dirs) {
163
+ activeAttachmentDirs.delete(dir);
164
+ }
165
+ };
166
+ }
167
+ function attachmentRootDir(workspaceDir) {
168
+ return path.join(path.resolve(workspaceDir), ".parall", "attachments");
169
+ }
170
+ /**
171
+ * Append `.parall/` to the workspace's git info/exclude. Best-effort — a
172
+ * missing exclude entry should not block dispatch. Used by both Claude and
173
+ * Codex bridges' workspace bootstrap; lives here next to the attachment
174
+ * download logic so the directory convention stays in one place.
175
+ */
176
+ export function ensureLocalAttachmentGitExclude(workingDirectory) {
177
+ try {
178
+ const rel = execSync("git rev-parse --git-path info/exclude", {
179
+ cwd: workingDirectory,
180
+ encoding: "utf8",
181
+ stdio: ["ignore", "pipe", "ignore"],
182
+ }).trim();
183
+ const excludePath = path.isAbsolute(rel) ? rel : path.join(workingDirectory, rel);
184
+ fsSync.mkdirSync(path.dirname(excludePath), { recursive: true });
185
+ const existing = fsSync.existsSync(excludePath) ? fsSync.readFileSync(excludePath, "utf8") : "";
186
+ if (existing.split(/\r?\n/).some((line) => line.trim() === ".parall/"))
187
+ return;
188
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
189
+ fsSync.appendFileSync(excludePath, `${prefix}.parall/\n`, "utf8");
190
+ }
191
+ catch {
192
+ // Best-effort only. A missing exclude entry should not block dispatch.
193
+ }
194
+ }
195
+ const maintenanceStateByRoot = new Map();
196
+ /**
197
+ * Throttled, off-path cache maintenance. Returns a promise the caller can
198
+ * await in test mode; production callers ignore the return value so the prune
199
+ * walk runs in the background without blocking dispatch latency.
200
+ *
201
+ * Concurrency model: when a pass is in-flight, concurrent dispatches see the
202
+ * existing promise and skip; they don't pile up redundant walks. When the
203
+ * cooldown is unexpired, dispatches return null immediately. Worst case the
204
+ * cache exceeds `maxBytes` by a bounded amount until the next cooldown
205
+ * window — acceptable, since the cap is a soft ceiling, not a hard limit.
206
+ */
207
+ function scheduleAttachmentMaintenance(rootDir, opts) {
208
+ const state = maintenanceStateByRoot.get(rootDir);
209
+ if (state?.inFlight)
210
+ return state.inFlight;
211
+ const now = Date.now();
212
+ if (state && now - state.lastRunMs < opts.cooldownMs)
213
+ return null;
214
+ const newState = state ?? { lastRunMs: 0, inFlight: null };
215
+ if (!state)
216
+ maintenanceStateByRoot.set(rootDir, newState);
217
+ const run = (async () => {
218
+ try {
219
+ await cleanupOldAttachmentFiles(rootDir, opts.ttlMs, opts.log, activeDirsForRoot(rootDir));
220
+ await pruneAttachmentCache(rootDir, opts.maxBytes, opts.log, activeDirsForRoot(rootDir));
221
+ }
222
+ catch (err) {
223
+ opts.log?.warn?.(`agent-core: attachment cache maintenance failed: ${String(err)}`);
224
+ }
225
+ finally {
226
+ newState.lastRunMs = Date.now();
227
+ newState.inFlight = null;
228
+ }
229
+ })();
230
+ newState.inFlight = run;
231
+ return run;
232
+ }
233
+ async function ensureAttachmentRootDir(workspaceDir) {
234
+ const workspaceRoot = path.resolve(workspaceDir);
235
+ const parallDir = path.join(workspaceRoot, ".parall");
236
+ const rootDir = attachmentRootDir(workspaceRoot);
237
+ await fs.mkdir(workspaceRoot, { recursive: true });
238
+ await ensurePathIsNotSymlink(parallDir);
239
+ await fs.mkdir(parallDir, { recursive: true, mode: 0o700 });
240
+ await ensurePathIsNotSymlink(parallDir);
241
+ await ensurePathIsNotSymlink(rootDir);
242
+ await fs.mkdir(rootDir, { recursive: true, mode: 0o700 });
243
+ await ensurePathIsNotSymlink(rootDir);
244
+ const realWorkspace = await fs.realpath(workspaceRoot);
245
+ const realRoot = await fs.realpath(rootDir);
246
+ if (!isPathInside(realRoot, realWorkspace)) {
247
+ throw new Error(`attachment root escapes workspace: ${rootDir}`);
248
+ }
249
+ return rootDir;
250
+ }
251
+ async function ensurePathIsNotSymlink(filePath) {
252
+ try {
253
+ const stat = await fs.lstat(filePath);
254
+ if (stat.isSymbolicLink()) {
255
+ throw new Error(`refusing to use symlinked attachment path ${filePath}`);
256
+ }
257
+ }
258
+ catch (err) {
259
+ if (err?.code === "ENOENT")
260
+ return;
261
+ throw err;
262
+ }
263
+ }
264
+ function isPathInside(childPath, parentPath) {
265
+ const rel = path.relative(parentPath, childPath);
266
+ return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
267
+ }
268
+ async function existingUsableFile(filePath, expectedSize, rootDir) {
269
+ try {
270
+ const stat = await localFileStatInsideRoot(filePath, rootDir);
271
+ return stat.isFile() && stat.size > 0 && (expectedSize <= 0 || stat.size === expectedSize);
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ }
277
+ async function localFileStatInsideRoot(filePath, rootDir) {
278
+ const stat = await fs.lstat(filePath);
279
+ if (stat.isSymbolicLink()) {
280
+ throw new Error(`refusing to use symlinked attachment file ${filePath}`);
281
+ }
282
+ if (!stat.isFile()) {
283
+ throw new Error(`attachment path is not a file ${filePath}`);
284
+ }
285
+ const realRoot = await fs.realpath(rootDir);
286
+ const realFile = await fs.realpath(filePath);
287
+ if (!isPathInside(realFile, realRoot)) {
288
+ throw new Error(`attachment file escapes workspace: ${filePath}`);
289
+ }
290
+ return stat;
291
+ }
292
+ async function localDirectoryStatInsideRoot(dirPath, rootDir) {
293
+ const stat = await fs.lstat(dirPath);
294
+ if (stat.isSymbolicLink()) {
295
+ throw new Error(`refusing to use symlinked attachment directory ${dirPath}`);
296
+ }
297
+ if (!stat.isDirectory()) {
298
+ throw new Error(`attachment path is not a directory ${dirPath}`);
299
+ }
300
+ const realRoot = await fs.realpath(rootDir);
301
+ const realDir = await fs.realpath(dirPath);
302
+ if (!isPathInside(realDir, realRoot)) {
303
+ throw new Error(`attachment directory escapes workspace: ${dirPath}`);
304
+ }
305
+ return stat;
306
+ }
307
+ async function openLocalFileInsideRoot(filePath, rootDir) {
308
+ const checkedStat = await localFileStatInsideRoot(filePath, rootDir);
309
+ const file = await fs.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
310
+ let keepOpen = false;
311
+ try {
312
+ const openedStat = await file.stat();
313
+ if (!sameFile(checkedStat, openedStat)) {
314
+ throw new Error(`attachment file changed during validation: ${filePath}`);
315
+ }
316
+ keepOpen = true;
317
+ return file;
318
+ }
319
+ finally {
320
+ if (!keepOpen) {
321
+ await file.close();
322
+ }
323
+ }
324
+ }
325
+ async function openLocalTempFileInsideRoot(filePath, rootDir) {
326
+ // Trusted-workspace assumption: the workspace dir is owned by the runtime
327
+ // bridge and not shared with untrusted local writers. There is a narrow
328
+ // TOCTOU window between the parent-directory validation here and the
329
+ // O_NOFOLLOW open; closing it would require descriptor-relative (openat)
330
+ // operations, which Node's fs API does not expose. The post-open inode
331
+ // identity check below + the post-write rename validation in
332
+ // writeResponseToFileWithLimit ensure downloaded bytes can never escape
333
+ // the attachment root, even if the window is exploited — the worst case
334
+ // is a write that lands in a directory the attacker already controls.
335
+ await localDirectoryStatInsideRoot(path.dirname(filePath), rootDir);
336
+ const file = await fs.open(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
337
+ let keepOpen = false;
338
+ try {
339
+ const checkedStat = await localFileStatInsideRoot(filePath, rootDir);
340
+ const openedStat = await file.stat();
341
+ if (!sameFileIdentity(checkedStat, openedStat)) {
342
+ throw new Error(`attachment temp file changed during validation: ${filePath}`);
343
+ }
344
+ keepOpen = true;
345
+ return { file, openedStat };
346
+ }
347
+ finally {
348
+ if (!keepOpen) {
349
+ await file.close();
350
+ }
351
+ }
352
+ }
353
+ async function assertLocalFileIdentity(filePath, rootDir, expected) {
354
+ const stat = await localFileStatInsideRoot(filePath, rootDir);
355
+ if (!sameFileIdentity(stat, expected)) {
356
+ throw new Error(`attachment file changed during validation: ${filePath}`);
357
+ }
358
+ }
359
+ async function removeLocalFileIfInside(filePath, rootDir) {
360
+ try {
361
+ await localFileStatInsideRoot(filePath, rootDir);
362
+ await fs.rm(filePath, { force: true });
363
+ }
364
+ catch {
365
+ // If the path no longer resolves inside the attachment root, do not follow it for cleanup.
366
+ }
367
+ }
368
+ function sameFileIdentity(a, b) {
369
+ return a.dev === b.dev && a.ino === b.ino;
370
+ }
371
+ function sameFile(a, b) {
372
+ return sameFileIdentity(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs;
373
+ }
374
+ async function cleanupOldAttachmentFiles(rootDir, ttlMs, log, preserveDirs) {
375
+ let entries;
376
+ try {
377
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
378
+ }
379
+ catch {
380
+ return;
381
+ }
382
+ const cutoff = Date.now() - ttlMs;
383
+ await Promise.all(entries.map(async (entry) => {
384
+ if (!entry.isDirectory())
385
+ return;
386
+ const fullPath = path.join(rootDir, entry.name);
387
+ try {
388
+ if (preserveDirs?.has(path.resolve(fullPath)))
389
+ return;
390
+ // lstat (not stat) for consistency with the symlink-hardened download
391
+ // path: if a racing local writer swapped the dir entry for a symlink
392
+ // between readdir and now, we don't want fs.rm to follow it and delete
393
+ // outside the attachment root.
394
+ const stat = await fs.lstat(fullPath);
395
+ if (!stat.isDirectory())
396
+ return;
397
+ if (stat.mtimeMs < cutoff) {
398
+ await fs.rm(fullPath, { recursive: true, force: true });
399
+ }
400
+ }
401
+ catch (err) {
402
+ log?.warn?.(`agent-core: failed to clean attachment temp dir ${fullPath}: ${String(err)}`);
403
+ }
404
+ }));
405
+ }
406
+ async function pruneAttachmentCache(rootDir, maxBytes, log, preserveDirs) {
407
+ if (maxBytes <= 0)
408
+ return;
409
+ let entries;
410
+ try {
411
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
412
+ }
413
+ catch {
414
+ return;
415
+ }
416
+ const dirs = [];
417
+ let total = 0;
418
+ for (const entry of entries) {
419
+ if (!entry.isDirectory())
420
+ continue;
421
+ const fullPath = path.join(rootDir, entry.name);
422
+ try {
423
+ // lstat (not stat) for the same reason as cleanupOldAttachmentFiles —
424
+ // never follow a swapped-in symlink during the readdir → rm window.
425
+ const stat = await fs.lstat(fullPath);
426
+ if (!stat.isDirectory())
427
+ continue;
428
+ const size = await directorySize(fullPath);
429
+ dirs.push({ path: fullPath, mtimeMs: stat.mtimeMs, size });
430
+ total += size;
431
+ }
432
+ catch (err) {
433
+ log?.warn?.(`agent-core: failed to inspect attachment cache dir ${fullPath}: ${String(err)}`);
434
+ }
435
+ }
436
+ if (total <= maxBytes)
437
+ return;
438
+ dirs.sort((a, b) => a.mtimeMs - b.mtimeMs);
439
+ for (const dir of dirs) {
440
+ if (total <= maxBytes)
441
+ break;
442
+ if (preserveDirs?.has(path.resolve(dir.path)))
443
+ continue;
444
+ try {
445
+ await fs.rm(dir.path, { recursive: true, force: true });
446
+ total -= dir.size;
447
+ }
448
+ catch (err) {
449
+ log?.warn?.(`agent-core: failed to prune attachment cache dir ${dir.path}: ${String(err)}`);
450
+ }
451
+ }
452
+ }
453
+ async function directorySize(dirPath) {
454
+ let total = 0;
455
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
456
+ for (const entry of entries) {
457
+ const fullPath = path.join(dirPath, entry.name);
458
+ // lstat (not stat) for consistency with cleanup/prune — never follow a
459
+ // racing-swap symlink during the recursive walk, even just to count
460
+ // bytes. Skip anything that isn't a real file or real directory.
461
+ let stat;
462
+ try {
463
+ stat = await fs.lstat(fullPath);
464
+ }
465
+ catch {
466
+ continue;
467
+ }
468
+ if (stat.isDirectory()) {
469
+ total += await directorySize(fullPath);
470
+ continue;
471
+ }
472
+ if (!stat.isFile())
473
+ continue;
474
+ total += stat.size;
475
+ }
476
+ return total;
477
+ }
478
+ function activeDirsForRoot(rootDir) {
479
+ const root = path.resolve(rootDir);
480
+ const dirs = new Set();
481
+ for (const dir of activeAttachmentDirs) {
482
+ if (dir === root || dir.startsWith(`${root}${path.sep}`)) {
483
+ dirs.add(dir);
484
+ }
485
+ }
486
+ return dirs;
487
+ }
488
+ async function downloadAttachmentToFile(url, filePath, maxBytes, timeoutMs, rootDir) {
489
+ // Defense-in-depth: the SDK currently always returns HTTPS signed URLs, but a
490
+ // misconfigured API server (or a future regression) handing back `file://` /
491
+ // `data:` would let `fetch` read local files or inline payloads as if they
492
+ // were the attachment. Reject anything that isn't HTTP(S) before we open the
493
+ // network call. http:// stays allowed for local dev / tests.
494
+ let parsed;
495
+ try {
496
+ parsed = new URL(url);
497
+ }
498
+ catch {
499
+ throw new Error(`invalid attachment URL`);
500
+ }
501
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
502
+ throw new Error(`refusing attachment URL with scheme ${parsed.protocol}`);
503
+ }
504
+ const controller = new AbortController();
505
+ let timeout;
506
+ if (timeoutMs > 0) {
507
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
508
+ }
509
+ try {
510
+ const res = await fetch(url, { signal: controller.signal });
511
+ if (!res.ok) {
512
+ throw new Error(`${res.status} ${res.statusText}`);
513
+ }
514
+ // Content-Length is only an early-exit hint; the stream byte counter below is authoritative.
515
+ const contentLength = parseContentLength(res.headers.get("content-length"));
516
+ if (contentLength !== undefined && contentLength > maxBytes) {
517
+ throw new Error(`download would exceed ${formatBytes(maxBytes)} remaining turn limit`);
518
+ }
519
+ return await writeResponseToFileWithLimit(res, filePath, maxBytes, rootDir);
520
+ }
521
+ catch (err) {
522
+ if (isAbortError(err)) {
523
+ throw new Error(`download timed out after ${timeoutMs}ms`);
524
+ }
525
+ throw err;
526
+ }
527
+ finally {
528
+ if (timeout)
529
+ clearTimeout(timeout);
530
+ }
531
+ }
532
+ async function withTimeout(promise, timeoutMs, message) {
533
+ if (timeoutMs <= 0)
534
+ return promise;
535
+ let timeout;
536
+ try {
537
+ return await Promise.race([
538
+ promise,
539
+ new Promise((_, reject) => {
540
+ timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
541
+ }),
542
+ ]);
543
+ }
544
+ finally {
545
+ if (timeout)
546
+ clearTimeout(timeout);
547
+ }
548
+ }
549
+ async function writeResponseToFileWithLimit(res, filePath, maxBytes, rootDir) {
550
+ if (maxBytes <= 0) {
551
+ throw new Error("download would exceed turn limit");
552
+ }
553
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
554
+ const { file, openedStat } = await openLocalTempFileInsideRoot(tmpPath, rootDir);
555
+ let written = 0;
556
+ let completed = false;
557
+ let fileClosed = false;
558
+ let writtenStat = openedStat;
559
+ const closeFile = async () => {
560
+ if (fileClosed)
561
+ return;
562
+ await file.close();
563
+ fileClosed = true;
564
+ };
565
+ try {
566
+ if (!res.body) {
567
+ const buffer = Buffer.from(await res.arrayBuffer());
568
+ if (buffer.byteLength > maxBytes) {
569
+ throw new Error(`download exceeded ${formatBytes(maxBytes)} remaining turn limit`);
570
+ }
571
+ await file.write(buffer);
572
+ written = buffer.byteLength;
573
+ }
574
+ else {
575
+ const reader = res.body.getReader();
576
+ while (true) {
577
+ const { done, value } = await reader.read();
578
+ if (done)
579
+ break;
580
+ const chunk = Buffer.from(value);
581
+ written += chunk.byteLength;
582
+ if (written > maxBytes) {
583
+ throw new Error(`download exceeded ${formatBytes(maxBytes)} remaining turn limit`);
584
+ }
585
+ await file.write(chunk);
586
+ }
587
+ }
588
+ writtenStat = await file.stat();
589
+ await closeFile();
590
+ await localDirectoryStatInsideRoot(path.dirname(filePath), rootDir);
591
+ await assertLocalFileIdentity(tmpPath, rootDir, writtenStat);
592
+ await fs.rename(tmpPath, filePath);
593
+ completed = true;
594
+ return written;
595
+ }
596
+ finally {
597
+ if (!fileClosed) {
598
+ try {
599
+ await closeFile();
600
+ }
601
+ catch {
602
+ // Preserve the original write/rename error; cleanup below is best-effort.
603
+ }
604
+ }
605
+ if (!completed) {
606
+ await removeLocalFileIfInside(tmpPath, rootDir);
607
+ }
608
+ }
609
+ }
610
+ function localFileName(attachmentId, fileName, mimeType) {
611
+ const safeName = sanitizePathSegment(path.basename(fileName || attachmentId));
612
+ const ext = path.extname(safeName) || extensionForMime(mimeType);
613
+ const stem = path.basename(safeName, path.extname(safeName)) || attachmentId;
614
+ return `${sanitizePathSegment(attachmentId)}-${stem}${ext}`;
615
+ }
616
+ function extensionForMime(mimeType) {
617
+ switch (mimeType.toLowerCase()) {
618
+ case "image/jpeg":
619
+ case "image/jpg":
620
+ return ".jpg";
621
+ case "image/webp":
622
+ return ".webp";
623
+ case "image/gif":
624
+ return ".gif";
625
+ case "image/png":
626
+ default:
627
+ return ".png";
628
+ }
629
+ }
630
+ function sanitizePathSegment(value) {
631
+ const safe = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
632
+ if (!safe || /^\.+$/.test(safe))
633
+ return "attachment";
634
+ return safe;
635
+ }
636
+ async function downloadedFileMatchesImageMime(filePath, mimeType, rootDir) {
637
+ const file = await openLocalFileInsideRoot(filePath, rootDir);
638
+ try {
639
+ const buffer = Buffer.alloc(16);
640
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
641
+ return imageHeaderMatches(buffer.subarray(0, bytesRead), mimeType);
642
+ }
643
+ finally {
644
+ await file.close();
645
+ }
646
+ }
647
+ function imageHeaderMatches(bytes, mimeType) {
648
+ switch (mimeType.toLowerCase()) {
649
+ case "image/png":
650
+ return bytes.length >= 8
651
+ && bytes[0] === 0x89
652
+ && bytes[1] === 0x50
653
+ && bytes[2] === 0x4e
654
+ && bytes[3] === 0x47
655
+ && bytes[4] === 0x0d
656
+ && bytes[5] === 0x0a
657
+ && bytes[6] === 0x1a
658
+ && bytes[7] === 0x0a;
659
+ case "image/jpeg":
660
+ case "image/jpg":
661
+ return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
662
+ case "image/gif":
663
+ return bytes.length >= 6
664
+ && (bytes.subarray(0, 6).toString("ascii") === "GIF87a"
665
+ || bytes.subarray(0, 6).toString("ascii") === "GIF89a");
666
+ case "image/webp":
667
+ return bytes.length >= 12
668
+ && bytes.subarray(0, 4).toString("ascii") === "RIFF"
669
+ && bytes.subarray(8, 12).toString("ascii") === "WEBP";
670
+ default:
671
+ return false;
672
+ }
673
+ }
674
+ function isAbortError(err) {
675
+ return err instanceof DOMException && err.name === "AbortError";
676
+ }
677
+ function sanitizeMeta(value) {
678
+ return value.replace(/[\r\n]+/g, " ").replace(/[[\]|]/g, " ").trim();
679
+ }
680
+ function parseContentLength(value) {
681
+ if (!value)
682
+ return undefined;
683
+ const n = Number(value);
684
+ if (!Number.isFinite(n) || n < 0)
685
+ return undefined;
686
+ return n;
687
+ }