Skip to content

Commit b712aa4

Browse files
author
clawsweeper-repair
committed
fix(file-transfer): recheck dir fetch archive policy after fetch
1 parent 8d93a84 commit b712aa4

5 files changed

Lines changed: 423 additions & 122 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Docs: https://docs.openclaw.ai
4343
- Plugins/runtime-deps: verify staged package entry files before reusing mirrored runtime roots, so browser-control repairs incomplete `ajv`/MCP SDK installs after update instead of failing after restart on a missing `ajv/dist/ajv.js`. Refs #74630. Thanks @spickeringlr.
4444
- Heartbeat: resolve `responsePrefix` template variables with the selected provider, model, and thinking context before delivering alerts or suppressing prefixed `HEARTBEAT_OK` replies. Fixes #43064; repairs #43065; supersedes #46858. Thanks @yweiii and @JunJD.
4545
- Memory/LanceDB: show full memory UUIDs in the `memory_forget` candidate list so agents can pass the displayed ID back to targeted deletion without hitting the full-UUID validator. (#66913) Thanks @amittell.
46-
- File-transfer plugin: require canonical read-path preflight authorization for `file.fetch` and fail closed when `dir.fetch` preflight entries are missing, absolute, or traversing before archive bytes are requested from the node. Carries forward #74134. Thanks @omarshahine.
46+
- File-transfer plugin: require canonical read-path preflight authorization for `file.fetch`, fail closed when `dir.fetch` preflight entries are missing, absolute, or traversing, and recheck returned archive entries before handing archive bytes to callers. Carries forward #74134. Thanks @omarshahine.
4747
- Channels/Feishu: retry file-typed iOS video resource downloads as `media` after a Feishu/Lark HTTP 502 and preserve the original 502 when the fallback also fails. Fixes #49855; carries forward #50164 and #73986. Thanks @alex-xuweilong.
4848
- Providers/Amazon Bedrock: expose the full Claude Opus 4.7 thinking profile (`xhigh`, `adaptive`, and `max`) for Bedrock model refs, while keeping Opus/Sonnet 4.6 on adaptive-by-default, so `/think` menus and validation match the Anthropic transport behavior. Fixes #74701. Thanks @prasad-yashdeep, @sparkleHazard, @Sanjays2402, and @hclsys.
4949
- Plugins/tokenjuice: compile the bundled plugin against tokenjuice 0.7.0's published OpenClaw host types instead of a local compatibility shim, so package contract drift fails in OpenClaw validation before release. Thanks @vincentkoc.

extensions/file-transfer/src/node-host/dir-fetch.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ describe("handleDirFetch — happy path", () => {
110110
// file count covers the regular files we created (3); BSD tar may also
111111
// list directory entries, so be generous.
112112
expect(r.fileCount).toBeGreaterThanOrEqual(3);
113+
expect(r.entries).toEqual(expect.arrayContaining(["a.txt", "b.txt", "sub", "sub/c.txt"]));
114+
expect(r.fileCount).toBe(r.entries?.length);
113115
});
114116
});
115117

extensions/file-transfer/src/node-host/dir-fetch.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean>
8888
});
8989
}
9090

91-
async function countTarEntries(tarBuffer: Buffer): Promise<number> {
91+
async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
9292
// Async spawn so a slow `tar -tzf` doesn't park the node-host event
9393
// loop for up to 10s. Other in-flight requests continue to be served.
94-
return new Promise((resolve) => {
94+
return new Promise<string[]>((resolve) => {
9595
const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] });
9696
let stdoutBuf = "";
9797
let aborted = false;
@@ -102,7 +102,7 @@ async function countTarEntries(tarBuffer: Buffer): Promise<number> {
102102
} catch {
103103
/* gone */
104104
}
105-
resolve(0);
105+
resolve([]);
106106
}, 10_000);
107107
child.stdout.on("data", (chunk: Buffer) => {
108108
stdoutBuf += chunk.toString();
@@ -115,7 +115,7 @@ async function countTarEntries(tarBuffer: Buffer): Promise<number> {
115115
/* gone */
116116
}
117117
clearTimeout(watchdog);
118-
resolve(0);
118+
resolve([]);
119119
}
120120
});
121121
child.on("close", (code) => {
@@ -124,16 +124,19 @@ async function countTarEntries(tarBuffer: Buffer): Promise<number> {
124124
return;
125125
}
126126
if (code !== 0) {
127-
resolve(0);
127+
resolve([]);
128128
return;
129129
}
130-
const lines = stdoutBuf.split("\n").filter((l) => l.trim().length > 0 && l !== "./");
131-
resolve(lines.length);
130+
const lines = stdoutBuf
131+
.split("\n")
132+
.map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""))
133+
.filter((line) => line.length > 0);
134+
resolve(lines);
132135
});
133136
child.on("error", () => {
134137
clearTimeout(watchdog);
135138
if (!aborted) {
136-
resolve(0);
139+
resolve([]);
137140
}
138141
});
139142
child.stdin.end(tarBuffer);
@@ -364,14 +367,15 @@ export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchRe
364367
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
365368
const tarBase64 = tarBuffer.toString("base64");
366369
const tarBytes = tarBuffer.byteLength;
367-
const fileCount = await countTarEntries(tarBuffer);
370+
const entries = await listTarEntries(tarBuffer);
368371

