@parall/agent-core 1.23.0 → 1.25.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.
- package/dist/bridge-workspace.d.ts +1 -1
- package/dist/bridge-workspace.d.ts.map +1 -1
- package/dist/bridge-workspace.js +23 -5
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +2 -0
- package/dist/gateway-base.d.ts +1 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +46 -25
- package/dist/internal/attachment-input.d.ts +53 -0
- package/dist/internal/attachment-input.d.ts.map +1 -0
- package/dist/internal/attachment-input.js +687 -0
- package/dist/prompt-fragments.d.ts +15 -2
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +50 -9
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/bridge-workspace.ts +23 -5
- package/src/event-format.ts +1 -0
- package/src/gateway-base.ts +53 -23
- package/src/internal/attachment-input.ts +791 -0
- package/src/prompt-fragments.ts +68 -9
- package/src/types.ts +1 -0
|
@@ -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
|
+
}
|