Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
// File Transfer tests cover dir fetch plugin behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleDirFetch } from "./dir-fetch.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-fetch-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
// dir-fetch shells out to /usr/bin/tar. Skip the body of these tests on
// platforms without it (Windows CI). They still register, just no-op.
const HAS_TAR = process.platform !== "win32";
async function expectDirFetchError(input: Parameters<typeof handleDirFetch>[0], code: string) {
const result = await handleDirFetch(input);
if (result.ok) {
throw new Error("expected directory fetch error");
}
expect(result.code).toBe(code);
}
describe("handleDirFetch — input validation", () => {
it("rejects empty / non-string path", async () => {
await expectDirFetchError({ path: "" }, "INVALID_PATH");
});
it("rejects relative paths", async () => {
await expectDirFetchError({ path: "relative" }, "INVALID_PATH");
});
it("rejects paths with NUL bytes", async () => {
await expectDirFetchError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
});
});
describe("handleDirFetch — fs errors", () => {
it.runIf(HAS_TAR)("returns NOT_FOUND for a missing directory", async () => {
await expectDirFetchError({ path: path.join(tmpRoot, "missing") }, "NOT_FOUND");
});
it.runIf(HAS_TAR)("returns IS_FILE when path resolves to a file", async () => {
const f = path.join(tmpRoot, "f.txt");
await fs.writeFile(f, "x");
await expectDirFetchError({ path: f }, "IS_FILE");
});
});
describe("handleDirFetch — happy path", () => {
it("preflights directory entries without creating a tarball", async () => {
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
await fs.mkdir(path.join(tmpRoot, ".ssh"));
await fs.writeFile(path.join(tmpRoot, ".ssh", "id_rsa"), "secret\n");
await fs.mkdir(path.join(tmpRoot, "sub"));
await fs.writeFile(path.join(tmpRoot, "sub", "b.txt"), "beta\n");
const r = await handleDirFetch({ path: tmpRoot, preflightOnly: true });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.path).toBe(tmpRoot);
expect(r.tarBase64).toBe("");
expect(r.tarBytes).toBe(0);
expect(r.sha256).toBe("");
expect(r.preflightOnly).toBe(true);
expect(r.entries).toEqual([".ssh", ".ssh/id_rsa", "a.txt", "sub", "sub/b.txt"]);
expect(r.fileCount).toBe(r.entries?.length);
});
it.runIf(HAS_TAR)("returns a gzipped tar with byte count and sha256", async () => {
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
await fs.writeFile(path.join(tmpRoot, "b.txt"), "beta\n");
await fs.mkdir(path.join(tmpRoot, "sub"));
await fs.writeFile(path.join(tmpRoot, "sub", "c.txt"), "gamma\n");
const r = await handleDirFetch({ path: tmpRoot });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.tarBytes).toBeGreaterThan(0);
expect(r.tarBase64.length).toBeGreaterThan(0);
const buf = Buffer.from(r.tarBase64, "base64");
expect(buf.byteLength).toBe(r.tarBytes);
const expectedSha = crypto.createHash("sha256").update(buf).digest("hex");
expect(r.sha256).toBe(expectedSha);
// gzip magic bytes
expect(buf[0]).toBe(0x1f);
expect(buf[1]).toBe(0x8b);
// file count covers the regular files we created (3); BSD tar may also
// list directory entries, so be generous.
expect(r.fileCount).toBeGreaterThanOrEqual(3);
expect(r.entries).toContain("a.txt");
expect(r.entries).toContain("b.txt");
expect(r.entries).toContain("sub");
expect(r.entries).toContain("sub/c.txt");
expect(r.fileCount).toBe(r.entries?.length);
});
});
describe("handleDirFetch — size cap", () => {
it.runIf(HAS_TAR)(
"returns TREE_TOO_LARGE when content exceeds the cap mid-stream",
async () => {
// Write enough random content to exceed a small maxBytes. Random bytes
// don't compress, so gzip output is roughly the same size as input.
const big = crypto.randomBytes(512 * 1024);
await fs.writeFile(path.join(tmpRoot, "big1.bin"), big);
await fs.writeFile(path.join(tmpRoot, "big2.bin"), big);
await fs.writeFile(path.join(tmpRoot, "big3.bin"), big);
// 64KB cap should trip either the du preflight or the streaming SIGTERM.
await expectDirFetchError({ path: tmpRoot, maxBytes: 64 * 1024 }, "TREE_TOO_LARGE");
},
30_000,
);
});

View File

