mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
perf(file-transfer): lazy-load runtime handlers
This commit is contained in:
43
extensions/file-transfer/index.test.ts
Normal file
43
extensions/file-transfer/index.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
149
extensions/file-transfer/src/tools/descriptors.ts
Normal file
149
extensions/file-transfer/src/tools/descriptors.ts
Normal 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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user