mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
233 lines
8.3 KiB
JavaScript
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;
|