mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
* 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
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
import crypto from "node:crypto";
|
|
import {
|
|
callGatewayTool,
|
|
listNodes,
|
|
resolveNodeIdFromList,
|
|
type AnyAgentTool,
|
|
type NodeListNode,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { readMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
|
import { appendFileTransferAudit } from "../shared/audit.js";
|
|
import { throwFromNodePayload } from "../shared/errors.js";
|
|
import {
|
|
humanSize,
|
|
readBoolean,
|
|
readGatewayCallOptions,
|
|
readTrimmedString,
|
|
} from "../shared/params.js";
|
|
import {
|
|
FILE_TRANSFER_SUBDIR,
|
|
FILE_WRITE_HARD_MAX_BYTES,
|
|
FILE_WRITE_TOOL_DESCRIPTOR,
|
|
} from "./descriptors.js";
|
|
|
|
async function readSourceBytes(input: {
|
|
contentBase64?: string;
|
|
sourceMediaId?: string;
|
|
}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> {
|
|
const sourceMediaId = input.sourceMediaId?.trim();
|
|
if (sourceMediaId) {
|
|
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) {
|
|
throw new Error("contentBase64 or sourceMediaId required");
|
|
}
|
|
const buffer = Buffer.from(input.contentBase64, "base64");
|
|
return { buffer, contentBase64: input.contentBase64, source: "inline" };
|
|
}
|
|
|
|
type FileWriteSuccess = {
|
|
ok: true;
|
|
path: string;
|
|
size: number;
|
|
sha256: string;
|
|
overwritten: boolean;
|
|
};
|
|
|
|
type FileWriteError = {
|
|
ok: false;
|
|
code: string;
|
|
message: string;
|
|
canonicalPath?: string;
|
|
};
|
|
|
|
type FileWritePayload = FileWriteSuccess | FileWriteError;
|
|
|
|
export function createFileWriteTool(): AnyAgentTool {
|
|
return {
|
|
...FILE_WRITE_TOOL_DESCRIPTOR,
|
|
async execute(_toolCallId, params) {
|
|
const raw: Record<string, unknown> =
|
|
params && typeof params === "object" && !Array.isArray(params)
|
|
? (params as Record<string, unknown>)
|
|
: {};
|
|
|
|
const nodeQuery = readTrimmedString(raw, "node");
|
|
const filePath = readTrimmedString(raw, "path");
|
|
const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : undefined;
|
|
const sourceMediaId = typeof raw.sourceMediaId === "string" ? raw.sourceMediaId : undefined;
|
|
const overwrite = readBoolean(raw, "overwrite", false);
|
|
const createParents = readBoolean(raw, "createParents", false);
|
|
|
|
if (!nodeQuery) {
|
|
throw new Error("node required");
|
|
}
|
|
if (!filePath) {
|
|
throw new Error("path required");
|
|
}
|
|
// Compute the sha256 of the bytes we're sending so the node can do
|
|
// an end-to-end integrity check after writing. This is always
|
|
// sender-side computed; ignore any caller-supplied expectedSha256
|
|
// to avoid the model passing a wrong hash and triggering an
|
|
// unintended unlink.
|
|
const sourceBytes = await readSourceBytes({ contentBase64, sourceMediaId });
|
|
const buffer = sourceBytes.buffer;
|
|
const expectedSha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
|
|
|
const gatewayOpts = readGatewayCallOptions(raw);
|
|
const nodes: NodeListNode[] = await listNodes(gatewayOpts);
|
|
const nodeId = resolveNodeIdFromList(nodes, nodeQuery, false);
|
|
const nodeMeta = nodes.find((n) => n.nodeId === nodeId);
|
|
const nodeDisplayName = nodeMeta?.displayName ?? nodeQuery;
|
|
const startedAt = Date.now();
|
|
|
|
const result = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
|
|
nodeId,
|
|
command: "file.write",
|
|
params: {
|
|
path: filePath,
|
|
contentBase64: sourceBytes.contentBase64,
|
|
overwrite,
|
|
createParents,
|
|
expectedSha256,
|
|
},
|
|
idempotencyKey: crypto.randomUUID(),
|
|
});
|
|
|
|
const payload = (result as { payload?: unknown })?.payload;
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
await appendFileTransferAudit({
|
|
op: "file.write",
|
|
nodeId,
|
|
nodeDisplayName,
|
|
requestedPath: filePath,
|
|
decision: "error",
|
|
errorMessage: "unexpected response from node",
|
|
sizeBytes: buffer.byteLength,
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
throw new Error("unexpected file.write response from node");
|
|
}
|
|
|
|
const typed = payload as FileWritePayload;
|
|
if (!typed.ok) {
|
|
await appendFileTransferAudit({
|
|
op: "file.write",
|
|
nodeId,
|
|
nodeDisplayName,
|
|
requestedPath: filePath,
|
|
canonicalPath: typed.canonicalPath,
|
|
decision: "error",
|
|
errorCode: typed.code,
|
|
errorMessage: typed.message,
|
|
sizeBytes: buffer.byteLength,
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
throwFromNodePayload("file.write", typed as unknown as Record<string, unknown>);
|
|
}
|
|
|
|
await appendFileTransferAudit({
|
|
op: "file.write",
|
|
nodeId,
|
|
nodeDisplayName,
|
|
requestedPath: filePath,
|
|
canonicalPath: typed.path,
|
|
decision: "allowed",
|
|
sizeBytes: typed.size,
|
|
sha256: typed.sha256,
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
|
|
const overwriteNote = typed.overwritten ? " (overwrote existing file)" : "";
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: `Wrote ${typed.path} (${humanSize(typed.size)}, sha256:${typed.sha256.slice(0, 12)})${overwriteNote}`,
|
|
},
|
|
],
|
|
details: { ...typed, source: sourceBytes.source },
|
|
};
|
|
},
|
|
};
|
|
}
|