@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,50 @@
1
+ {
2
+ "id": "file-transfer",
3
+ "activation": {
4
+ "onStartup": true
5
+ },
6
+ "enabledByDefault": true,
7
+ "name": "File Transfer",
8
+ "description": "Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.",
9
+ "contracts": {
10
+ "tools": ["file_fetch", "dir_list", "dir_fetch", "file_write"]
11
+ },
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "nodes": {
17
+ "type": "object",
18
+ "additionalProperties": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "properties": {
22
+ "ask": {
23
+ "type": "string",
24
+ "enum": ["off", "on-miss", "always"]
25
+ },
26
+ "allowReadPaths": {
27
+ "type": "array",
28
+ "items": { "type": "string" }
29
+ },
30
+ "allowWritePaths": {
31
+ "type": "array",
32
+ "items": { "type": "string" }
33
+ },
34
+ "denyPaths": {
35
+ "type": "array",
36
+ "items": { "type": "string" }
37
+ },
38
+ "maxBytes": {
39
+ "type": "number"
40
+ },
41
+ "followSymlinks": {
42
+ "type": "boolean",
43
+ "default": false
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
package/index.test.ts ADDED
@@ -0,0 +1,93 @@
1
+ // File Transfer tests cover index plugin behavior.
2
+ import { afterAll, describe, expect, it, vi } from "vitest";
3
+ import pluginEntry from "./index.js";
4
+
5
+ function rejectRuntimeImport(moduleName: string) {
6
+ return () => {
7
+ throw new Error(`${moduleName} imported during descriptor registration`);
8
+ };
9
+ }
10
+
11
+ vi.mock("./src/node-host/file-fetch.js", rejectRuntimeImport("node-host/file-fetch"));
12
+ vi.mock("./src/node-host/dir-list.js", rejectRuntimeImport("node-host/dir-list"));
13
+ vi.mock("./src/node-host/dir-fetch.js", rejectRuntimeImport("node-host/dir-fetch"));
14
+ vi.mock("./src/node-host/file-write.js", rejectRuntimeImport("node-host/file-write"));
15
+ vi.mock("./src/tools/file-fetch-tool.js", rejectRuntimeImport("tools/file-fetch-tool"));
16
+ vi.mock("./src/tools/dir-list-tool.js", rejectRuntimeImport("tools/dir-list-tool"));
17
+ vi.mock("./src/tools/dir-fetch-tool.js", rejectRuntimeImport("tools/dir-fetch-tool"));
18
+ vi.mock("./src/tools/file-write-tool.js", rejectRuntimeImport("tools/file-write-tool"));
19
+ vi.mock("./src/shared/node-invoke-policy.js", rejectRuntimeImport("shared/node-invoke-policy"));
20
+
21
+ afterAll(() => {
22
+ vi.doUnmock("./src/node-host/file-fetch.js");
23
+ vi.doUnmock("./src/node-host/dir-list.js");
24
+ vi.doUnmock("./src/node-host/dir-fetch.js");
25
+ vi.doUnmock("./src/node-host/file-write.js");
26
+ vi.doUnmock("./src/tools/file-fetch-tool.js");
27
+ vi.doUnmock("./src/tools/dir-list-tool.js");
28
+ vi.doUnmock("./src/tools/dir-fetch-tool.js");
29
+ vi.doUnmock("./src/tools/file-write-tool.js");
30
+ vi.doUnmock("./src/shared/node-invoke-policy.js");
31
+ vi.resetModules();
32
+ });
33
+
34
+ describe("file-transfer plugin entry", () => {
35
+ it("registers static command and tool descriptors without importing runtime handlers", () => {
36
+ const registerNodeInvokePolicy = vi.fn();
37
+ const registerTool = vi.fn();
38
+
39
+ pluginEntry.register({
40
+ registerNodeInvokePolicy,
41
+ registerTool,
42
+ } as never);
43
+
44
+ expect(pluginEntry.nodeHostCommands?.map((entry) => entry.command)).toEqual([
45
+ "file.fetch",
46
+ "dir.list",
47
+ "dir.fetch",
48
+ "file.write",
49
+ ]);
50
+ expect(registerNodeInvokePolicy).toHaveBeenCalledTimes(1);
51
+ expect(registerNodeInvokePolicy.mock.calls[0]?.[0].commands).toEqual([
52
+ "file.fetch",
53
+ "dir.list",
54
+ "dir.fetch",
55
+ "file.write",
56
+ ]);
57
+ expect(registerTool.mock.calls.map(([tool]) => tool.name)).toEqual([
58
+ "file_fetch",
59
+ "dir_list",
60
+ "dir_fetch",
61
+ "file_write",
62
+ ]);
63
+ });
64
+
65
+ it("fails closed if the lazy policy module cannot load", async () => {
66
+ const registerNodeInvokePolicy = vi.fn();
67
+ const registerTool = vi.fn();
68
+ const invokeNode = vi.fn();
69
+
70
+ pluginEntry.register({
71
+ registerNodeInvokePolicy,
72
+ registerTool,
73
+ } as never);
74
+
75
+ const policy = registerNodeInvokePolicy.mock.calls[0]?.[0];
76
+ await expect(
77
+ policy.handle({
78
+ nodeId: "node-1",
79
+ command: "file.fetch",
80
+ params: { path: "/tmp/a.txt" },
81
+ config: {},
82
+ pluginConfig: {},
83
+ client: null,
84
+ invokeNode,
85
+ }),
86
+ ).resolves.toMatchObject({
87
+ ok: false,
88
+ code: "PLUGIN_POLICY_UNAVAILABLE",
89
+ unavailable: true,
90
+ });
91
+ expect(invokeNode).not.toHaveBeenCalled();
92
+ });
93
+ });
package/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ // File Transfer plugin entrypoint registers its ACTAgent integration.
2
+ import {
3
+ definePluginEntry,
4
+ type AnyAgentTool,
5
+ type ACTAgentPluginNodeHostCommand,
6
+ } from "actagent/plugin-sdk/plugin-entry";
7
+ import { createLazyFileTransferNodeInvokePolicy } from "./src/shared/lazy-node-invoke-policy.js";
8
+ import {
9
+ DIR_FETCH_TOOL_DESCRIPTOR,
10
+ DIR_LIST_TOOL_DESCRIPTOR,
11
+ FILE_FETCH_TOOL_DESCRIPTOR,
12
+ FILE_WRITE_TOOL_DESCRIPTOR,
13
+ } from "./src/tools/descriptors.js";
14
+
15
+ type FileTransferToolDescriptor = Pick<
16
+ AnyAgentTool,
17
+ "label" | "name" | "description" | "parameters"
18
+ >;
19
+
20
+ function readNodeCommandParams(paramsJSON: string | null | undefined): unknown {
21
+ return paramsJSON ? JSON.parse(paramsJSON) : {};
22
+ }
23
+
24
+ function createLazyTool(
25
+ descriptor: FileTransferToolDescriptor,
26
+ loadTool: () => Promise<AnyAgentTool>,
27
+ ): AnyAgentTool {
28
+ let toolPromise: Promise<AnyAgentTool> | undefined;
29
+ const loadOnce = () => {
30
+ toolPromise ??= loadTool();
31
+ return toolPromise;
32
+ };
33
+ return {
34
+ ...descriptor,
35
+ async execute(toolCallId, args, signal, onUpdate) {
36
+ const tool = await loadOnce();
37
+ return await tool.execute(toolCallId, args, signal, onUpdate);
38
+ },
39
+ };
40
+ }
41
+
42
+ const fileTransferNodeHostCommands: ACTAgentPluginNodeHostCommand[] = [
43
+ {
44
+ command: "file.fetch",
45
+ cap: "file",
46
+ dangerous: true,
47
+ handle: async (paramsJSON) => {
48
+ const { handleFileFetch } = await import("./src/node-host/file-fetch.js");
49
+ const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleFileFetch>[0];
50
+ const result = await handleFileFetch(params);
51
+ return JSON.stringify(result);
52
+ },
53
+ },
54
+ {
55
+ command: "dir.list",
56
+ cap: "file",
57
+ dangerous: true,
58
+ handle: async (paramsJSON) => {
59
+ const { handleDirList } = await import("./src/node-host/dir-list.js");
60
+ const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleDirList>[0];
61
+ const result = await handleDirList(params);
62
+ return JSON.stringify(result);
63
+ },
64
+ },
65
+ {
66
+ command: "dir.fetch",
67
+ cap: "file",
68
+ dangerous: true,
69
+ handle: async (paramsJSON) => {
70
+ const { handleDirFetch } = await import("./src/node-host/dir-fetch.js");
71
+ const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleDirFetch>[0];
72
+ const result = await handleDirFetch(params);
73
+ return JSON.stringify(result);
74
+ },
75
+ },
76
+ {
77
+ command: "file.write",
78
+ cap: "file",
79
+ dangerous: true,
80
+ handle: async (paramsJSON) => {
81
+ const { handleFileWrite } = await import("./src/node-host/file-write.js");
82
+ const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleFileWrite>[0];
83
+ const result = await handleFileWrite(params);
84
+ return JSON.stringify(result);
85
+ },
86
+ },
87
+ ];
88
+
89
+ export default definePluginEntry({
90
+ id: "file-transfer",
91
+ name: "File Transfer",
92
+ description: "Fetch, list, and write files on paired nodes via dedicated node commands.",
93
+ nodeHostCommands: fileTransferNodeHostCommands,
94
+ register(api) {
95
+ api.registerNodeInvokePolicy(createLazyFileTransferNodeInvokePolicy());
96
+ api.registerTool(
97
+ createLazyTool(FILE_FETCH_TOOL_DESCRIPTOR, async () => {
98
+ const { createFileFetchTool } = await import("./src/tools/file-fetch-tool.js");
99
+ return createFileFetchTool();
100
+ }),
101
+ );
102
+ api.registerTool(
103
+ createLazyTool(DIR_LIST_TOOL_DESCRIPTOR, async () => {
104
+ const { createDirListTool } = await import("./src/tools/dir-list-tool.js");
105
+ return createDirListTool();
106
+ }),
107
+ );
108
+ api.registerTool(
109
+ createLazyTool(DIR_FETCH_TOOL_DESCRIPTOR, async () => {
110
+ const { createDirFetchTool } = await import("./src/tools/dir-fetch-tool.js");
111
+ return createDirFetchTool();
112
+ }),
113
+ );
114
+ api.registerTool(
115
+ createLazyTool(FILE_WRITE_TOOL_DESCRIPTOR, async () => {
116
+ const { createFileWriteTool } = await import("./src/tools/file-write-tool.js");
117
+ return createFileWriteTool();
118
+ }),
119
+ );
120
+ },
121
+ });
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@actagent/file-transfer",
3
+ "version": "2026.6.2",
4
+ "description": "ACTAgent file transfer plugin (file_fetch, dir_list, dir_fetch, file_write)",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "minimatch": "10.2.5",
8
+ "typebox": "1.1.39"
9
+ },
10
+ "devDependencies": {
11
+ "@actagent/plugin-sdk": "workspace:*"
12
+ },
13
+ "actagent": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,131 @@
1
+ // File Transfer tests cover dir fetch plugin behavior.
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { handleDirFetch } from "./dir-fetch.js";
8
+
9
+ let tmpRoot: string;
10
+
11
+ beforeEach(async () => {
12
+ // realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
13
+ tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-fetch-test-")));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(tmpRoot, { recursive: true, force: true });
18
+ });
19
+
20
+ // dir-fetch shells out to /usr/bin/tar. Skip the body of these tests on
21
+ // platforms without it (Windows CI). They still register, just no-op.
22
+ const HAS_TAR = process.platform !== "win32";
23
+
24
+ async function expectDirFetchError(input: Parameters<typeof handleDirFetch>[0], code: string) {
25
+ const result = await handleDirFetch(input);
26
+ if (result.ok) {
27
+ throw new Error("expected directory fetch error");
28
+ }
29
+ expect(result.code).toBe(code);
30
+ }
31
+
32
+ describe("handleDirFetch — input validation", () => {
33
+ it("rejects empty / non-string path", async () => {
34
+ await expectDirFetchError({ path: "" }, "INVALID_PATH");
35
+ });
36
+
37
+ it("rejects relative paths", async () => {
38
+ await expectDirFetchError({ path: "relative" }, "INVALID_PATH");
39
+ });
40
+
41
+ it("rejects paths with NUL bytes", async () => {
42
+ await expectDirFetchError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
43
+ });
44
+ });
45
+
46
+ describe("handleDirFetch — fs errors", () => {
47
+ it.runIf(HAS_TAR)("returns NOT_FOUND for a missing directory", async () => {
48
+ await expectDirFetchError({ path: path.join(tmpRoot, "missing") }, "NOT_FOUND");
49
+ });
50
+
51
+ it.runIf(HAS_TAR)("returns IS_FILE when path resolves to a file", async () => {
52
+ const f = path.join(tmpRoot, "f.txt");
53
+ await fs.writeFile(f, "x");
54
+ await expectDirFetchError({ path: f }, "IS_FILE");
55
+ });
56
+ });
57
+
58
+ describe("handleDirFetch — happy path", () => {
59
+ it("preflights directory entries without creating a tarball", async () => {
60
+ await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
61
+ await fs.mkdir(path.join(tmpRoot, ".ssh"));
62
+ await fs.writeFile(path.join(tmpRoot, ".ssh", "id_rsa"), "secret\n");
63
+ await fs.mkdir(path.join(tmpRoot, "sub"));
64
+ await fs.writeFile(path.join(tmpRoot, "sub", "b.txt"), "beta\n");
65
+
66
+ const r = await handleDirFetch({ path: tmpRoot, preflightOnly: true });
67
+ if (!r.ok) {
68
+ throw new Error(`expected ok, got ${r.code}: ${r.message}`);
69
+ }
70
+
71
+ expect(r.path).toBe(tmpRoot);
72
+ expect(r.tarBase64).toBe("");
73
+ expect(r.tarBytes).toBe(0);
74
+ expect(r.sha256).toBe("");
75
+ expect(r.preflightOnly).toBe(true);
76
+ expect(r.entries).toEqual([".ssh", ".ssh/id_rsa", "a.txt", "sub", "sub/b.txt"]);
77
+ expect(r.fileCount).toBe(r.entries?.length);
78
+ });
79
+
80
+ it.runIf(HAS_TAR)("returns a gzipped tar with byte count and sha256", async () => {
81
+ await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
82
+ await fs.writeFile(path.join(tmpRoot, "b.txt"), "beta\n");
83
+ await fs.mkdir(path.join(tmpRoot, "sub"));
84
+ await fs.writeFile(path.join(tmpRoot, "sub", "c.txt"), "gamma\n");
85
+
86
+ const r = await handleDirFetch({ path: tmpRoot });
87
+ if (!r.ok) {
88
+ throw new Error(`expected ok, got ${r.code}: ${r.message}`);
89
+ }
90
+
91
+ expect(r.tarBytes).toBeGreaterThan(0);
92
+ expect(r.tarBase64.length).toBeGreaterThan(0);
93
+
94
+ const buf = Buffer.from(r.tarBase64, "base64");
95
+ expect(buf.byteLength).toBe(r.tarBytes);
96
+
97
+ const expectedSha = crypto.createHash("sha256").update(buf).digest("hex");
98
+ expect(r.sha256).toBe(expectedSha);
99
+
100
+ // gzip magic bytes
101
+ expect(buf[0]).toBe(0x1f);
102
+ expect(buf[1]).toBe(0x8b);
103
+
104
+ // file count covers the regular files we created (3); BSD tar may also
105
+ // list directory entries, so be generous.
106
+ expect(r.fileCount).toBeGreaterThanOrEqual(3);
107
+ expect(r.entries).toContain("a.txt");
108
+ expect(r.entries).toContain("b.txt");
109
+ expect(r.entries).toContain("sub");
110
+ expect(r.entries).toContain("sub/c.txt");
111
+ expect(r.fileCount).toBe(r.entries?.length);
112
+ });
113
+ });
114
+
115
+ describe("handleDirFetch — size cap", () => {
116
+ it.runIf(HAS_TAR)(
117
+ "returns TREE_TOO_LARGE when content exceeds the cap mid-stream",
118
+ async () => {
119
+ // Write enough random content to exceed a small maxBytes. Random bytes
120
+ // don't compress, so gzip output is roughly the same size as input.
121
+ const big = crypto.randomBytes(512 * 1024);
122
+ await fs.writeFile(path.join(tmpRoot, "big1.bin"), big);
123
+ await fs.writeFile(path.join(tmpRoot, "big2.bin"), big);
124
+ await fs.writeFile(path.join(tmpRoot, "big3.bin"), big);
125
+
126
+ // 64KB cap should trip either the du preflight or the streaming SIGTERM.
127
+ await expectDirFetchError({ path: tmpRoot, maxBytes: 64 * 1024 }, "TREE_TOO_LARGE");
128
+ },
129
+ 30_000,
130
+ );
131
+ });