@@ -0,0 +1,363 @@
// File Transfer plugin module implements dir fetch behavior.
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import path from "node:path";
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import {
classifyFsSafeReadError,
readAbsolutePath,
resolveCanonicalReadPath,
statRequiredDirectory,
} from "./path-errors.js";
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
type DirFetchParams = {
path?: unknown;
maxBytes?: unknown;
includeDotfiles?: unknown;
followSymlinks?: unknown;
preflightOnly?: unknown;
};
type DirFetchOk = {
ok: true;
path: string;
tarBase64: string;
tarBytes: number;
sha256: string;
fileCount: number;
entries?: string[];
preflightOnly?: boolean;
};
type DirFetchErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "IS_FILE"
| "TREE_TOO_LARGE"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
type DirFetchErr = {
ok: false;
code: DirFetchErrCode;
message: string;
canonicalPath?: string;
};
type DirFetchResult = DirFetchOk | DirFetchErr;
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return DIR_FETCH_DEFAULT_MAX_BYTES;
}
return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES);
}
function classifyFsError(err: unknown): DirFetchErrCode {
const safeCode = classifyFsSafeReadError(err);
if (safeCode) {
return safeCode;
}
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
return "READ_ERROR";
}
async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean> {
// du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k)
// We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses)
const heuristicKb = Math.ceil((maxBytes * 4) / 1024);
return new Promise((resolve) => {
const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] });
let output = "";
du.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
du.on("close", (code) => {
if (code !== 0) {
// du failed; be permissive and let tar catch the overflow
resolve(true);
return;
}
const match = /^(\d+)/.exec(output.trim());
if (!match) {
resolve(true);
return;
}
const sizeKb = Number.parseInt(match[1], 10);
resolve(sizeKb <= heuristicKb);
});
du.on("error", () => {
// du not available; skip preflight
resolve(true);
});
});
}
async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
// Async spawn so a slow `tar -tzf` doesn't park the node-host event
// loop for up to 10s. Other in-flight requests continue to be served.
return new Promise<string[]>((resolve) => {
const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] });
let stdoutBuf = "";
let aborted = false;
const watchdog = setTimeout(() => {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve([]);
}, 10_000);
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString();
// Bound buffer growth — pathological archives shouldn't OOM us.
if (stdoutBuf.length > 32 * 1024 * 1024) {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
clearTimeout(watchdog);
resolve([]);
}
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve([]);
return;
}
const lines = stdoutBuf
.split("\n")
.map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""))
.filter((line) => line.length > 0);
resolve(lines);
});
child.on("error", () => {
clearTimeout(watchdog);
if (!aborted) {
resolve([]);
}
});
child.stdin.end(tarBuffer);
});
}
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
const results: string[] = [];
const rootHandle = await fsRoot(root);
async function visit(relativeDir: string): Promise<boolean> {
const entries = await rootHandle.list(relativeDir, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const rel = path.posix.join(relativeDir === "." ? "" : relativeDir, entry.name);
results.push(rel);
if (results.length > maxEntries) {
return false;
}
if (entry.isDirectory) {
const ok = await visit(rel);
if (!ok) {
return false;
}
}
}
return true;
}
return (await visit(".")) ? results : "TOO_MANY";
}
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
const requestedPath = readAbsolutePath(params.path);
if (typeof requestedPath !== "string") {
return requestedPath;
}
const maxBytes = clampMaxBytes(params.maxBytes);
const includeDotfiles = params.includeDotfiles === true;
const followSymlinks = params.followSymlinks === true;
const preflightOnly = params.preflightOnly === true;
const canonical = await resolveCanonicalReadPath({
requestedPath,
followSymlinks,
classifyError: classifyFsError,
notFoundMessage: "directory not found",
});
if (typeof canonical !== "string") {
return canonical;
}
const directory = await statRequiredDirectory(canonical, classifyFsError);
if (!directory.ok) {
return directory;
}
if (preflightOnly) {
try {
const entries = await listTreeEntries(canonical, 5000);
if (entries === "TOO_MANY") {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: "directory tree exceeds 5000 entries during preflight",
canonicalPath: canonical,
};
}
return {
ok: true,
path: canonical,
tarBase64: "",
tarBytes: 0,
sha256: "",
fileCount: entries.length,
entries,
preflightOnly: true,
};
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `preflight readdir failed: ${String(err)}`,
canonicalPath: canonical,
};
}
}
// Preflight size check using du
const withinBudget = await preflightDu(canonical, maxBytes);
if (!withinBudget) {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`,
canonicalPath: canonical,
};
}
// Build tar args. Shell out to /usr/bin/tar for portability.
// -cz: create + gzip
// -C <dir>: change to directory so paths in archive are relative
// .: include everything from that directory
// v1: includeDotfiles is accepted in the API but not enforced. BSD tar's
// --exclude pattern matching is unreliable for dotfiles (every plausible
// pattern except "*/.*" collapses the archive on macOS). Reliable filtering
// requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2.
// For now we always archive everything in the directory.
void includeDotfiles;
const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."];
// Capture tar output with a hard byte cap and a wall-clock timeout.
// SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires
// (covers tar hanging on a slow filesystem or symlink loop).
const TAR_HARD_TIMEOUT_MS = 60_000;
const tarBuffer = await new Promise<Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR">((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, tarArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
let totalBytes = 0;
let aborted = false;
const watchdog = setTimeout(() => {
if (aborted) {
return;
}
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
resolve("TIMEOUT");
}, TAR_HARD_TIMEOUT_MS);
child.stdout.on("data", (chunk: Buffer) => {
if (aborted) {
return;
}
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
aborted = true;
clearTimeout(watchdog);
child.kill("SIGTERM");
resolve("TOO_LARGE");
return;
}
chunks.push(chunk);
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve("ERROR");
return;
}
resolve(Buffer.concat(chunks));
});
child.on("error", () => {
clearTimeout(watchdog);
if (!aborted) {
resolve("ERROR");
}
});
});
if (tarBuffer === "TOO_LARGE") {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: `tarball exceeded ${maxBytes} byte limit mid-stream`,
canonicalPath: canonical,
};
}
if (tarBuffer === "TIMEOUT") {
return {
ok: false,
code: "READ_ERROR",
message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)",
canonicalPath: canonical,
};
}
if (tarBuffer === "ERROR") {
return {
ok: false,
code: "READ_ERROR",
message: "tar command failed",
canonicalPath: canonical,
};
}
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
const tarBase64 = tarBuffer.toString("base64");
const tarBytes = tarBuffer.byteLength;
const entries = await listTarEntries(tarBuffer);
return {
ok: true,
path: canonical,
tarBase64,
tarBytes,
sha256,
fileCount: entries.length,
entries,
};
}

View File

@@ -0,0 +1,169 @@
// File Transfer tests cover dir list plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DIR_LIST_DEFAULT_MAX_ENTRIES,
DIR_LIST_HARD_MAX_ENTRIES,
handleDirList,
} from "./dir-list.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-list-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
async function expectDirListError(
input: Parameters<typeof handleDirList>[0],
code: "INVALID_PATH" | "IS_FILE" | "NOT_FOUND",
) {
const result = await handleDirList(input);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(code);
}
}
describe("handleDirList — input validation", () => {
it("rejects empty / non-string path", async () => {
await expectDirListError({ path: "" }, "INVALID_PATH");
await expectDirListError({ path: undefined }, "INVALID_PATH");
});
it("rejects relative paths", async () => {
await expectDirListError({ path: "relative" }, "INVALID_PATH");
});
it("rejects paths with NUL bytes", async () => {
await expectDirListError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
});
});
describe("handleDirList — fs errors", () => {
it("returns NOT_FOUND for a missing directory", async () => {
await expectDirListError({ path: path.join(tmpRoot, "does-not-exist") }, "NOT_FOUND");
});
it("returns IS_FILE when path resolves to a regular file", async () => {
const f = path.join(tmpRoot, "f.txt");
await fs.writeFile(f, "x");
await expectDirListError({ path: f }, "IS_FILE");
});
});
describe("handleDirList — happy path", () => {
it("lists files and subdirs with metadata, sorted by name", async () => {
await fs.writeFile(path.join(tmpRoot, "z.txt"), "Z");
await fs.writeFile(path.join(tmpRoot, "a.png"), "PNG-bytes");
await fs.mkdir(path.join(tmpRoot, "subdir"));
const r = await handleDirList({ path: tmpRoot });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual(["a.png", "subdir", "z.txt"]);
const a = r.entries.find((e) => e.name === "a.png")!;
expect(a.isDir).toBe(false);
expect(a.size).toBeGreaterThan(0);
expect(a.mimeType).toBe("image/png");
const sub = r.entries.find((e) => e.name === "subdir")!;
expect(sub.isDir).toBe(true);
expect(sub.size).toBe(0);
expect(sub.mimeType).toBe("inode/directory");
expect(r.truncated).toBe(false);
expect(r.nextPageToken).toBeUndefined();
});
it("includes dotfiles in the listing", async () => {
await fs.writeFile(path.join(tmpRoot, ".hidden"), "x");
await fs.writeFile(path.join(tmpRoot, "visible"), "x");
const r = await handleDirList({ path: tmpRoot });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual([".hidden", "visible"]);
});
it("paginates via pageToken (offset-based)", async () => {
for (let i = 0; i < 7; i++) {
// zero-pad so localeCompare-stable sort matches creation order
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
}
const page1 = await handleDirList({ path: tmpRoot, maxEntries: 3 });
if (!page1.ok) {
throw new Error("page1");
}
expect(page1.entries.map((e) => e.name)).toEqual(["f-0.txt", "f-1.txt", "f-2.txt"]);
expect(page1.truncated).toBe(true);
expect(page1.nextPageToken).toBe("3");
const page2 = await handleDirList({
path: tmpRoot,
maxEntries: 3,
pageToken: page1.nextPageToken,
});
if (!page2.ok) {
throw new Error("page2");
}
expect(page2.entries.map((e) => e.name)).toEqual(["f-3.txt", "f-4.txt", "f-5.txt"]);
expect(page2.truncated).toBe(true);
const page3 = await handleDirList({
path: tmpRoot,
maxEntries: 3,
pageToken: page2.nextPageToken,
});
if (!page3.ok) {
throw new Error("page3");
}
expect(page3.entries.map((e) => e.name)).toEqual(["f-6.txt"]);
expect(page3.truncated).toBe(false);
expect(page3.nextPageToken).toBeUndefined();
});
it("does not coerce partial page tokens", async () => {
for (let i = 0; i < 3; i++) {
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
}
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "1next" });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual(["f-0.txt"]);
expect(r.nextPageToken).toBe("1");
});
it("accepts plus-signed page tokens", async () => {
for (let i = 0; i < 3; i++) {
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
}
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "+01" });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual(["f-1.txt"]);
expect(r.nextPageToken).toBe("2");
});
});
describe("handleDirList — limits", () => {
it("clamps maxEntries to the hard ceiling and uses the default for invalid values", () => {
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBe(200);
expect(DIR_LIST_HARD_MAX_ENTRIES).toBe(5000);
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBeLessThan(DIR_LIST_HARD_MAX_ENTRIES);
});
});

View File

@@ -0,0 +1,155 @@
// File Transfer plugin module implements dir list behavior.
import path from "node:path";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import { root } from "openclaw/plugin-sdk/security-runtime";
import { mimeFromExtension } from "../shared/mime.js";
import {
classifyFsSafeReadError,
readAbsolutePath,
resolveCanonicalReadPath,
statRequiredDirectory,
} from "./path-errors.js";
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
type DirListParams = {
path?: unknown;
pageToken?: unknown;
maxEntries?: unknown;
followSymlinks?: unknown;
};
type DirListEntry = {
name: string;
path: string;
size: number;
mimeType: string;
isDir: boolean;
mtime: number;
};
type DirListOk = {
ok: true;
path: string;
entries: DirListEntry[];
nextPageToken?: string;
truncated: boolean;
};
type DirListErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "IS_FILE"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
type DirListErr = {
ok: false;
code: DirListErrCode;
message: string;
canonicalPath?: string;
};
type DirListResult = DirListOk | DirListErr;
function clampMaxEntries(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return DIR_LIST_DEFAULT_MAX_ENTRIES;
}
return Math.min(Math.floor(input), DIR_LIST_HARD_MAX_ENTRIES);
}
function parsePageOffset(input: unknown): number {
if (typeof input !== "string") {
return 0;
}
return parseStrictNonNegativeInteger(input) ?? 0;
}
function classifyFsError(err: unknown): DirListErrCode {
const safeCode = classifyFsSafeReadError(err);
if (safeCode) {
return safeCode;
}
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
if (code === "EACCES" || code === "EPERM") {
return "PERMISSION_DENIED";
}
return "READ_ERROR";
}
export async function handleDirList(params: DirListParams): Promise<DirListResult> {
const requestedPath = readAbsolutePath(params.path);
if (typeof requestedPath !== "string") {
return requestedPath;
}
const maxEntries = clampMaxEntries(params.maxEntries);
const offset = parsePageOffset(params.pageToken);
const followSymlinks = params.followSymlinks === true;
const canonical = await resolveCanonicalReadPath({
requestedPath,
followSymlinks,
classifyError: classifyFsError,
notFoundMessage: "path not found",
});
if (typeof canonical !== "string") {
return canonical;
}
const directory = await statRequiredDirectory(canonical, classifyFsError);
if (!directory.ok) {
return directory;
}
let listedEntries: { name: string; isDirectory: boolean; size: number; mtimeMs: number }[];
try {
const dirRoot = await root(canonical);
listedEntries = await dirRoot.list(".", { withFileTypes: true });
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `list failed: ${String(err)}`,
canonicalPath: canonical,
};
}
listedEntries.sort((a, b) => a.name.localeCompare(b.name));
const total = listedEntries.length;
const page = listedEntries.slice(offset, offset + maxEntries);
const truncated = offset + maxEntries < total;
const nextPageToken = truncated ? String(offset + maxEntries) : undefined;
const entries: DirListEntry[] = [];
for (const entry of page) {
const entryPath = path.join(canonical, entry.name);
const isDir = entry.isDirectory;
entries.push({
name: entry.name,
path: entryPath,
size: isDir ? 0 : entry.size,
mimeType: isDir ? "inode/directory" : mimeFromExtension(entry.name),
isDir,
mtime: entry.mtimeMs,
});
}
return {
ok: true,
path: canonical,
entries,
nextPageToken,
truncated,
};
}

View File

@@ -0,0 +1,255 @@
// File Transfer tests cover file fetch plugin behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FILE_FETCH_DEFAULT_MAX_BYTES,
FILE_FETCH_HARD_MAX_BYTES,
handleFileFetch,
} from "./file-fetch.js";
let tmpRoot: string;
type FileFetchResult = Awaited<ReturnType<typeof handleFileFetch>>;
type FileFetchSuccess = Extract<FileFetchResult, { ok: true }>;
type FileFetchFailure = Extract<FileFetchResult, { ok: false }>;
function expectFailureCode(
result: FileFetchResult,
code: string,
): asserts result is FileFetchFailure {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error(`expected failure ${code}`);
}
expect(result.code).toBe(code);
}
function expectSuccess(result: FileFetchResult): asserts result is FileFetchSuccess {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
}
}
beforeEach(async () => {
// realpath the mkdtemp result — on macOS /tmp/foo and /var/folders/... are
// symlinks to /private/{tmp,var/folders}, and the new SYMLINK_REDIRECT
// default would otherwise refuse every test path. Tests want to exercise
// the happy path with canonical paths; symlink-specific assertions create
// explicit symlinks inside tmpRoot.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-fetch-test-")));
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpRoot, { recursive: true, force: true });
});
describe("handleFileFetch — input validation", () => {
it("returns INVALID_PATH for empty / non-string path", async () => {
expectFailureCode(await handleFileFetch({ path: "" }), "INVALID_PATH");
expectFailureCode(await handleFileFetch({ path: undefined }), "INVALID_PATH");
expectFailureCode(await handleFileFetch({ path: 42 as unknown }), "INVALID_PATH");
});
it("rejects relative paths", async () => {
const r = await handleFileFetch({ path: "relative/file.txt" });
expectFailureCode(r, "INVALID_PATH");
expect(r.ok ? "" : r.message).toMatch(/absolute/);
});
it("rejects paths with NUL bytes", async () => {
const r = await handleFileFetch({ path: "/tmp/foo\0bar" });
expectFailureCode(r, "INVALID_PATH");
expect(r.ok ? "" : r.message).toMatch(/NUL/);
});
});
describe("handleFileFetch — fs errors", () => {
it("returns NOT_FOUND for a missing file", async () => {
const target = path.join(tmpRoot, "missing.txt");
expectFailureCode(await handleFileFetch({ path: target }), "NOT_FOUND");
});
it("returns IS_DIRECTORY when the path resolves to a directory", async () => {
const r = await handleFileFetch({ path: tmpRoot });
expectFailureCode(r, "IS_DIRECTORY");
// canonical path is reported back so the caller can re-check policy
if (r.ok) {
throw new Error("expected directory fetch to fail");
}
expect(r.canonicalPath).toBe(tmpRoot);
});
});
describe("handleFileFetch — zero-byte round-trip", () => {
it("fetches an empty image-named file with extension-derived MIME", async () => {
const target = path.join(tmpRoot, "empty.png");
await fs.writeFile(target, "");
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(0);
expect(r.mimeType).toBe("image/png");
expect(r.base64).toBe("");
// SHA-256 of empty input.
expect(r.sha256).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
});
});
describe("handleFileFetch — happy path", () => {
it("reads a small file and returns size + sha256 + base64", async () => {
const target = path.join(tmpRoot, "hello.txt");
const contents = "hello world\n";
await fs.writeFile(target, contents);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(contents.length);
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
expect(r.sha256).toBe(expectedSha);
// canonicalized path may differ from input on macOS (/tmp -> /private/tmp)
expect(path.basename(r.path)).toBe("hello.txt");
});
it("preflights canonical path and size without reading bytes", async () => {
const target = path.join(tmpRoot, "hello.txt");
await fs.writeFile(target, "hello world\n");
const readFileSpy = vi.spyOn(fs, "readFile");
const r = await handleFileFetch({ path: target, preflightOnly: true });
expectSuccess(r);
expect(r.path).toBe(target);
expect(r.size).toBe(12);
expect(r.base64).toBe("");
expect(r.sha256).toBe("");
expect(r.preflightOnly).toBe(true);
expect(readFileSpy).not.toHaveBeenCalled();
});
it("returns a sensible mime type for known extensions", async () => {
const target = path.join(tmpRoot, "readme.md");
await fs.writeFile(target, "# heading\n");
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
// libmagic ("file" cli) typically reports text/plain or text/markdown for
// a one-line markdown file; the extension fallback yields text/markdown.
// Accept either.
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
});
it("detects extensionless plain text as text/plain", async () => {
const target = path.join(tmpRoot, "LICENSE");
const contents = "Permission is hereby granted\n";
await fs.writeFile(target, contents);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("text/plain");
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
});
it("does not classify extensionless binary content as text/plain", async () => {
const target = path.join(tmpRoot, "opaque");
await fs.writeFile(target, Buffer.from([0x00, 0x01, 0x02, 0xff]));
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("application/octet-stream");
});
it("sniffs binary content instead of trusting a misleading extension", async () => {
const target = path.join(tmpRoot, "image.txt");
await fs.writeFile(
target,
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52,
]),
);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("image/png");
});
});
describe("handleFileFetch — size enforcement", () => {
it("returns FILE_TOO_LARGE when stat size exceeds the cap", async () => {
const target = path.join(tmpRoot, "big.bin");
const data = Buffer.alloc(2048, 0xab);
await fs.writeFile(target, data);
const r = await handleFileFetch({ path: target, maxBytes: 1024 });
expectFailureCode(r, "FILE_TOO_LARGE");
});
it("clamps maxBytes to the hard ceiling", async () => {
expect(FILE_FETCH_HARD_MAX_BYTES).toBe(16 * 1024 * 1024);
expect(FILE_FETCH_DEFAULT_MAX_BYTES).toBeLessThanOrEqual(FILE_FETCH_HARD_MAX_BYTES);
// A request asking for a maxBytes well above the hard ceiling should
// still be honored for a small file (no error).
const target = path.join(tmpRoot, "tiny.bin");
await fs.writeFile(target, Buffer.from([0x01, 0x02, 0x03]));
const r = await handleFileFetch({ path: target, maxBytes: Number.MAX_SAFE_INTEGER });
expect(r.ok).toBe(true);
});
it("uses default cap when maxBytes is not finite or non-positive", async () => {
const target = path.join(tmpRoot, "small.bin");
await fs.writeFile(target, Buffer.from([0xff]));
expectSuccess(await handleFileFetch({ path: target, maxBytes: -1 }));
expectSuccess(await handleFileFetch({ path: target, maxBytes: Number.NaN }));
expectSuccess(await handleFileFetch({ path: target, maxBytes: "8" as unknown }));
});
});
describe("handleFileFetch — symlink handling", () => {
it("refuses to follow a symlink by default (SYMLINK_REDIRECT)", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "data");
await fs.symlink(real, link);
const r = await handleFileFetch({ path: link });
expectFailureCode(r, "SYMLINK_REDIRECT");
// Caller learns the canonical target so the operator can update the
// allowlist or set followSymlinks=true.
expect(r.ok ? null : r.canonicalPath).toBe(real);
});
it("follows symlinks and returns the canonical path when followSymlinks=true", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "data");
await fs.symlink(real, link);
const r = await handleFileFetch({ path: link, followSymlinks: true });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}`);
}
expect(path.basename(r.path)).toBe("real.txt");
});
});

