Skip to content

Commit a25d59c

Browse files
omarshahineclawsweeper-repair
authored andcommitted
feat(file-transfer): add bundled plugin for binary file ops on nodes
New extensions/file-transfer/ plugin exposing four agent tools (file_fetch, dir_list, dir_fetch, file_write) and four matching node-host commands (file.fetch, dir.list, dir.fetch, file.write). Lets agents read and write files on paired nodes by absolute path, bypassing the bash output cap (200KB) and the live tool-result text cap that would otherwise truncate base64 payloads. Public surface -------------- - file_fetch({ node, path, maxBytes? }) Image MIMEs return image content blocks; small text (<=8 KB) inlines as text content; everything else returns a saved-media-path text block. sha256-verified end-to-end. - dir_list({ node, path, pageToken?, maxEntries? }) Structured directory listing — name, path, size, mimeType, isDir, mtime. Paginated. No content transfer. - dir_fetch({ node, path, maxBytes?, includeDotfiles? }) Server-side tar -czf streamed back, unpacked into the gateway media store, returns a manifest of saved paths. Single round-trip. 60s wall-clock timeouts on tar create/unpack. tar -xzf without -P rejects absolute paths in archive entries. - file_write({ node, path, contentBase64, mimeType?, overwrite?, createParents? }) Atomic write (temp + rename). Refuses to overwrite by default. Refuses to write through symlinks (lstat check). Buffer-side sha256 (no read-back race). Pair with file_fetch to round-trip files between nodes — DO NOT use exec/cp for file copies. All four commands gated by: - dangerous-by-default node command policy (gateway.nodes.allowCommands opt-in) - per-node path policy (gateway.nodes.fileTransfer) - optional operator approval prompt (ask: off | on-miss | always) 16 MB raw byte ceiling per single-frame round-trip (25 MB WS frame with ~33% base64 overhead and JSON envelope). 8 MB defaults. Path policy and approvals ------------------------- Default behavior is DENY. The operator must explicitly opt in: { "gateway": { "nodes": { "fileTransfer": { "<nodeId-or-displayName>": { "ask": "off" | "on-miss" | "always", "allowReadPaths": ["~/Screenshots/**", "/tmp/**"], "allowWritePaths": ["~/Downloads/**"], "denyPaths": ["**/.ssh/**", "**/.aws/**"], "maxBytes": 16777216 }, "*": { "ask": "on-miss" } } } } } ask modes: off — silent: allow if matched, deny if not (default) on-miss — silent allow if matched; prompt on miss always — prompt every call (denyPaths still hard-deny) denyPaths always wins. allow-always from the prompt persists the exact path back into allowReadPaths/allowWritePaths via mutateConfigFile so subsequent matching calls go silent. Reuses existing primitives — no new gateway methods: plugin.approval.request / plugin.approval.waitDecision decision: allow-once | allow-always | deny Pre-flight against requested path AND post-flight against the canonicalPath returned by the node — closes symlink-escape attacks where the requested path matched policy but realpath resolves somewhere else. Audit log --------- JSONL at ~/.openclaw/audit/file-transfer.jsonl. Records every decision (allow/allowed-once/allowed-always/denied/error) with timestamp, op, nodeId, displayName, requestedPath, canonicalPath, decision, error code, sizeBytes, sha256, durationMs. Best-effort writes; never propagates failure. Plugin layout ------------- extensions/file-transfer/ index.ts definePluginEntry, nodeHostCommands openclaw.plugin.json contracts.tools registration package.json src/node-host/{file-fetch,dir-list,dir-fetch,file-write}.ts src/tools/{file-fetch,dir-list,dir-fetch,file-write}-tool.ts src/shared/ mime.ts single-source extension->MIME map + image/text sets errors.ts shared error code enum and helpers params.ts shared param-validation helpers + GatewayCallOptions policy.ts evaluateFilePolicy, persistAllowAlways approval.ts plugin.approval.request wrapper gatekeep.ts one-stop policy + approval + audit orchestrator audit.ts JSONL audit sink Core touch points ----------------- - src/infra/node-commands.ts: NODE_FILE_FETCH_COMMAND, NODE_DIR_LIST_COMMAND, NODE_DIR_FETCH_COMMAND, NODE_FILE_WRITE_COMMAND, NODE_FILE_COMMANDS array - src/gateway/node-command-policy.ts: all four added to DEFAULT_DANGEROUS_NODE_COMMANDS - src/security/audit-extra.sync.ts: audit detail mentions file ops - src/agents/tools/nodes-tool-media.ts: MEDIA_INVOKE_ACTIONS entry for file.fetch redirects raw nodes(action=invoke) callers to the dedicated file_fetch tool to prevent base64 context bloat - src/agents/tools/nodes-tool.ts: nodes tool description points to the dedicated file_fetch tool Known limitations / follow-ups ------------------------------ - No tests in this PR. For a security-sensitive surface this is a gap; will follow up with a test pass. - Direct CLI invocation (openclaw nodes invoke --command file.fetch) bypasses the plugin policy entirely. Plugin-side gating is the realistic threat model (agent on iMessage requesting paths it shouldn't), but for true defense-in-depth, policy belongs in the gateway-side node.invoke dispatch. Move-policy-to-core is a separate PR. - file_watch (long-lived filesystem event subscription) is not included; it needs a new node-protocol primitive for streaming event channels and was descoped from this PR. - dir_fetch includeDotfiles: true is the only supported mode; BSD tar exclude patterns reliably collapse dotfile filtering to an empty archive. Reliable filtering needs a `find ! -name ".*" | tar -T -` pipeline; deferred. - dir_fetch du -sk preflight is a heuristic (du * 4 vs maxBytes); the mid-stream byte cap is the actual safety net.
1 parent bbf932f commit a25d59c

23 files changed

Lines changed: 2764 additions & 2 deletions

extensions/file-transfer/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
definePluginEntry,
3+
type AnyAgentTool,
4+
type OpenClawPluginNodeHostCommand,
5+
} from "openclaw/plugin-sdk/plugin-entry";
6+
import { handleDirFetch } from "./src/node-host/dir-fetch.js";
7+
import { handleDirList } from "./src/node-host/dir-list.js";
8+
import { handleFileFetch } from "./src/node-host/file-fetch.js";
9+
import { handleFileWrite } from "./src/node-host/file-write.js";
10+
import { createDirFetchTool } from "./src/tools/dir-fetch-tool.js";
11+
import { createDirListTool } from "./src/tools/dir-list-tool.js";
12+
import { createFileFetchTool } from "./src/tools/file-fetch-tool.js";
13+
import { createFileWriteTool } from "./src/tools/file-write-tool.js";
14+
15+
const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
16+
{
17+
command: "file.fetch",
18+
cap: "file",
19+
handle: async (paramsJSON) => {
20+
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
21+
const result = await handleFileFetch(params);
22+
return JSON.stringify(result);
23+
},
24+
},
25+
{
26+
command: "dir.list",
27+
cap: "file",
28+
handle: async (paramsJSON) => {
29+
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
30+
const result = await handleDirList(params);
31+
return JSON.stringify(result);
32+
},
33+
},
34+
{
35+
command: "dir.fetch",
36+
cap: "file",
37+
handle: async (paramsJSON) => {
38+
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
39+
const result = await handleDirFetch(params);
40+
return JSON.stringify(result);
41+
},
42+
},
43+
{
44+
command: "file.write",
45+
cap: "file",
46+
handle: async (paramsJSON) => {
47+
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
48+
const result = await handleFileWrite(params);
49+
return JSON.stringify(result);
50+
},
51+
},
52+
];
53+
54+
export default definePluginEntry({
55+
id: "file-transfer",
56+
name: "File Transfer",
57+
description: "Fetch, list, write, and watch files on paired nodes via dedicated node commands.",
58+
nodeHostCommands: fileTransferNodeHostCommands,
59+
register(api) {
60+
api.registerTool(createFileFetchTool() as AnyAgentTool);
61+
api.registerTool(createDirListTool() as AnyAgentTool);
62+
api.registerTool(createDirFetchTool() as AnyAgentTool);
63+
api.registerTool(createFileWriteTool() as AnyAgentTool);
64+
},
65+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"id": "file-transfer",
3+
"activation": {
4+
"onStartup": true
5+
},
6+
"enabledByDefault": true,
7+
"name": "File Transfer",
8+
"description": "Fetch, list, write, and watch files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.",
9+
"contracts": {
10+
"tools": ["file_fetch", "dir_list", "dir_fetch", "file_write"]
11+
},
12+
"configSchema": {
13+
"type": "object",
14+
"additionalProperties": false,
15+
"properties": {}
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@openclaw/file-transfer",
3+
"version": "2026.4.27",
4+
"description": "OpenClaw file transfer plugin (file_fetch, dir_list, dir_fetch, file_write, file_watch)",
5+
"type": "module",
6+
"devDependencies": {
7+
"@openclaw/plugin-sdk": "workspace:*"
8+
},
9+
"openclaw": {
10+
"extensions": [
11+
"./index.ts"
12+
],
13+
"bundle": {
14+
"stageRuntimeDependencies": false
15+
}
16+
}
17+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { spawn, spawnSync } from "node:child_process";
2+
import crypto from "node:crypto";
3+
import fs from "node:fs/promises";
4+
import path from "node:path";
5+
6+
export const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
7+
export const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
8+
9+
export type DirFetchParams = {
10+
path?: unknown;
11+
maxBytes?: unknown;
12+
includeDotfiles?: unknown;
13+
};
14+
15+
export type DirFetchOk = {
16+
ok: true;
17+
path: string;
18+
tarBase64: string;
19+
tarBytes: number;
20+
sha256: string;
21+
fileCount: number;
22+
};
23+
24+
export type DirFetchErrCode =
25+
| "INVALID_PATH"
26+
| "NOT_FOUND"
27+
| "IS_FILE"
28+
| "TREE_TOO_LARGE"
29+
| "READ_ERROR";
30+
31+
export type DirFetchErr = {
32+
ok: false;
33+
code: DirFetchErrCode;
34+
message: string;
35+
canonicalPath?: string;
36+
};
37+
38+
export type DirFetchResult = DirFetchOk | DirFetchErr;
39+
40+
function clampMaxBytes(input: unknown): number {
41+
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
42+
return DIR_FETCH_DEFAULT_MAX_BYTES;
43+
}
44+
return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES);
45+
}
46+
47+
function classifyFsError(err: unknown): DirFetchErrCode {
48+
const code = (err as { code?: string } | null)?.code;
49+
if (code === "ENOENT") {
50+
return "NOT_FOUND";
51+
}
52+
return "READ_ERROR";
53+
}
54+
55+
async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean> {
56+
// du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k)
57+
// We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses)
58+
const heuristicKb = Math.ceil((maxBytes * 4) / 1024);
59+
return new Promise((resolve) => {
60+
const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] });
61+
let output = "";
62+
du.stdout.on("data", (chunk: Buffer) => {
63+
output += chunk.toString();
64+
});
65+
du.on("close", (code) => {
66+
if (code !== 0) {
67+
// du failed; be permissive and let tar catch the overflow
68+
resolve(true);
69+
return;
70+
}
71+
const match = /^(\d+)/.exec(output.trim());
72+
if (!match) {
73+
resolve(true);
74+
return;
75+
}
76+
const sizeKb = parseInt(match[1], 10);
77+
resolve(sizeKb <= heuristicKb);
78+
});
79+
du.on("error", () => {
80+
// du not available; skip preflight
81+
resolve(true);
82+
});
83+
});
84+
}
85+
86+
function countTarEntries(tarBuffer: Buffer): number {
87+
const result = spawnSync("tar", ["-tzf", "-"], {
88+
input: tarBuffer,
89+
maxBuffer: 32 * 1024 * 1024,
90+
timeout: 10000,
91+
});
92+
if (result.status !== 0 || !result.stdout) {
93+
return 0;
94+
}
95+
const lines = (result.stdout as Buffer)
96+
.toString("utf-8")
97+
.split("\n")
98+
.filter((l) => l.trim().length > 0 && l !== "./");
99+
return lines.length;
100+
}
101+
102+
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
103+
const requestedPath = params.path;
104+
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
105+
return { ok: false, code: "INVALID_PATH", message: "path required" };
106+
}
107+
if (requestedPath.includes("\0")) {
108+
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
109+
}
110+
if (!path.isAbsolute(requestedPath)) {
111+
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
112+
}
113+
114+
const maxBytes = clampMaxBytes(params.maxBytes);
115+
const includeDotfiles = params.includeDotfiles === true;
116+
117+
let canonical: string;
118+
try {
119+
canonical = await fs.realpath(requestedPath);
120+
} catch (err) {
121+
const code = classifyFsError(err);
122+
return {
123+
ok: false,
124+
code,
125+
message: code === "NOT_FOUND" ? "directory not found" : `realpath failed: ${String(err)}`,
126+
};
127+
}
128+
129+
let stats: Awaited<ReturnType<typeof fs.stat>>;
130+
try {
131+
stats = await fs.stat(canonical);
132+
} catch (err) {
133+
const code = classifyFsError(err);
134+
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
135+
}
136+
137+
if (!stats.isDirectory()) {
138+
return {
139+
ok: false,
140+
code: "IS_FILE",
141+
message: "path is not a directory",
142+
canonicalPath: canonical,
143+
};
144+
}
145+
146+
// Preflight size check using du
147+
const withinBudget = await preflightDu(canonical, maxBytes);
148+
if (!withinBudget) {
149+
return {
150+
ok: false,
151+
code: "TREE_TOO_LARGE",
152+
message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`,
153+
canonicalPath: canonical,
154+
};
155+
}
156+
157+
// Build tar args. Shell out to /usr/bin/tar for portability.
158+
// -cz: create + gzip
159+
// -C <dir>: change to directory so paths in archive are relative
160+
// .: include everything from that directory
161+
// v1: includeDotfiles is accepted in the API but not enforced. BSD tar's
162+
// --exclude pattern matching is unreliable for dotfiles (every plausible
163+
// pattern except "*/.*" collapses the archive on macOS). Reliable filtering
164+
// requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2.
165+
// For now we always archive everything in the directory.
166+
void includeDotfiles;
167+
const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."];
168+
169+
// Capture tar output with a hard byte cap and a wall-clock timeout.
170+
// SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires
171+
// (covers tar hanging on a slow filesystem or symlink loop).
172+
const TAR_HARD_TIMEOUT_MS = 60_000;
173+
const tarBuffer = await new Promise<Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR">((resolve) => {
174+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
175+
const child = spawn(tarBin, tarArgs, {
176+
stdio: ["ignore", "pipe", "pipe"],
177+
});
178+
179+
const chunks: Buffer[] = [];
180+
let totalBytes = 0;
181+
let aborted = false;
182+
183+
const watchdog = setTimeout(() => {
184+
if (aborted) return;
185+
aborted = true;
186+
try {
187+
child.kill("SIGKILL");
188+
} catch {
189+
/* already gone */
190+
}
191+
resolve("TIMEOUT");
192+
}, TAR_HARD_TIMEOUT_MS);
193+
194+
child.stdout.on("data", (chunk: Buffer) => {
195+
if (aborted) return;
196+
totalBytes += chunk.byteLength;
197+
if (totalBytes > maxBytes) {
198+
aborted = true;
199+
clearTimeout(watchdog);
200+
child.kill("SIGTERM");
201+
resolve("TOO_LARGE");
202+
return;
203+
}
204+
chunks.push(chunk);
205+
});
206+
207+
child.on("close", (code) => {
208+
clearTimeout(watchdog);
209+
if (aborted) return;
210+
if (code !== 0) {
211+
resolve("ERROR");
212+
return;
213+
}
214+
resolve(Buffer.concat(chunks));
215+
});
216+
217+
child.on("error", () => {
218+
clearTimeout(watchdog);
219+
if (!aborted) {
220+
resolve("ERROR");
221+
}
222+
});
223+
});
224+
225+
if (tarBuffer === "TOO_LARGE") {
226+
return {
227+
ok: false,
228+
code: "TREE_TOO_LARGE",
229+
message: `tarball exceeded ${maxBytes} byte limit mid-stream`,
230+
canonicalPath: canonical,
231+
};
232+
}
233+
if (tarBuffer === "TIMEOUT") {
234+
return {
235+
ok: false,
236+
code: "READ_ERROR",
237+
message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)",
238+
canonicalPath: canonical,
239+
};
240+
}
241+
if (tarBuffer === "ERROR") {
242+
return {
243+
ok: false,
244+
code: "READ_ERROR",
245+
message: "tar command failed",
246+
canonicalPath: canonical,
247+
};
248+
}
249+
250+
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
251+
const tarBase64 = tarBuffer.toString("base64");
252+
const tarBytes = tarBuffer.byteLength;
253+
const fileCount = countTarEntries(tarBuffer);
254+
255+
return {
256+
ok: true,
257+
path: canonical,
258+
tarBase64,
259+
tarBytes,
260+
sha256,
261+
fileCount,
262+
};
263+
}

0 commit comments

Comments
 (0)