/** * 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;