Files
openclaw/scripts/e2e-sandbox-bind-conflict.mjs
2026-08-01 10:43:08 +08:00

233 lines
8.3 KiB
JavaScript

/**
* End-to-end verification: protected skill mounts keep authority; conflicting
* user binds are skipped to prevent duplicate mount destination errors.
*
* Prerequisites: Docker or Podman, Node >=22.19, pnpm install, and the selected image.
* Usage:
* OPENCLAW_SANDBOX_E2E_ENGINE=podman \
* OPENCLAW_SANDBOX_E2E_IMAGE=alpine:3.20 \
* node --import tsx scripts/e2e-sandbox-bind-conflict.mjs
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const engine = process.env.OPENCLAW_SANDBOX_E2E_ENGINE?.trim() || "docker";
const image = process.env.OPENCLAW_SANDBOX_E2E_IMAGE?.trim() || "e2e-sleep:latest";
const useSudo = process.env.OPENCLAW_SANDBOX_E2E_SUDO === "1";
if (engine !== "docker" && engine !== "podman") {
throw new Error(`Unsupported container engine "${engine}". Use docker or podman.`);
}
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-"));
const skillsDir = path.join(workspaceDir, "skills", "demo");
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, "SKILL.md"), "# E2E demo\n");
console.log("Workspace:", workspaceDir);
const customBindHost = path.join(workspaceDir, "custom-mount");
fs.mkdirSync(customBindHost, { recursive: true });
fs.writeFileSync(path.join(customBindHost, "data.txt"), "user data\n");
const userBinds = [`${customBindHost}:/workspace/skills:rw`];
const containerName = `oc-e2e-${engine}-bind-${Date.now()}`.slice(0, 63);
let failureCount = 0;
function fail(label) {
console.log(`❌ FAIL: ${label}`);
failureCount += 1;
}
function pass(label) {
console.log(`${label}`);
}
// ── Load production code ──────────────────────────────────────────────
const {
resolveReadOnlyWorkspaceSkillMounts,
resolveProtectedSkillMountContainerPaths,
filterBindsConflictingWithProtectedMounts,
} = await import(path.join(repoRoot, "src/agents/sandbox/workspace-mounts.js"));
// ── Resolve protected skill mounts ────────────────────────────────────
console.log("\n--- Protected skill mounts ---");
const protectedMounts = resolveReadOnlyWorkspaceSkillMounts({
workspaceDir,
agentWorkspaceDir: workspaceDir,
workdir: "/workspace",
workspaceAccess: "rw",
});
console.log(
"Protected:",
protectedMounts.map((m) => `${m.hostPath} -> ${m.containerPath}`),
);
// ── Resolve protected container paths ─────────────────────────────────
const protectedPaths = resolveProtectedSkillMountContainerPaths(protectedMounts);
console.log("Protected paths:", [...protectedPaths]);
// ── Filter user binds ─────────────────────────────────────────────────
console.log("\nUser binds:", userBinds);
const safeBinds = filterBindsConflictingWithProtectedMounts(userBinds, protectedPaths);
console.log(
"Safe binds (after skipping conflicts):",
safeBinds.length === 0 ? "(none)" : safeBinds,
);
// Conflicting bind should be filtered out (no safe binds remain)
if (safeBinds.length > 0) {
fail("conflicting user bind was not filtered out");
} else {
pass("conflicting user bind correctly skipped");
}
// ── Build container create args ───────────────────────────────────────
const createArgs = [
"create",
"--name",
containerName,
"--label",
"openclaw.e2e=1",
"--workdir",
"/workspace",
"-v",
`${workspaceDir}:/workspace`,
...safeBinds.flatMap((b) => ["-v", b]),
];
// Protected skill mounts always appended (authoritative, read-only)
for (const m of protectedMounts) {
createArgs.push("-v", `${m.hostPath}:${m.containerPath}:ro`);
}
createArgs.push(image, "sleep", "infinity");
// ── Duplicate check ───────────────────────────────────────────────────
console.log(`\n--- ${engine} args ---`);
let nextIsMount = false;
for (const a of createArgs) {
if (nextIsMount) {
console.log(` -v ${a}`);
nextIsMount = false;
} else if (a === "-v") {
nextIsMount = true;
} else if (a.startsWith("-")) {
console.log(` ${a}`);
} else {
console.log(` ${a}`);
}
}
console.log("\n--- Duplicate check ---");
const seen = new Map();
let dupes = 0;
for (let i = 0; i < createArgs.length - 1; i++) {
if (createArgs[i] !== "-v") {
continue;
}
const parts = createArgs[i + 1].split(":");
if (parts.length < 2) {
continue;
}
const cpath = parts[1];
if (seen.has(cpath)) {
console.log(`❌ DUPLICATE: ${cpath}`);
dupes++;
} else {
seen.set(cpath, createArgs[i + 1]);
}
}
if (dupes === 0) {
console.log("✅ No duplicate container paths in -v args");
} else {
fail(`found ${dupes} duplicate container paths`);
}
// ── Helper: run the selected engine with argv (no shell string) ───────
function runEngine(args, opts = {}) {
return spawnSync(useSudo ? "sudo" : engine, useSudo ? [engine, ...args] : args, {
encoding: "utf8",
timeout: 30_000,
...opts,
});
}
// ── Container create ──────────────────────────────────────────────────
console.log(`\n--- ${engine} create ${containerName} ---`);
let created = false;
try {
const result = runEngine(createArgs, { stdio: "pipe" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const err = new Error(result.stderr?.trim() || `docker create exited ${result.status}`);
err.code = result.status;
err.stdout = result.stdout;
err.stderr = result.stderr;
throw err;
}
created = true;
pass(`Container created with ${engine} without duplicate mount destinations`);
const inspectResult = runEngine(["inspect", containerName]);
if (inspectResult.error) {
throw inspectResult.error;
}
if (inspectResult.status !== 0) {
const err = new Error(`inspect exited ${inspectResult.status}`);
err.stderr = inspectResult.stderr;
throw err;
}
const inspectedContainer = JSON.parse(inspectResult.stdout)[0];
const mounts = Array.isArray(inspectedContainer?.Mounts) ? inspectedContainer.Mounts : [];
const dests = mounts
.map((mount) => mount?.Destination)
.filter((destination) => typeof destination === "string");
const skillsCount = dests.filter((d) => d === "/workspace/skills").length;
console.log(`Mount destinations: ${dests.join(" ")}`);
console.log(`/workspace/skills count: ${skillsCount}`);
if (skillsCount <= 1) {
pass("/workspace/skills appears at most once");
} else {
fail(`/workspace/skills appears ${skillsCount} times (expected ≤1)`);
}
// Verify protected mount source (not user bind)
const skillMount = mounts.find((mount) => mount?.Destination === "/workspace/skills");
const mountSrc = typeof skillMount?.Source === "string" ? skillMount.Source : "";
console.log(`Mount source for /workspace/skills: ${mountSrc}`);
const isReadOnly = skillMount?.RW === false;
const isProtectedSource = mountSrc === path.join(workspaceDir, "skills");
if (isReadOnly) {
pass("mount is read-only");
} else {
fail("mount is NOT read-only");
}
if (isProtectedSource) {
pass("mount source is the protected skill directory");
} else {
fail("mount source is NOT the protected skill directory");
}
} catch (err) {
const msg = err?.stderr ? String(err.stderr) : String(err.message ?? err);
if (msg.includes("Duplicate mount point") || msg.includes("duplicate mount")) {
fail(`Duplicate mount point rejected by ${engine}`);
console.log(msg.slice(0, 500));
} else {
console.log(`❌ Error: ${msg.slice(0, 500)}`);
fail(msg.slice(0, 200));
}
} finally {
if (created) {
runEngine(["rm", "-f", containerName]);
}
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
console.log(
failureCount > 0 ? `\n❌ Done. ${failureCount} failure(s)` : "\n✅ Done. All checks passed.",
);
process.exitCode = failureCount > 0 ? 1 : 0;