Files
openclaw/extensions/openshell/src/remote-fs-bridge.ts
2026-03-15 20:28:19 -07:00

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);
}