@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.
Files changed (36) hide show
  1. package/actagent.plugin.json +50 -0
  2. package/index.test.ts +93 -0
  3. package/index.ts +121 -0
  4. package/package.json +18 -0
  5. package/src/node-host/dir-fetch.test.ts +131 -0
  6. package/src/node-host/dir-fetch.ts +363 -0
  7. package/src/node-host/dir-list.test.ts +169 -0
  8. package/src/node-host/dir-list.ts +155 -0
  9. package/src/node-host/file-fetch.test.ts +254 -0
  10. package/src/node-host/file-fetch.ts +203 -0
  11. package/src/node-host/file-write.test.ts +378 -0
  12. package/src/node-host/file-write.ts +280 -0
  13. package/src/node-host/path-errors.ts +112 -0
  14. package/src/shared/audit.ts +98 -0
  15. package/src/shared/errors.test.ts +63 -0
  16. package/src/shared/errors.ts +68 -0
  17. package/src/shared/lazy-node-invoke-policy.test.ts +102 -0
  18. package/src/shared/lazy-node-invoke-policy.ts +36 -0
  19. package/src/shared/mime.test.ts +61 -0
  20. package/src/shared/mime.ts +30 -0
  21. package/src/shared/node-invoke-policy-commands.ts +9 -0
  22. package/src/shared/node-invoke-policy.test.ts +763 -0
  23. package/src/shared/node-invoke-policy.ts +947 -0
  24. package/src/shared/params.test.ts +42 -0
  25. package/src/shared/params.ts +60 -0
  26. package/src/shared/policy.test.ts +568 -0
  27. package/src/shared/policy.ts +383 -0
  28. package/src/tools/descriptors.ts +145 -0
  29. package/src/tools/dir-fetch-tool.test.ts +194 -0
  30. package/src/tools/dir-fetch-tool.ts +660 -0
  31. package/src/tools/dir-list-tool.ts +79 -0
  32. package/src/tools/file-fetch-tool.test.ts +82 -0
  33. package/src/tools/file-fetch-tool.ts +133 -0
  34. package/src/tools/file-write-tool.test.ts +30 -0
  35. package/src/tools/file-write-tool.ts +122 -0
  36. package/src/tools/node-tool-invoke.ts +97 -0