369372
return {
370373
ok: true,
371374
path: canonical,
372375
tarBase64,
373376
tarBytes,
374377
sha256,
375-
fileCount,
378+
fileCount: entries.length,
379+
entries,
376380
};
377381
}

extensions/file-transfer/src/shared/node-invoke-policy.test.ts

Lines changed: 152 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { spawn } from "node:child_process";
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
15
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
2-
import { describe, expect, it, vi } from "vitest";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
37
import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js";
48

59
vi.mock("./audit.js", () => ({
@@ -14,6 +18,44 @@ vi.mock("./policy.js", async (importOriginal) => {
1418
};
1519
});
1620

21+
const tmpRoots: string[] = [];
22+
const testUnlessWindows = process.platform === "win32" ? it.skip : it;
23+
24+
afterEach(async () => {
25+
await Promise.all(tmpRoots.map((tmpRoot) => fs.rm(tmpRoot, { recursive: true, force: true })));
26+
tmpRoots.length = 0;
27+
});
28+
29+
async function tarEntries(entries: Record<string, string>): Promise<string> {
30+
const tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "node-policy-tar-")));
31+
tmpRoots.push(tmpRoot);
32+
for (const [relPath, contents] of Object.entries(entries)) {
33+
const absPath = path.join(tmpRoot, relPath);
34+
await fs.mkdir(path.dirname(absPath), { recursive: true });
35+
await fs.writeFile(absPath, contents);
36+
}
37+
return await new Promise<string>((resolve, reject) => {
38+
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
39+
const child = spawn(tarBin, ["-czf", "-", "-C", tmpRoot, "."], {
40+
stdio: ["ignore", "pipe", "pipe"],
41+
});
42+
const chunks: Buffer[] = [];
43+
let stderr = "";
44+
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
45+
child.stderr.on("data", (chunk: Buffer) => {
46+
stderr += chunk.toString();
47+
});
48+
child.on("close", (code) => {
49+
if (code !== 0) {
50+
reject(new Error(`tar exited ${code}: ${stderr}`));
51+
return;
52+
}
53+
resolve(Buffer.concat(chunks).toString("base64"));
54+
});
55+
child.on("error", reject);
56+
});
57+
}
58+
1759
function createCtx(overrides: {
1860
command?: string;
1961
params?: Record<string, unknown>;
@@ -403,20 +445,123 @@ describe("file-transfer node invoke policy", () => {
403445
expect(invokeNode).toHaveBeenCalledTimes(1);
404446
});
405447

406-
it("continues dir.fetch after preflight without forwarding caller preflightOnly", async () => {
448+
testUnlessWindows(
449+
"continues dir.fetch after preflight without forwarding caller preflightOnly",
450+
async () => {
451+
const policy = createFileTransferNodeInvokePolicy();
452+
const tarBase64 = await tarEntries({
453+
"a.txt": "a",
454+
"sub/b.txt": "b",
455+
});
456+
const { ctx, invokeNode } = createCtx({
457+
command: "dir.fetch",
458+
params: { path: "/tmp/project", preflightOnly: true },
459+
});
460+
invokeNode
461+
.mockResolvedValueOnce({
462+
ok: true,
463+
payload: {
464+
ok: true,
465+
path: "/tmp/project",
466+
entries: ["a.txt", "sub/b.txt"],
467+
fileCount: 2,
468+
preflightOnly: true,
469+
},
470+
})
471+
.mockResolvedValueOnce({
472+
ok: true,
473+
payload: {
474+
ok: true,
475+
path: "/tmp/project",
476+
tarBase64,
477+
tarBytes: 7,
478+
sha256: "c".repeat(64),
479+
fileCount: 2,
480+
entries: ["a.txt", "sub/b.txt"],
481+
},
482+
});
483+
484+
const result = await policy.handle(ctx);
485+
486+
expect(result).toMatchObject({ ok: true });
487+
expect(invokeNode).toHaveBeenCalledTimes(2);
488+
expect(invokeNode).toHaveBeenNthCalledWith(1, {
489+
params: expect.objectContaining({ path: "/tmp/project", preflightOnly: true }),
490+
});
491+
expect(invokeNode).toHaveBeenNthCalledWith(2, {
492+
params: expect.not.objectContaining({ preflightOnly: true }),
493+
});
494+
},
495+
);
496+
497+
testUnlessWindows(
498+
"checks final dir.fetch archive entries before returning the archive",
499+
async () => {
500+
const policy = createFileTransferNodeInvokePolicy();
501+
const tarBase64 = await tarEntries({
502+
"ok.txt": "ok",
503+
".ssh/id_rsa": "secret",
504+
});
505+
const { ctx, invokeNode } = createCtx({
506+
command: "dir.fetch",
507+
params: { path: "/home/me" },
508+
pluginConfig: {
509+
nodes: {
510+
"node-1": {
511+
allowReadPaths: ["/home/me", "/home/me/**"],
512+
denyPaths: ["**/.ssh/**"],
513+
},
514+
},
515+
},
516+
});
517+
invokeNode
518+
.mockResolvedValueOnce({
519+
ok: true,
520+
payload: {
521+
ok: true,
522+
path: "/home/me",
523+
entries: ["ok.txt"],
524+
fileCount: 1,
525+
preflightOnly: true,
526+
},
527+
})
528+
.mockResolvedValueOnce({
529+
ok: true,
530+
payload: {
531+
ok: true,
532+
path: "/home/me",
533+
tarBase64,
534+
tarBytes: 7,
535+
sha256: "c".repeat(64),
536+
fileCount: 2,
537+
},
538+
});
539+
540+
const result = await policy.handle(ctx);
541+
542+
expect(result).toMatchObject({
543+
ok: false,
544+
code: "PATH_POLICY_DENIED",
545+
details: { path: "/home/me/.ssh/id_rsa" },
546+
});
547+
expect(invokeNode).toHaveBeenCalledTimes(2);
548+
},
549+
);
550+
551+
it("rejects final dir.fetch archive responses without readable archive entries", async () => {
407552
const policy = createFileTransferNodeInvokePolicy();
408553
const { ctx, invokeNode } = createCtx({
409554
command: "dir.fetch",
410-
params: { path: "/tmp/project", preflightOnly: true },
555+
params: { path: "/tmp/project" },
411556
});
412557
invokeNode
413558
.mockResolvedValueOnce({
414559
ok: true,
415560
payload: {
416561
ok: true,
417562
path: "/tmp/project",
418-
entries: ["a.txt", "sub/b.txt"],
419-
fileCount: 2,
563+
entries: ["a.txt"],
564+
fileCount: 1,
420565
preflightOnly: true,
421566
},
422567
})
@@ -425,22 +570,15 @@ describe("file-transfer node invoke policy", () => {
425570
payload: {
426571
ok: true,
427572
path: "/tmp/project",
428-
tarBase64: Buffer.from("archive").toString("base64"),
429573
tarBytes: 7,
430574
sha256: "c".repeat(64),
431-
fileCount: 2,
575+
fileCount: 1,
432576
},
433577
});
434578

435579
const result = await policy.handle(ctx);
436580

437-
expect(result).toMatchObject({ ok: true });
581+
expect(result).toMatchObject({ ok: false, code: "ARCHIVE_ENTRIES_MISSING" });
438582
expect(invokeNode).toHaveBeenCalledTimes(2);
439-
expect(invokeNode).toHaveBeenNthCalledWith(1, {
440-
params: expect.objectContaining({ path: "/tmp/project", preflightOnly: true }),
441-
});
442-
expect(invokeNode).toHaveBeenNthCalledWith(2, {
443-
params: expect.not.objectContaining({ preflightOnly: true }),
444-
});
445583
});
446584
});

0 commit comments

Comments
 (0)