perf(file-transfer): lazy-load runtime handlers

This commit is contained in:
Peter Steinberger
2026-05-02 15:12:10 +01:00
parent 3a52e95473
commit 91bb76d8b9
7 changed files with 285 additions and 155 deletions

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import pluginEntry from "./index.js";
function rejectRuntimeImport(moduleName: string) {
return () => {
throw new Error(`${moduleName} imported during descriptor registration`);
};
}
vi.mock("./src/node-host/file-fetch.js", rejectRuntimeImport("node-host/file-fetch"));
vi.mock("./src/node-host/dir-list.js", rejectRuntimeImport("node-host/dir-list"));
vi.mock("./src/node-host/dir-fetch.js", rejectRuntimeImport("node-host/dir-fetch"));
vi.mock("./src/node-host/file-write.js", rejectRuntimeImport("node-host/file-write"));
vi.mock("./src/tools/file-fetch-tool.js", rejectRuntimeImport("tools/file-fetch-tool"));
vi.mock("./src/tools/dir-list-tool.js", rejectRuntimeImport("tools/dir-list-tool"));
vi.mock("./src/tools/dir-fetch-tool.js", rejectRuntimeImport("tools/dir-fetch-tool"));
vi.mock("./src/tools/file-write-tool.js", rejectRuntimeImport("tools/file-write-tool"));
describe("file-transfer plugin entry", () => {
it("registers static command and tool descriptors without importing runtime handlers", () => {
const registerNodeInvokePolicy = vi.fn();
const registerTool = vi.fn();
pluginEntry.register({
registerNodeInvokePolicy,
registerTool,
} as never);
expect(pluginEntry.nodeHostCommands?.map((entry) => entry.command)).toEqual([
"file.fetch",
"dir.list",
"dir.fetch",
"file.write",
]);
expect(registerNodeInvokePolicy).toHaveBeenCalledTimes(1);
expect(registerTool.mock.calls.map(([tool]) => tool.name)).toEqual([
"file_fetch",
"dir_list",
"dir_fetch",
"file_write",
]);
});
});

View File

@@ -1,16 +1,42 @@
import {
definePluginEntry,
type AnyAgentTool,
type OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import { handleDirFetch } from "./src/node-host/dir-fetch.js";
import { handleDirList } from "./src/node-host/dir-list.js";
import { handleFileFetch } from "./src/node-host/file-fetch.js";
import { handleFileWrite } from "./src/node-host/file-write.js";
import { createFileTransferNodeInvokePolicy } from "./src/shared/node-invoke-policy.js";
import { createDirFetchTool } from "./src/tools/dir-fetch-tool.js";
import { createDirListTool } from "./src/tools/dir-list-tool.js";
import { createFileFetchTool } from "./src/tools/file-fetch-tool.js";
import { createFileWriteTool } from "./src/tools/file-write-tool.js";
import {
DIR_FETCH_TOOL_DESCRIPTOR,
DIR_LIST_TOOL_DESCRIPTOR,
FILE_FETCH_TOOL_DESCRIPTOR,
FILE_WRITE_TOOL_DESCRIPTOR,
} from "./src/tools/descriptors.js";
type FileTransferToolDescriptor = Pick<
AnyAgentTool,
"label" | "name" | "description" | "parameters"
>;
function readNodeCommandParams(paramsJSON: string | null | undefined): unknown {
return paramsJSON ? JSON.parse(paramsJSON) : {};
}
function createLazyTool(
descriptor: FileTransferToolDescriptor,
loadTool: () => Promise<AnyAgentTool>,
): AnyAgentTool {
let toolPromise: Promise<AnyAgentTool> | undefined;
const loadOnce = () => {
toolPromise ??= loadTool();
return toolPromise;
};
return {
...descriptor,
async execute(toolCallId, args, signal, onUpdate) {
const tool = await loadOnce();
return await tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
@@ -18,7 +44,8 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const { handleFileFetch } = await import("./src/node-host/file-fetch.js");
const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleFileFetch>[0];
const result = await handleFileFetch(params);
return JSON.stringify(result);
},
@@ -28,7 +55,8 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const { handleDirList } = await import("./src/node-host/dir-list.js");
const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleDirList>[0];
const result = await handleDirList(params);
return JSON.stringify(result);
},
@@ -38,7 +66,8 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const { handleDirFetch } = await import("./src/node-host/dir-fetch.js");
const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleDirFetch>[0];
const result = await handleDirFetch(params);
return JSON.stringify(result);
},
@@ -48,7 +77,8 @@ const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const { handleFileWrite } = await import("./src/node-host/file-write.js");
const params = readNodeCommandParams(paramsJSON) as Parameters<typeof handleFileWrite>[0];
const result = await handleFileWrite(params);
return JSON.stringify(result);
},
@@ -62,9 +92,29 @@ export default definePluginEntry({
nodeHostCommands: fileTransferNodeHostCommands,
register(api) {
api.registerNodeInvokePolicy(createFileTransferNodeInvokePolicy());
api.registerTool(createFileFetchTool());
api.registerTool(createDirListTool());
api.registerTool(createDirFetchTool());
api.registerTool(createFileWriteTool());
api.registerTool(
createLazyTool(FILE_FETCH_TOOL_DESCRIPTOR, async () => {
const { createFileFetchTool } = await import("./src/tools/file-fetch-tool.js");
return createFileFetchTool();
}),
);
api.registerTool(
createLazyTool(DIR_LIST_TOOL_DESCRIPTOR, async () => {
const { createDirListTool } = await import("./src/tools/dir-list-tool.js");
return createDirListTool();
}),
);
api.registerTool(
createLazyTool(DIR_FETCH_TOOL_DESCRIPTOR, async () => {
const { createDirFetchTool } = await import("./src/tools/dir-fetch-tool.js");
return createDirFetchTool();
}),
);
api.registerTool(
createLazyTool(FILE_WRITE_TOOL_DESCRIPTOR, async () => {
const { createFileWriteTool } = await import("./src/tools/file-write-tool.js");
return createFileWriteTool();
}),
);
},
});

