mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 07:01:40 +03:00
Sandbox: add shared bind-aware fs path resolver
This commit is contained in:
@@ -30,11 +30,15 @@ function resolveToCwd(filePath: string, cwd: string): string {
|
|||||||
return path.resolve(cwd, expanded);
|
return path.resolve(cwd, expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSandboxInputPath(filePath: string, cwd: string): string {
|
||||||
|
return resolveToCwd(filePath, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSandboxPath(params: { filePath: string; cwd: string; root: string }): {
|
export function resolveSandboxPath(params: { filePath: string; cwd: string; root: string }): {
|
||||||
resolved: string;
|
resolved: string;
|
||||||
relative: string;
|
relative: string;
|
||||||
} {
|
} {
|
||||||
const resolved = resolveToCwd(params.filePath, params.cwd);
|
const resolved = resolveSandboxInputPath(params.filePath, params.cwd);
|
||||||
const rootResolved = path.resolve(params.root);
|
const rootResolved = path.resolve(params.root);
|
||||||
const relative = path.relative(rootResolved, resolved);
|
const relative = path.relative(rootResolved, resolved);
|
||||||
if (!relative || relative === "") {
|
if (!relative || relative === "") {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SandboxContext } from "./types.js";
|
||||||
|
import {
|
||||||
|
buildSandboxFsMounts,
|
||||||
|
parseSandboxBindMount,
|
||||||
|
resolveSandboxFsPathWithMounts,
|
||||||
|
} from "./fs-paths.js";
|
||||||
|
|
||||||
|
function createSandbox(overrides?: Partial<SandboxContext>): SandboxContext {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
sessionKey: "sandbox:test",
|
||||||
|
workspaceDir: "/tmp/workspace",
|
||||||
|
agentWorkspaceDir: "/tmp/workspace",
|
||||||
|
workspaceAccess: "rw",
|
||||||
|
containerName: "openclaw-sbx-test",
|
||||||
|
containerWorkdir: "/workspace",
|
||||||
|
docker: {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
network: "none",
|
||||||
|
user: "1000:1000",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
capDrop: [],
|
||||||
|
seccompProfile: "",
|
||||||
|
apparmorProfile: "",
|
||||||
|
setupCommand: "",
|
||||||
|
binds: [],
|
||||||
|
dns: [],
|
||||||
|
extraHosts: [],
|
||||||
|
pidsLimit: 0,
|
||||||
|
},
|
||||||
|
tools: { allow: ["*"], deny: [] },
|
||||||
|
browserAllowHostControl: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseSandboxBindMount", () => {
|
||||||
|
it("parses bind mode and writeability", () => {
|
||||||
|
expect(parseSandboxBindMount("/tmp/a:/workspace-a:ro")).toEqual({
|
||||||
|
hostRoot: "/tmp/a",
|
||||||
|
containerRoot: "/workspace-a",
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
expect(parseSandboxBindMount("/tmp/b:/workspace-b:rw")).toEqual({
|
||||||
|
hostRoot: "/tmp/b",
|
||||||
|
containerRoot: "/workspace-b",
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSandboxFsPathWithMounts", () => {
|
||||||
|
it("maps mounted container absolute paths to host paths", () => {
|
||||||
|
const sandbox = createSandbox({
|
||||||
|
docker: {
|
||||||
|
...createSandbox().docker,
|
||||||
|
binds: ["/tmp/workspace-two:/workspace-two:ro"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mounts = buildSandboxFsMounts(sandbox);
|
||||||
|
const resolved = resolveSandboxFsPathWithMounts({
|
||||||
|
filePath: "/workspace-two/docs/AGENTS.md",
|
||||||
|
cwd: sandbox.workspaceDir,
|
||||||
|
defaultWorkspaceRoot: sandbox.workspaceDir,
|
||||||
|
defaultContainerRoot: sandbox.containerWorkdir,
|
||||||
|
mounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.hostPath).toBe("/tmp/workspace-two/docs/AGENTS.md");
|
||||||
|
expect(resolved.containerPath).toBe("/workspace-two/docs/AGENTS.md");
|
||||||
|
expect(resolved.relativePath).toBe("/workspace-two/docs/AGENTS.md");
|
||||||
|
expect(resolved.writable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps workspace-relative display paths for default workspace files", () => {
|
||||||
|
const sandbox = createSandbox();
|
||||||
|
const mounts = buildSandboxFsMounts(sandbox);
|
||||||
|
const resolved = resolveSandboxFsPathWithMounts({
|
||||||
|
filePath: "src/index.ts",
|
||||||
|
cwd: sandbox.workspaceDir,
|
||||||
|
defaultWorkspaceRoot: sandbox.workspaceDir,
|
||||||
|
defaultContainerRoot: sandbox.containerWorkdir,
|
||||||
|
mounts,
|
||||||
|
});
|
||||||
|
expect(resolved.hostPath).toBe("/tmp/workspace/src/index.ts");
|
||||||
|
expect(resolved.containerPath).toBe("/workspace/src/index.ts");
|
||||||
|
expect(resolved.relativePath).toBe("src/index.ts");
|
||||||
|
expect(resolved.writable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves legacy sandbox-root error for outside paths", () => {
|
||||||
|
const sandbox = createSandbox();
|
||||||
|
const mounts = buildSandboxFsMounts(sandbox);
|
||||||
|
expect(() =>
|
||||||
|
resolveSandboxFsPathWithMounts({
|
||||||
|
filePath: "/etc/passwd",
|
||||||
|
cwd: sandbox.workspaceDir,
|
||||||
|
defaultWorkspaceRoot: sandbox.workspaceDir,
|
||||||
|
defaultContainerRoot: sandbox.containerWorkdir,
|
||||||
|
mounts,
|
||||||
|
}),
|
||||||
|
).toThrow(/Path escapes sandbox root/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { SandboxContext } from "./types.js";
|
||||||
|
import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js";
|
||||||
|
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||||
|
|
||||||
|
export type SandboxFsMount = {
|
||||||
|
hostRoot: string;
|
||||||
|
containerRoot: string;
|
||||||
|
writable: boolean;
|
||||||
|
source: "workspace" | "agent" | "bind";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SandboxResolvedFsPath = {
|
||||||
|
hostPath: string;
|
||||||
|
relativePath: string;
|
||||||
|
containerPath: string;
|
||||||
|
writable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedBindMount = {
|
||||||
|
hostRoot: string;
|
||||||
|
containerRoot: string;
|
||||||
|
writable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSandboxBindMount(spec: string): ParsedBindMount | null {
|
||||||
|
const trimmed = spec.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = trimmed.split(":");
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hostToken = (parts[0] ?? "").trim();
|
||||||
|
const containerToken = (parts[1] ?? "").trim();
|
||||||
|
if (!hostToken || !containerToken || !path.posix.isAbsolute(containerToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const optionsToken = parts.slice(2).join(":").trim().toLowerCase();
|
||||||
|
const optionParts = optionsToken
|
||||||
|
? optionsToken
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const writable = !optionParts.includes("ro");
|
||||||
|
return {
|
||||||
|
hostRoot: path.resolve(hostToken),
|
||||||
|
containerRoot: normalizeContainerPath(containerToken),
|
||||||
|
writable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSandboxFsMounts(sandbox: SandboxContext): SandboxFsMount[] {
|
||||||
|
const mounts: SandboxFsMount[] = [
|
||||||
|
{
|
||||||
|
hostRoot: path.resolve(sandbox.workspaceDir),
|
||||||
|
containerRoot: normalizeContainerPath(sandbox.containerWorkdir),
|
||||||
|
writable: sandbox.workspaceAccess === "rw",
|
||||||
|
source: "workspace",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
sandbox.workspaceAccess !== "none" &&
|
||||||
|
path.resolve(sandbox.agentWorkspaceDir) !== path.resolve(sandbox.workspaceDir)
|
||||||
|
) {
|
||||||
|
mounts.push({
|
||||||
|
hostRoot: path.resolve(sandbox.agentWorkspaceDir),
|
||||||
|
containerRoot: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||||
|
writable: sandbox.workspaceAccess === "rw",
|
||||||
|
source: "agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bind of sandbox.docker.binds ?? []) {
|
||||||
|
const parsed = parseSandboxBindMount(bind);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mounts.push({
|
||||||
|
hostRoot: parsed.hostRoot,
|
||||||
|
containerRoot: parsed.containerRoot,
|
||||||
|
writable: parsed.writable,
|
||||||
|
source: "bind",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeMounts(mounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSandboxFsPathWithMounts(params: {
|
||||||
|
filePath: string;
|
||||||
|
cwd: string;
|
||||||
|
defaultWorkspaceRoot: string;
|
||||||
|
defaultContainerRoot: string;
|
||||||
|
mounts: SandboxFsMount[];
|
||||||
|
}): SandboxResolvedFsPath {
|
||||||
|
const mountsByContainer = [...params.mounts].toSorted(
|
||||||
|
(a, b) => b.containerRoot.length - a.containerRoot.length,
|
||||||
|
);
|
||||||
|
const mountsByHost = [...params.mounts].toSorted((a, b) => b.hostRoot.length - a.hostRoot.length);
|
||||||
|
const input = params.filePath;
|
||||||
|
const inputPosix = normalizePosixInput(input);
|
||||||
|
|
||||||
|
if (path.posix.isAbsolute(inputPosix)) {
|
||||||
|
const containerMount = findMountByContainerPath(mountsByContainer, inputPosix);
|
||||||
|
if (containerMount) {
|
||||||
|
const rel = path.posix.relative(containerMount.containerRoot, inputPosix);
|
||||||
|
const hostPath = rel
|
||||||
|
? path.resolve(containerMount.hostRoot, ...toHostSegments(rel))
|
||||||
|
: containerMount.hostRoot;
|
||||||
|
return {
|
||||||
|
hostPath,
|
||||||
|
containerPath: rel
|
||||||
|
? path.posix.join(containerMount.containerRoot, rel)
|
||||||
|
: containerMount.containerRoot,
|
||||||
|
relativePath: toDisplayRelative({
|
||||||
|
containerPath: rel
|
||||||
|
? path.posix.join(containerMount.containerRoot, rel)
|
||||||
|
: containerMount.containerRoot,
|
||||||
|
defaultContainerRoot: params.defaultContainerRoot,
|
||||||
|
}),
|
||||||
|
writable: containerMount.writable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostResolved = resolveSandboxInputPath(input, params.cwd);
|
||||||
|
const hostMount = findMountByHostPath(mountsByHost, hostResolved);
|
||||||
|
if (hostMount) {
|
||||||
|
const relHost = path.relative(hostMount.hostRoot, hostResolved);
|
||||||
|
const relPosix = relHost ? relHost.split(path.sep).join(path.posix.sep) : "";
|
||||||
|
const containerPath = relPosix
|
||||||
|
? path.posix.join(hostMount.containerRoot, relPosix)
|
||||||
|
: hostMount.containerRoot;
|
||||||
|
return {
|
||||||
|
hostPath: hostResolved,
|
||||||
|
containerPath,
|
||||||
|
relativePath: toDisplayRelative({
|
||||||
|
containerPath,
|
||||||
|
defaultContainerRoot: params.defaultContainerRoot,
|
||||||
|
}),
|
||||||
|
writable: hostMount.writable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve legacy error wording for out-of-sandbox paths.
|
||||||
|
resolveSandboxPath({
|
||||||
|
filePath: input,
|
||||||
|
cwd: params.cwd,
|
||||||
|
root: params.defaultWorkspaceRoot,
|
||||||
|
});
|
||||||
|
throw new Error(`Path escapes sandbox root (${params.defaultWorkspaceRoot}): ${input}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeMounts(mounts: SandboxFsMount[]): SandboxFsMount[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: SandboxFsMount[] = [];
|
||||||
|
for (const mount of mounts) {
|
||||||
|
const key = `${mount.hostRoot}=>${mount.containerRoot}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push(mount);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMountByContainerPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null {
|
||||||
|
for (const mount of mounts) {
|
||||||
|
if (isPathInsidePosix(mount.containerRoot, target)) {
|
||||||
|
return mount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMountByHostPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null {
|
||||||
|
for (const mount of mounts) {
|
||||||
|
if (isPathInsideHost(mount.hostRoot, target)) {
|
||||||
|
return mount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsidePosix(root: string, target: string): boolean {
|
||||||
|
const rel = path.posix.relative(root, target);
|
||||||
|
if (!rel) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !(rel.startsWith("..") || path.posix.isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsideHost(root: string, target: string): boolean {
|
||||||
|
const rel = path.relative(root, target);
|
||||||
|
if (!rel) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !(rel.startsWith("..") || path.isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHostSegments(relativePosix: string): string[] {
|
||||||
|
return relativePosix.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDisplayRelative(params: {
|
||||||
|
containerPath: string;
|
||||||
|
defaultContainerRoot: string;
|
||||||
|
}): string {
|
||||||
|
const rel = path.posix.relative(params.defaultContainerRoot, params.containerPath);
|
||||||
|
if (!rel) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!rel.startsWith("..") && !path.posix.isAbsolute(rel)) {
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
return params.containerPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContainerPath(value: string): string {
|
||||||
|
const normalized = path.posix.normalize(value);
|
||||||
|
return normalized === "." ? "/" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosixInput(value: string): string {
|
||||||
|
return value.replace(/\\/g, "/").trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user