[codex] Extract filesystem safety primitives (#77918)

* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
This commit is contained in:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -2,6 +2,11 @@ import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
FsSafeError,
resolveAbsolutePathForRead,
root as fsRoot,
} from "openclaw/plugin-sdk/security-runtime";
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
@@ -50,6 +55,17 @@ function clampMaxBytes(input: unknown): number {
}
function classifyFsError(err: unknown): DirFetchErrCode {
if (err instanceof FsSafeError) {
if (err.code === "not-found") {
return "NOT_FOUND";
}
if (err.code === "symlink") {
return "SYMLINK_REDIRECT";
}
if (err.code === "invalid-path") {
return "INVALID_PATH";
}
}
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
@@ -145,18 +161,18 @@ async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
const results: string[] = [];
async function visit(dir: string): Promise<boolean> {
const entries = await fs.readdir(dir, { withFileTypes: true });
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 abs = path.join(dir, entry.name);
const rel = path.relative(root, abs).replace(/\\/gu, "/");
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(abs);
if (entry.isDirectory) {
const ok = await visit(rel);
if (!ok) {
return false;
}
@@ -164,7 +180,7 @@ async function listTreeEntries(root: string, maxEntries: number): Promise<string
}
return true;
}
return (await visit(root)) ? results : "TOO_MANY";
return (await visit(".")) ? results : "TOO_MANY";
}
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
@@ -186,22 +202,31 @@ export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchRe
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
canonical = (
await resolveAbsolutePathForRead(requestedPath, {
symlinks: followSymlinks ? "follow" : "reject",
})
).canonicalPath;
} catch (err) {
const code = classifyFsError(err);
const canonicalPath =
err instanceof FsSafeError &&
err.cause &&
typeof err.cause === "object" &&
"canonicalPath" in err.cause &&
typeof err.cause.canonicalPath === "string"
? err.cause.canonicalPath
: undefined;
return {
ok: false,
code,
message: code === "NOT_FOUND" ? "directory not found" : `realpath failed: ${String(err)}`,
};
}
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
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)`,
canonicalPath: canonical,
message:
code === "NOT_FOUND"
? "directory not found"
: code === "SYMLINK_REDIRECT"
? "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)"
: `realpath failed: ${String(err)}`,
...(canonicalPath ? { canonicalPath } : {}),
};
}

View File

@@ -1,5 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
FsSafeError,
resolveAbsolutePathForRead,
root,
} from "openclaw/plugin-sdk/security-runtime";
import { mimeFromExtension } from "../shared/mime.js";
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
@@ -54,6 +59,17 @@ function clampMaxEntries(input: unknown): number {
}
function classifyFsError(err: unknown): DirListErrCode {
if (err instanceof FsSafeError) {
if (err.code === "not-found") {
return "NOT_FOUND";
}
if (err.code === "symlink") {
return "SYMLINK_REDIRECT";
}
if (err.code === "invalid-path") {
return "INVALID_PATH";
}
}
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
@@ -86,22 +102,31 @@ export async function handleDirList(params: DirListParams): Promise<DirListResul
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
canonical = (
await resolveAbsolutePathForRead(requestedPath, {
symlinks: followSymlinks ? "follow" : "reject",
})
).canonicalPath;
} catch (err) {
const code = classifyFsError(err);
const canonicalPath =
err instanceof FsSafeError &&
err.cause &&
typeof err.cause === "object" &&
"canonicalPath" in err.cause &&
typeof err.cause.canonicalPath === "string"
? err.cause.canonicalPath
: undefined;
return {
ok: false,
code,
message: code === "NOT_FOUND" ? "path not found" : `realpath failed: ${String(err)}`,
};
}
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
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)`,
canonicalPath: canonical,
message:
code === "NOT_FOUND"
? "path not found"
: code === "SYMLINK_REDIRECT"
? "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)"
: `realpath failed: ${String(err)}`,
...(canonicalPath ? { canonicalPath } : {}),
};
}
@@ -122,50 +147,39 @@ export async function handleDirList(params: DirListParams): Promise<DirListResul
};
}
let names: string[];
let listedEntries: { name: string; isDirectory: boolean; size: number; mtimeMs: number }[];
try {
names = await fs.readdir(canonical, { encoding: "utf8" });
const dirRoot = await root(canonical);
listedEntries = await dirRoot.list(".", { withFileTypes: true });
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `readdir failed: ${String(err)}`,
message: `list failed: ${String(err)}`,
canonicalPath: canonical,
};
}
// Sort by name for stable pagination
names.sort((a, b) => a.localeCompare(b));
listedEntries.sort((a, b) => a.name.localeCompare(b.name));
const total = names.length;
const page = names.slice(offset, offset + maxEntries);
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 name of page) {
const entryPath = path.join(canonical, name);
let isDir = false;
let size = 0;
let mtime = 0;
try {
const s = await fs.stat(entryPath);
isDir = s.isDirectory();
size = isDir ? 0 : s.size;
mtime = s.mtimeMs;
} catch {
// stat may fail for broken symlinks; keep zeros and treat as file
}
for (const entry of page) {
const entryPath = path.join(canonical, entry.name);
const isDir = entry.isDirectory;
entries.push({
name,
name: entry.name,
path: entryPath,
size,
mimeType: isDir ? "inode/directory" : mimeFromExtension(name),
size: isDir ? 0 : entry.size,
mimeType: isDir ? "inode/directory" : mimeFromExtension(entry.name),
isDir,
mtime,
mtime: entry.mtimeMs,
});
}

View File

@@ -1,7 +1,11 @@
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
FsSafeError,
resolveAbsolutePathForRead,
root,
} from "openclaw/plugin-sdk/security-runtime";
import { EXTENSION_MIME } from "../shared/mime.js";
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
@@ -70,6 +74,20 @@ function clampMaxBytes(input: unknown): number {
}
function classifyFsError(err: unknown): FileFetchErrCode {
if (err instanceof FsSafeError) {
if (err.code === "not-found") {
return "NOT_FOUND";
}
if (err.code === "symlink") {
return "SYMLINK_REDIRECT";
}
if (err.code === "invalid-path") {
return "INVALID_PATH";
}
if (err.code === "not-file") {
return "IS_DIRECTORY";
}
}
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
@@ -101,103 +119,102 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
canonical = (
await resolveAbsolutePathForRead(requestedPath, {
symlinks: followSymlinks ? "follow" : "reject",
})
).canonicalPath;
} catch (err) {
const code = classifyFsError(err);
const canonicalPath =
err instanceof FsSafeError &&
err.cause &&
typeof err.cause === "object" &&
"canonicalPath" in err.cause &&
typeof err.cause.canonicalPath === "string"
? err.cause.canonicalPath
: undefined;
return {
ok: false,
code,
message:
code === "NOT_FOUND"
? "file not found"
: code === "SYMLINK_REDIRECT"
? "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)"
: `realpath failed: ${String(err)}`,
...(canonicalPath ? { canonicalPath } : {}),
};
}
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 === "NOT_FOUND" ? "file not found" : `realpath failed: ${String(err)}`,
};
}
// Refuse to follow symlinks anywhere in the path unless the operator
// has explicitly opted in. A symlink in user-controlled territory
// (e.g. ~/Downloads/evil → /etc) could redirect an allowed-looking
// request to a disallowed canonical target. The error includes the
// canonical path so the operator can either update their allowlist
// to the canonical form or set followSymlinks=true on this node.
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
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)`,
message: code === "IS_DIRECTORY" ? "path is a directory" : `open failed: ${String(err)}`,
canonicalPath: canonical,
};
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
}
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 (stats.isDirectory()) {
return {
ok: false,
code: "IS_DIRECTORY",
message: "path is a directory",
canonicalPath: canonical,
};
}
if (!stats.isFile()) {
return {
ok: false,
code: "READ_ERROR",
message: "path is not a regular file",
canonicalPath: canonical,
};
}
if (stats.size > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
canonicalPath: canonical,
};
}
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 = detectMimeType(opened.realPath);
if (preflightOnly) {
return {
ok: true,
path: canonical,
size: stats.size,
mimeType: "",
base64: "",
sha256: "",
preflightOnly: true,
path: opened.realPath,
size: buffer.byteLength,
mimeType,
base64,
sha256,
};
}
let buffer: Buffer;
try {
buffer = await fs.readFile(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `read failed: ${String(err)}`, canonicalPath: canonical };
}
if (buffer.byteLength > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
canonicalPath: canonical,
code,
message: `read failed: ${String(err)}`,
canonicalPath: opened.realPath,
};
} finally {
await opened.handle.close().catch(() => undefined);
}
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const base64 = buffer.toString("base64");
const mimeType = detectMimeType(canonical);
return {
ok: true,
path: canonical,
size: buffer.byteLength,
mimeType,
base64,
sha256,
};
}

View File

@@ -1,6 +1,12 @@
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
@@ -39,74 +45,37 @@ function err(code: string, message: string, canonicalPath?: string): FileWriteEr
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function findExistingAncestor(p: string): Promise<string | null> {
let current = p;
while (true) {
try {
await fs.lstat(current);
return current;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
async function canonicalTargetFromExistingAncestor(targetPath: string): Promise<string> {
const ancestor = await findExistingAncestor(targetPath);
if (!ancestor) {
return targetPath;
}
let canonicalAncestor: string;
try {
canonicalAncestor = await fs.realpath(ancestor);
} catch {
canonicalAncestor = ancestor;
}
const relative = path.relative(ancestor, targetPath);
return relative ? path.join(canonicalAncestor, relative) : canonicalAncestor;
}
async function rejectParentSymlinkRedirect(
targetPath: string,
parentDir: string,
): Promise<FileWriteError | null> {
const ancestor = await findExistingAncestor(parentDir);
if (!ancestor) {
return null;
}
let canonicalAncestor: string;
try {
canonicalAncestor = await fs.realpath(ancestor);
} catch {
return null;
}
if (canonicalAncestor === ancestor) {
return null;
}
const canonicalTarget = path.join(canonicalAncestor, path.relative(ancestor, targetPath));
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",
`parent ${ancestor} resolves through a symlink to ${canonicalAncestor}; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)`,
"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> {
@@ -158,20 +127,21 @@ export async function handleFileWrite(
);
}
// 3. Resolve parent dir
const targetPath = path.normalize(rawPath);
const parentDir = path.dirname(targetPath);
const parentExists = await pathExists(parentDir);
// Refuse symlink traversal in the existing parent chain before creating
// missing directories. Recursive mkdir follows symlinked ancestors, so this
// has to run before mkdir can mutate the canonical target.
if (!followSymlinks) {
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
if (redirect) {
return redirect;
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) {
@@ -189,7 +159,7 @@ export async function handleFileWrite(
}
return {
ok: true,
path: await canonicalTargetFromExistingAncestor(targetPath),
path: await canonicalPathFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten: false,
@@ -203,15 +173,19 @@ export async function handleFileWrite(
}
}
// Re-check after mkdir as a race-defense: if the parent chain changed
// between the first check and directory creation, fail before writing bytes.
if (!followSymlinks) {
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
if (redirect) {
return redirect;
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);
@@ -232,8 +206,9 @@ export async function handleFileWrite(
}
overwritten = true;
} catch (statErr: unknown) {
// ENOENT is fine — file does not exist yet
if ((statErr as NodeJS.ErrnoException).code !== "ENOENT") {
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}`);
@@ -259,55 +234,45 @@ export async function handleFileWrite(
if (preflightOnly) {
return {
ok: true,
path: await canonicalTargetFromExistingAncestor(targetPath),
path: await canonicalPathFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten,
};
}
// 6. Atomic write: write to tmp, then rename
const tmpSuffix = crypto.randomBytes(8).toString("hex");
const tmpPath = `${targetPath}.${tmpSuffix}.tmp`;
try {
await fs.writeFile(tmpPath, buf);
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);
// Clean up tmp if possible
await fs.unlink(tmpPath).catch(() => {});
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}`);
}
try {
await fs.rename(tmpPath, targetPath);
} catch (renameErr) {
const message = renameErr instanceof Error ? renameErr.message : String(renameErr);
await fs.unlink(tmpPath).catch(() => {});
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
return err("PERMISSION_DENIED", `permission denied renaming to: ${targetPath}`);
}
return err("WRITE_ERROR", `failed to rename tmp to target: ${message}`);
}
const writtenBuf = buf;
// 8. Re-realpath to resolve any symlinks in the final path
let canonicalPath = targetPath;
try {
canonicalPath = await fs.realpath(targetPath);
} catch {
// Best effort; use normalized path as fallback
canonicalPath = targetPath;
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: writtenBuf.length,
size: buf.length,
sha256: computedSha256,
overwritten,
};

View File

@@ -11,6 +11,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
export type FileTransferAuditOp = "file.fetch" | "dir.list" | "dir.fetch" | "file.write";
@@ -86,7 +87,11 @@ export async function appendFileTransferAudit(
timestamp: new Date().toISOString(),
...record,
})}\n`;
await fs.appendFile(auditFilePath(dir), line, { mode: 0o600 });
await appendRegularFile({
filePath: auditFilePath(dir),
content: line,
rejectSymlinkParents: true,
});
} catch (e) {
process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`);
}

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import {
callGatewayTool,
listNodes,
@@ -7,7 +6,7 @@ import {
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveMediaBufferPath } from "openclaw/plugin-sdk/media-store";
import { readMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import {
@@ -28,14 +27,11 @@ async function readSourceBytes(input: {
}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> {
const sourceMediaId = input.sourceMediaId?.trim();
if (sourceMediaId) {
const mediaPath = await resolveMediaBufferPath(sourceMediaId, FILE_TRANSFER_SUBDIR);
const stat = await fs.stat(mediaPath);
if (stat.size > FILE_WRITE_HARD_MAX_BYTES) {
throw new Error(
`sourceMediaId too large: ${stat.size} bytes; maximum is ${FILE_WRITE_HARD_MAX_BYTES} bytes`,
);
}
const buffer = await fs.readFile(mediaPath);
const { buffer } = await readMediaBuffer(
sourceMediaId,
FILE_TRANSFER_SUBDIR,
FILE_WRITE_HARD_MAX_BYTES,
);
return { buffer, contentBase64: buffer.toString("base64"), source: "media" };
}
if (input.contentBase64 === undefined) {