View File

@@ -0,0 +1,149 @@
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "typebox";
type FileTransferToolDescriptor = Pick<
AnyAgentTool,
"label" | "name" | "description" | "parameters"
>;
// Stash fetched files in a non-TTL subdir so follow-up tool calls within
// the same turn can still reference them.
export const FILE_TRANSFER_SUBDIR = "file-transfer";
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
export const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
export const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FILE_WRITE_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FileFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the file on the node. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description: "Max bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export const FILE_FETCH_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
label: "File Fetch",
name: "file_fetch",
description:
"Retrieve a file from a paired node by absolute path. Returns image content blocks for image MIME types, inlines small text files (≤8 KB) as text content, and saves everything else under the gateway media store with a path you can pass to file_write or other tools. Use this for screenshots, photos, receipts, logs, source files. Pair with file_write to copy a file from one node to another (no exec/cp shell-out needed). Requires operator opt-in: gateway.nodes.allowCommands must include 'file.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the path. Without policy configured, every call is denied.",
parameters: FileFetchToolSchema,
};
export const DirListToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node. Canonicalized server-side.",
}),
pageToken: Type.Optional(
Type.String({
description:
"Pagination token from a previous dir_list call. Omit to start from the beginning.",
}),
),
maxEntries: Type.Optional(
Type.Number({
description: `Max entries per page. Default ${DIR_LIST_DEFAULT_MAX_ENTRIES}, hard ceiling ${DIR_LIST_HARD_MAX_ENTRIES}.`,
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export const DIR_LIST_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
label: "Directory List",
name: "dir_list",
description:
"Retrieve a structured directory listing from a paired node. Returns file and subdirectory metadata (name, path, size, mimeType, isDir, mtime) without transferring file content. Use this to discover what files exist before fetching them with file_fetch. Pagination is offset-based; pass nextPageToken from the previous result. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.list' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path. Without policy configured, every call is denied.",
parameters: DirListToolSchema,
};
export const DirFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node to fetch. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description:
"Max gzipped tarball bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
includeDotfiles: Type.Optional(
Type.Boolean({
description: "Reserved for v2; currently always includes dotfiles (v1 quirk in BSD tar).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export const DIR_FETCH_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
label: "Directory Fetch",
name: "dir_fetch",
description:
"Retrieve a directory tree from a paired node as a gzipped tarball, unpack it on the gateway, and return a manifest of saved paths. Use to pull source trees, asset folders, or log directories in a single round-trip. The unpacked files live on the GATEWAY (not your local machine); pass localPath into other tools or use file_fetch on individual entries to ship them elsewhere. Rejects trees larger than 16 MB compressed. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path.",
parameters: DirFetchToolSchema,
};
export const FileWriteToolSchema = Type.Object({
node: Type.String({ description: "Node id or display name to write the file on." }),
path: Type.String({
description: "Absolute path on the node to write. Canonicalized server-side.",
}),
contentBase64: Type.Optional(
Type.String({
description: "Base64-encoded bytes to write. Maximum 16 MB after decode.",
}),
),
sourceMediaId: Type.Optional(
Type.String({
description:
"Media id returned by file_fetch. Preferred for binary copies because bytes stay in the gateway media store.",
}),
),
mimeType: Type.Optional(
Type.String({
description: "Content type hint. Not validated against the content.",
}),
),
overwrite: Type.Optional(
Type.Boolean({
description: "Allow overwriting an existing file. Default false.",
default: false,
}),
),
createParents: Type.Optional(
Type.Boolean({
description: "Create missing parent directories (mkdir -p). Default false.",
default: false,
}),
),
});
export const FILE_WRITE_TOOL_DESCRIPTOR: FileTransferToolDescriptor = {
label: "File Write",
name: "file_write",
description:
"Write file bytes to a paired node by absolute path. Atomic write (temp + rename). Refuses to overwrite by default — pass overwrite=true to replace. Refuses to write through symlink targets unless policy explicitly allows following symlinks. Pair with file_fetch by passing its mediaId as sourceMediaId for binary copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND plugins.entries.file-transfer.config.nodes.<node>.allowWritePaths must match the destination path. Without policy configured, every call is denied.",
parameters: FileWriteToolSchema,
};

View File

@@ -10,7 +10,6 @@ import {
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js";
@@ -21,10 +20,12 @@ import {
readGatewayCallOptions,
readTrimmedString,
} from "../shared/params.js";
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const FILE_TRANSFER_SUBDIR = "file-transfer";
import {
DIR_FETCH_DEFAULT_MAX_BYTES,
DIR_FETCH_HARD_MAX_BYTES,
DIR_FETCH_TOOL_DESCRIPTOR,
FILE_TRANSFER_SUBDIR,
} from "./descriptors.js";
// Cap how many local file paths we surface in details.media.mediaUrls.
// Larger trees still land on disk but we don't spam the channel adapter
@@ -47,29 +48,6 @@ const TAR_UNPACK_MAX_ENTRIES = 5000;
const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16 * 1024 * 1024;
const DirFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node to fetch. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description:
"Max gzipped tarball bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
includeDotfiles: Type.Optional(
Type.Boolean({
description: "Reserved for v2; currently always includes dotfiles (v1 quirk in BSD tar).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
async function computeFileSha256(filePath: string): Promise<string> {
// Stream the hash so we never pull a whole large file into memory.
// file_fetch caps single files at 16MB, but unpacked dir_fetch entries
@@ -462,11 +440,7 @@ async function walkDir(
export function createDirFetchTool(): AnyAgentTool {
return {
label: "Directory Fetch",
name: "dir_fetch",
description:
"Retrieve a directory tree from a paired node as a gzipped tarball, unpack it on the gateway, and return a manifest of saved paths. Use to pull source trees, asset folders, or log directories in a single round-trip. The unpacked files live on the GATEWAY (not your local machine); pass localPath into other tools or use file_fetch on individual entries to ship them elsewhere. Rejects trees larger than 16 MB compressed. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path.",
parameters: DirFetchToolSchema,
...DIR_FETCH_TOOL_DESCRIPTOR,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");

View File

@@ -6,44 +6,18 @@ import {
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import { readClampedInt, readGatewayCallOptions, readTrimmedString } from "../shared/params.js";
const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
const DIR_LIST_HARD_MAX_ENTRIES = 5000;
const DirListToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node. Canonicalized server-side.",
}),
pageToken: Type.Optional(
Type.String({
description:
"Pagination token from a previous dir_list call. Omit to start from the beginning.",
}),
),
maxEntries: Type.Optional(
Type.Number({
description: `Max entries per page. Default ${DIR_LIST_DEFAULT_MAX_ENTRIES}, hard ceiling ${DIR_LIST_HARD_MAX_ENTRIES}.`,
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
import {
DIR_LIST_DEFAULT_MAX_ENTRIES,
DIR_LIST_HARD_MAX_ENTRIES,
DIR_LIST_TOOL_DESCRIPTOR,
} from "./descriptors.js";
export function createDirListTool(): AnyAgentTool {
return {
label: "Directory List",
name: "dir_list",
description:
"Retrieve a structured directory listing from a paired node. Returns file and subdirectory metadata (name, path, size, mimeType, isDir, mtime) without transferring file content. Use this to discover what files exist before fetching them with file_fetch. Pagination is offset-based; pass nextPageToken from the previous result. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.list' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path. Without policy configured, every call is denied.",
parameters: DirListToolSchema,
...DIR_LIST_TOOL_DESCRIPTOR,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");

View File

@@ -7,7 +7,6 @@ import {
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import {
@@ -16,38 +15,16 @@ import {
TEXT_INLINE_MIME_SET,
} from "../shared/mime.js";
import { humanSize, readGatewayCallOptions, readTrimmedString } from "../shared/params.js";
const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
// Stash fetched files in a non-TTL subdir so a follow-up tool call within
// the same agent turn can still reference them. The default "inbound"
// subdir gets cleaned every 2 minutes which has bitten us in iMessage flows.
const FILE_TRANSFER_SUBDIR = "file-transfer";
const FileFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the file on the node. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description: "Max bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
import {
FILE_FETCH_DEFAULT_MAX_BYTES,
FILE_FETCH_HARD_MAX_BYTES,
FILE_FETCH_TOOL_DESCRIPTOR,
FILE_TRANSFER_SUBDIR,
} from "./descriptors.js";
export function createFileFetchTool(): AnyAgentTool {
return {
label: "File Fetch",
name: "file_fetch",
description:
"Retrieve a file from a paired node by absolute path. Returns image content blocks for image MIME types, inlines small text files (≤8 KB) as text content, and saves everything else under the gateway media store with a path you can pass to file_write or other tools. Use this for screenshots, photos, receipts, logs, source files. Pair with file_write to copy a file from one node to another (no exec/cp shell-out needed). Requires operator opt-in: gateway.nodes.allowCommands must include 'file.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the path. Without policy configured, every call is denied.",
parameters: FileFetchToolSchema,
...FILE_FETCH_TOOL_DESCRIPTOR,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");

View File

@@ -8,7 +8,6 @@ import {
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveMediaBufferPath } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import {
@@ -17,43 +16,11 @@ import {
readGatewayCallOptions,
readTrimmedString,
} from "../shared/params.js";
const FILE_WRITE_HARD_MAX_BYTES = 16 * 1024 * 1024;
const FILE_WRITE_SCHEMA = Type.Object({
node: Type.String({ description: "Node id or display name to write the file on." }),
path: Type.String({
description: "Absolute path on the node to write. Canonicalized server-side.",
}),
contentBase64: Type.Optional(
Type.String({
description: "Base64-encoded bytes to write. Maximum 16 MB after decode.",
}),
),
sourceMediaId: Type.Optional(
Type.String({
description:
"Media id returned by file_fetch. Preferred for binary copies because bytes stay in the gateway media store.",
}),
),
mimeType: Type.Optional(
Type.String({
description: "Content type hint. Not validated against the content.",
}),
),
overwrite: Type.Optional(
Type.Boolean({
description: "Allow overwriting an existing file. Default false.",
default: false,
}),
),
createParents: Type.Optional(
Type.Boolean({
description: "Create missing parent directories (mkdir -p). Default false.",
default: false,
}),
),
});
import {
FILE_TRANSFER_SUBDIR,
FILE_WRITE_HARD_MAX_BYTES,
FILE_WRITE_TOOL_DESCRIPTOR,
} from "./descriptors.js";
async function readSourceBytes(input: {
contentBase64?: string;
@@ -61,7 +28,7 @@ async function readSourceBytes(input: {
}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> {
const sourceMediaId = input.sourceMediaId?.trim();
if (sourceMediaId) {
const mediaPath = await resolveMediaBufferPath(sourceMediaId, "file-transfer");
const mediaPath = await resolveMediaBufferPath(sourceMediaId, FILE_TRANSFER_SUBDIR);
const stat = await fs.stat(mediaPath);
if (stat.size > FILE_WRITE_HARD_MAX_BYTES) {
throw new Error(
@@ -97,11 +64,7 @@ type FileWritePayload = FileWriteSuccess | FileWriteError;
export function createFileWriteTool(): AnyAgentTool {
return {
label: "File Write",
name: "file_write",
description:
"Write file bytes to a paired node by absolute path. Atomic write (temp + rename). Refuses to overwrite by default — pass overwrite=true to replace. Refuses to write through symlink targets unless policy explicitly allows following symlinks. Pair with file_fetch by passing its mediaId as sourceMediaId for binary copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND plugins.entries.file-transfer.config.nodes.<node>.allowWritePaths must match the destination path. Without policy configured, every call is denied.",
parameters: FILE_WRITE_SCHEMA,
...FILE_WRITE_TOOL_DESCRIPTOR,
async execute(_toolCallId, params) {
const raw: Record<string, unknown> =
params && typeof params === "object" && !Array.isArray(params)