View File

@@ -0,0 +1,203 @@
// File Transfer plugin module implements file fetch behavior.
import crypto from "node:crypto";
import path from "node:path";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { root } from "openclaw/plugin-sdk/security-runtime";
import {
classifyFsSafeReadError,
readAbsolutePath,
resolveCanonicalReadPath,
} from "./path-errors.js";
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const TEXT_SNIFF_MAX_BYTES = 8192;
type FileFetchParams = {
path?: unknown;
maxBytes?: unknown;
followSymlinks?: unknown;
preflightOnly?: unknown;
};
type FileFetchOk = {
ok: true;
path: string;
size: number;
mimeType: string;
base64: string;
sha256: string;
preflightOnly?: boolean;
};
type FileFetchErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "IS_DIRECTORY"
| "FILE_TOO_LARGE"
| "PATH_TRAVERSAL"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
type FileFetchErr = {
ok: false;
code: FileFetchErrCode;
message: string;
canonicalPath?: string;
};
type FileFetchResult = FileFetchOk | FileFetchErr;
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return FILE_FETCH_DEFAULT_MAX_BYTES;
}
return Math.min(Math.floor(input), FILE_FETCH_HARD_MAX_BYTES);
}
function classifyFsError(err: unknown): FileFetchErrCode {
const safeCode = classifyFsSafeReadError(err);
if (safeCode) {
return safeCode;
}
const code = (err as { code?: string } | null)?.code;
if (code === "not-file") {
return "IS_DIRECTORY";
}
if (code === "ENOENT") {
return "NOT_FOUND";
}
if (code === "EACCES" || code === "EPERM") {
return "PERMISSION_DENIED";
}
if (code === "EISDIR") {
return "IS_DIRECTORY";
}
return "READ_ERROR";
}
function isLikelyPlainText(buffer: Buffer): boolean {
if (buffer.byteLength === 0) {
return true;
}
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
if (sample.includes(0)) {
return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(sample);
} catch {
return false;
}
let controlBytes = 0;
for (const byte of sample) {
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
controlBytes += 1;
}
}
return controlBytes / sample.byteLength < 0.01;
}
async function detectFetchedFileMime(params: {
buffer: Buffer;
filePath: string;
}): Promise<string> {
const detected = await detectMime(params);
if (detected) {
return detected;
}
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
}
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
const requestedPath = readAbsolutePath(params.path);
if (typeof requestedPath !== "string") {
return requestedPath;
}
const maxBytes = clampMaxBytes(params.maxBytes);
const followSymlinks = params.followSymlinks === true;
const preflightOnly = params.preflightOnly === true;
const canonical = await resolveCanonicalReadPath({
requestedPath,
followSymlinks,
classifyError: classifyFsError,
notFoundMessage: "file not found",
});
if (typeof canonical !== "string") {
return canonical;
}
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof root>>["open"]>>;
try {
const parentRoot = await root(path.dirname(canonical));
opened = await parentRoot.open(path.basename(canonical));
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: code === "IS_DIRECTORY" ? "path is a directory" : `open failed: ${String(err)}`,
canonicalPath: canonical,
};
}
try {
const stats = opened.stat;
if (stats.size > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
canonicalPath: opened.realPath,
};
}
if (preflightOnly) {
return {
ok: true,
path: opened.realPath,
size: stats.size,
mimeType: "",
base64: "",
sha256: "",
preflightOnly: true,
};
}
const buffer = await opened.handle.readFile();
if (buffer.byteLength > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
canonicalPath: opened.realPath,
};
}
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const base64 = buffer.toString("base64");
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
return {
ok: true,
path: opened.realPath,
size: buffer.byteLength,
mimeType,
base64,
sha256,
};
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `read failed: ${String(err)}`,
canonicalPath: opened.realPath,
};
} finally {
await opened.handle.close().catch(() => undefined);
}
}

