@actagent/file-transfer 2026.6.2
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/actagent.plugin.json +50 -0
- package/index.test.ts +93 -0
- package/index.ts +121 -0
- package/package.json +18 -0
- package/src/node-host/dir-fetch.test.ts +131 -0
- package/src/node-host/dir-fetch.ts +363 -0
- package/src/node-host/dir-list.test.ts +169 -0
- package/src/node-host/dir-list.ts +155 -0
- package/src/node-host/file-fetch.test.ts +254 -0
- package/src/node-host/file-fetch.ts +203 -0
- package/src/node-host/file-write.test.ts +378 -0
- package/src/node-host/file-write.ts +280 -0
- package/src/node-host/path-errors.ts +112 -0
- package/src/shared/audit.ts +98 -0
- package/src/shared/errors.test.ts +63 -0
- package/src/shared/errors.ts +68 -0
- package/src/shared/lazy-node-invoke-policy.test.ts +102 -0
- package/src/shared/lazy-node-invoke-policy.ts +36 -0
- package/src/shared/mime.test.ts +61 -0
- package/src/shared/mime.ts +30 -0
- package/src/shared/node-invoke-policy-commands.ts +9 -0
- package/src/shared/node-invoke-policy.test.ts +763 -0
- package/src/shared/node-invoke-policy.ts +947 -0
- package/src/shared/params.test.ts +42 -0
- package/src/shared/params.ts +60 -0
- package/src/shared/policy.test.ts +568 -0
- package/src/shared/policy.ts +383 -0
- package/src/tools/descriptors.ts +145 -0
- package/src/tools/dir-fetch-tool.test.ts +194 -0
- package/src/tools/dir-fetch-tool.ts +660 -0
- package/src/tools/dir-list-tool.ts +79 -0
- package/src/tools/file-fetch-tool.test.ts +82 -0
- package/src/tools/file-fetch-tool.ts +133 -0
- package/src/tools/file-write-tool.test.ts +30 -0
- package/src/tools/file-write-tool.ts +122 -0
- package/src/tools/node-tool-invoke.ts +97 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// File Transfer plugin module implements dir fetch behavior.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { root as fsRoot } from "actagent/plugin-sdk/security-runtime";
|
|
6
|
+
import {
|
|
7
|
+
classifyFsSafeReadError,
|
|
8
|
+
readAbsolutePath,
|
|
9
|
+
resolveCanonicalReadPath,
|
|
10
|
+
statRequiredDirectory,
|
|
11
|
+
} from "./path-errors.js";
|
|
12
|
+
|
|
13
|
+
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
type DirFetchParams = {
|
|
17
|
+
path?: unknown;
|
|
18
|
+
maxBytes?: unknown;
|
|
19
|
+
includeDotfiles?: unknown;
|
|
20
|
+
followSymlinks?: unknown;
|
|
21
|
+
preflightOnly?: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DirFetchOk = {
|
|
25
|
+
ok: true;
|
|
26
|
+
path: string;
|
|
27
|
+
tarBase64: string;
|
|
28
|
+
tarBytes: number;
|
|
29
|
+
sha256: string;
|
|
30
|
+
fileCount: number;
|
|
31
|
+
entries?: string[];
|
|
32
|
+
preflightOnly?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type DirFetchErrCode =
|
|
36
|
+
| "INVALID_PATH"
|
|
37
|
+
| "NOT_FOUND"
|
|
38
|
+
| "IS_FILE"
|
|
39
|
+
| "TREE_TOO_LARGE"
|
|
40
|
+
| "SYMLINK_REDIRECT"
|
|
41
|
+
| "READ_ERROR";
|
|
42
|
+
|
|
43
|
+
type DirFetchErr = {
|
|
44
|
+
ok: false;
|
|
45
|
+
code: DirFetchErrCode;
|
|
46
|
+
message: string;
|
|
47
|
+
canonicalPath?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type DirFetchResult = DirFetchOk | DirFetchErr;
|
|
51
|
+
|
|
52
|
+
function clampMaxBytes(input: unknown): number {
|
|
53
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
|
54
|
+
return DIR_FETCH_DEFAULT_MAX_BYTES;
|
|
55
|
+
}
|
|
56
|
+
return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classifyFsError(err: unknown): DirFetchErrCode {
|
|
60
|
+
const safeCode = classifyFsSafeReadError(err);
|
|
61
|
+
if (safeCode) {
|
|
62
|
+
return safeCode;
|
|
63
|
+
}
|
|
64
|
+
const code = (err as { code?: string } | null)?.code;
|
|
65
|
+
if (code === "ENOENT") {
|
|
66
|
+
return "NOT_FOUND";
|
|
67
|
+
}
|
|
68
|
+
return "READ_ERROR";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean> {
|
|
72
|
+
// du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k)
|
|
73
|
+
// We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses)
|
|
74
|
+
const heuristicKb = Math.ceil((maxBytes * 4) / 1024);
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] });
|
|
77
|
+
let output = "";
|
|
78
|
+
du.stdout.on("data", (chunk: Buffer) => {
|
|
79
|
+
output += chunk.toString();
|
|
80
|
+
});
|
|
81
|
+
du.on("close", (code) => {
|
|
82
|
+
if (code !== 0) {
|
|
83
|
+
// du failed; be permissive and let tar catch the overflow
|
|
84
|
+
resolve(true);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const match = /^(\d+)/.exec(output.trim());
|
|
88
|
+
if (!match) {
|
|
89
|
+
resolve(true);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const sizeKb = Number.parseInt(match[1], 10);
|
|
93
|
+
resolve(sizeKb <= heuristicKb);
|
|
94
|
+
});
|
|
95
|
+
du.on("error", () => {
|
|
96
|
+
// du not available; skip preflight
|
|
97
|
+
resolve(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
|
|
103
|
+
// Async spawn so a slow `tar -tzf` doesn't park the node-host event
|
|
104
|
+
// loop for up to 10s. Other in-flight requests continue to be served.
|
|
105
|
+
return new Promise<string[]>((resolve) => {
|
|
106
|
+
const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
107
|
+
let stdoutBuf = "";
|
|
108
|
+
let aborted = false;
|
|
109
|
+
const watchdog = setTimeout(() => {
|
|
110
|
+
aborted = true;
|
|
111
|
+
try {
|
|
112
|
+
child.kill("SIGKILL");
|
|
113
|
+
} catch {
|
|
114
|
+
/* gone */
|
|
115
|
+
}
|
|
116
|
+
resolve([]);
|
|
117
|
+
}, 10_000);
|
|
118
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
119
|
+
stdoutBuf += chunk.toString();
|
|
120
|
+
// Bound buffer growth — pathological archives shouldn't OOM us.
|
|
121
|
+
if (stdoutBuf.length > 32 * 1024 * 1024) {
|
|
122
|
+
aborted = true;
|
|
123
|
+
try {
|
|
124
|
+
child.kill("SIGKILL");
|
|
125
|
+
} catch {
|
|
126
|
+
/* gone */
|
|
127
|
+
}
|
|
128
|
+
clearTimeout(watchdog);
|
|
129
|
+
resolve([]);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
child.on("close", (code) => {
|
|
133
|
+
clearTimeout(watchdog);
|
|
134
|
+
if (aborted) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (code !== 0) {
|
|
138
|
+
resolve([]);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const lines = stdoutBuf
|
|
142
|
+
.split("\n")
|
|
143
|
+
.map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""))
|
|
144
|
+
.filter((line) => line.length > 0);
|
|
145
|
+
resolve(lines);
|
|
146
|
+
});
|
|
147
|
+
child.on("error", () => {
|
|
148
|
+
clearTimeout(watchdog);
|
|
149
|
+
if (!aborted) {
|
|
150
|
+
resolve([]);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
child.stdin.end(tarBuffer);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
|
|
158
|
+
const results: string[] = [];
|
|
159
|
+
const rootHandle = await fsRoot(root);
|
|
160
|
+
async function visit(relativeDir: string): Promise<boolean> {
|
|
161
|
+
const entries = await rootHandle.list(relativeDir, { withFileTypes: true });
|
|
162
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const rel = path.posix.join(relativeDir === "." ? "" : relativeDir, entry.name);
|
|
165
|
+
results.push(rel);
|
|
166
|
+
if (results.length > maxEntries) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (entry.isDirectory) {
|
|
170
|
+
const ok = await visit(rel);
|
|
171
|
+
if (!ok) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return (await visit(".")) ? results : "TOO_MANY";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
|
|
182
|
+
const requestedPath = readAbsolutePath(params.path);
|
|
183
|
+
if (typeof requestedPath !== "string") {
|
|
184
|
+
return requestedPath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const maxBytes = clampMaxBytes(params.maxBytes);
|
|
188
|
+
const includeDotfiles = params.includeDotfiles === true;
|
|
189
|
+
const followSymlinks = params.followSymlinks === true;
|
|
190
|
+
const preflightOnly = params.preflightOnly === true;
|
|
191
|
+
|
|
192
|
+
const canonical = await resolveCanonicalReadPath({
|
|
193
|
+
requestedPath,
|
|
194
|
+
followSymlinks,
|
|
195
|
+
classifyError: classifyFsError,
|
|
196
|
+
notFoundMessage: "directory not found",
|
|
197
|
+
});
|
|
198
|
+
if (typeof canonical !== "string") {
|
|
199
|
+
return canonical;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const directory = await statRequiredDirectory(canonical, classifyFsError);
|
|
203
|
+
if (!directory.ok) {
|
|
204
|
+
return directory;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (preflightOnly) {
|
|
208
|
+
try {
|
|
209
|
+
const entries = await listTreeEntries(canonical, 5000);
|
|
210
|
+
if (entries === "TOO_MANY") {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
code: "TREE_TOO_LARGE",
|
|
214
|
+
message: "directory tree exceeds 5000 entries during preflight",
|
|
215
|
+
canonicalPath: canonical,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
path: canonical,
|
|
221
|
+
tarBase64: "",
|
|
222
|
+
tarBytes: 0,
|
|
223
|
+
sha256: "",
|
|
224
|
+
fileCount: entries.length,
|
|
225
|
+
entries,
|
|
226
|
+
preflightOnly: true,
|
|
227
|
+
};
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const code = classifyFsError(err);
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
code,
|
|
233
|
+
message: `preflight readdir failed: ${String(err)}`,
|
|
234
|
+
canonicalPath: canonical,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Preflight size check using du
|
|
240
|
+
const withinBudget = await preflightDu(canonical, maxBytes);
|
|
241
|
+
if (!withinBudget) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
code: "TREE_TOO_LARGE",
|
|
245
|
+
message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`,
|
|
246
|
+
canonicalPath: canonical,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Build tar args. Shell out to /usr/bin/tar for portability.
|
|
251
|
+
// -cz: create + gzip
|
|
252
|
+
// -C <dir>: change to directory so paths in archive are relative
|
|
253
|
+
// .: include everything from that directory
|
|
254
|
+
// v1: includeDotfiles is accepted in the API but not enforced. BSD tar's
|
|
255
|
+
// --exclude pattern matching is unreliable for dotfiles (every plausible
|
|
256
|
+
// pattern except "*/.*" collapses the archive on macOS). Reliable filtering
|
|
257
|
+
// requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2.
|
|
258
|
+
// For now we always archive everything in the directory.
|
|
259
|
+
void includeDotfiles;
|
|
260
|
+
const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."];
|
|
261
|
+
|
|
262
|
+
// Capture tar output with a hard byte cap and a wall-clock timeout.
|
|
263
|
+
// SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires
|
|
264
|
+
// (covers tar hanging on a slow filesystem or symlink loop).
|
|
265
|
+
const TAR_HARD_TIMEOUT_MS = 60_000;
|
|
266
|
+
const tarBuffer = await new Promise<Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR">((resolve) => {
|
|
267
|
+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
|
|
268
|
+
const child = spawn(tarBin, tarArgs, {
|
|
269
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const chunks: Buffer[] = [];
|
|
273
|
+
let totalBytes = 0;
|
|
274
|
+
let aborted = false;
|
|
275
|
+
|
|
276
|
+
const watchdog = setTimeout(() => {
|
|
277
|
+
if (aborted) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
aborted = true;
|
|
281
|
+
try {
|
|
282
|
+
child.kill("SIGKILL");
|
|
283
|
+
} catch {
|
|
284
|
+
/* already gone */
|
|
285
|
+
}
|
|
286
|
+
resolve("TIMEOUT");
|
|
287
|
+
}, TAR_HARD_TIMEOUT_MS);
|
|
288
|
+
|
|
289
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
290
|
+
if (aborted) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
totalBytes += chunk.byteLength;
|
|
294
|
+
if (totalBytes > maxBytes) {
|
|
295
|
+
aborted = true;
|
|
296
|
+
clearTimeout(watchdog);
|
|
297
|
+
child.kill("SIGTERM");
|
|
298
|
+
resolve("TOO_LARGE");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
chunks.push(chunk);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
child.on("close", (code) => {
|
|
305
|
+
clearTimeout(watchdog);
|
|
306
|
+
if (aborted) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (code !== 0) {
|
|
310
|
+
resolve("ERROR");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
resolve(Buffer.concat(chunks));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
child.on("error", () => {
|
|
317
|
+
clearTimeout(watchdog);
|
|
318
|
+
if (!aborted) {
|
|
319
|
+
resolve("ERROR");
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (tarBuffer === "TOO_LARGE") {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
code: "TREE_TOO_LARGE",
|
|
328
|
+
message: `tarball exceeded ${maxBytes} byte limit mid-stream`,
|
|
329
|
+
canonicalPath: canonical,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (tarBuffer === "TIMEOUT") {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
code: "READ_ERROR",
|
|
336
|
+
message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)",
|
|
337
|
+
canonicalPath: canonical,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (tarBuffer === "ERROR") {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
code: "READ_ERROR",
|
|
344
|
+
message: "tar command failed",
|
|
345
|
+
canonicalPath: canonical,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
|
|
350
|
+
const tarBase64 = tarBuffer.toString("base64");
|
|
351
|
+
const tarBytes = tarBuffer.byteLength;
|
|
352
|
+
const entries = await listTarEntries(tarBuffer);
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
path: canonical,
|
|
357
|
+
tarBase64,
|
|
358
|
+
tarBytes,
|
|
359
|
+
sha256,
|
|
360
|
+
fileCount: entries.length,
|
|
361
|
+
entries,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// File Transfer tests cover dir list plugin behavior.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
DIR_LIST_DEFAULT_MAX_ENTRIES,
|
|
8
|
+
DIR_LIST_HARD_MAX_ENTRIES,
|
|
9
|
+
handleDirList,
|
|
10
|
+
} from "./dir-list.js";
|
|
11
|
+
|
|
12
|
+
let tmpRoot: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
|
|
16
|
+
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-list-test-")));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function expectDirListError(
|
|
24
|
+
input: Parameters<typeof handleDirList>[0],
|
|
25
|
+
code: "INVALID_PATH" | "IS_FILE" | "NOT_FOUND",
|
|
26
|
+
) {
|
|
27
|
+
const result = await handleDirList(input);
|
|
28
|
+
expect(result.ok).toBe(false);
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
expect(result.code).toBe(code);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("handleDirList — input validation", () => {
|
|
35
|
+
it("rejects empty / non-string path", async () => {
|
|
36
|
+
await expectDirListError({ path: "" }, "INVALID_PATH");
|
|
37
|
+
await expectDirListError({ path: undefined }, "INVALID_PATH");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects relative paths", async () => {
|
|
41
|
+
await expectDirListError({ path: "relative" }, "INVALID_PATH");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects paths with NUL bytes", async () => {
|
|
45
|
+
await expectDirListError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("handleDirList — fs errors", () => {
|
|
50
|
+
it("returns NOT_FOUND for a missing directory", async () => {
|
|
51
|
+
await expectDirListError({ path: path.join(tmpRoot, "does-not-exist") }, "NOT_FOUND");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns IS_FILE when path resolves to a regular file", async () => {
|
|
55
|
+
const f = path.join(tmpRoot, "f.txt");
|
|
56
|
+
await fs.writeFile(f, "x");
|
|
57
|
+
await expectDirListError({ path: f }, "IS_FILE");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("handleDirList — happy path", () => {
|
|
62
|
+
it("lists files and subdirs with metadata, sorted by name", async () => {
|
|
63
|
+
await fs.writeFile(path.join(tmpRoot, "z.txt"), "Z");
|
|
64
|
+
await fs.writeFile(path.join(tmpRoot, "a.png"), "PNG-bytes");
|
|
65
|
+
await fs.mkdir(path.join(tmpRoot, "subdir"));
|
|
66
|
+
|
|
67
|
+
const r = await handleDirList({ path: tmpRoot });
|
|
68
|
+
if (!r.ok) {
|
|
69
|
+
throw new Error("expected ok");
|
|
70
|
+
}
|
|
71
|
+
expect(r.entries.map((e) => e.name)).toEqual(["a.png", "subdir", "z.txt"]);
|
|
72
|
+
|
|
73
|
+
const a = r.entries.find((e) => e.name === "a.png")!;
|
|
74
|
+
expect(a.isDir).toBe(false);
|
|
75
|
+
expect(a.size).toBeGreaterThan(0);
|
|
76
|
+
expect(a.mimeType).toBe("image/png");
|
|
77
|
+
|
|
78
|
+
const sub = r.entries.find((e) => e.name === "subdir")!;
|
|
79
|
+
expect(sub.isDir).toBe(true);
|
|
80
|
+
expect(sub.size).toBe(0);
|
|
81
|
+
expect(sub.mimeType).toBe("inode/directory");
|
|
82
|
+
|
|
83
|
+
expect(r.truncated).toBe(false);
|
|
84
|
+
expect(r.nextPageToken).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("includes dotfiles in the listing", async () => {
|
|
88
|
+
await fs.writeFile(path.join(tmpRoot, ".hidden"), "x");
|
|
89
|
+
await fs.writeFile(path.join(tmpRoot, "visible"), "x");
|
|
90
|
+
|
|
91
|
+
const r = await handleDirList({ path: tmpRoot });
|
|
92
|
+
if (!r.ok) {
|
|
93
|
+
throw new Error("expected ok");
|
|
94
|
+
}
|
|
95
|
+
expect(r.entries.map((e) => e.name)).toEqual([".hidden", "visible"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("paginates via pageToken (offset-based)", async () => {
|
|
99
|
+
for (let i = 0; i < 7; i++) {
|
|
100
|
+
// zero-pad so localeCompare-stable sort matches creation order
|
|
101
|
+
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const page1 = await handleDirList({ path: tmpRoot, maxEntries: 3 });
|
|
105
|
+
if (!page1.ok) {
|
|
106
|
+
throw new Error("page1");
|
|
107
|
+
}
|
|
108
|
+
expect(page1.entries.map((e) => e.name)).toEqual(["f-0.txt", "f-1.txt", "f-2.txt"]);
|
|
109
|
+
expect(page1.truncated).toBe(true);
|
|
110
|
+
expect(page1.nextPageToken).toBe("3");
|
|
111
|
+
|
|
112
|
+
const page2 = await handleDirList({
|
|
113
|
+
path: tmpRoot,
|
|
114
|
+
maxEntries: 3,
|
|
115
|
+
pageToken: page1.nextPageToken,
|
|
116
|
+
});
|
|
117
|
+
if (!page2.ok) {
|
|
118
|
+
throw new Error("page2");
|
|
119
|
+
}
|
|
120
|
+
expect(page2.entries.map((e) => e.name)).toEqual(["f-3.txt", "f-4.txt", "f-5.txt"]);
|
|
121
|
+
expect(page2.truncated).toBe(true);
|
|
122
|
+
|
|
123
|
+
const page3 = await handleDirList({
|
|
124
|
+
path: tmpRoot,
|
|
125
|
+
maxEntries: 3,
|
|
126
|
+
pageToken: page2.nextPageToken,
|
|
127
|
+
});
|
|
128
|
+
if (!page3.ok) {
|
|
129
|
+
throw new Error("page3");
|
|
130
|
+
}
|
|
131
|
+
expect(page3.entries.map((e) => e.name)).toEqual(["f-6.txt"]);
|
|
132
|
+
expect(page3.truncated).toBe(false);
|
|
133
|
+
expect(page3.nextPageToken).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not coerce partial page tokens", async () => {
|
|
137
|
+
for (let i = 0; i < 3; i++) {
|
|
138
|
+
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "1next" });
|
|
142
|
+
if (!r.ok) {
|
|
143
|
+
throw new Error("expected ok");
|
|
144
|
+
}
|
|
145
|
+
expect(r.entries.map((e) => e.name)).toEqual(["f-0.txt"]);
|
|
146
|
+
expect(r.nextPageToken).toBe("1");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts plus-signed page tokens", async () => {
|
|
150
|
+
for (let i = 0; i < 3; i++) {
|
|
151
|
+
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "+01" });
|
|
155
|
+
if (!r.ok) {
|
|
156
|
+
throw new Error("expected ok");
|
|
157
|
+
}
|
|
158
|
+
expect(r.entries.map((e) => e.name)).toEqual(["f-1.txt"]);
|
|
159
|
+
expect(r.nextPageToken).toBe("2");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("handleDirList — limits", () => {
|
|
164
|
+
it("clamps maxEntries to the hard ceiling and uses the default for invalid values", () => {
|
|
165
|
+
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBe(200);
|
|
166
|
+
expect(DIR_LIST_HARD_MAX_ENTRIES).toBe(5000);
|
|
167
|
+
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBeLessThan(DIR_LIST_HARD_MAX_ENTRIES);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// File Transfer plugin module implements dir list behavior.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseStrictNonNegativeInteger } from "actagent/plugin-sdk/number-runtime";
|
|
4
|
+
import { root } from "actagent/plugin-sdk/security-runtime";
|
|
5
|
+
import { mimeFromExtension } from "../shared/mime.js";
|
|
6
|
+
import {
|
|
7
|
+
classifyFsSafeReadError,
|
|
8
|
+
readAbsolutePath,
|
|
9
|
+
resolveCanonicalReadPath,
|
|
10
|
+
statRequiredDirectory,
|
|
11
|
+
} from "./path-errors.js";
|
|
12
|
+
|
|
13
|
+
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
|
|
14
|
+
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
|
|
15
|
+
|
|
16
|
+
type DirListParams = {
|
|
17
|
+
path?: unknown;
|
|
18
|
+
pageToken?: unknown;
|
|
19
|
+
maxEntries?: unknown;
|
|
20
|
+
followSymlinks?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type DirListEntry = {
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
size: number;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
isDir: boolean;
|
|
29
|
+
mtime: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type DirListOk = {
|
|
33
|
+
ok: true;
|
|
34
|
+
path: string;
|
|
35
|
+
entries: DirListEntry[];
|
|
36
|
+
nextPageToken?: string;
|
|
37
|
+
truncated: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type DirListErrCode =
|
|
41
|
+
| "INVALID_PATH"
|
|
42
|
+
| "NOT_FOUND"
|
|
43
|
+
| "PERMISSION_DENIED"
|
|
44
|
+
| "IS_FILE"
|
|
45
|
+
| "SYMLINK_REDIRECT"
|
|
46
|
+
| "READ_ERROR";
|
|
47
|
+
|
|
48
|
+
type DirListErr = {
|
|
49
|
+
ok: false;
|
|
50
|
+
code: DirListErrCode;
|
|
51
|
+
message: string;
|
|
52
|
+
canonicalPath?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type DirListResult = DirListOk | DirListErr;
|
|
56
|
+
|
|
57
|
+
function clampMaxEntries(input: unknown): number {
|
|
58
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
|
59
|
+
return DIR_LIST_DEFAULT_MAX_ENTRIES;
|
|
60
|
+
}
|
|
61
|
+
return Math.min(Math.floor(input), DIR_LIST_HARD_MAX_ENTRIES);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parsePageOffset(input: unknown): number {
|
|
65
|
+
if (typeof input !== "string") {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
return parseStrictNonNegativeInteger(input) ?? 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function classifyFsError(err: unknown): DirListErrCode {
|
|
72
|
+
const safeCode = classifyFsSafeReadError(err);
|
|
73
|
+
if (safeCode) {
|
|
74
|
+
return safeCode;
|
|
75
|
+
}
|
|
76
|
+
const code = (err as { code?: string } | null)?.code;
|
|
77
|
+
if (code === "ENOENT") {
|
|
78
|
+
return "NOT_FOUND";
|
|
79
|
+
}
|
|
80
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
81
|
+
return "PERMISSION_DENIED";
|
|
82
|
+
}
|
|
83
|
+
return "READ_ERROR";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleDirList(params: DirListParams): Promise<DirListResult> {
|
|
87
|
+
const requestedPath = readAbsolutePath(params.path);
|
|
88
|
+
if (typeof requestedPath !== "string") {
|
|
89
|
+
return requestedPath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const maxEntries = clampMaxEntries(params.maxEntries);
|
|
93
|
+
const offset = parsePageOffset(params.pageToken);
|
|
94
|
+
|
|
95
|
+
const followSymlinks = params.followSymlinks === true;
|
|
96
|
+
|
|
97
|
+
const canonical = await resolveCanonicalReadPath({
|
|
98
|
+
requestedPath,
|
|
99
|
+
followSymlinks,
|
|
100
|
+
classifyError: classifyFsError,
|
|
101
|
+
notFoundMessage: "path not found",
|
|
102
|
+
});
|
|
103
|
+
if (typeof canonical !== "string") {
|
|
104
|
+
return canonical;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const directory = await statRequiredDirectory(canonical, classifyFsError);
|
|
108
|
+
if (!directory.ok) {
|
|
109
|
+
return directory;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let listedEntries: { name: string; isDirectory: boolean; size: number; mtimeMs: number }[];
|
|
113
|
+
try {
|
|
114
|
+
const dirRoot = await root(canonical);
|
|
115
|
+
listedEntries = await dirRoot.list(".", { withFileTypes: true });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const code = classifyFsError(err);
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code,
|
|
121
|
+
message: `list failed: ${String(err)}`,
|
|
122
|
+
canonicalPath: canonical,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
listedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
|
|
128
|
+
const total = listedEntries.length;
|
|
129
|
+
const page = listedEntries.slice(offset, offset + maxEntries);
|
|
130
|
+
const truncated = offset + maxEntries < total;
|
|
131
|
+
const nextPageToken = truncated ? String(offset + maxEntries) : undefined;
|
|
132
|
+
|
|
133
|
+
const entries: DirListEntry[] = [];
|
|
134
|
+
for (const entry of page) {
|
|
135
|
+
const entryPath = path.join(canonical, entry.name);
|
|
136
|
+
const isDir = entry.isDirectory;
|
|
137
|
+
|
|
138
|
+
entries.push({
|
|
139
|
+
name: entry.name,
|
|
140
|
+
path: entryPath,
|
|
141
|
+
size: isDir ? 0 : entry.size,
|
|
142
|
+
mimeType: isDir ? "inode/directory" : mimeFromExtension(entry.name),
|
|
143
|
+
isDir,
|
|
144
|
+
mtime: entry.mtimeMs,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
path: canonical,
|
|
151
|
+
entries,
|
|
152
|
+
nextPageToken,
|
|
153
|
+
truncated,
|
|
154
|
+
};
|
|
155
|
+
}
|