diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 8bb9352b972..e233488b60c 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js"; +import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; import type { SandboxConfig } from "./types.js"; let BROWSER_BRIDGES: Map; @@ -168,7 +169,7 @@ describe("ensureSandboxBrowser create args", () => { entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="), ); expect(passwordEntry).toMatch(/^OPENCLAW_BROWSER_NOVNC_PASSWORD=[A-Za-z0-9]{8}$/); - expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:19000\/sandbox\/novnc\?token=/); + expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/sandbox\/novnc\?token=/); expect(result?.noVncUrl).not.toContain("password="); }); @@ -202,7 +203,7 @@ describe("ensureSandboxBrowser create args", () => { const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); expect(createArgs).toBeDefined(); - expect(createArgs).toContain("/tmp/workspace:/workspace:ro"); + expect(createArgs).toContain("/tmp/workspace:/workspace:ro,z"); }); it("keeps the main workspace writable when workspaceAccess is rw", async () => { @@ -219,7 +220,20 @@ describe("ensureSandboxBrowser create args", () => { const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); expect(createArgs).toBeDefined(); - expect(createArgs).toContain("/tmp/workspace:/workspace"); - expect(createArgs).not.toContain("/tmp/workspace:/workspace:ro"); + expect(createArgs).toContain("/tmp/workspace:/workspace:z"); + expect(createArgs).not.toContain("/tmp/workspace:/workspace:ro,z"); + }); + + it("stamps the mount format version label on browser containers", async () => { + await ensureSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: buildConfig(false), + }); + + const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); + const labels = collectDockerFlagValues(createArgs ?? [], "--label"); + expect(labels).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`); }); }); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index eacf45a0c20..1280b47be32 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -35,7 +35,7 @@ import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js"; import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; import { validateNetworkMode } from "./validate-sandbox-security.js"; -import { appendWorkspaceMountArgs } from "./workspace-mounts.js"; +import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE"; @@ -167,6 +167,7 @@ export async function ensureSandboxBrowser(params: { workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }); const now = Date.now(); @@ -270,6 +271,7 @@ export async function ensureSandboxBrowser(params: { args.push(browserImage); await execDocker(args); await execDocker(["start", containerName]); + } else if (!running) { } else if (!running) { await execDocker(["start", containerName]); } @@ -333,8 +335,8 @@ export async function ensureSandboxBrowser(params: { const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { - const state = await dockerContainerState(containerName); - if (state.exists && !state.running) { + const currentState = await dockerContainerState(containerName); + if (currentState.exists && !currentState.running) { await execDocker(["start", containerName]); } const ok = await waitForSandboxCdp({ diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts index a4ea2bbb1c5..b0bbc481b40 100644 --- a/src/agents/sandbox/config-hash.test.ts +++ b/src/agents/sandbox/config-hash.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { computeSandboxBrowserConfigHash, computeSandboxConfigHash } from "./config-hash.js"; +import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; import type { SandboxDockerConfig } from "./types.js"; function createDockerConfig(overrides?: Partial): SandboxDockerConfig { @@ -59,6 +60,7 @@ describe("computeSandboxConfigHash", () => { workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }; const left = computeSandboxConfigHash({ ...shared, @@ -88,6 +90,7 @@ describe("computeSandboxConfigHash", () => { workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }; const left = computeSandboxConfigHash({ ...shared, @@ -120,6 +123,7 @@ describe("computeSandboxBrowserConfigHash", () => { workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }; const left = computeSandboxBrowserConfigHash({ ...shared, @@ -150,6 +154,7 @@ describe("computeSandboxBrowserConfigHash", () => { workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }; const left = computeSandboxBrowserConfigHash({ ...shared, @@ -176,6 +181,7 @@ describe("computeSandboxBrowserConfigHash", () => { workspaceAccess: "rw" as const, workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }; const left = computeSandboxBrowserConfigHash({ ...shared, @@ -187,4 +193,31 @@ describe("computeSandboxBrowserConfigHash", () => { }); expect(left).not.toBe(right); }); + + it("changes when mount format version changes", () => { + const shared = { + docker: createDockerConfig(), + browser: { + cdpPort: 9222, + cdpSourceRange: undefined, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true, + }, + securityEpoch: "epoch-v1", + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxBrowserConfigHash({ + ...shared, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, + }); + const right = computeSandboxBrowserConfigHash({ + ...shared, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION - 1, + }); + expect(left).not.toBe(right); + }); }); diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index c5354c24097..baab0116c5d 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -6,6 +6,7 @@ type SandboxHashInput = { workspaceAccess: SandboxWorkspaceAccess; workspaceDir: string; agentWorkspaceDir: string; + mountFormatVersion: number; }; type SandboxBrowserHashInput = { @@ -18,6 +19,7 @@ type SandboxBrowserHashInput = { workspaceAccess: SandboxWorkspaceAccess; workspaceDir: string; agentWorkspaceDir: string; + mountFormatVersion: number; }; function normalizeForHash(value: unknown): unknown { diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 47729939b6b..c7fa00898ca 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -3,6 +3,7 @@ import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { computeSandboxConfigHash } from "./config-hash.js"; import { collectDockerFlagValues } from "./test-args.js"; +import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; import type { SandboxConfig } from "./types.js"; type SpawnCall = { @@ -183,12 +184,14 @@ describe("ensureSandboxContainer config-hash recreation", () => { workspaceAccess: oldCfg.workspaceAccess, workspaceDir, agentWorkspaceDir: workspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }); const newHash = computeSandboxConfigHash({ docker: newCfg.docker, workspaceAccess: newCfg.workspaceAccess, workspaceDir, agentWorkspaceDir: workspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }); expect(newHash).not.toBe(oldHash); @@ -244,6 +247,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { workspaceAccess: cfg.workspaceAccess, workspaceDir, agentWorkspaceDir: workspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }); spawnState.inspectRunning = false; @@ -275,16 +279,16 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(createCall?.args).toContain(`openclaw.configHash=${expectedHash}`); const bindArgs = collectDockerFlagValues(createCall?.args ?? [], "-v"); - const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace"); + const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace:z"); const customMountIdx = bindArgs.indexOf("/tmp/workspace-shared/USER.md:/workspace/USER.md:ro"); expect(workspaceMountIdx).toBeGreaterThanOrEqual(0); expect(customMountIdx).toBeGreaterThan(workspaceMountIdx); }); it.each([ - { workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace" }, - { workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro" }, - { workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:ro" }, + { workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace:z" }, + { workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" }, + { workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" }, ])( "uses expected main mount permissions when workspaceAccess=$workspaceAccess", async ({ workspaceAccess, expectedMainMount }) => { @@ -312,4 +316,26 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(bindArgs).toContain(expectedMainMount); }, ); + + it("stamps the mount format version label on created containers", async () => { + const workspaceDir = "/tmp/workspace"; + const cfg = createSandboxConfig([]); + + spawnState.inspectRunning = false; + spawnState.labelHash = ""; + registryMocks.readRegistry.mockResolvedValue({ entries: [] }); + + await ensureSandboxContainer({ + sessionKey: "agent:main:session-1", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg, + }); + + const createCall = spawnState.calls.find( + (call) => call.command === "docker" && call.args[0] === "create", + ); + expect(createCall).toBeDefined(); + expect(createCall?.args).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`); + }); }); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 80a2921cb6b..6f90bbea94e 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -171,7 +171,7 @@ import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; import { validateSandboxSecurity } from "./validate-sandbox-security.js"; -import { appendWorkspaceMountArgs } from "./workspace-mounts.js"; +import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; const log = createSubsystemLogger("docker"); @@ -348,6 +348,7 @@ export function buildSandboxCreateArgs(params: { args.push("--label", "openclaw.sandbox=1"); args.push("--label", `openclaw.sessionKey=${params.scopeKey}`); args.push("--label", `openclaw.createdAtMs=${createdAtMs}`); + args.push("--label", `openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`); if (params.configHash) { args.push("--label", `openclaw.configHash=${params.configHash}`); } @@ -504,6 +505,7 @@ export async function ensureSandboxContainer(params: { workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, + mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION, }); const now = Date.now(); const state = await dockerContainerState(containerName); diff --git a/src/agents/sandbox/workspace-mounts.test.ts b/src/agents/sandbox/workspace-mounts.test.ts index 0fe8c3897b3..f4f2deeab76 100644 --- a/src/agents/sandbox/workspace-mounts.test.ts +++ b/src/agents/sandbox/workspace-mounts.test.ts @@ -3,9 +3,9 @@ import { appendWorkspaceMountArgs } from "./workspace-mounts.js"; describe("appendWorkspaceMountArgs", () => { it.each([ - { access: "rw" as const, expected: "/tmp/workspace:/workspace" }, - { access: "ro" as const, expected: "/tmp/workspace:/workspace:ro" }, - { access: "none" as const, expected: "/tmp/workspace:/workspace:ro" }, + { access: "rw" as const, expected: "/tmp/workspace:/workspace:z" }, + { access: "ro" as const, expected: "/tmp/workspace:/workspace:ro,z" }, + { access: "none" as const, expected: "/tmp/workspace:/workspace:ro,z" }, ])("sets main mount permissions for workspaceAccess=$access", ({ access, expected }) => { const args: string[] = []; appendWorkspaceMountArgs({ @@ -30,7 +30,7 @@ describe("appendWorkspaceMountArgs", () => { }); const mounts = args.filter((arg) => arg.startsWith("/tmp/")); - expect(mounts).toEqual(["/tmp/workspace:/workspace:ro"]); + expect(mounts).toEqual(["/tmp/workspace:/workspace:ro,z"]); }); it("omits agent workspace mount when paths are identical", () => { @@ -44,6 +44,23 @@ describe("appendWorkspaceMountArgs", () => { }); const mounts = args.filter((arg) => arg.startsWith("/tmp/")); - expect(mounts).toEqual(["/tmp/workspace:/workspace"]); + expect(mounts).toEqual(["/tmp/workspace:/workspace:z"]); + }); + + it("marks split agent workspace mounts shared for SELinux", () => { + const args: string[] = []; + appendWorkspaceMountArgs({ + args, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/agent-workspace", + workdir: "/workspace", + workspaceAccess: "ro", + }); + + const mounts = args.filter((arg) => arg.startsWith("/tmp/")); + expect(mounts).toEqual([ + "/tmp/workspace:/workspace:ro,z", + "/tmp/agent-workspace:/agent:ro,z", + ]); }); }); diff --git a/src/agents/sandbox/workspace-mounts.ts b/src/agents/sandbox/workspace-mounts.ts index ee7627eb1ad..cf216b5076a 100644 --- a/src/agents/sandbox/workspace-mounts.ts +++ b/src/agents/sandbox/workspace-mounts.ts @@ -1,12 +1,14 @@ import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import type { SandboxWorkspaceAccess } from "./types.js"; -function mainWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" { - return access === "rw" ? "" : ":ro"; -} +export const SANDBOX_MOUNT_FORMAT_VERSION = 2; -function agentWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" { - return access === "ro" ? ":ro" : ""; +function formatManagedWorkspaceBind(params: { + hostPath: string; + containerPath: string; + readOnly: boolean; +}): string { + return `${params.hostPath}:${params.containerPath}:${params.readOnly ? "ro,z" : "z"}`; } export function appendWorkspaceMountArgs(params: { @@ -18,11 +20,22 @@ export function appendWorkspaceMountArgs(params: { }) { const { args, workspaceDir, agentWorkspaceDir, workdir, workspaceAccess } = params; - args.push("-v", `${workspaceDir}:${workdir}${mainWorkspaceMountSuffix(workspaceAccess)}`); + args.push( + "-v", + formatManagedWorkspaceBind({ + hostPath: workspaceDir, + containerPath: workdir, + readOnly: workspaceAccess !== "rw", + }), + ); if (workspaceAccess !== "none" && workspaceDir !== agentWorkspaceDir) { args.push( "-v", - `${agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentWorkspaceMountSuffix(workspaceAccess)}`, + formatManagedWorkspaceBind({ + hostPath: agentWorkspaceDir, + containerPath: SANDBOX_AGENT_WORKSPACE_MOUNT, + readOnly: workspaceAccess === "ro", + }), ); } }