View File

@@ -0,0 +1,378 @@
// File Transfer tests cover file write plugin behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleFileWrite } from "./file-write.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-write-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
function b64(s: string): string {
return Buffer.from(s, "utf-8").toString("base64");
}
function expectFailure(result: Awaited<ReturnType<typeof handleFileWrite>>, code: string) {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected file write failure");
}
expect(result.code).toBe(code);
}
function expectSuccessFields(
result: Awaited<ReturnType<typeof handleFileWrite>>,
fields: Record<string, unknown>,
) {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
}
for (const [key, value] of Object.entries(fields)) {
expect(result[key as keyof typeof result]).toEqual(value);
}
}
async function expectAccessMissing(target: string) {
try {
await fs.access(target);
} catch (error) {
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
return;
}
throw new Error(`expected ${target} to be missing`);
}
describe("handleFileWrite — input validation", () => {
it("rejects empty / non-string path", async () => {
expectFailure(await handleFileWrite({ path: "", contentBase64: b64("x") }), "INVALID_PATH");
});
it("rejects relative paths", async () => {
const r = await handleFileWrite({ path: "relative.txt", contentBase64: b64("x") });
expectFailure(r, "INVALID_PATH");
});
it("rejects paths with NUL bytes", async () => {
const r = await handleFileWrite({ path: "/tmp/foo\0bar", contentBase64: b64("x") });
expectFailure(r, "INVALID_PATH");
});
it("requires contentBase64 but allows an empty encoded payload", async () => {
const missing = await handleFileWrite({ path: path.join(tmpRoot, "missing.bin") });
expectFailure(missing, "INVALID_BASE64");
const target = path.join(tmpRoot, "empty.bin");
const empty = await handleFileWrite({ path: target, contentBase64: "" });
expectSuccessFields(empty, {
size: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
});
expect(await fs.readFile(target)).toHaveLength(0);
});
});
describe("handleFileWrite — happy path", () => {
it("writes a new file and returns size + sha256 + overwritten=false", async () => {
const target = path.join(tmpRoot, "out.txt");
const contents = "hello write\n";
const r = await handleFileWrite({ path: target, contentBase64: b64(contents) });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(contents.length);
expect(r.overwritten).toBe(false);
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
expect(r.sha256).toBe(expectedSha);
const onDisk = await fs.readFile(target, "utf-8");
expect(onDisk).toBe(contents);
});
it("does not leave .tmp files behind on success", async () => {
const target = path.join(tmpRoot, "atomic.txt");
const r = await handleFileWrite({ path: target, contentBase64: b64("body") });
expect(r.ok).toBe(true);
const entries = await fs.readdir(tmpRoot);
const tmpFiles = entries.filter((n) => n.includes(".tmp"));
expect(tmpFiles).toStrictEqual([]);
});
});
describe("handleFileWrite — overwrite policy", () => {
it("refuses to overwrite an existing file when overwrite=false", async () => {
const target = path.join(tmpRoot, "exists.txt");
await fs.writeFile(target, "before");
const r = await handleFileWrite({
path: target,
contentBase64: b64("after"),
overwrite: false,
});
expectFailure(r, "EXISTS_NO_OVERWRITE");
expect(await fs.readFile(target, "utf-8")).toBe("before");
});
it("overwrites and reports overwritten=true when overwrite=true", async () => {
const target = path.join(tmpRoot, "exists.txt");
await fs.writeFile(target, "before");
const r = await handleFileWrite({
path: target,
contentBase64: b64("after"),
overwrite: true,
});
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.overwritten).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe("after");
});
});
describe("handleFileWrite — parent directory handling", () => {
it("returns PARENT_NOT_FOUND when parent is missing and createParents=false", async () => {
const target = path.join(tmpRoot, "nested", "child.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
createParents: false,
});
expectFailure(r, "PARENT_NOT_FOUND");
});
it("creates missing parents when createParents=true", async () => {
const target = path.join(tmpRoot, "deep", "nested", "child.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
createParents: true,
});
expect(r.ok).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe("x");
});
});
describe("handleFileWrite — symlink protection", () => {
it("refuses to write through an existing symlink (lstat)", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "untouched");
await fs.symlink(real, link);
const r = await handleFileWrite({
path: link,
contentBase64: b64("evil"),
overwrite: true,
});
expectFailure(r, "SYMLINK_TARGET_DENIED");
// The original file must be unchanged.
expect(await fs.readFile(real, "utf-8")).toBe("untouched");
});
it("refuses to write through a symlink in a parent directory by default", async () => {
// realDir is the actual victim; sentinel is a pre-existing file in it.
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const sentinel = path.join(realDir, "sentinel.txt");
await fs.writeFile(sentinel, "DO_NOT_TOUCH");
// /tmpRoot/allowed -> /tmpRoot/real-dir (symlink in a parent segment).
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
// Asking to write to .../allowed/new-file.txt — the lexical parent
// (.../allowed) resolves through a symlink to .../real-dir. Refuse.
const r = await handleFileWrite({
path: path.join(allowed, "new-file.txt"),
contentBase64: b64("payload"),
});
expectFailure(r, "SYMLINK_REDIRECT");
// The error includes the canonical target so the operator can
// either update allowWritePaths or set followSymlinks=true.
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new-file.txt"));
// No file was created at the canonical target.
await expectAccessMissing(path.join(realDir, "new-file.txt"));
// Sentinel must be untouched.
expect(await fs.readFile(sentinel, "utf-8")).toBe("DO_NOT_TOUCH");
});
it("checks symlinked parents before recursive mkdir", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new", "child.txt"),
contentBase64: b64("payload"),
createParents: true,
});
expectFailure(r, "SYMLINK_REDIRECT");
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new", "child.txt"));
await expectAccessMissing(path.join(realDir, "new"));
});
it("follows the parent symlink when followSymlinks=true", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new-file.txt"),
contentBase64: b64("payload"),
followSymlinks: true,
});
expect(r.ok).toBe(true);
// The file landed in the canonical (real) directory.
expect(await fs.readFile(path.join(realDir, "new-file.txt"), "utf-8")).toBe("payload");
});
it("preflights canonical write targets without creating files or parents", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new", "child.txt"),
contentBase64: b64("payload"),
createParents: true,
followSymlinks: true,
preflightOnly: true,
});
expectSuccessFields(r, {
path: path.join(realDir, "new", "child.txt"),
size: "payload".length,
});
await expectAccessMissing(path.join(realDir, "new"));
});
it("refuses to overwrite a directory", async () => {
const target = path.join(tmpRoot, "is-a-dir");
await fs.mkdir(target);
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
overwrite: true,
});
expectFailure(r, "IS_DIRECTORY");
});
});
describe("handleFileWrite — integrity check", () => {
it("returns INTEGRITY_FAILURE before writing when expectedSha256 mismatches", async () => {
const target = path.join(tmpRoot, "checked.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("real-content"),
expectedSha256: "0".repeat(64),
});
expectFailure(r, "INTEGRITY_FAILURE");
// The file must never be created on a mismatch.
await expectAccessMissing(target);
});
it("does NOT replace or delete an existing file when overwrite=true and expectedSha256 mismatches", async () => {
const target = path.join(tmpRoot, "victim.txt");
await fs.writeFile(target, "ORIGINAL_CONTENT_DO_NOT_TOUCH");
const r = await handleFileWrite({
path: target,
contentBase64: b64("attacker-content"),
overwrite: true,
expectedSha256: "0".repeat(64),
});
expectFailure(r, "INTEGRITY_FAILURE");
// Critical: the original must survive. A bad caller hash must not
// be a primitive for replacing-then-deleting an existing file.
expect(await fs.readFile(target, "utf-8")).toBe("ORIGINAL_CONTENT_DO_NOT_TOUCH");
});
it("accepts a matching expectedSha256 and keeps the file", async () => {
const target = path.join(tmpRoot, "checked.txt");
const contents = "real-content";
const sha = crypto.createHash("sha256").update(contents).digest("hex");
const r = await handleFileWrite({
path: target,
contentBase64: b64(contents),
expectedSha256: sha,
});
expect(r.ok).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe(contents);
});
it("treats expectedSha256 as case-insensitive", async () => {
const target = path.join(tmpRoot, "checked.txt");
const contents = "abc";
const sha = crypto.createHash("sha256").update(contents).digest("hex").toUpperCase();
const r = await handleFileWrite({
path: target,
contentBase64: b64(contents),
expectedSha256: sha,
});
expect(r.ok).toBe(true);
});
});
describe("handleFileWrite — base64 round-trip validation", () => {
it("rejects malformed base64 that silently drops characters", async () => {
const target = path.join(tmpRoot, "bad.bin");
// "@" is not in the base64 alphabet — Buffer.from would silently drop
// it and decode "AAA" instead of failing.
const r = await handleFileWrite({
path: target,
contentBase64: "AAA@@@",
});
expectFailure(r, "INVALID_BASE64");
await expectAccessMissing(target);
});
it("accepts standard base64 with and without padding", async () => {
const target = path.join(tmpRoot, "padded.bin");
// Buffer.from("hi") -> "aGk=" with padding, "aGk" without.
const r1 = await handleFileWrite({ path: target, contentBase64: "aGk=" });
expect(r1.ok).toBe(true);
const target2 = path.join(tmpRoot, "unpadded.bin");
const r2 = await handleFileWrite({ path: target2, contentBase64: "aGk" });
expect(r2.ok).toBe(true);
});
it("accepts base64url variant (-_ instead of +/)", async () => {
const target = path.join(tmpRoot, "url.bin");
// Buffer.from([0xfb, 0xff]) -> "+/8=" standard, "-_8=" url
const r = await handleFileWrite({ path: target, contentBase64: "-_8=" });
expect(r.ok).toBe(true);
});
});
describe("handleFileWrite — size cap", () => {
it("rejects content larger than the 16MB cap", async () => {
const target = path.join(tmpRoot, "big.bin");
// 17MB of zero-bytes — base64 inflates by ~4/3 but we're checking the
// decoded buffer length so this is fine.
const big = Buffer.alloc(17 * 1024 * 1024, 0);
const r = await handleFileWrite({
path: target,
contentBase64: big.toString("base64"),
});
expectFailure(r, "FILE_TOO_LARGE");
});
});

