openclaw / src /agents /sandbox /browser.create.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
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();
});
});