mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 10:30:21 +00:00
feat: add openshell sandbox backend
This commit is contained in:
336
extensions/openshell/src/fs-bridge.ts
Normal file
336
extensions/openshell/src/fs-bridge.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
SandboxContext,
|
||||
SandboxFsBridge,
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
type ResolvedMountPath = SandboxResolvedPath & {
|
||||
mountHostRoot: string;
|
||||
writable: boolean;
|
||||
source: "workspace" | "agent";
|
||||
};
|
||||
|
||||
export function createOpenShellFsBridge(params: {
|
||||
sandbox: SandboxContext;
|
||||
backend: OpenShellSandboxBackend;
|
||||
}): SandboxFsBridge {
|
||||
return new OpenShellFsBridge(params.sandbox, params.backend);
|
||||
}
|
||||
|
||||
class OpenShellFsBridge implements SandboxFsBridge {
|
||||
constructor(
|
||||
private readonly sandbox: SandboxContext,
|
||||
private readonly backend: OpenShellSandboxBackend,
|
||||
) {}
|
||||
|
||||
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
||||
const target = this.resolveTarget(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.resolveTarget(params);
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
return await fsPromises.readFile(target.hostPath);
|
||||
}
|
||||
|
||||
async writeFile(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
data: Buffer | string;
|
||||
encoding?: BufferEncoding;
|
||||
mkdir?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
this.ensureWritable(target, "write files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
const buffer = Buffer.isBuffer(params.data)
|
||||
? params.data
|
||||
: Buffer.from(params.data, params.encoding ?? "utf8");
|
||||
const parentDir = path.dirname(target.hostPath);
|
||||
if (params.mkdir !== false) {
|
||||
await fsPromises.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
const tempPath = path.join(
|
||||
parentDir,
|
||||
`.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
await fsPromises.writeFile(tempPath, buffer);
|
||||
await fsPromises.rename(tempPath, target.hostPath);
|
||||
await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath);
|
||||
}
|
||||
|
||||
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
this.ensureWritable(target, "create directories");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(target.hostPath, { recursive: true });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1"',
|
||||
args: [target.containerPath],
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
recursive?: boolean;
|
||||
force?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
this.ensureWritable(target, "remove files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: params.force !== false,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
});
|
||||
await fsPromises.rm(target.hostPath, {
|
||||
recursive: params.recursive ?? false,
|
||||
force: params.force !== false,
|
||||
});
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: params.recursive
|
||||
? 'rm -rf -- "$1"'
|
||||
: 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi',
|
||||
args: [target.containerPath],
|
||||
signal: params.signal,
|
||||
allowFailure: params.force !== false,
|
||||
});
|
||||
}
|
||||
|
||||
async rename(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd });
|
||||
const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd });
|
||||
this.ensureWritable(from, "rename files");
|
||||
this.ensureWritable(to, "rename files");
|
||||
await assertLocalPathSafety({
|
||||
target: from,
|
||||
root: from.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
});
|
||||
await assertLocalPathSafety({
|
||||
target: to,
|
||||
root: to.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true });
|
||||
await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"',
|
||||
args: [from.containerPath, to.containerPath],
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async stat(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SandboxFsStat | null> {
|
||||
const target = this.resolveTarget(params);
|
||||
const stats = await fsPromises.lstat(target.hostPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
return {
|
||||
type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
private ensureWritable(target: ResolvedMountPath, action: string) {
|
||||
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
|
||||
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath {
|
||||
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
||||
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
||||
const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
|
||||
const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
);
|
||||
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
||||
const input = params.filePath.trim();
|
||||
|
||||
if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
|
||||
const relative = path.posix.relative(workspaceContainerRoot, input) || "";
|
||||
const hostPath = relative
|
||||
? path.resolve(workspaceRoot, ...relative.split("/"))
|
||||
: workspaceRoot;
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative,
|
||||
containerPath: relative
|
||||
? path.posix.join(workspaceContainerRoot, relative)
|
||||
: workspaceContainerRoot,
|
||||
mountHostRoot: workspaceRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "workspace",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
hasAgentMount &&
|
||||
(input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)
|
||||
) {
|
||||
const relative = path.posix.relative(agentContainerRoot, input) || "";
|
||||
const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
|
||||
containerPath: relative
|
||||
? path.posix.join(agentContainerRoot, relative)
|
||||
: agentContainerRoot,
|
||||
mountHostRoot: agentRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
||||
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
||||
|
||||
if (isPathInside(workspaceRoot, hostPath)) {
|
||||
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative,
|
||||
containerPath: relative
|
||||
? path.posix.join(workspaceContainerRoot, relative)
|
||||
: workspaceContainerRoot,
|
||||
mountHostRoot: workspaceRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "workspace",
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
||||
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
||||
containerPath: relative
|
||||
? path.posix.join(agentContainerRoot, relative)
|
||||
: agentContainerRoot,
|
||||
mountHostRoot: agentRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function assertLocalPathSafety(params: {
|
||||
target: ResolvedMountPath;
|
||||
root: string;
|
||||
allowMissingLeaf: boolean;
|
||||
allowFinalSymlinkForUnlink: boolean;
|
||||
}): Promise<void> {
|
||||
const canonicalRoot = await fsPromises
|
||||
.realpath(params.root)
|
||||
.catch(() => path.resolve(params.root));
|
||||
const candidate = await resolveCanonicalCandidate(params.target.hostPath);
|
||||
if (!isPathInside(canonicalRoot, candidate)) {
|
||||
throw new Error(
|
||||
`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const relative = path.relative(params.root, params.target.hostPath);
|
||||
const segments = relative
|
||||
.split(path.sep)
|
||||
.filter(Boolean)
|
||||
.slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
|
||||
let cursor = params.root;
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
cursor = path.join(cursor, segments[index]!);
|
||||
const stats = await fsPromises.lstat(cursor).catch(() => null);
|
||||
if (!stats) {
|
||||
if (index === segments.length - 1 && params.allowMissingLeaf) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const isFinal = index === segments.length - 1;
|
||||
if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) {
|
||||
throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
|
||||
const missing: string[] = [];
|
||||
let cursor = path.resolve(targetPath);
|
||||
while (true) {
|
||||
const exists = await fsPromises
|
||||
.lstat(cursor)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
const canonical = await fsPromises.realpath(cursor).catch(() => cursor);
|
||||
return path.resolve(canonical, ...missing);
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
return path.resolve(cursor, ...missing);
|
||||
}
|
||||
missing.unshift(path.basename(cursor));
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user