| 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<typeof import("./docker.js")>(); |
| 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"); |
| }); |
| }); |
|
|