From 5a59bb07b391b12e087f5029aee5c216c42ac862 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 09:21:54 +0800 Subject: [PATCH] test(qa): cover sandbox workspace isolation (#119038) --- .../openclaw-sandbox-workspace-isolation.yaml | 25 +++ ...aw-sandbox-workspace-isolation.e2e.test.ts | 158 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 qa/scenarios/runtime/openclaw-sandbox-workspace-isolation.yaml create mode 100644 test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts diff --git a/qa/scenarios/runtime/openclaw-sandbox-workspace-isolation.yaml b/qa/scenarios/runtime/openclaw-sandbox-workspace-isolation.yaml new file mode 100644 index 000000000000..f6e7e8b7d15f --- /dev/null +++ b/qa/scenarios/runtime/openclaw-sandbox-workspace-isolation.yaml @@ -0,0 +1,25 @@ +title: OpenClaw sandbox workspace isolation + +scenario: + id: openclaw-sandbox-workspace-isolation + surface: runtime-tools + coverage: + primary: + - tools.workspace-isolation + objective: Verify real Docker sandbox mounts isolate host workspaces according to none, read-only, and read-write access. + successCriteria: + - None mode exposes neither the agent workspace nor its sentinel and rejects sandbox workspace mutation. + - Read-only mode exposes the agent workspace at /agent, rejects mutation, and leaves host content unchanged. + - Read-write mode persists /workspace mutation to the host workspace. + - Every mode keeps an unrelated host sentinel inaccessible and cleans up its runtime and state. + docsRefs: + - docs/gateway/sandboxing.md + codeRefs: + - src/agents/sandbox/context.ts + - src/agents/sandbox/docker-backend.ts + - src/agents/sandbox/workspace-mounts.ts + - test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts + summary: Provision real Docker sandboxes and prove observed mount behavior through backend shell commands. diff --git a/test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts b/test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts new file mode 100644 index 000000000000..ce8f417e01a9 --- /dev/null +++ b/test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js"; +import { captureEnv, setTestEnvValue } from "../../../../src/test-utils/env.js"; + +type WorkspaceAccess = "none" | "ro" | "rw"; +type SandboxContext = NonNullable< + Awaited< + ReturnType + > +>; + +function createConfig(params: { + access: WorkspaceAccess; + image: string; + prefix: string; + workspaceRoot: string; +}): OpenClawConfig { + return { + agents: { + defaults: { + skipBootstrap: true, + sandbox: { + mode: "all", + backend: "docker", + scope: "session", + workspaceAccess: params.access, + workspaceRoot: params.workspaceRoot, + docker: { + image: params.image, + containerPrefix: params.prefix, + }, + browser: { enabled: false }, + prune: { idleHours: 0, maxAgeDays: 0 }, + }, + }, + }, + }; +} + +async function run( + sandbox: SandboxContext, + script: string, + args: string[] = [], + allowFailure = false, +) { + if (!sandbox.backend) { + throw new Error("provisioned sandbox has no backend handle"); + } + return await sandbox.backend.runShellCommand({ script, args, allowFailure }); +} + +async function expectOk(sandbox: SandboxContext, script: string, args: string[] = []) { + const result = await run(sandbox, script, args); + expect(result.code, result.stderr.toString()).toBe(0); + return result.stdout.toString(); +} + +test("Docker enforces none, read-only, and read-write workspace isolation", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-isolation-")); + const stateDir = path.join(root, "state"); + const workspaceRoot = path.join(root, "sandboxes"); + const unrelatedSentinel = path.join(root, "host-only-sentinel.txt"); + const image = process.env.OPENCLAW_SANDBOX_TEST_IMAGE ?? "openclaw-sandbox:bookworm-slim"; + const prefix = `oc-qa-${process.pid}-`; + const env = captureEnv(["OPENCLAW_STATE_DIR"]); + const runtimes: string[] = []; + + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(unrelatedSentinel, "host-only"); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + + try { + const [{ resolveSandboxContext }, { removeSandboxContainer }] = await Promise.all([ + import("../../../../src/agents/sandbox/context.js"), + import("../../../../src/agents/sandbox/manage.js"), + ]); + + for (const access of ["none", "ro", "rw"] as const) { + const hostWorkspace = path.join(root, `agent-${access}-${randomUUID()}`); + const hostSentinel = path.join(hostWorkspace, "host-sentinel.txt"); + await fs.mkdir(hostWorkspace, { recursive: true }); + if (access === "rw") { + // Rootful Docker otherwise creates this nested bind target as root, + // which leaves fixture teardown unable to remove its own temp tree. + await fs.mkdir(path.join(hostWorkspace, ".openclaw", "sandbox-skills", "skills"), { + recursive: true, + }); + } + await fs.writeFile(hostSentinel, `original-${access}`); + + let sandbox: SandboxContext | null = null; + try { + sandbox = await resolveSandboxContext({ + config: createConfig({ access, image, prefix, workspaceRoot }), + agentId: `workspace-${access}`, + sessionKey: `agent:workspace-${access}:qa-${randomUUID()}`, + workspaceDir: hostWorkspace, + requireCurrentConfig: true, + }); + expect(sandbox).not.toBeNull(); + if (!sandbox) { + throw new Error(`sandbox context missing for ${access}`); + } + runtimes.push(sandbox.runtimeId); + expect(sandbox.workspaceAccess).toBe(access); + await expectOk(sandbox, "grep -Eq ' /workspace ' /proc/self/mountinfo"); + await expectOk(sandbox, 'test ! -e "$1"', [unrelatedSentinel]); + + if (access === "none") { + await expectOk( + sandbox, + 'test ! -e /agent && test ! -e /workspace/host-sentinel.txt && test ! -e "$1"', + [hostSentinel], + ); + const mutation = await run(sandbox, "printf blocked > /workspace/blocked.txt", [], true); + expect(mutation.code).not.toBe(0); + await expect(fs.readFile(hostSentinel, "utf8")).resolves.toBe("original-none"); + } else if (access === "ro") { + await expectOk( + sandbox, + 'grep -Eq \' /agent \' /proc/self/mountinfo && test "$(cat /agent/host-sentinel.txt)" = "$1"', + ["original-ro"], + ); + const mutation = await run( + sandbox, + "printf blocked > /agent/host-sentinel.txt", + [], + true, + ); + expect(mutation.code).not.toBe(0); + await expect(fs.readFile(hostSentinel, "utf8")).resolves.toBe("original-ro"); + } else { + await expectOk( + sandbox, + 'printf persisted > /workspace/host-sentinel.txt && test "$(cat /workspace/host-sentinel.txt)" = persisted', + ); + await expect(fs.readFile(hostSentinel, "utf8")).resolves.toBe("persisted"); + } + } finally { + if (sandbox) { + await removeSandboxContainer(sandbox.runtimeId); + runtimes.splice(runtimes.indexOf(sandbox.runtimeId), 1); + } + } + } + } finally { + const { execDocker } = await import("../../../../src/agents/sandbox/docker.js"); + for (const runtimeId of runtimes) { + await execDocker(["rm", "-f", runtimeId], { allowFailure: true }); + } + env.restore(); + await fs.rm(root, { recursive: true, force: true }); + } +}, 120_000);