import { beforeEach, describe, expect, it, vi } from "vitest"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resetNoVncObserverTokensForTests } from "./novnc-auth.js"; import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js"; import type { SandboxConfig } from "./types.js"; const dockerMocks = vi.hoisted(() => ({ dockerContainerState: vi.fn(), execDocker: vi.fn(), readDockerContainerEnvVar: vi.fn(), readDockerContainerLabel: vi.fn(), readDockerPort: vi.fn(), })); const registryMocks = vi.hoisted(() => ({ readBrowserRegistry: vi.fn(), updateBrowserRegistry: vi.fn(), })); const bridgeMocks = vi.hoisted(() => ({ startBrowserBridgeServer: vi.fn(), stopBrowserBridgeServer: vi.fn(), })); vi.mock("./docker.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dockerContainerState: dockerMocks.dockerContainerState, execDocker: dockerMocks.execDocker, readDockerContainerEnvVar: dockerMocks.readDockerContainerEnvVar, readDockerContainerLabel: dockerMocks.readDockerContainerLabel, readDockerPort: dockerMocks.readDockerPort, }; }); vi.mock("./registry.js", () => ({ readBrowserRegistry: registryMocks.readBrowserRegistry, updateBrowserRegistry: registryMocks.updateBrowserRegistry, })); vi.mock("../../browser/bridge-server.js", () => ({ startBrowserBridgeServer: bridgeMocks.startBrowserBridgeServer, stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer, })); function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", scope: "session", workspaceAccess: "none", workspaceRoot: "/tmp/openclaw-sandboxes", docker: { image: "openclaw-sandbox:bookworm-slim", containerPrefix: "openclaw-sbx-", workdir: "/workspace", readOnlyRoot: true, tmpfs: ["/tmp", "/var/tmp", "/run"], network: "none", capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, }, browser: { enabled: true, image: "openclaw-sandbox-browser:bookworm-slim", containerPrefix: "openclaw-sbx-browser-", network: "openclaw-sandbox-browser", cdpPort: 9222, vncPort: 5900, noVncPort: 6080, headless: false, enableNoVnc, allowHostControl: false, autoStart: true, autoStartTimeoutMs: 12_000, }, tools: { allow: ["browser"], deny: [], }, prune: { idleHours: 24, maxAgeDays: 7, }, }; } describe("ensureSandboxBrowser create args", () => { beforeEach(() => { BROWSER_BRIDGES.clear(); resetNoVncObserverTokensForTests(); dockerMocks.dockerContainerState.mockClear(); dockerMocks.execDocker.mockClear(); dockerMocks.readDockerContainerEnvVar.mockClear(); dockerMocks.readDockerContainerLabel.mockClear(); dockerMocks.readDockerPort.mockClear(); registryMocks.readBrowserRegistry.mockClear(); registryMocks.updateBrowserRegistry.mockClear(); bridgeMocks.startBrowserBridgeServer.mockClear(); bridgeMocks.stopBrowserBridgeServer.mockClear(); dockerMocks.dockerContainerState.mockResolvedValue({ exists: false, running: false }); dockerMocks.execDocker.mockImplementation(async (args: string[]) => { if (args[0] === "image" && args[1] === "inspect") { return { stdout: "[]", stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; }); dockerMocks.readDockerContainerLabel.mockResolvedValue(null); dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null); dockerMocks.readDockerPort.mockImplementation(async (_containerName: string, port: number) => { if (port === 9222) { return 49100; } if (port === 6080) { return 49101; } return null; }); registryMocks.readBrowserRegistry.mockResolvedValue({ entries: [] }); registryMocks.updateBrowserRegistry.mockResolvedValue(undefined); bridgeMocks.startBrowserBridgeServer.mockResolvedValue({ server: {} as never, port: 19000, baseUrl: "http://127.0.0.1:19000", state: { server: null, port: 19000, resolved: { profiles: {} }, profiles: new Map(), }, }); bridgeMocks.stopBrowserBridgeServer.mockResolvedValue(undefined); }); it("publishes noVNC on loopback and injects noVNC password env", async () => { const result = await ensureSandboxBrowser({ scopeKey: "session:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", cfg: buildConfig(true), }); const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); expect(createArgs).toBeDefined(); expect(createArgs).toContain("127.0.0.1::6080"); const envEntries = collectDockerFlagValues(createArgs ?? [], "-e"); expect(envEntries).toContain("OPENCLAW_BROWSER_NO_SANDBOX=1"); const passwordEntry = envEntries.find((entry) => 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).not.toContain("password="); }); it("does not inject noVNC password env when noVNC is disabled", async () => { const result = await ensureSandboxBrowser({ scopeKey: "session:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", cfg: buildConfig(false), }); const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); const envEntries = collectDockerFlagValues(createArgs ?? [], "-e"); expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="))).toBe( false, ); expect(result?.noVncUrl).toBeUndefined(); }); it("mounts the main workspace read-only when workspaceAccess is none", async () => { const cfg = buildConfig(false); cfg.workspaceAccess = "none"; await ensureSandboxBrowser({ scopeKey: "session:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", cfg, }); const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); expect(createArgs).toBeDefined(); expect(createArgs).toContain("/tmp/workspace:/workspace:ro"); }); it("keeps the main workspace writable when workspaceAccess is rw", async () => { const cfg = buildConfig(false); cfg.workspaceAccess = "rw"; await ensureSandboxBrowser({ scopeKey: "session:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", cfg, }); 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"); }); });