mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 06:02:13 +00:00
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
import path from "node:path";
|
|
import type {
|
|
SandboxContext,
|
|
SandboxFsBridge,
|
|
SandboxFsStat,
|
|
SandboxResolvedPath,
|
|
} from "openclaw/plugin-sdk/core";
|
|
import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js";
|
|
import type { OpenShellSandboxBackend } from "./backend.js";
|
|
|
|
type ResolvedRemotePath = SandboxResolvedPath & {
|
|
writable: boolean;
|
|
mountRootPath: string;
|
|
source: "workspace" | "agent";
|
|
};
|
|
|
|
type MountInfo = {
|
|
containerRoot: string;
|
|
writable: boolean;
|
|
source: "workspace" | "agent";
|
|
};
|
|
|
|
export function createOpenShellRemoteFsBridge(params: {
|
|
sandbox: SandboxContext;
|
|
backend: OpenShellSandboxBackend;
|
|
}): SandboxFsBridge {
|
|
return new OpenShellRemoteFsBridge(params.sandbox, params.backend);
|
|
}
|
|
|
|
class OpenShellRemoteFsBridge implements SandboxFsBridge {
|
|
constructor(
|
|
private readonly sandbox: SandboxContext,
|
|
private readonly backend: OpenShellSandboxBackend,
|
|
) {}
|
|
|
|
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
|
const target = this.resolveTarget(params);
|
|
return {
|
|
relativePath: target.relativePath,
|
|
containerPath: target.containerPath,
|
|
};
|
|
}
|
|
|
|
async readFile(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<Buffer> {
|
|
const target = this.resolveTarget(params);
|
|
const canonical = await this.resolveCanonicalPath({
|
|
containerPath: target.containerPath,
|
|
action: "read files",
|
|
});
|
|
await this.assertNoHardlinkedFile({
|
|
containerPath: canonical,
|
|
action: "read files",
|
|
signal: params.signal,
|
|
});
|
|
const result = await this.runRemoteScript({
|
|
script: 'set -eu\ncat -- "$1"',
|
|
args: [canonical],
|
|
signal: params.signal,
|
|
});
|
|
return result.stdout;
|
|
}
|
|
|
|
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");
|
|
const pinned = await this.resolvePinnedParent({
|
|
containerPath: target.containerPath,
|
|
action: "write files",
|
|
requireWritable: true,
|
|
});
|
|
await this.assertNoHardlinkedFile({
|
|
containerPath: target.containerPath,
|
|
action: "write files",
|
|
signal: params.signal,
|
|
});
|
|
const buffer = Buffer.isBuffer(params.data)
|
|
? params.data
|
|
: Buffer.from(params.data, params.encoding ?? "utf8");
|
|
await this.runMutation({
|
|
args: [
|
|
"write",
|
|
pinned.mountRootPath,
|
|
pinned.relativeParentPath,
|
|
pinned.basename,
|
|
params.mkdir !== false ? "1" : "0",
|
|
],
|
|
stdin: buffer,
|
|
signal: params.signal,
|
|
});
|
|
}
|
|
|
|
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
|
|
const target = this.resolveTarget(params);
|
|
this.ensureWritable(target, "create directories");
|
|
const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
|
|
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`,
|
|
);
|
|
}
|
|
await this.runMutation({
|
|
args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath],
|
|
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");
|
|
const exists = await this.remotePathExists(target.containerPath, params.signal);
|
|
if (!exists) {
|
|
if (params.force === false) {
|
|
throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`);
|
|
}
|
|
return;
|
|
}
|
|
const pinned = await this.resolvePinnedParent({
|
|
containerPath: target.containerPath,
|
|
action: "remove files",
|
|
requireWritable: true,
|
|
allowFinalSymlinkForUnlink: true,
|
|
});
|
|
await this.runMutation({
|
|
args: [
|
|
"remove",
|
|
pinned.mountRootPath,
|
|
pinned.relativeParentPath,
|
|
pinned.basename,
|
|
params.recursive ? "1" : "0",
|
|
params.force === false ? "0" : "1",
|
|
],
|
|
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");
|
|
const fromPinned = await this.resolvePinnedParent({
|
|
containerPath: from.containerPath,
|
|
action: "rename files",
|
|
requireWritable: true,
|
|
allowFinalSymlinkForUnlink: true,
|
|
});
|
|
const toPinned = await this.resolvePinnedParent({
|
|
containerPath: to.containerPath,
|
|
action: "rename files",
|
|
requireWritable: true,
|
|
});
|
|
await this.runMutation({
|
|
args: [
|
|
"rename",
|
|
fromPinned.mountRootPath,
|
|
fromPinned.relativeParentPath,
|
|
fromPinned.basename,
|
|
toPinned.mountRootPath,
|
|
toPinned.relativeParentPath,
|
|
toPinned.basename,
|
|
"1",
|
|
],
|
|
signal: params.signal,
|
|
});
|
|
}
|
|
|
|
async stat(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<SandboxFsStat | null> {
|
|
const target = this.resolveTarget(params);
|
|
const exists = await this.remotePathExists(target.containerPath, params.signal);
|
|
if (!exists) {
|
|
return null;
|
|
}
|
|
const canonical = await this.resolveCanonicalPath({
|
|
containerPath: target.containerPath,
|
|
action: "stat files",
|
|
signal: params.signal,
|
|
});
|
|
await this.assertNoHardlinkedFile({
|
|
containerPath: canonical,
|
|
action: "stat files",
|
|
signal: params.signal,
|
|
});
|
|
const result = await this.runRemoteScript({
|
|
script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"',
|
|
args: [canonical],
|
|
signal: params.signal,
|
|
});
|
|
const output = result.stdout.toString("utf8").trim();
|
|
const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|");
|
|
return {
|
|
type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other",
|
|
size: Number(sizeRaw),
|
|
mtimeMs: Number(mtimeRaw) * 1000,
|
|
};
|
|
}
|
|
|
|
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath {
|
|
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
|
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
|
const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir);
|
|
const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir);
|
|
const mounts: MountInfo[] = [
|
|
{
|
|
containerRoot: workspaceContainerRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "workspace",
|
|
},
|
|
];
|
|
if (
|
|
this.sandbox.workspaceAccess !== "none" &&
|
|
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
|
|
) {
|
|
mounts.push({
|
|
containerRoot: agentContainerRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "agent",
|
|
});
|
|
}
|
|
|
|
const input = params.filePath.trim();
|
|
const inputPosix = input.replace(/\\/g, "/");
|
|
const maybeContainerMount = path.posix.isAbsolute(inputPosix)
|
|
? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix))
|
|
: null;
|
|
if (maybeContainerMount) {
|
|
return this.toResolvedPath({
|
|
mount: maybeContainerMount,
|
|
containerPath: normalizeContainerPath(inputPosix),
|
|
});
|
|
}
|
|
|
|
const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
|
const hostCandidate = path.isAbsolute(input)
|
|
? path.resolve(input)
|
|
: path.resolve(hostCwd, input);
|
|
if (isPathInside(workspaceRoot, hostCandidate)) {
|
|
const relative = toPosixRelative(workspaceRoot, hostCandidate);
|
|
return this.toResolvedPath({
|
|
mount: mounts[0]!,
|
|
containerPath: relative
|
|
? path.posix.join(workspaceContainerRoot, relative)
|
|
: workspaceContainerRoot,
|
|
});
|
|
}
|
|
if (mounts[1] && isPathInside(agentRoot, hostCandidate)) {
|
|
const relative = toPosixRelative(agentRoot, hostCandidate);
|
|
return this.toResolvedPath({
|
|
mount: mounts[1],
|
|
containerPath: relative
|
|
? path.posix.join(agentContainerRoot, relative)
|
|
: agentContainerRoot,
|
|
});
|
|
}
|
|
|
|
if (params.cwd) {
|
|
const cwdPosix = params.cwd.replace(/\\/g, "/");
|
|
if (path.posix.isAbsolute(cwdPosix)) {
|
|
const cwdContainer = normalizeContainerPath(cwdPosix);
|
|
const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer);
|
|
if (cwdMount) {
|
|
return this.toResolvedPath({
|
|
mount: cwdMount,
|
|
containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`);
|
|
}
|
|
|
|
private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath {
|
|
const relative = path.posix.relative(params.mount.containerRoot, params.containerPath);
|
|
if (relative.startsWith("..") || path.posix.isAbsolute(relative)) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`,
|
|
);
|
|
}
|
|
return {
|
|
relativePath:
|
|
params.mount.source === "workspace"
|
|
? relative === "."
|
|
? ""
|
|
: relative
|
|
: relative === "."
|
|
? params.mount.containerRoot
|
|
: `${params.mount.containerRoot}/${relative}`,
|
|
containerPath: params.containerPath,
|
|
writable: params.mount.writable,
|
|
mountRootPath: params.mount.containerRoot,
|
|
source: params.mount.source,
|
|
};
|
|
}
|
|
|
|
private resolveMountByContainerPath(
|
|
mounts: MountInfo[],
|
|
containerPath: string,
|
|
): MountInfo | null {
|
|
const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length);
|
|
for (const mount of ordered) {
|
|
if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) {
|
|
return mount;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private ensureWritable(target: ResolvedRemotePath, action: string) {
|
|
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
|
|
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
|
}
|
|
}
|
|
|
|
private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise<boolean> {
|
|
const result = await this.runRemoteScript({
|
|
script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi',
|
|
args: [containerPath],
|
|
signal,
|
|
});
|
|
return result.stdout.toString("utf8").trim() === "1";
|
|
}
|
|
|
|
private async resolveCanonicalPath(params: {
|
|
containerPath: string;
|
|
action: string;
|
|
allowFinalSymlinkForUnlink?: boolean;
|
|
signal?: AbortSignal;
|
|
}): Promise<string> {
|
|
const script = [
|
|
"set -eu",
|
|
'target="$1"',
|
|
'allow_final="$2"',
|
|
'suffix=""',
|
|
'probe="$target"',
|
|
'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi',
|
|
'cursor="$probe"',
|
|
'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do',
|
|
' parent=$(dirname -- "$cursor")',
|
|
' if [ "$parent" = "$cursor" ]; then break; fi',
|
|
' base=$(basename -- "$cursor")',
|
|
' suffix="/$base$suffix"',
|
|
' cursor="$parent"',
|
|
"done",
|
|
'canonical=$(readlink -f -- "$cursor")',
|
|
'printf "%s%s\\n" "$canonical" "$suffix"',
|
|
].join("\n");
|
|
const result = await this.runRemoteScript({
|
|
script,
|
|
args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"],
|
|
signal: params.signal,
|
|
});
|
|
const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim());
|
|
const mount = this.resolveMountByContainerPath(
|
|
[
|
|
{
|
|
containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir),
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "workspace",
|
|
},
|
|
...(this.sandbox.workspaceAccess !== "none" &&
|
|
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
|
|
? [
|
|
{
|
|
containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir),
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "agent" as const,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
canonical,
|
|
);
|
|
if (!mount) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
|
|
);
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
private async assertNoHardlinkedFile(params: {
|
|
containerPath: string;
|
|
action: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<void> {
|
|
const result = await this.runRemoteScript({
|
|
script: [
|
|
'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi',
|
|
'stats=$(stat -c "%F|%h" -- "$1")',
|
|
'printf "%s\\n" "$stats"',
|
|
].join("\n"),
|
|
args: [params.containerPath],
|
|
signal: params.signal,
|
|
allowFailure: true,
|
|
});
|
|
const output = result.stdout.toString("utf8").trim();
|
|
if (!output) {
|
|
return;
|
|
}
|
|
const [kind = "", linksRaw = "1"] = output.split("|");
|
|
if (kind === "regular file" && Number(linksRaw) > 1) {
|
|
throw new Error(
|
|
`Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resolvePinnedParent(params: {
|
|
containerPath: string;
|
|
action: string;
|
|
requireWritable?: boolean;
|
|
allowFinalSymlinkForUnlink?: boolean;
|
|
}): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> {
|
|
const basename = path.posix.basename(params.containerPath);
|
|
if (!basename || basename === "." || basename === "/") {
|
|
throw new Error(`Invalid sandbox entry target: ${params.containerPath}`);
|
|
}
|
|
const canonicalParent = await this.resolveCanonicalPath({
|
|
containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)),
|
|
action: params.action,
|
|
allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink,
|
|
});
|
|
const mount = this.resolveMountByContainerPath(
|
|
[
|
|
{
|
|
containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir),
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "workspace",
|
|
},
|
|
...(this.sandbox.workspaceAccess !== "none" &&
|
|
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
|
|
? [
|
|
{
|
|
containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir),
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "agent" as const,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
canonicalParent,
|
|
);
|
|
if (!mount) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
|
|
);
|
|
}
|
|
if (params.requireWritable && !mount.writable) {
|
|
throw new Error(
|
|
`Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`,
|
|
);
|
|
}
|
|
const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent);
|
|
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
|
|
);
|
|
}
|
|
return {
|
|
mountRootPath: mount.containerRoot,
|
|
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
|
|
basename,
|
|
};
|
|
}
|
|
|
|
private async runMutation(params: {
|
|
args: string[];
|
|
stdin?: Buffer | string;
|
|
signal?: AbortSignal;
|
|
allowFailure?: boolean;
|
|
}) {
|
|
await this.runRemoteScript({
|
|
script: [
|
|
"set -eu",
|
|
"python3 /dev/fd/3 \"$@\" 3<<'PY'",
|
|
SANDBOX_PINNED_MUTATION_PYTHON,
|
|
"PY",
|
|
].join("\n"),
|
|
args: params.args,
|
|
stdin: params.stdin,
|
|
signal: params.signal,
|
|
allowFailure: params.allowFailure,
|
|
});
|
|
}
|
|
|
|
private async runRemoteScript(params: {
|
|
script: string;
|
|
args?: string[];
|
|
stdin?: Buffer | string;
|
|
signal?: AbortSignal;
|
|
allowFailure?: boolean;
|
|
}) {
|
|
return await this.backend.runRemoteShellScript({
|
|
script: params.script,
|
|
args: params.args,
|
|
stdin: params.stdin,
|
|
signal: params.signal,
|
|
allowFailure: params.allowFailure,
|
|
});
|
|
}
|
|
}
|
|
|
|
function normalizeContainerPath(value: string): string {
|
|
const normalized = path.posix.normalize(value.trim() || "/");
|
|
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
}
|
|
|
|
function isPathInsideContainerRoot(root: string, candidate: string): boolean {
|
|
const normalizedRoot = normalizeContainerPath(root);
|
|
const normalizedCandidate = normalizeContainerPath(candidate);
|
|
return (
|
|
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
|
);
|
|
}
|
|
|
|
function isPathInside(root: string, candidate: string): boolean {
|
|
const relative = path.relative(root, candidate);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function toPosixRelative(root: string, candidate: string): string {
|
|
return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep);
|
|
}
|