Files
openclaw/src/agents/sandbox/fs-bridge.ts
2026-03-12 03:52:24 +01:00

336 lines
9.8 KiB
TypeScript

import fs from "node:fs";
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
import {
buildPinnedMkdirpPlan,
buildPinnedRemovePlan,
buildPinnedRenamePlan,
buildPinnedWritePlan,
} from "./fs-bridge-mutation-helper.js";
import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
import { buildStatPlan, type SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js";
import {
buildSandboxFsMounts,
resolveSandboxFsPathWithMounts,
type SandboxResolvedFsPath,
} from "./fs-paths.js";
import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js";
type RunCommandOptions = {
args?: string[];
stdin?: Buffer | string;
allowFailure?: boolean;
signal?: AbortSignal;
};
export type SandboxResolvedPath = {
hostPath: string;
relativePath: string;
containerPath: string;
};
export type SandboxFsStat = {
type: "file" | "directory" | "other";
size: number;
mtimeMs: number;
};
export type SandboxFsBridge = {
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath;
readFile(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<Buffer>;
writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void>;
mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
remove(params: {
filePath: string;
cwd?: string;
recursive?: boolean;
force?: boolean;
signal?: AbortSignal;
}): Promise<void>;
rename(params: { from: string; to: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
stat(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<SandboxFsStat | null>;
};
export function createSandboxFsBridge(params: { sandbox: SandboxContext }): SandboxFsBridge {
return new SandboxFsBridgeImpl(params.sandbox);
}
class SandboxFsBridgeImpl implements SandboxFsBridge {
private readonly sandbox: SandboxContext;
private readonly mounts: ReturnType<typeof buildSandboxFsMounts>;
private readonly pathGuard: SandboxFsPathGuard;
constructor(sandbox: SandboxContext) {
this.sandbox = sandbox;
this.mounts = buildSandboxFsMounts(sandbox);
const mountsByContainer = [...this.mounts].toSorted(
(a, b) => b.containerRoot.length - a.containerRoot.length,
);
this.pathGuard = new SandboxFsPathGuard({
mountsByContainer,
runCommand: (script, options) => this.runCommand(script, options),
});
}
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
const target = this.resolveResolvedPath(params);
return {
hostPath: target.hostPath,
relativePath: target.relativePath,
containerPath: target.containerPath,
};
}
async readFile(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<Buffer> {
const target = this.resolveResolvedPath(params);
return this.readPinnedFile(target);
}
async writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "write files");
const writeCheck = {
target,
options: { action: "write files", requireWritable: true } as const,
};
await this.pathGuard.assertPathSafety(target, writeCheck.options);
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
const pinnedWriteTarget = await this.pathGuard.resolveAnchoredPinnedEntry(
target,
"write files",
);
await this.runCheckedCommand({
...buildPinnedWritePlan({
check: writeCheck,
pinned: pinnedWriteTarget,
mkdir: params.mkdir !== false,
}),
stdin: buffer,
signal: params.signal,
});
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "create directories");
const mkdirCheck = {
target,
options: {
action: "create directories",
requireWritable: true,
allowedType: "directory",
} as const,
};
await this.runCheckedCommand({
...buildPinnedMkdirpPlan({
check: mkdirCheck,
pinned: this.pathGuard.resolvePinnedDirectoryEntry(target, "create directories"),
}),
signal: params.signal,
});
}
async remove(params: {
filePath: string;
cwd?: string;
recursive?: boolean;
force?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "remove files");
const removeCheck = {
target,
options: {
action: "remove files",
requireWritable: true,
} as const,
};
await this.runCheckedCommand({
...buildPinnedRemovePlan({
check: removeCheck,
pinned: this.pathGuard.resolvePinnedEntry(target, "remove files"),
recursive: params.recursive,
force: params.force,
}),
signal: params.signal,
});
}
async rename(params: {
from: string;
to: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<void> {
const from = this.resolveResolvedPath({ filePath: params.from, cwd: params.cwd });
const to = this.resolveResolvedPath({ filePath: params.to, cwd: params.cwd });
this.ensureWriteAccess(from, "rename files");
this.ensureWriteAccess(to, "rename files");
const fromCheck = {
target: from,
options: {
action: "rename files",
requireWritable: true,
} as const,
};
const toCheck = {
target: to,
options: {
action: "rename files",
requireWritable: true,
} as const,
};
await this.runCheckedCommand({
...buildPinnedRenamePlan({
fromCheck,
toCheck,
from: this.pathGuard.resolvePinnedEntry(from, "rename files"),
to: this.pathGuard.resolvePinnedEntry(to, "rename files"),
}),
signal: params.signal,
});
}
async stat(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<SandboxFsStat | null> {
const target = this.resolveResolvedPath(params);
const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target, "stat files");
const result = await this.runPlannedCommand(
buildStatPlan(target, anchoredTarget),
params.signal,
);
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8");
if (stderr.includes("No such file or directory")) {
return null;
}
const message = stderr.trim() || `stat failed with code ${result.code}`;
throw new Error(`stat failed for ${target.containerPath}: ${message}`);
}
const text = result.stdout.toString("utf8").trim();
const [typeRaw, sizeRaw, mtimeRaw] = text.split("|");
const size = Number.parseInt(sizeRaw ?? "0", 10);
const mtime = Number.parseInt(mtimeRaw ?? "0", 10) * 1000;
return {
type: coerceStatType(typeRaw),
size: Number.isFinite(size) ? size : 0,
mtimeMs: Number.isFinite(mtime) ? mtime : 0,
};
}
private async runCommand(
script: string,
options: RunCommandOptions = {},
): Promise<ExecDockerRawResult> {
const dockerArgs = [
"exec",
"-i",
this.sandbox.containerName,
"sh",
"-c",
script,
"moltbot-sandbox-fs",
];
if (options.args?.length) {
dockerArgs.push(...options.args);
}
return execDockerRaw(dockerArgs, {
input: options.stdin,
allowFailure: options.allowFailure,
signal: options.signal,
});
}
private async readPinnedFile(target: SandboxResolvedFsPath): Promise<Buffer> {
const opened = await this.pathGuard.openReadableFile(target);
try {
return fs.readFileSync(opened.fd);
} finally {
fs.closeSync(opened.fd);
}
}
private async runCheckedCommand(
plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal },
): Promise<ExecDockerRawResult> {
await this.pathGuard.assertPathChecks(plan.checks);
if (plan.recheckBeforeCommand) {
await this.pathGuard.assertPathChecks(plan.checks);
}
return await this.runCommand(plan.script, {
args: plan.args,
stdin: plan.stdin,
allowFailure: plan.allowFailure,
signal: plan.signal,
});
}
private async runPlannedCommand(
plan: SandboxFsCommandPlan,
signal?: AbortSignal,
): Promise<ExecDockerRawResult> {
return await this.runCheckedCommand({ ...plan, signal });
}
private ensureWriteAccess(target: SandboxResolvedFsPath, action: string) {
if (!allowsWrites(this.sandbox.workspaceAccess) || !target.writable) {
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
}
}
private resolveResolvedPath(params: { filePath: string; cwd?: string }): SandboxResolvedFsPath {
return resolveSandboxFsPathWithMounts({
filePath: params.filePath,
cwd: params.cwd ?? this.sandbox.workspaceDir,
defaultWorkspaceRoot: this.sandbox.workspaceDir,
defaultContainerRoot: this.sandbox.containerWorkdir,
mounts: this.mounts,
});
}
}
function allowsWrites(access: SandboxWorkspaceAccess): boolean {
return access === "rw";
}
function coerceStatType(typeRaw?: string): "file" | "directory" | "other" {
if (!typeRaw) {
return "other";
}
const normalized = typeRaw.trim().toLowerCase();
if (normalized.includes("directory")) {
return "directory";
}
if (normalized.includes("file")) {
return "file";
}
return "other";
}