View File

@@ -0,0 +1,280 @@
// File Transfer plugin module implements file write behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
canonicalPathFromExistingAncestor,
FsSafeError,
resolveAbsolutePathForWrite,
root,
} from "openclaw/plugin-sdk/security-runtime";
const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB
type FileWriteParams = {
path: string;
contentBase64: string;
overwrite: boolean;
createParents: boolean;
expectedSha256?: string;
followSymlinks?: boolean;
preflightOnly?: boolean;
};
type FileWriteSuccess = {
ok: true;
path: string;
size: number;
sha256: string;
overwritten: boolean;
};
type FileWriteError = {
ok: false;
code: string;
message: string;
canonicalPath?: string;
};
type FileWriteResult = FileWriteSuccess | FileWriteError;
function sha256Hex(buf: Buffer): string {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function err(code: string, message: string, canonicalPath?: string): FileWriteError {
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
}
function symlinkRedirectError(error: FsSafeError): FileWriteError {
const canonicalTarget =
error.cause &&
typeof error.cause === "object" &&
"canonicalPath" in error.cause &&
typeof error.cause.canonicalPath === "string"
? error.cause.canonicalPath
: undefined;
return err(
"SYMLINK_REDIRECT",
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)",
canonicalTarget,
);
}
function writeFsSafeError(error: FsSafeError, targetPath: string): FileWriteError {
if (error.code === "symlink") {
return err(
"SYMLINK_TARGET_DENIED",
`path is a symlink; refusing to write through it: ${targetPath}`,
);
}
if (error.code === "not-file") {
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
}
if (error.code === "already-exists") {
return err("EXISTS_NO_OVERWRITE", `file already exists and overwrite is false: ${targetPath}`);
}
return err("WRITE_ERROR", error.message, targetPath);
}
export async function handleFileWrite(
params: Partial<FileWriteParams> & Record<string, unknown>,
): Promise<FileWriteResult> {
const rawPath = typeof params?.path === "string" ? params.path : "";
const hasContentBase64 = typeof params?.contentBase64 === "string";
const contentBase64 = hasContentBase64 ? (params.contentBase64 as string) : "";
const overwrite = params?.overwrite === true;
const createParents = params?.createParents === true;
const expectedSha256 =
typeof params?.expectedSha256 === "string" ? params.expectedSha256 : undefined;
const followSymlinks = params?.followSymlinks === true;
const preflightOnly = params?.preflightOnly === true;
// 1. Validate path: must be absolute, non-empty, no NUL byte
if (!rawPath) {
return err("INVALID_PATH", "path is required");
}
if (rawPath.includes("\0")) {
return err("INVALID_PATH", "path must not contain NUL bytes");
}
if (!path.isAbsolute(rawPath)) {
return err("INVALID_PATH", "path must be absolute");
}
if (!hasContentBase64) {
return err("INVALID_BASE64", "contentBase64 is required");
}
// 2. Decode base64 → Buffer.
// Buffer.from(s, "base64") in Node never throws — it silently drops
// non-base64 characters and returns whatever it could decode. That
// means a typo or truncated input would land garbage on disk if we
// accepted whatever decoded. Defense: round-trip the decoded buffer
// back to base64 and compare against the input modulo padding/url
// variants. A mismatch means characters were silently dropped.
const buf = Buffer.from(contentBase64, "base64");
const reEncoded = buf.toString("base64");
// Normalize: drop padding and convert base64url chars to standard so the
// comparison tolerates both "=" / no-"=" inputs and "-_" base64url.
const normalize = (s: string): string =>
s.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
if (normalize(reEncoded) !== normalize(contentBase64)) {
return err("INVALID_BASE64", "contentBase64 is not valid base64");
}
if (buf.length > MAX_CONTENT_BYTES) {
return err(
"FILE_TOO_LARGE",
`decoded content is ${buf.length} bytes; maximum is ${MAX_CONTENT_BYTES} bytes (16 MB)`,
);
}
let targetPath: string;
let parentDir: string;
let parentExists: boolean;
try {
const resolved = await resolveAbsolutePathForWrite(rawPath, {
symlinks: followSymlinks ? "follow" : "reject",
});
targetPath = resolved.path;
parentDir = resolved.parentDir;
parentExists = resolved.parentExists;
} catch (error) {
if (error instanceof FsSafeError && error.code === "symlink") {
return symlinkRedirectError(error);
}
throw error;
}
if (!parentExists) {
if (!createParents) {
return err("PARENT_NOT_FOUND", `parent directory does not exist: ${parentDir}`);
}
if (preflightOnly) {
const computedSha256 = sha256Hex(buf);
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
return err(
"INTEGRITY_FAILURE",
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
targetPath,
);
}
return {
ok: true,
path: await canonicalPathFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten: false,
};
}
try {
await fs.mkdir(parentDir, { recursive: true });
} catch (mkdirErr) {
const message = mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr);
return err("WRITE_ERROR", `failed to create parent directories: ${message}`);
}
}
try {
await resolveAbsolutePathForWrite(targetPath, {
symlinks: followSymlinks ? "follow" : "reject",
});
} catch (error) {
if (error instanceof FsSafeError && error.code === "symlink") {
return symlinkRedirectError(error);
}
throw error;
}
const targetFileName = path.basename(targetPath);
const parentRoot = await root(parentDir);
let overwritten = false;
try {
const existingLStat = await fs.lstat(targetPath);
if (existingLStat.isSymbolicLink()) {
return err(
"SYMLINK_TARGET_DENIED",
`path is a symlink; refusing to write through it: ${targetPath}`,
);
}
if (existingLStat.isDirectory()) {
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
}
if (!overwrite) {
return err(
"EXISTS_NO_OVERWRITE",
`file already exists and overwrite is false: ${targetPath}`,
);
}
overwritten = true;
} catch (statErr: unknown) {
const statErrorCode =
statErr instanceof FsSafeError ? statErr.code : (statErr as NodeJS.ErrnoException).code;
if (statErrorCode !== "not-found" && statErrorCode !== "ENOENT") {
const message = statErr instanceof Error ? statErr.message : String(statErr);
if (message.toLowerCase().includes("permission")) {
return err("PERMISSION_DENIED", `permission denied: ${targetPath}`);
}
return err("WRITE_ERROR", `unexpected stat error: ${message}`);
}
}
// 5. Hash the decoded buffer BEFORE touching disk. If the caller
// supplied expectedSha256 and it doesn't match, refuse outright so
// a bad caller hash with overwrite=true can't replace + delete the
// original. Computing from the buffer (not a re-read) is the right
// source of truth — the caller asked us to write THESE bytes.
const computedSha256 = sha256Hex(buf);
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
return err(
"INTEGRITY_FAILURE",
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
targetPath,
);
}
if (preflightOnly) {
return {
ok: true,
path: await canonicalPathFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten,
};
}
try {
if (overwrite) {
await parentRoot.write(targetFileName, buf);
} else {
await parentRoot.create(targetFileName, buf);
}
} catch (writeErr) {
if (writeErr instanceof FsSafeError) {
return writeFsSafeError(writeErr, targetPath);
}
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`);
}
return err("WRITE_ERROR", `failed to write file: ${message}`);
}
let canonicalPath = targetPath;
try {
const opened = await parentRoot.open(targetFileName);
canonicalPath = opened.realPath;
await opened.handle.close().catch(() => undefined);
} catch (openErr) {
if (openErr instanceof FsSafeError) {
return writeFsSafeError(openErr, targetPath);
}
}
return {
ok: true,
path: canonicalPath,
size: buf.length,
sha256: computedSha256,
overwritten,
};
}

View File

@@ -0,0 +1,112 @@
// File Transfer plugin module implements path errors behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { FsSafeError, resolveAbsolutePathForRead } from "openclaw/plugin-sdk/security-runtime";
export type InvalidPathResult = {
ok: false;
code: "INVALID_PATH";
message: string;
};
export const SYMLINK_REJECTED_MESSAGE =
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)";
export type FsSafeReadErrorCode = "INVALID_PATH" | "NOT_FOUND" | "SYMLINK_REDIRECT";
export function classifyFsSafeReadError(err: unknown): FsSafeReadErrorCode | undefined {
if (!(err instanceof FsSafeError)) {
return undefined;
}
if (err.code === "not-found") {
return "NOT_FOUND";
}
if (err.code === "symlink") {
return "SYMLINK_REDIRECT";
}
if (err.code === "invalid-path") {
return "INVALID_PATH";
}
return undefined;
}
export function readAbsolutePath(input: unknown): string | InvalidPathResult {
if (typeof input !== "string" || input.length === 0) {
return { ok: false, code: "INVALID_PATH", message: "path required" };
}
if (input.includes("\0")) {
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
}
if (!path.isAbsolute(input)) {
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
}
return input;
}
export function canonicalPathFromFsSafeError(err: unknown): string | undefined {
if (!(err instanceof FsSafeError) || !err.cause || typeof err.cause !== "object") {
return undefined;
}
return "canonicalPath" in err.cause && typeof err.cause.canonicalPath === "string"
? err.cause.canonicalPath
: undefined;
}
export async function resolveCanonicalReadPath<Code extends string>(input: {
classifyError: (err: unknown) => Code;
followSymlinks: boolean;
notFoundMessage: string;
requestedPath: string;
}): Promise<string | { ok: false; code: Code; message: string; canonicalPath?: string }> {
try {
return (
await resolveAbsolutePathForRead(input.requestedPath, {
symlinks: input.followSymlinks ? "follow" : "reject",
})
).canonicalPath;
} catch (err) {
const code = input.classifyError(err);
const canonicalPath = canonicalPathFromFsSafeError(err);
return {
ok: false,
code,
message:
code === "NOT_FOUND"
? input.notFoundMessage
: code === "SYMLINK_REDIRECT"
? SYMLINK_REJECTED_MESSAGE
: `realpath failed: ${String(err)}`,
...(canonicalPath ? { canonicalPath } : {}),
};
}
}
export async function statRequiredDirectory<Code extends string>(
canonicalPath: string,
classifyError: (err: unknown) => Code,
): Promise<
{ ok: true } | { ok: false; code: Code | "IS_FILE"; message: string; canonicalPath: string }
> {
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(canonicalPath);
} catch (err) {
const code = classifyError(err);
return {
ok: false,
code,
message: `stat failed: ${String(err)}`,
canonicalPath,
};
}
if (!stats.isDirectory()) {
return {
ok: false,
code: "IS_FILE",
message: "path is not a directory",
canonicalPath,
};
}
return { ok: true };
}