diff --git a/extensions/file-transfer/index.test.ts b/extensions/file-transfer/index.test.ts new file mode 100644 index 00000000000..83280f61182 --- /dev/null +++ b/extensions/file-transfer/index.test.ts @@ -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", + ]); + }); +}); diff --git a/extensions/file-transfer/index.ts b/extensions/file-transfer/index.ts index 8dc0da12faa..a14a6fa11b0 100644 --- a/extensions/file-transfer/index.ts +++ b/extensions/file-transfer/index.ts @@ -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 { + let toolPromise: Promise | 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[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[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[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[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(); + }), + ); }, }); diff --git a/extensions/file-transfer/src/tools/descriptors.ts b/extensions/file-transfer/src/tools/descriptors.ts new file mode 100644 index 00000000000..5f6bededa58 --- /dev/null +++ b/extensions/file-transfer/src/tools/descriptors.ts @@ -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..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..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..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..allowWritePaths must match the destination path. Without policy configured, every call is denied.", + parameters: FileWriteToolSchema, +}; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 103c680034c..24694d12bab 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -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 { // 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..allowReadPaths must match the directory path.", - parameters: DirFetchToolSchema, + ...DIR_FETCH_TOOL_DESCRIPTOR, execute: async (_toolCallId, args) => { const params = args as Record; const node = readTrimmedString(params, "node"); diff --git a/extensions/file-transfer/src/tools/dir-list-tool.ts b/extensions/file-transfer/src/tools/dir-list-tool.ts index aa9e1ad52d6..803e54f4dbf 100644 --- a/extensions/file-transfer/src/tools/dir-list-tool.ts +++ b/extensions/file-transfer/src/tools/dir-list-tool.ts @@ -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..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; const node = readTrimmedString(params, "node"); diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.ts b/extensions/file-transfer/src/tools/file-fetch-tool.ts index 404f4a4b2ec..3643ea9b846 100644 --- a/extensions/file-transfer/src/tools/file-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/file-fetch-tool.ts @@ -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..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; const node = readTrimmedString(params, "node"); diff --git a/extensions/file-transfer/src/tools/file-write-tool.ts b/extensions/file-transfer/src/tools/file-write-tool.ts index 1a3086319cd..dd5dde87079 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -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..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 = params && typeof params === "object" && !Array.isArray(params)