@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,112 @@
|
|
|
1
|
+
// File Transfer plugin module implements path errors behavior.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FsSafeError, resolveAbsolutePathForRead } from "actagent/plugin-sdk/security-runtime";
|
|
5
|
+
|
|
6
|
+
export type InvalidPathResult = {
|
|
7
|
+
ok: false;
|
|
8
|
+
code: "INVALID_PATH";
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const SYMLINK_REJECTED_MESSAGE =
|
|
13
|
+
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)";
|
|
14
|
+
|
|
15
|
+
export type FsSafeReadErrorCode = "INVALID_PATH" | "NOT_FOUND" | "SYMLINK_REDIRECT";
|
|
16
|
+
|
|
17
|
+
export function classifyFsSafeReadError(err: unknown): FsSafeReadErrorCode | undefined {
|
|
18
|
+
if (!(err instanceof FsSafeError)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (err.code === "not-found") {
|
|
22
|
+
return "NOT_FOUND";
|
|
23
|
+
}
|
|
24
|
+
if (err.code === "symlink") {
|
|
25
|
+
return "SYMLINK_REDIRECT";
|
|
26
|
+
}
|
|
27
|
+
if (err.code === "invalid-path") {
|
|
28
|
+
return "INVALID_PATH";
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readAbsolutePath(input: unknown): string | InvalidPathResult {
|
|
34
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
35
|
+
return { ok: false, code: "INVALID_PATH", message: "path required" };
|
|
36
|
+
}
|
|
37
|
+
if (input.includes("\0")) {
|
|
38
|
+
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
|
|
39
|
+
}
|
|
40
|
+
if (!path.isAbsolute(input)) {
|
|
41
|
+
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
|
|
42
|
+
}
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function canonicalPathFromFsSafeError(err: unknown): string | undefined {
|
|
47
|
+
if (!(err instanceof FsSafeError) || !err.cause || typeof err.cause !== "object") {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return "canonicalPath" in err.cause && typeof err.cause.canonicalPath === "string"
|
|
51
|
+
? err.cause.canonicalPath
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function resolveCanonicalReadPath<Code extends string>(input: {
|
|
56
|
+
classifyError: (err: unknown) => Code;
|
|
57
|
+
followSymlinks: boolean;
|
|
58
|
+
notFoundMessage: string;
|
|
59
|
+
requestedPath: string;
|
|
60
|
+
}): Promise<string | { ok: false; code: Code; message: string; canonicalPath?: string }> {
|
|
61
|
+
try {
|
|
62
|
+
return (
|
|
63
|
+
await resolveAbsolutePathForRead(input.requestedPath, {
|
|
64
|
+
symlinks: input.followSymlinks ? "follow" : "reject",
|
|
65
|
+
})
|
|
66
|
+
).canonicalPath;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const code = input.classifyError(err);
|
|
69
|
+
const canonicalPath = canonicalPathFromFsSafeError(err);
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code,
|
|
73
|
+
message:
|
|
74
|
+
code === "NOT_FOUND"
|
|
75
|
+
? input.notFoundMessage
|
|
76
|
+
: code === "SYMLINK_REDIRECT"
|
|
77
|
+
? SYMLINK_REJECTED_MESSAGE
|
|
78
|
+
: `realpath failed: ${String(err)}`,
|
|
79
|
+
...(canonicalPath ? { canonicalPath } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function statRequiredDirectory<Code extends string>(
|
|
85
|
+
canonicalPath: string,
|
|
86
|
+
classifyError: (err: unknown) => Code,
|
|
87
|
+
): Promise<
|
|
88
|
+
{ ok: true } | { ok: false; code: Code | "IS_FILE"; message: string; canonicalPath: string }
|
|
89
|
+
> {
|
|
90
|
+
let stats: Awaited<ReturnType<typeof fs.stat>>;
|
|
91
|
+
try {
|
|
92
|
+
stats = await fs.stat(canonicalPath);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const code = classifyError(err);
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code,
|
|
98
|
+
message: `stat failed: ${String(err)}`,
|
|
99
|
+
canonicalPath,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!stats.isDirectory()) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
code: "IS_FILE",
|
|
107
|
+
message: "path is not a directory",
|
|
108
|
+
canonicalPath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Append-only audit log for file-transfer operations.
|
|
2
|
+
//
|
|
3
|
+
// Records every decision (allow/deny/error) at the gateway-side tool
|
|
4
|
+
// layer. Lands at ~/.actagent/audit/file-transfer.jsonl. Rotation is
|
|
5
|
+
// caller's responsibility — the file grows unbounded.
|
|
6
|
+
//
|
|
7
|
+
// Log records do NOT include file contents or hashes of secrets. They do
|
|
8
|
+
// include canonical paths and sha256 of the payload, so treat the audit
|
|
9
|
+
// file as sensitive.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { appendRegularFile } from "actagent/plugin-sdk/security-runtime";
|
|
15
|
+
|
|
16
|
+
export type FileTransferAuditOp = "file.fetch" | "dir.list" | "dir.fetch" | "file.write";
|
|
17
|
+
|
|
18
|
+
type FileTransferAuditDecision =
|
|
19
|
+
| "allowed"
|
|
20
|
+
| "allowed:once"
|
|
21
|
+
| "allowed:always"
|
|
22
|
+
| "denied:no_policy"
|
|
23
|
+
| "denied:policy"
|
|
24
|
+
| "denied:approval"
|
|
25
|
+
| "denied:command_not_allowed"
|
|
26
|
+
| "denied:symlink_escape"
|
|
27
|
+
| "error";
|
|
28
|
+
|
|
29
|
+
type FileTransferAuditRecord = {
|
|
30
|
+
timestamp: string;
|
|
31
|
+
op: FileTransferAuditOp;
|
|
32
|
+
nodeId: string;
|
|
33
|
+
nodeDisplayName?: string;
|
|
34
|
+
requestedPath: string;
|
|
35
|
+
canonicalPath?: string;
|
|
36
|
+
decision: FileTransferAuditDecision;
|
|
37
|
+
errorCode?: string;
|
|
38
|
+
errorMessage?: string;
|
|
39
|
+
sizeBytes?: number;
|
|
40
|
+
sha256?: string;
|
|
41
|
+
durationMs?: number;
|
|
42
|
+
// Tying back to the agent that initiated the op
|
|
43
|
+
requesterAgentId?: string;
|
|
44
|
+
sessionKey?: string;
|
|
45
|
+
// Reason text for denials
|
|
46
|
+
reason?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let auditDirPromise: Promise<string> | null = null;
|
|
50
|
+
|
|
51
|
+
async function ensureAuditDir(): Promise<string> {
|
|
52
|
+
if (auditDirPromise) {
|
|
53
|
+
return auditDirPromise;
|
|
54
|
+
}
|
|
55
|
+
const promise = (async () => {
|
|
56
|
+
const dir = path.join(os.homedir(), ".actagent", "audit");
|
|
57
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
58
|
+
return dir;
|
|
59
|
+
})();
|
|
60
|
+
// If the mkdir rejects (transient permission error etc.), clear the
|
|
61
|
+
// cached singleton so the NEXT call retries instead of permanently
|
|
62
|
+
// silencing the audit log.
|
|
63
|
+
promise.catch(() => {
|
|
64
|
+
if (auditDirPromise === promise) {
|
|
65
|
+
auditDirPromise = null;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
auditDirPromise = promise;
|
|
69
|
+
return promise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function auditFilePath(dir: string): string {
|
|
73
|
+
return path.join(dir, "file-transfer.jsonl");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Append an audit record. Best-effort — failures are logged to stderr and
|
|
78
|
+
* never propagated to the caller (the caller's operation is the source of
|
|
79
|
+
* truth, not the audit write).
|
|
80
|
+
*/
|
|
81
|
+
export async function appendFileTransferAudit(
|
|
82
|
+
record: Omit<FileTransferAuditRecord, "timestamp">,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const dir = await ensureAuditDir();
|
|
86
|
+
const line = `${JSON.stringify({
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
...record,
|
|
89
|
+
})}\n`;
|
|
90
|
+
await appendRegularFile({
|
|
91
|
+
filePath: auditFilePath(dir),
|
|
92
|
+
content: line,
|
|
93
|
+
rejectSymlinkParents: true,
|
|
94
|
+
});
|
|
95
|
+
} catch (e) {
|
|
96
|
+
process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// File Transfer tests cover errors plugin behavior.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { classifyFsError, err, throwFromNodePayload } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
describe("err", () => {
|
|
6
|
+
it("returns an error envelope without canonicalPath when omitted", () => {
|
|
7
|
+
const e = err("INVALID_PATH", "path required");
|
|
8
|
+
expect(e).toEqual({ ok: false, code: "INVALID_PATH", message: "path required" });
|
|
9
|
+
expect("canonicalPath" in e).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("includes canonicalPath only when provided non-empty", () => {
|
|
13
|
+
const withPath = err("NOT_FOUND", "missing", "/tmp/x");
|
|
14
|
+
expect(withPath.canonicalPath).toBe("/tmp/x");
|
|
15
|
+
|
|
16
|
+
const blankPath = err("NOT_FOUND", "missing", "");
|
|
17
|
+
expect("canonicalPath" in blankPath).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("classifyFsError", () => {
|
|
22
|
+
it("maps ENOENT to NOT_FOUND", () => {
|
|
23
|
+
expect(classifyFsError({ code: "ENOENT" })).toBe("NOT_FOUND");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("maps EACCES and EPERM to PERMISSION_DENIED", () => {
|
|
27
|
+
expect(classifyFsError({ code: "EACCES" })).toBe("PERMISSION_DENIED");
|
|
28
|
+
expect(classifyFsError({ code: "EPERM" })).toBe("PERMISSION_DENIED");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("maps EISDIR to IS_DIRECTORY", () => {
|
|
32
|
+
expect(classifyFsError({ code: "EISDIR" })).toBe("IS_DIRECTORY");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to READ_ERROR for unknown / null / non-object input", () => {
|
|
36
|
+
expect(classifyFsError({ code: "EUNKNOWN" })).toBe("READ_ERROR");
|
|
37
|
+
expect(classifyFsError(null)).toBe("READ_ERROR");
|
|
38
|
+
expect(classifyFsError(undefined)).toBe("READ_ERROR");
|
|
39
|
+
expect(classifyFsError("nope")).toBe("READ_ERROR");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("throwFromNodePayload", () => {
|
|
44
|
+
it("preserves code and message in the thrown Error", () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
throwFromNodePayload("file.fetch", { code: "NOT_FOUND", message: "file not found" }),
|
|
47
|
+
).toThrow(/file\.fetch NOT_FOUND: file not found/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("appends canonicalPath when present", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
throwFromNodePayload("file.fetch", {
|
|
53
|
+
code: "POLICY_DENIED",
|
|
54
|
+
message: "blocked",
|
|
55
|
+
canonicalPath: "/tmp/x",
|
|
56
|
+
}),
|
|
57
|
+
).toThrow(/canonical=\/tmp\/x/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to ERROR / generic message when fields are missing", () => {
|
|
61
|
+
expect(() => throwFromNodePayload("dir.list", {})).toThrow(/dir\.list ERROR: dir\.list failed/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shared error code surface across the four file-transfer tools/handlers.
|
|
2
|
+
// Every tool returns the same { ok: false, code, message, canonicalPath? }
|
|
3
|
+
// shape so the model can reason about errors uniformly.
|
|
4
|
+
|
|
5
|
+
type FileTransferErrCode =
|
|
6
|
+
// Path-shape errors (caller's fault)
|
|
7
|
+
| "INVALID_PATH"
|
|
8
|
+
| "INVALID_BASE64"
|
|
9
|
+
| "INVALID_PARAMS"
|
|
10
|
+
// Filesystem errors (file/dir layer)
|
|
11
|
+
| "NOT_FOUND"
|
|
12
|
+
| "PERMISSION_DENIED"
|
|
13
|
+
| "IS_DIRECTORY"
|
|
14
|
+
| "IS_FILE"
|
|
15
|
+
| "PARENT_NOT_FOUND"
|
|
16
|
+
| "EXISTS_NO_OVERWRITE"
|
|
17
|
+
| "READ_ERROR"
|
|
18
|
+
| "WRITE_ERROR"
|
|
19
|
+
// Size/limit errors
|
|
20
|
+
| "FILE_TOO_LARGE"
|
|
21
|
+
| "TREE_TOO_LARGE"
|
|
22
|
+
// Safety errors
|
|
23
|
+
| "PATH_TRAVERSAL"
|
|
24
|
+
| "SYMLINK_TARGET_DENIED"
|
|
25
|
+
| "INTEGRITY_FAILURE"
|
|
26
|
+
// Policy errors (gateway-side)
|
|
27
|
+
| "POLICY_DENIED"
|
|
28
|
+
| "NO_POLICY";
|
|
29
|
+
|
|
30
|
+
type FileTransferErr = {
|
|
31
|
+
ok: false;
|
|
32
|
+
code: FileTransferErrCode;
|
|
33
|
+
message: string;
|
|
34
|
+
canonicalPath?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function err(
|
|
38
|
+
code: FileTransferErrCode,
|
|
39
|
+
message: string,
|
|
40
|
+
canonicalPath?: string,
|
|
41
|
+
): FileTransferErr {
|
|
42
|
+
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Translate a node-side fs error to a public error code.
|
|
46
|
+
export function classifyFsError(e: unknown): FileTransferErrCode {
|
|
47
|
+
const code = (e as { code?: string } | null)?.code;
|
|
48
|
+
if (code === "ENOENT") {
|
|
49
|
+
return "NOT_FOUND";
|
|
50
|
+
}
|
|
51
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
52
|
+
return "PERMISSION_DENIED";
|
|
53
|
+
}
|
|
54
|
+
if (code === "EISDIR") {
|
|
55
|
+
return "IS_DIRECTORY";
|
|
56
|
+
}
|
|
57
|
+
return "READ_ERROR";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Convert a node-host error payload to a thrown Error for agent-tool consumption.
|
|
61
|
+
// The agent-tool surfaces these as failed tool results uniformly.
|
|
62
|
+
export function throwFromNodePayload(operation: string, payload: Record<string, unknown>): never {
|
|
63
|
+
const code = typeof payload.code === "string" ? payload.code : "ERROR";
|
|
64
|
+
const message = typeof payload.message === "string" ? payload.message : `${operation} failed`;
|
|
65
|
+
const canonical =
|
|
66
|
+
typeof payload.canonicalPath === "string" ? ` (canonical=${payload.canonicalPath})` : "";
|
|
67
|
+
throw new Error(`${operation} ${code}: ${message}${canonical}`);
|
|
68
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// File Transfer tests cover lazy node invoke policy plugin behavior.
|
|
2
|
+
import type {
|
|
3
|
+
ACTAgentPluginNodeInvokePolicy,
|
|
4
|
+
ACTAgentPluginNodeInvokePolicyContext,
|
|
5
|
+
} from "actagent/plugin-sdk/plugin-entry";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { createLazyFileTransferNodeInvokePolicy } from "./lazy-node-invoke-policy.js";
|
|
8
|
+
|
|
9
|
+
function createPolicyContext(
|
|
10
|
+
overrides: Partial<ACTAgentPluginNodeInvokePolicyContext> = {},
|
|
11
|
+
): ACTAgentPluginNodeInvokePolicyContext {
|
|
12
|
+
return {
|
|
13
|
+
nodeId: "node-1",
|
|
14
|
+
command: "file.fetch",
|
|
15
|
+
params: { path: "/tmp/a.txt" },
|
|
16
|
+
config: {} as never,
|
|
17
|
+
pluginConfig: {},
|
|
18
|
+
node: {
|
|
19
|
+
nodeId: "node-1",
|
|
20
|
+
displayName: "Test Node",
|
|
21
|
+
commands: ["file.fetch"],
|
|
22
|
+
},
|
|
23
|
+
client: null,
|
|
24
|
+
invokeNode: vi.fn<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
|
|
25
|
+
ok: true,
|
|
26
|
+
payload: { ok: true },
|
|
27
|
+
payloadJSON: null,
|
|
28
|
+
})),
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("lazy file-transfer node invoke policy", () => {
|
|
34
|
+
it("exposes command metadata without loading the delegate", () => {
|
|
35
|
+
const loadPolicy = vi.fn<() => Promise<ACTAgentPluginNodeInvokePolicy>>();
|
|
36
|
+
|
|
37
|
+
const policy = createLazyFileTransferNodeInvokePolicy(loadPolicy);
|
|
38
|
+
|
|
39
|
+
expect(policy.commands).toEqual(["file.fetch", "dir.list", "dir.fetch", "file.write"]);
|
|
40
|
+
expect(loadPolicy).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("loads and caches the delegate on first handle", async () => {
|
|
44
|
+
const invokeNode = vi.fn<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
|
|
45
|
+
ok: true,
|
|
46
|
+
payload: { ok: true },
|
|
47
|
+
payloadJSON: null,
|
|
48
|
+
}));
|
|
49
|
+
const delegateHandle = vi.fn<ACTAgentPluginNodeInvokePolicy["handle"]>(async (ctx) => {
|
|
50
|
+
await ctx.invokeNode();
|
|
51
|
+
return { ok: true, payload: { delegated: true } };
|
|
52
|
+
});
|
|
53
|
+
const loadPolicy = vi.fn<() => Promise<ACTAgentPluginNodeInvokePolicy>>(async () => ({
|
|
54
|
+
commands: ["file.fetch"],
|
|
55
|
+
handle: delegateHandle,
|
|
56
|
+
}));
|
|
57
|
+
const policy = createLazyFileTransferNodeInvokePolicy(loadPolicy);
|
|
58
|
+
|
|
59
|
+
await expect(policy.handle(createPolicyContext({ invokeNode }))).resolves.toEqual({
|
|
60
|
+
ok: true,
|
|
61
|
+
payload: { delegated: true },
|
|
62
|
+
});
|
|
63
|
+
await expect(policy.handle(createPolicyContext({ invokeNode }))).resolves.toEqual({
|
|
64
|
+
ok: true,
|
|
65
|
+
payload: { delegated: true },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(loadPolicy).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(delegateHandle).toHaveBeenCalledTimes(2);
|
|
70
|
+
expect(invokeNode).toHaveBeenCalledTimes(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails closed when the delegate cannot load", async () => {
|
|
74
|
+
const invokeNode = vi.fn<ACTAgentPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
|
|
75
|
+
ok: true,
|
|
76
|
+
payload: { ok: true },
|
|
77
|
+
payloadJSON: null,
|
|
78
|
+
}));
|
|
79
|
+
const policy = createLazyFileTransferNodeInvokePolicy(async () => {
|
|
80
|
+
throw new Error("load failed");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await expect(policy.handle(createPolicyContext({ invokeNode }))).resolves.toMatchObject({
|
|
84
|
+
ok: false,
|
|
85
|
+
code: "PLUGIN_POLICY_UNAVAILABLE",
|
|
86
|
+
unavailable: true,
|
|
87
|
+
});
|
|
88
|
+
expect(invokeNode).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not rewrite delegate failures as load failures", async () => {
|
|
92
|
+
const delegateError = new Error("delegate failed");
|
|
93
|
+
const policy = createLazyFileTransferNodeInvokePolicy(async () => ({
|
|
94
|
+
commands: ["file.fetch"],
|
|
95
|
+
handle: async () => {
|
|
96
|
+
throw delegateError;
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
await expect(policy.handle(createPolicyContext())).rejects.toBe(delegateError);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// File Transfer plugin module implements lazy node invoke policy behavior.
|
|
2
|
+
import type { ACTAgentPluginNodeInvokePolicy } from "actagent/plugin-sdk/plugin-entry";
|
|
3
|
+
import { FILE_TRANSFER_NODE_INVOKE_COMMANDS } from "./node-invoke-policy-commands.js";
|
|
4
|
+
|
|
5
|
+
type LoadFileTransferNodeInvokePolicy = () => Promise<ACTAgentPluginNodeInvokePolicy>;
|
|
6
|
+
|
|
7
|
+
const loadFileTransferNodeInvokePolicy: LoadFileTransferNodeInvokePolicy = async () => {
|
|
8
|
+
const { createFileTransferNodeInvokePolicy } = await import("./node-invoke-policy.js");
|
|
9
|
+
return createFileTransferNodeInvokePolicy();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createLazyFileTransferNodeInvokePolicy(
|
|
13
|
+
loadPolicy: LoadFileTransferNodeInvokePolicy = loadFileTransferNodeInvokePolicy,
|
|
14
|
+
): ACTAgentPluginNodeInvokePolicy {
|
|
15
|
+
let policyPromise: Promise<ACTAgentPluginNodeInvokePolicy> | undefined;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
commands: [...FILE_TRANSFER_NODE_INVOKE_COMMANDS],
|
|
19
|
+
async handle(ctx) {
|
|
20
|
+
let policy: ACTAgentPluginNodeInvokePolicy;
|
|
21
|
+
try {
|
|
22
|
+
policyPromise ??= loadPolicy();
|
|
23
|
+
policy = await policyPromise;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const message = error instanceof Error && error.message ? error.message : String(error);
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
code: "PLUGIN_POLICY_UNAVAILABLE",
|
|
29
|
+
message: `file-transfer PLUGIN_POLICY_UNAVAILABLE: node.invoke policy unavailable: ${message}`,
|
|
30
|
+
unavailable: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return await policy.handle(ctx);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// File Transfer tests cover mime plugin behavior.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
IMAGE_MIME_INLINE_SET,
|
|
5
|
+
TEXT_INLINE_MAX_BYTES,
|
|
6
|
+
TEXT_INLINE_MIME_SET,
|
|
7
|
+
mimeFromExtension,
|
|
8
|
+
} from "./mime.js";
|
|
9
|
+
|
|
10
|
+
describe("mimeFromExtension", () => {
|
|
11
|
+
it("returns the mapped mime for known extensions", () => {
|
|
12
|
+
expect(mimeFromExtension("foo.png")).toBe("image/png");
|
|
13
|
+
expect(mimeFromExtension("/abs/path/bar.JPG")).toBe("image/jpeg");
|
|
14
|
+
expect(mimeFromExtension("doc.pdf")).toBe("application/pdf");
|
|
15
|
+
expect(mimeFromExtension("notes.md")).toBe("text/markdown");
|
|
16
|
+
expect(mimeFromExtension("trace.log")).toBe("text/plain");
|
|
17
|
+
expect(mimeFromExtension("bitmap.bmp")).toBe("image/bmp");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to application/octet-stream for unknown extensions", () => {
|
|
21
|
+
expect(mimeFromExtension("blob.xyz")).toBe("application/octet-stream");
|
|
22
|
+
expect(mimeFromExtension("Makefile")).toBe("application/octet-stream");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is case-insensitive on the extension", () => {
|
|
26
|
+
expect(mimeFromExtension("foo.PNG")).toBe("image/png");
|
|
27
|
+
expect(mimeFromExtension("foo.WeBp")).toBe("image/webp");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("MIME constants", () => {
|
|
32
|
+
it("EXTENSION_MIME includes the v1 image set", () => {
|
|
33
|
+
expect(mimeFromExtension("image.png")).toBe("image/png");
|
|
34
|
+
expect(mimeFromExtension("image.jpg")).toBe("image/jpeg");
|
|
35
|
+
expect(mimeFromExtension("image.jpeg")).toBe("image/jpeg");
|
|
36
|
+
expect(mimeFromExtension("image.webp")).toBe("image/webp");
|
|
37
|
+
expect(mimeFromExtension("image.gif")).toBe("image/gif");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("IMAGE_MIME_INLINE_SET is the inline-renderable image set", () => {
|
|
41
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/png")).toBe(true);
|
|
42
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/jpeg")).toBe(true);
|
|
43
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/webp")).toBe(true);
|
|
44
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/gif")).toBe(true);
|
|
45
|
+
// heic/heif intentionally excluded
|
|
46
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/heic")).toBe(false);
|
|
47
|
+
expect(IMAGE_MIME_INLINE_SET.has("image/heif")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("TEXT_INLINE_MIME_SET covers small-text inlining types", () => {
|
|
51
|
+
expect(TEXT_INLINE_MIME_SET.has("text/plain")).toBe(true);
|
|
52
|
+
expect(TEXT_INLINE_MIME_SET.has("text/markdown")).toBe(true);
|
|
53
|
+
expect(TEXT_INLINE_MIME_SET.has("application/json")).toBe(true);
|
|
54
|
+
expect(TEXT_INLINE_MIME_SET.has("text/csv")).toBe(true);
|
|
55
|
+
expect(TEXT_INLINE_MIME_SET.has("text/xml")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("TEXT_INLINE_MAX_BYTES is the documented 8KB cap", () => {
|
|
59
|
+
expect(TEXT_INLINE_MAX_BYTES).toBe(8 * 1024);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// File Transfer plugin module implements mime behavior.
|
|
2
|
+
import { mimeTypeFromFilePath } from "actagent/plugin-sdk/media-mime";
|
|
3
|
+
|
|
4
|
+
// MIME types we treat as inline-displayable images for vision-capable models.
|
|
5
|
+
// Note: heic/heif are detectable but not all providers can render them, so we
|
|
6
|
+
// leave them out of the inline-image set and let them flow as text+saved-path.
|
|
7
|
+
export const IMAGE_MIME_INLINE_SET = new Set([
|
|
8
|
+
"image/png",
|
|
9
|
+
"image/jpeg",
|
|
10
|
+
"image/webp",
|
|
11
|
+
"image/gif",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Plain-text MIME types where inlining the content into a text block is more
|
|
15
|
+
// useful than a "saved at <path>" stub for small files (under TEXT_INLINE_MAX).
|
|
16
|
+
export const TEXT_INLINE_MIME_SET = new Set([
|
|
17
|
+
"text/plain",
|
|
18
|
+
"text/markdown",
|
|
19
|
+
"text/csv",
|
|
20
|
+
"text/html",
|
|
21
|
+
"application/json",
|
|
22
|
+
"application/xml",
|
|
23
|
+
"text/xml",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const TEXT_INLINE_MAX_BYTES = 8 * 1024;
|
|
27
|
+
|
|
28
|
+
export function mimeFromExtension(filePath: string): string {
|
|
29
|
+
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// File Transfer plugin module implements node invoke policy commands behavior.
|
|
2
|
+
export const FILE_TRANSFER_NODE_INVOKE_COMMANDS = [
|
|
3
|
+
"file.fetch",
|
|
4
|
+
"dir.list",
|
|
5
|
+
"dir.fetch",
|
|
6
|
+
"file.write",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type FileTransferNodeInvokeCommand = (typeof FILE_TRANSFER_NODE_INVOKE_COMMANDS)[number];
|