import crypto from "node:crypto"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; import { DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "../../browser/constants.js"; import { defaultRuntime } from "../../runtime.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { computeSandboxBrowserConfigHash } from "./config-hash.js"; import { resolveSandboxBrowserDockerCreateConfig } from "./config.js"; import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { buildSandboxCreateArgs, dockerContainerState, execDocker, readDockerContainerLabel, readDockerPort, } from "./docker.js"; import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js"; import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js"; import { isToolAllowed } from "./tool-policy.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise { const deadline = Date.now() + Math.max(0, params.timeoutMs); const url = `http://127.0.0.1:${params.cdpPort}/json/version`; while (Date.now() < deadline) { try { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), 1000); try { const res = await fetch(url, { signal: ctrl.signal }); if (res.ok) { return true; } } finally { clearTimeout(t); } } catch { // ignore } await new Promise((r) => setTimeout(r, 150)); } return false; } function buildSandboxBrowserResolvedConfig(params: { controlPort: number; cdpPort: number; headless: boolean; evaluateEnabled: boolean; }): ResolvedBrowserConfig { const cdpHost = "127.0.0.1"; return { enabled: true, evaluateEnabled: params.evaluateEnabled, controlPort: params.controlPort, cdpProtocol: "http", cdpHost, cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: undefined, headless: params.headless, noSandbox: false, attachOnly: true, defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, extraArgs: [], profiles: { [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: { cdpPort: params.cdpPort, color: DEFAULT_OPENCLAW_BROWSER_COLOR, }, }, }; } async function ensureSandboxBrowserImage(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, }); if (result.code === 0) { return; } throw new Error( `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, ); } export async function ensureSandboxBrowser(params: { scopeKey: string; workspaceDir: string; agentWorkspaceDir: string; cfg: SandboxConfig; evaluateEnabled?: boolean; bridgeAuth?: { token?: string; password?: string }; }): Promise { if (!params.cfg.browser.enabled) { return null; } if (!isToolAllowed(params.cfg.tools, "browser")) { return null; } const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const name = `${params.cfg.browser.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); const browserImage = params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE; const browserDockerCfg = resolveSandboxBrowserDockerCreateConfig({ docker: params.cfg.docker, browser: { ...params.cfg.browser, image: browserImage }, }); const expectedHash = computeSandboxBrowserConfigHash({ docker: browserDockerCfg, browser: { cdpPort: params.cfg.browser.cdpPort, vncPort: params.cfg.browser.vncPort, noVncPort: params.cfg.browser.noVncPort, headless: params.cfg.browser.headless, enableNoVnc: params.cfg.browser.enableNoVnc, }, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, }); const now = Date.now(); let hasContainer = state.exists; let running = state.running; let currentHash: string | null = null; let hashMismatch = false; if (hasContainer) { const registry = await readBrowserRegistry(); const registryEntry = registry.entries.find((entry) => entry.containerName === containerName); currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash"); hashMismatch = !currentHash || currentHash !== expectedHash; if (!currentHash) { currentHash = registryEntry?.configHash ?? null; hashMismatch = !currentHash || currentHash !== expectedHash; } if (hashMismatch) { const lastUsedAtMs = registryEntry?.lastUsedAtMs; const isHot = running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_BROWSER_WINDOW_MS); if (isHot) { const hint = (() => { if (params.cfg.scope === "session") { return `openclaw sandbox recreate --browser --session ${params.scopeKey}`; } if (params.cfg.scope === "agent") { const agentId = resolveSandboxAgentId(params.scopeKey) ?? "main"; return `openclaw sandbox recreate --browser --agent ${agentId}`; } return "openclaw sandbox recreate --browser --all"; })(); defaultRuntime.log( `Sandbox browser config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, ); } else { await execDocker(["rm", "-f", containerName], { allowFailure: true }); hasContainer = false; running = false; } } } if (!hasContainer) { await ensureSandboxBrowserImage(browserImage); const args = buildSandboxCreateArgs({ name: containerName, cfg: browserDockerCfg, scopeKey: params.scopeKey, labels: { "openclaw.sandboxBrowser": "1" }, configHash: expectedHash, }); const mainMountSuffix = params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`); if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; args.push( "-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, ); } args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); } args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); args.push(browserImage); await execDocker(args); await execDocker(["start", containerName]); } else if (!running) { await execDocker(["start", containerName]); } const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort); if (!mappedCdp) { throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); } const mappedNoVnc = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? await readDockerPort(containerName, params.cfg.browser.noVncPort) : null; const existing = BROWSER_BRIDGES.get(params.scopeKey); const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) : null; let desiredAuthToken = params.bridgeAuth?.token?.trim() || undefined; let desiredAuthPassword = params.bridgeAuth?.password?.trim() || undefined; if (!desiredAuthToken && !desiredAuthPassword) { // Always require auth for the sandbox bridge server, even if gateway auth // mode doesn't produce a shared secret (e.g. trusted-proxy). // Keep it stable across calls by reusing the existing bridge auth. desiredAuthToken = existing?.authToken; desiredAuthPassword = existing?.authPassword; if (!desiredAuthToken && !desiredAuthPassword) { desiredAuthToken = crypto.randomBytes(24).toString("hex"); } } const shouldReuse = existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; const authMatches = !existing || (existing.authToken === desiredAuthToken && existing.authPassword === desiredAuthPassword); if (existing && !shouldReuse) { await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); BROWSER_BRIDGES.delete(params.scopeKey); } if (existing && shouldReuse && !authMatches) { await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); BROWSER_BRIDGES.delete(params.scopeKey); } const bridge = (() => { if (shouldReuse && authMatches && existing) { return existing.bridge; } return null; })(); const ensureBridge = async () => { if (bridge) { return bridge; } const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { const state = await dockerContainerState(containerName); if (state.exists && !state.running) { await execDocker(["start", containerName]); } const ok = await waitForSandboxCdp({ cdpPort: mappedCdp, timeoutMs: params.cfg.browser.autoStartTimeoutMs, }); if (!ok) { throw new Error( `Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, ); } } : undefined; return await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ controlPort: 0, cdpPort: mappedCdp, headless: params.cfg.browser.headless, evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, }), authToken: desiredAuthToken, authPassword: desiredAuthPassword, onEnsureAttachTarget, }); }; const resolvedBridge = await ensureBridge(); if (!shouldReuse || !authMatches) { BROWSER_BRIDGES.set(params.scopeKey, { bridge: resolvedBridge, containerName, authToken: desiredAuthToken, authPassword: desiredAuthPassword, }); } await updateBrowserRegistry({ containerName, sessionKey: params.scopeKey, createdAtMs: now, lastUsedAtMs: now, image: browserImage, configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, cdpPort: mappedCdp, noVncPort: mappedNoVnc ?? undefined, }); const noVncUrl = mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` : undefined; return { bridgeUrl: resolvedBridge.baseUrl, noVncUrl, containerName, }; }