@@ -0,0 +1,763 @@
1
+ // File Transfer tests cover node invoke policy plugin behavior.
2
+ import fs from "node:fs/promises";
3
+ import { gzipSync } from "node:zlib";
4
+ import type { ACTAgentPluginNodeInvokePolicyContext } from "actagent/plugin-sdk/plugin-entry";
5
+ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js";
7
+
8
+ vi.mock("./audit.js", () => ({
9
+ appendFileTransferAudit: vi.fn(async () => undefined),
10
+ }));
11
+
12
+ vi.mock("./policy.js", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("./policy.js")>();
14
+ return {
15
+ ...actual,
16
+ persistAllowAlways: vi.fn(async () => undefined),
17
+ };
18
+ });
19
+
20
+ const tmpRoots: string[] = [];
21
+ const testUnlessWindows = process.platform === "win32" ? it.skip : it;
22
+
23
+ afterEach(async () => {
24
+ await Promise.all(tmpRoots.map((tmpRoot) => fs.rm(tmpRoot, { recursive: true, force: true })));
25
+ tmpRoots.length = 0;
26
+ });
27
+
28
+ afterAll(() => {
29
+ vi.doUnmock("./audit.js");
30
+ vi.doUnmock("./policy.js");
31
+ vi.resetModules();
32
+ });
33
+
34
+ function tarEntries(entries: Record<string, string>): string {
35
+ const blocks: Buffer[] = [];
36
+ for (const [relPath, contents] of Object.entries(entries)) {
37
+ const payload = Buffer.from(contents);
38
+ blocks.push(createTarFileHeader(relPath, payload.byteLength), payload);
39
+ const padding = (512 - (payload.byteLength % 512)) % 512;
40
+ if (padding > 0) {
41
+ blocks.push(Buffer.alloc(padding));
42
+ }
43
+ }
44
+ blocks.push(Buffer.alloc(1024));
45
+ return gzipSync(Buffer.concat(blocks)).toString("base64");
46
+ }
47
+
48
+ function writeTarString(header: Buffer, offset: number, length: number, value: string): void {
49
+ header.write(value.slice(0, length), offset, length, "utf8");
50
+ }
51
+
52
+ function writeTarOctal(header: Buffer, offset: number, length: number, value: number): void {
53
+ const text = value.toString(8).padStart(length - 1, "0");
54
+ header.write(`${text}\0`.slice(-length), offset, length, "ascii");
55
+ }
56
+
57
+ function createTarFileHeader(name: string, size: number): Buffer {
58
+ const header = Buffer.alloc(512);
59
+ writeTarString(header, 0, 100, name);
60
+ writeTarOctal(header, 100, 8, 0o644);
61
+ writeTarOctal(header, 108, 8, 0);
62
+ writeTarOctal(header, 116, 8, 0);
63
+ writeTarOctal(header, 124, 12, size);
64
+ writeTarOctal(header, 136, 12, 0);
65
+ header.fill(" ", 148, 156);
66
+ header.write("0", 156, 1, "ascii");
67
+ header.write("ustar\0", 257, 6, "ascii");
68
+ header.write("00", 263, 2, "ascii");
69
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
70
+ header.write(checksum.toString(8).padStart(6, "0"), 148, 6, "ascii");
71
+ header[154] = 0;
72
+ header[155] = 0x20;
73
+ return header;
74
+ }
75
+
76
+ function createCtx(overrides: {
77
+ command?: string;
78
+ params?: Record<string, unknown>;
79
+ pluginConfig?: Record<string, unknown>;
80
+ approvals?: ACTAgentPluginNodeInvokePolicyContext["approvals"];
81
+ }) {
82
+ const invokeNode = vi.fn<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>(
83
+ async ({
84
+ params,
85
+ }: Parameters<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>[0] = {}) => ({
86
+ ok: true,
87
+ payload: {
88
+ ok: true,
89
+ path:
90
+ typeof (params as { path?: unknown } | undefined)?.path === "string"
91
+ ? (params as { path: string }).path
92
+ : "/tmp/file.txt",
93
+ size: 1,
94
+ sha256: "a".repeat(64),
95
+ },
96
+ }),
97
+ );
98
+ return {
99
+ ctx: {
100
+ nodeId: "node-1",
101
+ command: overrides.command ?? "file.fetch",
102
+ params: overrides.params ?? { path: "/tmp/file.txt", maxBytes: 1024 },
103
+ config: {},
104
+ pluginConfig: overrides.pluginConfig ?? {
105
+ nodes: {
106
+ "node-1": {
107
+ allowReadPaths: ["/tmp/**"],
108
+ allowWritePaths: ["/tmp/**"],
109
+ maxBytes: 512,
110
+ },
111
+ },
112
+ },
113
+ node: { nodeId: "node-1", displayName: "Node One" },
114
+ ...(overrides.approvals ? { approvals: overrides.approvals } : {}),
115
+ invokeNode,
116
+ },
117
+ invokeNode,
118
+ };
119
+ }
120
+
121
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
122
+ if (typeof value !== "object" || value === null) {
123
+ throw new Error(`${label} was not an object`);
124
+ }
125
+ return value as Record<string, unknown>;
126
+ }
127
+
128
+ function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
129
+ for (const [key, value] of Object.entries(fields)) {
130
+ expect(record[key]).toEqual(value);
131
+ }
132
+ }
133
+
134
+ function expectResultFields(result: unknown, fields: Record<string, unknown>) {
135
+ expectRecordFields(requireRecord(result, "policy result"), fields);
136
+ }
137
+
138
+ function requireInvokeParams(
139
+ invokeNode: ReturnType<typeof vi.fn<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>>,
140
+ callIndex: number,
141
+ ) {
142
+ const call = (invokeNode.mock.calls as unknown[][])[callIndex]?.[0];
143
+ const request = requireRecord(call, `invoke call ${callIndex + 1}`);
144
+ return requireRecord(request.params, `invoke call ${callIndex + 1} params`);
145
+ }
146
+
147
+ describe("file-transfer node invoke policy", () => {
148
+ it("injects policy-owned limits before invoking the node", async () => {
149
+ const policy = createFileTransferNodeInvokePolicy();
150
+ const { ctx, invokeNode } = createCtx({
151
+ command: "file.fetch",
152
+ params: { path: "/tmp/file.txt", maxBytes: 4096, followSymlinks: true },
153
+ });
154
+
155
+ const result = await policy.handle(ctx);
156
+
157
+ expect(result.ok).toBe(true);
158
+ expect(invokeNode).toHaveBeenNthCalledWith(1, {
159
+ params: {
160
+ path: "/tmp/file.txt",
161
+ maxBytes: 512,
162
+ followSymlinks: false,
163
+ preflightOnly: true,
164
+ },
165
+ });
166
+ expect(invokeNode).toHaveBeenNthCalledWith(2, {
167
+ params: {
168
+ path: "/tmp/file.txt",
169
+ maxBytes: 512,
170
+ followSymlinks: false,
171
+ },
172
+ });
173
+ });
174
+
175
+ it("normalizes string maxBytes before invoking the node", async () => {
176
+ const policy = createFileTransferNodeInvokePolicy();
177
+ const { ctx, invokeNode } = createCtx({
178
+ params: { path: "/tmp/file.txt", maxBytes: "1024" },
179
+ pluginConfig: {
180
+ nodes: {
181
+ "node-1": {
182
+ allowReadPaths: ["/tmp/**"],
183
+ },
184
+ },
185
+ },
186
+ });
187
+
188
+ const result = await policy.handle(ctx);
189
+
190
+ expect(result.ok).toBe(true);
191
+ expect(invokeNode).toHaveBeenNthCalledWith(1, {
192
+ params: {
193
+ path: "/tmp/file.txt",
194
+ maxBytes: 1024,
195
+ followSymlinks: false,
196
+ preflightOnly: true,
197
+ },
198
+ });
199
+ });
200
+
201
+ it("rejects malformed maxBytes before invoking the node", async () => {
202
+ const policy = createFileTransferNodeInvokePolicy();
203
+ const { ctx, invokeNode } = createCtx({
204
+ params: { path: "/tmp/file.txt", maxBytes: "1024.5" },
205
+ });
206
+
207
+ const result = await policy.handle(ctx);
208
+
209
+ expectResultFields(result, {
210
+ ok: false,
211
+ code: "INVALID_PARAMS",
212
+ message: "maxBytes must be a positive integer",
213
+ });
214
+ expect(invokeNode).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("rejects malformed maxBytes before requesting approval", async () => {
218
+ const policy = createFileTransferNodeInvokePolicy();
219
+ const approvals = {
220
+ request: vi.fn(async () => ({ id: "approval-1", decision: "allow-always" as const })),
221
+ };
222
+ const { ctx, invokeNode } = createCtx({
223
+ params: { path: "/tmp/new.txt", maxBytes: "1024.5" },
224
+ pluginConfig: {
225
+ nodes: {
226
+ "node-1": {
227
+ ask: "on-miss",
228
+ allowReadPaths: ["/allowed/**"],
229
+ },
230
+ },
231
+ },
232
+ approvals,
233
+ });
234
+
235
+ const result = await policy.handle(ctx);
236
+
237
+ expectResultFields(result, {
238
+ ok: false,
239
+ code: "INVALID_PARAMS",
240
+ message: "maxBytes must be a positive integer",
241
+ });
242
+ expect(approvals.request).not.toHaveBeenCalled();
243
+ expect(invokeNode).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("denies raw node.invoke before the node when plugin policy is missing", async () => {
247
+ const policy = createFileTransferNodeInvokePolicy();
248
+ const { ctx, invokeNode } = createCtx({ pluginConfig: {} });
249
+
250
+ const result = await policy.handle(ctx);
251
+
252
+ expectResultFields(result, { ok: false, code: "NO_POLICY" });
253
+ expect(invokeNode).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it("uses plugin approvals for ask-on-miss before invoking the node", async () => {
257
+ const policy = createFileTransferNodeInvokePolicy();
258
+ const approvals = {
259
+ request: vi.fn(async () => ({ id: "approval-1", decision: "allow-once" as const })),
260
+ };
261
+ const { ctx, invokeNode } = createCtx({
262
+ params: { path: "/tmp/new.txt" },
263
+ pluginConfig: {
264
+ nodes: {
265
+ "node-1": {
266
+ ask: "on-miss",
267
+ allowReadPaths: ["/allowed/**"],
268
+ maxBytes: 256,
269
+ },
270
+ },
271
+ },
272
+ approvals,
273
+ });
274
+
275
+ const result = await policy.handle(ctx);
276
+
277
+ expect(result.ok).toBe(true);
278
+ const approvalCalls = approvals.request.mock.calls as unknown[][];
279
+ const approvalRequest = requireRecord(approvalCalls[0]?.[0], "approval request");
280
+ expectRecordFields(approvalRequest, {
281
+ title: "Read file: /tmp/new.txt",
282
+ severity: "info",
283
+ toolName: "file.fetch",
284
+ });
285
+ expect(invokeNode).toHaveBeenNthCalledWith(1, {
286
+ params: {
287
+ path: "/tmp/new.txt",
288
+ followSymlinks: false,
289
+ maxBytes: 256,
290
+ preflightOnly: true,
291
+ },
292
+ });
293
+ expect(invokeNode).toHaveBeenNthCalledWith(2, {
294
+ params: {
295
+ path: "/tmp/new.txt",
296
+ followSymlinks: false,
297
+ maxBytes: 256,
298
+ },
299
+ });
300
+ });
301
+
302
+ it("marks node transport failures as unavailable", async () => {
303
+ const policy = createFileTransferNodeInvokePolicy();
304
+ const { ctx, invokeNode } = createCtx({
305
+ params: { path: "/tmp/file.txt" },
306
+ });
307
+ invokeNode.mockResolvedValueOnce({
308
+ ok: false,
309
+ code: "TIMEOUT",
310
+ message: "node timed out",
311
+ details: { nodeError: { code: "TIMEOUT" } },
312
+ });
313
+
314
+ const result = await policy.handle(ctx);
315
+
316
+ expectResultFields(result, {
317
+ ok: false,
318
+ code: "TIMEOUT",
319
+ unavailable: true,
320
+ details: { nodeError: { code: "TIMEOUT" } },
321
+ });
322
+ });
323
+
324
+ it("checks file.fetch canonical policy before requesting bytes", async () => {
325
+ const policy = createFileTransferNodeInvokePolicy();
326
+ const { ctx, invokeNode } = createCtx({
327
+ params: { path: "/tmp/link.txt" },
328
+ });
329
+ invokeNode.mockResolvedValueOnce({
330
+ ok: true,
331
+ payload: {
332
+ ok: true,
333
+ path: "/etc/passwd",
334
+ size: 1,
335
+ sha256: "a".repeat(64),
336
+ },
337
+ });
338
+
339
+ const result = await policy.handle(ctx);
340
+
341
+ expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" });
342
+ expect(invokeNode).toHaveBeenCalledTimes(1);
343
+ expectRecordFields(requireInvokeParams(invokeNode, 0), {
344
+ path: "/tmp/link.txt",
345
+ followSymlinks: false,
346
+ preflightOnly: true,
347
+ });
348
+ });
349
+
350
+ it("continues file.fetch after preflight without forwarding caller preflightOnly", async () => {
351
+ const policy = createFileTransferNodeInvokePolicy();
352
+ const { ctx, invokeNode } = createCtx({
353
+ params: { path: "/tmp/file.txt", preflightOnly: true },
354
+ });
355
+
356
+ const result = await policy.handle(ctx);
357
+
358
+ expectResultFields(result, { ok: true });
359
+ expect(invokeNode).toHaveBeenCalledTimes(2);
360
+ expectRecordFields(requireInvokeParams(invokeNode, 0), {
361
+ path: "/tmp/file.txt",
362
+ preflightOnly: true,
363
+ });
364
+ expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
365
+ });
366
+
367
+ it("checks file.write canonical policy before the mutating node call", async () => {
368
+ const policy = createFileTransferNodeInvokePolicy();
369
+ const { ctx, invokeNode } = createCtx({
370
+ command: "file.write",
371
+ params: {
372
+ path: "/tmp/link/out.txt",
373
+ contentBase64: Buffer.from("payload").toString("base64"),
374
+ createParents: true,
375
+ },
376
+ pluginConfig: {
377
+ nodes: {
378
+ "node-1": {
379
+ allowWritePaths: ["/tmp/**"],
380
+ followSymlinks: true,
381
+ },
382
+ },
383
+ },
384
+ });
385
+ invokeNode.mockResolvedValueOnce({
386
+ ok: true,
387
+ payload: {
388
+ ok: true,
389
+ path: "/etc/out.txt",
390
+ size: 7,
391
+ sha256: "b".repeat(64),
392
+ overwritten: false,
393
+ },
394
+ });
395
+
396
+ const result = await policy.handle(ctx);
397
+
398
+ expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" });
399
+ expect(invokeNode).toHaveBeenCalledTimes(1);
400
+ expectRecordFields(requireInvokeParams(invokeNode, 0), {
401
+ path: "/tmp/link/out.txt",
402
+ followSymlinks: true,
403
+ preflightOnly: true,
404
+ });
405
+ });
406
+
407
+ it("continues file.write after preflight without forwarding caller preflightOnly", async () => {
408
+ const policy = createFileTransferNodeInvokePolicy();
409
+ const { ctx, invokeNode } = createCtx({
410
+ command: "file.write",
411
+ params: {
412
+ path: "/tmp/link/out.txt",
413
+ contentBase64: Buffer.from("payload").toString("base64"),
414
+ createParents: true,
415
+ preflightOnly: true,
416
+ },
417
+ pluginConfig: {
418
+ nodes: {
419
+ "node-1": {
420
+ allowWritePaths: ["/tmp/**", "/private/tmp/**"],
421
+ followSymlinks: true,
422
+ },
423
+ },
424
+ },
425
+ });
426
+ invokeNode
427
+ .mockResolvedValueOnce({
428
+ ok: true,
429
+ payload: {
430
+ ok: true,
431
+ path: "/private/tmp/out.txt",
432
+ size: 7,
433
+ sha256: "b".repeat(64),
434
+ overwritten: false,
435
+ },
436
+ })
437
+ .mockResolvedValueOnce({
438
+ ok: true,
439
+ payload: {
440
+ ok: true,
441
+ path: "/private/tmp/out.txt",
442
+ size: 7,
443
+ sha256: "b".repeat(64),
444
+ overwritten: false,
445
+ },
446
+ });
447
+
448
+ const result = await policy.handle(ctx);
449
+
450
+ expectResultFields(result, { ok: true });
451
+ expect(invokeNode).toHaveBeenCalledTimes(2);
452
+ expect(requireInvokeParams(invokeNode, 0).preflightOnly).toBe(true);
453
+ expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
454
+ });
455
+
456
+ it("checks every dir.fetch preflight entry before requesting the archive", async () => {
457
+ const policy = createFileTransferNodeInvokePolicy();
458
+ const { ctx, invokeNode } = createCtx({
459
+ command: "dir.fetch",
460
+ params: { path: "/home/me" },
461
+ pluginConfig: {
462
+ nodes: {
463
+ "node-1": {
464
+ allowReadPaths: ["/home/me", "/home/me/**"],
465
+ denyPaths: ["**/.ssh/**"],
466
+ },
467
+ },
468
+ },
469
+ });
470
+ invokeNode.mockResolvedValueOnce({
471
+ ok: true,
472
+ payload: {
473
+ ok: true,
474
+ path: "/home/me",
475
+ entries: ["ok.txt", ".ssh/id_rsa"],
476
+ fileCount: 2,
477
+ preflightOnly: true,
478
+ },
479
+ });
480
+
481
+ const result = await policy.handle(ctx);
482
+
483
+ expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" });
484
+ expect(
485
+ requireRecord(requireRecord(result, "policy result").details, "result details").path,
486
+ ).toBe("/home/me/.ssh/id_rsa");
487
+ expect(invokeNode).toHaveBeenCalledTimes(1);
488
+ expectRecordFields(requireInvokeParams(invokeNode, 0), {
489
+ path: "/home/me",
490
+ preflightOnly: true,
491
+ });
492
+ });
493
+
494
+ it("rejects dir.fetch preflight responses without an entry list", async () => {
495
+ const policy = createFileTransferNodeInvokePolicy();
496
+ const { ctx, invokeNode } = createCtx({
497
+ command: "dir.fetch",
498
+ params: { path: "/home/me" },
499
+ pluginConfig: {
500
+ nodes: {
501
+ "node-1": {
502
+ allowReadPaths: ["/home/me", "/home/me/**"],
503
+ },
504
+ },
505
+ },
506
+ });
507
+ invokeNode.mockResolvedValueOnce({
508
+ ok: true,
509
+ payload: {
510
+ ok: true,
511
+ path: "/home/me",
512
+ fileCount: 2,
513
+ preflightOnly: true,
514
+ },
515
+ });
516
+
517
+ const result = await policy.handle(ctx);
518
+
519
+ expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_MISSING" });
520
+ expect(invokeNode).toHaveBeenCalledTimes(1);
521
+ });
522
+
523
+ it("rejects invalid dir.fetch preflight entries before requesting the archive", async () => {
524
+ const policy = createFileTransferNodeInvokePolicy();
525
+ const { ctx, invokeNode } = createCtx({
526
+ command: "dir.fetch",
527
+ params: { path: "/home/me" },
528
+ pluginConfig: {
529
+ nodes: {
530
+ "node-1": {
531
+ allowReadPaths: ["/home/me", "/home/me/**"],
532
+ },
533
+ },
534
+ },
535
+ });
536
+ invokeNode.mockResolvedValueOnce({
537
+ ok: true,
538
+ payload: {
539
+ ok: true,
540
+ path: "/home/me",
541
+ entries: ["ok.txt", "/etc/passwd"],
542
+ fileCount: 2,
543
+ preflightOnly: true,
544
+ },
545
+ });
546
+
547
+ const result = await policy.handle(ctx);
548
+
549
+ expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRY_INVALID" });
550
+ expect(invokeNode).toHaveBeenCalledTimes(1);
551
+ });
552
+
553
+ it("rejects oversized dir.fetch preflight entry lists before requesting the archive", async () => {
554
+ const policy = createFileTransferNodeInvokePolicy();
555
+ const entries = Array.from({ length: 5001 }, (_, index) => `file-${index}.txt`);
556
+ const { ctx, invokeNode } = createCtx({
557
+ command: "dir.fetch",
558
+ params: { path: "/home/me" },
559
+ pluginConfig: {
560
+ nodes: {
561
+ "node-1": {
562
+ allowReadPaths: ["/home/me", "/home/me/**"],
563
+ },
564
+ },
565
+ },
566
+ });
567
+ invokeNode.mockResolvedValueOnce({
568
+ ok: true,
569
+ payload: {
570
+ ok: true,
571
+ path: "/home/me",
572
+ entries,
573
+ fileCount: entries.length,
574
+ preflightOnly: true,
575
+ },
576
+ });
577
+
578
+ const result = await policy.handle(ctx);
579
+
580
+ expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_TOO_MANY" });
581
+ expect(invokeNode).toHaveBeenCalledTimes(1);
582
+ });
583
+
584
+ testUnlessWindows(
585
+ "continues dir.fetch after preflight without forwarding caller preflightOnly",
586
+ async () => {
587
+ const policy = createFileTransferNodeInvokePolicy();
588
+ const tarBase64 = tarEntries({
589
+ "a.txt": "a",
590
+ "sub/b.txt": "b",
591
+ });
592
+ const { ctx, invokeNode } = createCtx({
593
+ command: "dir.fetch",
594
+ params: { path: "/tmp/project", preflightOnly: true },
595
+ });
596
+ invokeNode
597
+ .mockResolvedValueOnce({
598
+ ok: true,
599
+ payload: {
600
+ ok: true,
601
+ path: "/tmp/project",
602
+ entries: ["a.txt", "sub/b.txt"],
603
+ fileCount: 2,
604
+ preflightOnly: true,
605
+ },
606
+ })
607
+ .mockResolvedValueOnce({
608
+ ok: true,
609
+ payload: {
610
+ ok: true,
611
+ path: "/tmp/project",
612
+ tarBase64,
613
+ tarBytes: 7,
614
+ sha256: "c".repeat(64),
615
+ fileCount: 2,
616
+ entries: ["a.txt", "sub/b.txt"],
617
+ },
618
+ });
619
+
620
+ const result = await policy.handle(ctx);
621
+
622
+ expectResultFields(result, { ok: true });
623
+ expect(invokeNode).toHaveBeenCalledTimes(2);
624
+ expectRecordFields(requireInvokeParams(invokeNode, 0), {
625
+ path: "/tmp/project",
626
+ preflightOnly: true,
627
+ });
628
+ expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
629
+ },
630
+ );
631
+
632
+ testUnlessWindows(
633
+ "checks final dir.fetch archive entries before returning the archive",
634
+ async () => {
635
+ const policy = createFileTransferNodeInvokePolicy();
636
+ const tarBase64 = tarEntries({
637
+ "ok.txt": "ok",
638
+ ".ssh/id_rsa": "secret",
639
+ });
640
+ const { ctx, invokeNode } = createCtx({
641
+ command: "dir.fetch",
642
+ params: { path: "/home/me" },
643
+ pluginConfig: {
644
+ nodes: {
645
+ "node-1": {
646
+ allowReadPaths: ["/home/me", "/home/me/**"],
647
+ denyPaths: ["**/.ssh/**"],
648
+ },
649
+ },
650
+ },
651
+ });
652
+ invokeNode
653
+ .mockResolvedValueOnce({
654
+ ok: true,
655
+ payload: {
656
+ ok: true,
657
+ path: "/home/me",
658
+ entries: ["ok.txt"],
659
+ fileCount: 1,
660
+ preflightOnly: true,
661
+ },
662
+ })
663
+ .mockResolvedValueOnce({
664
+ ok: true,
665
+ payload: {
666
+ ok: true,
667
+ path: "/home/me",
668
+ tarBase64,
669
+ tarBytes: 7,
670
+ sha256: "c".repeat(64),
671
+ fileCount: 2,
672
+ },
673
+ });
674
+
675
+ const result = await policy.handle(ctx);
676
+
677
+ expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" });
678
+ expect(
679
+ requireRecord(requireRecord(result, "policy result").details, "result details").path,
680
+ ).toBe("/home/me/.ssh/id_rsa");
681
+ expect(invokeNode).toHaveBeenCalledTimes(2);
682
+ },
683
+ );
684
+
685
+ testUnlessWindows("rejects oversized final dir.fetch archive entry lists", async () => {
686
+ const policy = createFileTransferNodeInvokePolicy();
687
+ const tarBase64 = tarEntries(
688
+ Object.fromEntries(Array.from({ length: 5001 }, (_, index) => [`file-${index}.txt`, "x"])),
689
+ );
690
+ const { ctx, invokeNode } = createCtx({
691
+ command: "dir.fetch",
692
+ params: { path: "/tmp/project" },
693
+ pluginConfig: {
694
+ nodes: {
695
+ "node-1": {
696
+ allowReadPaths: ["/tmp/project", "/tmp/project/**"],
697
+ },
698
+ },
699
+ },
700
+ });
701
+ invokeNode
702
+ .mockResolvedValueOnce({
703
+ ok: true,
704
+ payload: {
705
+ ok: true,
706
+ path: "/tmp/project",
707
+ entries: ["file-0.txt"],
708
+ fileCount: 1,
709
+ preflightOnly: true,
710
+ },
711
+ })
712
+ .mockResolvedValueOnce({
713
+ ok: true,
714
+ payload: {
715
+ ok: true,
716
+ path: "/tmp/project",
717
+ tarBase64,
718
+ tarBytes: 7,
719
+ sha256: "c".repeat(64),
720
+ fileCount: 5001,
721
+ },
722
+ });
723
+
724
+ const result = await policy.handle(ctx);
725
+
726
+ expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY" });
727
+ expect(invokeNode).toHaveBeenCalledTimes(2);
728
+ });
729
+
730
+ it("rejects final dir.fetch archive responses without readable archive entries", async () => {
731
+ const policy = createFileTransferNodeInvokePolicy();
732
+ const { ctx, invokeNode } = createCtx({
733
+ command: "dir.fetch",
734
+ params: { path: "/tmp/project" },
735
+ });
736
+ invokeNode
737
+ .mockResolvedValueOnce({
738
+ ok: true,
739
+ payload: {
740
+ ok: true,
741
+ path: "/tmp/project",
742
+ entries: ["a.txt"],
743
+ fileCount: 1,
744
+ preflightOnly: true,
745
+ },
746
+ })
747
+ .mockResolvedValueOnce({
748
+ ok: true,
749
+ payload: {
750
+ ok: true,
751
+ path: "/tmp/project",
752
+ tarBytes: 7,
753
+ sha256: "c".repeat(64),
754
+ fileCount: 1,
755
+ },
756
+ });
757
+
758
+ const result = await policy.handle(ctx);
759
+
760
+ expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_MISSING" });
761
+ expect(invokeNode).toHaveBeenCalledTimes(2);
762
+ });
763
+ });