Download src/agents/sandbox/browser.create.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 27.5 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sandbox/browser.create.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/sandbox/browser.create.test.ts
-
curl -L -o browser.create.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sandbox/browser.create.test.ts
27.5 kB
| import { mkdirSync, readFileSync } from "node:fs"; | |
| import { createServer } from "node:http"; | |
| import type { AddressInfo, Socket } from "node:net"; | |
| import path from "node:path"; | |
| import { describe, expect, it, vi } from "vitest"; | |
| import { createSandboxBrowserTestHarness } from "./browser.create.test-helpers.js"; | |
| import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js"; | |
| import { | |
| SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH, | |
| SANDBOX_DOCKER_CREATE_ARGS_EPOCH, | |
| } from "./constants.js"; | |
| import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js"; | |
| describe("ensureSandboxBrowser create args", () => { | |
| const harness = createSandboxBrowserTestHarness(); | |
| const { | |
| dockerMocks, | |
| registryMocks, | |
| bridgeMocks, | |
| runtimeMocks, | |
| buildConfig, | |
| computeTestBrowserHash, | |
| ensureTestSandboxBrowser, | |
| requireDockerCreateArgs, | |
| snapshotDockerCreateEnvEntries, | |
| requireDockerCreateEnvEntries, | |
| requireValue, | |
| latestBridgeResolved, | |
| } = harness; | |
| it("rejects stale sandbox browser images without the relay auth contract", async () => { | |
| dockerMocks.execDocker.mockImplementation(async (args: string[]) => { | |
| if (args[0] === "image" && args[1] === "inspect") { | |
| return { stdout: "<no value>\n", stderr: "", code: 0 }; | |
| } | |
| return { stdout: "", stderr: "", code: 0 }; | |
| }); | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }), | |
| ).rejects.toThrow( | |
| "Sandbox browser image openclaw-sandbox-browser:bookworm-slim is stale or incompatible", | |
| ); | |
| expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined(); | |
| }); | |
| it("keeps the browser Dockerfile contract label aligned with the runtime constant", () => { | |
| const dockerfile = readFileSync( | |
| new URL("../../../scripts/docker/sandbox/Dockerfile.browser", import.meta.url), | |
| "utf8", | |
| ); | |
| const label = dockerfile.match( | |
| /^LABEL org\.openclaw\.sandbox-browser\.contract="([^"]+)"$/m, | |
| )?.[1]; | |
| expect(label).toBe(SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH); | |
| }); | |
| it("delivers configured browser and generated CDP/noVNC environment without exposing values in Docker argv", async () => { | |
| // noVNC password stays in the container environment; external access uses a | |
| // short-lived observer token so URLs do not carry the password. | |
| const configuredSentinel = "synthetic-browser-transport-value"; | |
| const cfg = buildConfig(true); | |
| cfg.docker.env = { ...cfg.docker.env, BROWSER_TRANSPORT_SENTINEL: configuredSentinel }; | |
| const result = await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }); | |
| const createArgs = requireDockerCreateArgs(); | |
| expect(createArgs.some((arg) => arg.includes(configuredSentinel))).toBe(false); | |
| expect(createArgs).toContain("127.0.0.1::6080"); | |
| expect(collectDockerFlagValues(createArgs, "--env-file")).toHaveLength(1); | |
| expect(createArgs).not.toContain("-e"); | |
| expect(createArgs).not.toContain("--env"); | |
| const envEntries = requireDockerCreateEnvEntries(); | |
| expect(envEntries).toContain(`BROWSER_TRANSPORT_SENTINEL=${configuredSentinel}`); | |
| 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}$/); | |
| const authEntry = envEntries.find((entry) => | |
| entry.startsWith("OPENCLAW_BROWSER_CDP_AUTH_TOKEN="), | |
| ); | |
| expect(authEntry).toMatch(/^OPENCLAW_BROWSER_CDP_AUTH_TOKEN=[0-9a-f]{48}$/); | |
| const noVncPassword = requireValue(passwordEntry, "noVNC password env").slice( | |
| "OPENCLAW_BROWSER_NOVNC_PASSWORD=".length, | |
| ); | |
| const cdpAuthToken = requireValue(authEntry, "CDP auth env").slice( | |
| "OPENCLAW_BROWSER_CDP_AUTH_TOKEN=".length, | |
| ); | |
| expect(createArgs.some((arg) => arg.includes(noVncPassword))).toBe(false); | |
| expect(createArgs.some((arg) => arg.includes(cdpAuthToken))).toBe(false); | |
| expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/sandbox\/novnc\?token=/); | |
| expect(result?.noVncUrl).not.toContain("password="); | |
| }); | |
| it("creates browser containers with Docker init and the shared args epoch", async () => { | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }); | |
| const createArgs = requireDockerCreateArgs(); | |
| expect(createArgs.filter((arg) => arg === "--init")).toHaveLength(1); | |
| expect(createArgs).toContain(`openclaw.createArgsEpoch=${SANDBOX_DOCKER_CREATE_ARGS_EPOCH}`); | |
| }); | |
| it("serializes concurrent provisioning for the same browser container", async () => { | |
| let created = false; | |
| let cdpAuthToken: string | undefined; | |
| let configHash: string | undefined; | |
| dockerMocks.dockerContainerState.mockImplementation(async () => ({ | |
| exists: created, | |
| running: created, | |
| })); | |
| dockerMocks.readDockerContainerEnvVar.mockImplementation(async (_containerName, key) => | |
| key === "OPENCLAW_BROWSER_CDP_AUTH_TOKEN" ? (cdpAuthToken ?? null) : null, | |
| ); | |
| dockerMocks.readDockerContainerLabel.mockImplementation(async () => configHash ?? null); | |
| dockerMocks.execDocker.mockImplementation(async (args: string[]) => { | |
| if (args[0] === "image" && args[1] === "inspect") { | |
| return { stdout: `${SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH}\n`, stderr: "", code: 0 }; | |
| } | |
| if (args[0] === "create") { | |
| if (created) { | |
| throw new Error("docker name conflict"); | |
| } | |
| created = true; | |
| const envEntries = requireValue( | |
| snapshotDockerCreateEnvEntries(args), | |
| "docker create environment file", | |
| ); | |
| cdpAuthToken = envEntries | |
| .find((entry) => entry.startsWith("OPENCLAW_BROWSER_CDP_AUTH_TOKEN=")) | |
| ?.slice("OPENCLAW_BROWSER_CDP_AUTH_TOKEN=".length); | |
| configHash = collectDockerFlagValues(args, "--label") | |
| .find((entry) => entry.startsWith("openclaw.configHash=")) | |
| ?.slice("openclaw.configHash=".length); | |
| } | |
| return { stdout: "", stderr: "", code: 0 }; | |
| }); | |
| const params = { | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }; | |
| await expect( | |
| Promise.all([ensureTestSandboxBrowser(params), ensureTestSandboxBrowser(params)]), | |
| ).resolves.toHaveLength(2); | |
| expect(dockerMocks.execDocker.mock.calls.filter(([args]) => args[0] === "create")).toHaveLength( | |
| 1, | |
| ); | |
| expect(dockerMocks.execDocker.mock.calls.filter(([args]) => args[0] === "start")).toHaveLength( | |
| 1, | |
| ); | |
| }); | |
| it("recreates a cold browser container when the shared args epoch changes", async () => { | |
| const cfg = buildConfig(false); | |
| const oldHash = await computeTestBrowserHash({ | |
| cfg, | |
| createArgsEpoch: "pre-init", | |
| }); | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| dockerMocks.readDockerContainerEnvVar.mockResolvedValue("existing-cdp-token"); | |
| dockerMocks.readDockerContainerLabel.mockResolvedValue(oldHash); | |
| registryMocks.readBrowserRegistry.mockResolvedValue({ | |
| entries: [ | |
| { | |
| containerName: "openclaw-sbx-browser-session-test-0661d10a", | |
| sessionKey: "session:test", | |
| createdAtMs: 1, | |
| lastUsedAtMs: 0, | |
| image: cfg.browser.image, | |
| configHash: oldHash, | |
| cdpPort: 49100, | |
| }, | |
| ], | |
| }); | |
| harness.BROWSER_BRIDGES.set("session:test", { | |
| containerName: "openclaw-sbx-browser-session-test-0661d10a", | |
| bridge: { server: { listening: true } }, | |
| }); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }); | |
| expect(dockerMocks.execDocker).toHaveBeenCalledWith( | |
| ["rm", "-f", "openclaw-sbx-browser-session-test-0661d10a"], | |
| { allowFailure: true }, | |
| ); | |
| const rmCallIndex = dockerMocks.execDocker.mock.calls.findIndex(([args]) => args[0] === "rm"); | |
| expect(bridgeMocks.stopBrowserBridgeServer.mock.invocationCallOrder[0]).toBeLessThan( | |
| dockerMocks.execDocker.mock.invocationCallOrder[rmCallIndex] ?? Number.MAX_SAFE_INTEGER, | |
| ); | |
| expect(requireDockerCreateArgs()).toContain("--init"); | |
| }); | |
| it("keeps a hot pre-init browser running and emits the recreate hint", async () => { | |
| const cfg = buildConfig(false); | |
| const oldHash = await computeTestBrowserHash({ | |
| cfg, | |
| createArgsEpoch: "pre-init", | |
| }); | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| dockerMocks.readDockerContainerEnvVar.mockResolvedValue("existing-cdp-token"); | |
| dockerMocks.readDockerContainerLabel.mockResolvedValue(oldHash); | |
| registryMocks.readBrowserRegistry.mockResolvedValue({ | |
| entries: [ | |
| { | |
| containerName: "openclaw-sbx-browser-session-test-0661d10a", | |
| sessionKey: "session:test", | |
| createdAtMs: 1, | |
| lastUsedAtMs: Date.now(), | |
| image: cfg.browser.image, | |
| configHash: oldHash, | |
| cdpPort: 49100, | |
| }, | |
| ], | |
| }); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }); | |
| expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "rm")).toBeUndefined(); | |
| expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined(); | |
| expect(runtimeMocks.log).toHaveBeenCalledWith( | |
| expect.stringContaining( | |
| "Recreate to apply: openclaw sandbox recreate --browser --session session:test", | |
| ), | |
| ); | |
| expect(registryMocks.updateBrowserRegistry.mock.calls.at(-1)?.[0]?.configHash).toBe(oldHash); | |
| }); | |
| it("does not inject noVNC password env when noVNC is disabled", async () => { | |
| const result = await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }); | |
| const envEntries = requireDockerCreateEnvEntries(); | |
| expect( | |
| envEntries.filter((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD=")), | |
| ).toStrictEqual([]); | |
| expect(result?.noVncUrl).toBeUndefined(); | |
| }); | |
| it.each([false, true])( | |
| "includes the explicit env policy epoch in the browser config hash with skill mount=%s", | |
| async (withSkillMount) => { | |
| const cfg = buildConfig(false); | |
| cfg.docker.env = { | |
| LANG: "C.UTF-8", | |
| GEMINI_API_KEY: "dummy-gemini", | |
| }; | |
| const scopeKey = "session-1"; | |
| const workspaceDir = harness.testWorkspaceDir; | |
| const agentWorkspaceDir = workspaceDir; | |
| if (withSkillMount) { | |
| mkdirSync(path.join(workspaceDir, "skills")); | |
| } | |
| const hashInputs = { | |
| cfg, | |
| dockerEnvPolicyEpoch: SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH, | |
| workspaceDir, | |
| agentWorkspaceDir, | |
| createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH, | |
| }; | |
| const expectedHash = await computeTestBrowserHash(hashInputs); | |
| expect(expectedHash).not.toBe( | |
| await computeTestBrowserHash({ ...hashInputs, dockerEnvPolicyEpoch: undefined }), | |
| ); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey, | |
| workspaceDir, | |
| agentWorkspaceDir, | |
| cfg, | |
| }); | |
| const createArgs = requireDockerCreateArgs(); | |
| expect(createArgs).toContain(`openclaw.configHash=${expectedHash}`); | |
| expect(requireDockerCreateEnvEntries()).toContain("GEMINI_API_KEY=dummy-gemini"); | |
| expect(createArgs.some((arg) => arg.includes("dummy-gemini"))).toBe(false); | |
| }, | |
| ); | |
| it("fails before creating a browser container when Docker daemon is unavailable", async () => { | |
| dockerMocks.execDocker.mockImplementation(async (args: string[]) => { | |
| if (args[0] === "network" && args[1] === "inspect") { | |
| return { stdout: "", stderr: "", code: 0 }; | |
| } | |
| if (args[0] === "image" && args[1] === "inspect") { | |
| return { | |
| stdout: "", | |
| stderr: | |
| "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", | |
| code: 1, | |
| }; | |
| } | |
| return { stdout: "", stderr: "", code: 0 }; | |
| }); | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }), | |
| ).rejects.toThrow("Docker daemon is not available"); | |
| expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined(); | |
| }); | |
| it("passes the browser SSRF policy to the sandbox bridge", async () => { | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, | |
| }); | |
| expect(latestBridgeResolved().ssrfPolicy).toEqual({ | |
| dangerouslyAllowPrivateNetwork: true, | |
| }); | |
| }); | |
| it("recreates a cached bridge when the SSRF policy changes", async () => { | |
| const existingBridge = { | |
| server: { listening: true } as never, | |
| port: 19000, | |
| baseUrl: "http://127.0.0.1:19000", | |
| state: { | |
| resolved: { | |
| enabled: true, | |
| evaluateEnabled: true, | |
| controlPort: 0, | |
| cdpProtocol: "http", | |
| cdpHost: "127.0.0.1", | |
| cdpIsLoopback: true, | |
| cdpPortRangeStart: 18800, | |
| cdpPortRangeEnd: 18899, | |
| extensionRelayDefaultPort: 18799, | |
| extensionRelayPorts: {}, | |
| remoteCdpTimeoutMs: 1500, | |
| remoteCdpHandshakeTimeoutMs: 3000, | |
| localLaunchTimeoutMs: 15_000, | |
| localCdpReadyTimeoutMs: 8_000, | |
| color: "#FF4500", | |
| headless: false, | |
| noSandbox: false, | |
| attachOnly: true, | |
| defaultProfile: "openclaw", | |
| extraArgs: [], | |
| tabCleanup: { | |
| enabled: true, | |
| idleMinutes: 120, | |
| maxTabsPerSession: 8, | |
| sweepMinutes: 5, | |
| }, | |
| profiles: { | |
| openclaw: { | |
| cdpPort: 49100, | |
| color: "#FF4500", | |
| }, | |
| }, | |
| ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, | |
| }, | |
| }, | |
| }; | |
| harness.BROWSER_BRIDGES.set("session:test", { | |
| bridge: existingBridge, | |
| containerName: "openclaw-sbx-browser-session-test-0661d10a", | |
| authToken: "test-bridge-token", | |
| }); | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| ssrfPolicy: { allowedHostnames: ["example.com"] }, | |
| }); | |
| expect(bridgeMocks.stopBrowserBridgeServer).toHaveBeenCalledWith(existingBridge.server); | |
| expect(latestBridgeResolved().ssrfPolicy).toEqual({ | |
| allowedHostnames: ["example.com"], | |
| }); | |
| }); | |
| it("recreates a cached bridge when evaluate permission changes", async () => { | |
| const existingBridge = { | |
| server: { listening: true } as never, | |
| port: 19000, | |
| baseUrl: "http://127.0.0.1:19000", | |
| state: { | |
| resolved: { | |
| enabled: true, | |
| evaluateEnabled: true, | |
| controlPort: 0, | |
| cdpProtocol: "http", | |
| cdpHost: "127.0.0.1", | |
| cdpIsLoopback: true, | |
| cdpPortRangeStart: 18800, | |
| cdpPortRangeEnd: 18899, | |
| extensionRelayDefaultPort: 18799, | |
| extensionRelayPorts: {}, | |
| remoteCdpTimeoutMs: 1500, | |
| remoteCdpHandshakeTimeoutMs: 3000, | |
| localLaunchTimeoutMs: 15_000, | |
| localCdpReadyTimeoutMs: 8_000, | |
| color: "#FF4500", | |
| headless: false, | |
| noSandbox: false, | |
| attachOnly: true, | |
| defaultProfile: "openclaw", | |
| extraArgs: [], | |
| tabCleanup: { | |
| enabled: true, | |
| idleMinutes: 120, | |
| maxTabsPerSession: 8, | |
| sweepMinutes: 5, | |
| }, | |
| profiles: { | |
| openclaw: { | |
| cdpPort: 49100, | |
| color: "#FF4500", | |
| }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| harness.BROWSER_BRIDGES.set("session:test", { | |
| bridge: existingBridge, | |
| containerName: "openclaw-sbx-browser-session-test-0661d10a", | |
| authToken: "test-bridge-token", | |
| }); | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| evaluateEnabled: false, | |
| }); | |
| expect(bridgeMocks.stopBrowserBridgeServer).toHaveBeenCalledWith(existingBridge.server); | |
| expect(latestBridgeResolved().evaluateEnabled).toBe(false); | |
| }); | |
| it("force-removes the browser container when CDP never becomes reachable", async () => { | |
| // A browser container that starts but never exposes CDP is unusable; remove | |
| // it immediately so the next attempt recreates from a clean state. | |
| vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout")); | |
| bridgeMocks.startBrowserBridgeServer.mockImplementationOnce(async (params) => { | |
| await params.onEnsureAttachTarget?.({}); | |
| return { | |
| server: {} as never, | |
| port: 19000, | |
| baseUrl: "http://127.0.0.1:19000", | |
| state: { | |
| server: null, | |
| port: 19000, | |
| resolved: { profiles: {} }, | |
| profiles: new Map(), | |
| }, | |
| }; | |
| }); | |
| const cfg = buildConfig(false); | |
| cfg.browser.autoStartTimeoutMs = 1; | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }), | |
| ).rejects.toThrow("hung container has been forcefully removed"); | |
| expect(dockerMocks.execDocker).toHaveBeenCalledWith( | |
| ["rm", "-f", "openclaw-sbx-browser-session-test-0661d10a"], | |
| { allowFailure: true }, | |
| ); | |
| }); | |
| it.each([200, 503])( | |
| "cancels the CDP probe response body after a %i startup probe", | |
| async (status) => { | |
| const cancels: Array<ReturnType<typeof vi.fn>> = []; | |
| vi.spyOn(globalThis, "fetch").mockImplementation(async () => { | |
| const cancel = vi.fn().mockResolvedValue(undefined); | |
| cancels.push(cancel); | |
| return { | |
| ok: status === 200, | |
| body: { cancel }, | |
| } as never; | |
| }); | |
| bridgeMocks.startBrowserBridgeServer.mockImplementationOnce(async (params) => { | |
| await params.onEnsureAttachTarget?.({}); | |
| throw new Error("probe completed before bridge creation"); | |
| }); | |
| const cfg = buildConfig(false); | |
| cfg.browser.autoStartTimeoutMs = 50; | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }), | |
| ).rejects.toThrow( | |
| status === 200 | |
| ? "probe completed before bridge creation" | |
| : "hung container has been forcefully removed", | |
| ); | |
| expect(cancels).not.toHaveLength(0); | |
| for (const cancel of cancels) { | |
| expect(cancel).toHaveBeenCalledOnce(); | |
| } | |
| }, | |
| ); | |
| it("keeps a stalled CDP request inside the browser startup deadline", async () => { | |
| const sockets = new Set<Socket>(); | |
| let requestPath: string | undefined; | |
| let resolveRequestReceived: (() => void) | undefined; | |
| const requestReceived = new Promise<void>((resolve) => { | |
| resolveRequestReceived = resolve; | |
| }); | |
| const server = createServer((req, _res) => { | |
| requestPath = req.url; | |
| req.resume(); | |
| resolveRequestReceived?.(); | |
| // Accept the CDP request but never send response headers. | |
| }); | |
| server.on("connection", (socket) => { | |
| sockets.add(socket); | |
| socket.on("close", () => sockets.delete(socket)); | |
| }); | |
| await new Promise<void>((resolve, reject) => { | |
| server.once("error", reject); | |
| server.listen(0, "127.0.0.1", resolve); | |
| }); | |
| const cdpPort = (server.address() as AddressInfo).port; | |
| dockerMocks.readDockerPort.mockImplementation(async (_containerName: string, port: number) => { | |
| if (port === 9222) { | |
| return cdpPort; | |
| } | |
| if (port === 6080) { | |
| return 49101; | |
| } | |
| return null; | |
| }); | |
| bridgeMocks.startBrowserBridgeServer.mockImplementationOnce(async (params) => { | |
| await params.onEnsureAttachTarget?.({}); | |
| throw new Error("expected CDP startup to time out before bridge creation"); | |
| }); | |
| const cfg = buildConfig(false); | |
| cfg.browser.autoStartTimeoutMs = 250; | |
| try { | |
| const startup = ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }); | |
| const startupResult = startup.then( | |
| () => ({ ok: true as const }), | |
| (error: unknown) => ({ ok: false as const, error }), | |
| ); | |
| await Promise.race([ | |
| requestReceived, | |
| new Promise<never>((_resolve, reject) => { | |
| setTimeout(() => reject(new Error("CDP request was not received")), 2_000).unref(); | |
| }), | |
| ]); | |
| expect(requestPath).toBe("/json/version"); | |
| const result = await startupResult; | |
| expect(result.ok).toBe(false); | |
| if (result.ok) { | |
| throw new Error("expected stalled CDP startup to fail"); | |
| } | |
| expect(result.error).toBeInstanceOf(Error); | |
| expect((result.error as Error).message).toContain( | |
| `within ${cfg.browser.autoStartTimeoutMs}ms. The hung container has been forcefully removed.`, | |
| ); | |
| } finally { | |
| for (const socket of sockets) { | |
| socket.destroy(); | |
| } | |
| await new Promise<void>((resolve, reject) => { | |
| server.close((error) => (error ? reject(error) : resolve())); | |
| }); | |
| } | |
| }); | |
| it("requires auth for the sandbox CDP relay without auto-derived source ranges", async () => { | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }); | |
| const envEntries = requireDockerCreateEnvEntries(); | |
| const authEntry = envEntries.find((entry) => | |
| entry.startsWith("OPENCLAW_BROWSER_CDP_AUTH_TOKEN="), | |
| ); | |
| expect(authEntry).toMatch(/^OPENCLAW_BROWSER_CDP_AUTH_TOKEN=[0-9a-f]{48}$/); | |
| expect(envEntries).not.toContain("OPENCLAW_BROWSER_CDP_SOURCE_RANGE=172.21.0.1/32"); | |
| const token = requireValue(authEntry, "CDP auth env").slice( | |
| "OPENCLAW_BROWSER_CDP_AUTH_TOKEN=".length, | |
| ); | |
| const profiles = latestBridgeResolved().profiles as Record< | |
| string, | |
| { cdpPort?: number; cdpUrl?: string } | |
| >; | |
| expect(profiles.openclaw?.cdpPort).toBe(49100); | |
| expect(profiles.openclaw?.cdpUrl).toBe(`http://openclaw:${token}@127.0.0.1:49100`); | |
| }); | |
| it("passes explicit cdpSourceRange as an additional relay filter", async () => { | |
| const cfg = buildConfig(false); | |
| cfg.browser.cdpSourceRange = "10.0.0.0/24"; | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }); | |
| const envEntries = requireDockerCreateEnvEntries(); | |
| expect(envEntries).toContain("OPENCLAW_BROWSER_CDP_SOURCE_RANGE=10.0.0.0/24"); | |
| }); | |
| it("recreates existing browser containers that do not expose relay auth", async () => { | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }); | |
| expect(dockerMocks.execDocker).toHaveBeenCalledWith( | |
| ["rm", "-f", "openclaw-sbx-browser-session-test-0661d10a"], | |
| { allowFailure: true }, | |
| ); | |
| requireDockerCreateArgs(); | |
| }); | |
| it("retains a stale container and cached bridge until bridge cleanup can retry", async () => { | |
| const containerName = "openclaw-sbx-browser-session-test-0661d10a"; | |
| const cached = { | |
| containerName, | |
| bridge: { server: { listening: true } }, | |
| }; | |
| harness.BROWSER_BRIDGES.set("session:test", cached); | |
| dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); | |
| dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null); | |
| bridgeMocks.stopBrowserBridgeServer.mockRejectedValueOnce(new Error("bridge cleanup failed")); | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }), | |
| ).rejects.toThrow("bridge cleanup failed"); | |
| expect(harness.BROWSER_BRIDGES.get("session:test")).toBe(cached); | |
| expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "rm")).toBeUndefined(); | |
| bridgeMocks.stopBrowserBridgeServer.mockClear(); | |
| dockerMocks.execDocker.mockClear(); | |
| await ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg: buildConfig(false), | |
| }); | |
| const rmCallIndex = dockerMocks.execDocker.mock.calls.findIndex(([args]) => args[0] === "rm"); | |
| expect(bridgeMocks.stopBrowserBridgeServer.mock.invocationCallOrder[0]).toBeLessThan( | |
| dockerMocks.execDocker.mock.invocationCallOrder[rmCallIndex] ?? Number.MAX_SAFE_INTEGER, | |
| ); | |
| expect(harness.BROWSER_BRIDGES.get("session:test")).not.toBe(cached); | |
| }); | |
| it("rejects network=none before Docker inspection or browser bridge startup", async () => { | |
| const cfg = buildConfig(false); | |
| cfg.browser.network = "none"; | |
| await expect( | |
| ensureTestSandboxBrowser({ | |
| scopeKey: "session:test", | |
| workspaceDir: harness.testWorkspaceDir, | |
| agentWorkspaceDir: harness.testWorkspaceDir, | |
| cfg, | |
| }), | |
| ).rejects.toThrow( | |
| 'Sandbox browser network mode "none" is unsupported because browser control requires a host-reachable published CDP port.', | |
| ); | |
| expect(dockerMocks.dockerContainerState).not.toHaveBeenCalled(); | |
| expect(dockerMocks.execDocker).not.toHaveBeenCalled(); | |
| expect(dockerMocks.readDockerPort).not.toHaveBeenCalled(); | |
| expect(bridgeMocks.startBrowserBridgeServer).not.toHaveBeenCalled(); | |
| }); | |
| }); | |