diff --git a/CHANGELOG.md b/CHANGELOG.md index d86aebd39..4e0b358af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Sandbox: allow sandbox browser to auto-start the CDP endpoint when the browser tool needs it. — thanks @steipete - Sandbox browser: proxy CDP out of the container so host port mappings work (fixes `attachOnly` “profile not running”). — thanks @steipete - Agents: gate heartbeat prompt to default agent sessions (including non-agent session keys). (#630) — thanks @adam91holt - Agent: fast abort on /stop and cancel tool calls between tool boundaries. (#617) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ccde7c27a..b8b0be4ba 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1354,7 +1354,9 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true + enableNoVnc: true, + autoStart: true, + autoStartTimeoutMs: 12000 }, prune: { idleHours: 24, // 0 disables idle pruning diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 6a2d46d2e..c4c403780 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -19,6 +19,8 @@ and process access when the model does something dumb. ## What gets sandboxed - Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.). - Optional sandboxed browser (`agents.defaults.sandbox.browser`). + - By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. + Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`. Not sandboxed: - The Gateway process itself. diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 1401399ce..94945b2ec 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -55,6 +55,8 @@ export type SandboxBrowserConfig = { noVncPort: number; headless: boolean; enableNoVnc: boolean; + autoStart: boolean; + autoStartTimeoutMs: number; }; export type SandboxDockerConfig = { @@ -159,6 +161,7 @@ const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-"; const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; +const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000; const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox"); @@ -307,9 +310,38 @@ export function resolveSandboxBrowserConfig(params: { headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true, + autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true, + autoStartTimeoutMs: + agentBrowser?.autoStartTimeoutMs ?? + globalBrowser?.autoStartTimeoutMs ?? + DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, }; } +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(), 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; +} + export function resolveSandboxPruneConfig(params: { scope: SandboxScope; globalPrune?: Partial; @@ -921,12 +953,31 @@ async function ensureSandboxBrowser(params: { if (shouldReuse && existing) { bridge = existing.bridge; } else { + 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; + bridge = await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ controlPort: 0, cdpPort: mappedCdp, headless: params.cfg.browser.headless, }), + onEnsureAttachTarget, }); } if (!shouldReuse) { diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts index 44161e9a5..9ee993acc 100644 --- a/src/browser/bridge-server.ts +++ b/src/browser/bridge-server.ts @@ -7,6 +7,7 @@ import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, createBrowserRouteContext, + type ProfileContext, } from "./server-context.js"; export type BrowserBridge = { @@ -20,6 +21,7 @@ export async function startBrowserBridgeServer(params: { resolved: ResolvedBrowserConfig; host?: string; port?: number; + onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise; }): Promise { const host = params.host ?? "127.0.0.1"; const port = params.port ?? 0; @@ -36,6 +38,7 @@ export async function startBrowserBridgeServer(params: { const ctx = createBrowserRouteContext({ getState: () => state, + onEnsureAttachTarget: params.onEnsureAttachTarget, }); registerBrowserRoutes(app, ctx); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index eb37e16ff..34ea5ac76 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -85,6 +85,7 @@ export type ProfileStatus = { type ContextOptions = { getState: () => BrowserServerState | null; + onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise; }; /** @@ -259,6 +260,13 @@ function createProfileContext( const httpReachable = await isHttpReachable(); if (!httpReachable) { + if ( + (current.resolved.attachOnly || remoteCdp) && + opts.onEnsureAttachTarget + ) { + await opts.onEnsureAttachTarget(profile); + if (await isHttpReachable(1200)) return; + } if (current.resolved.attachOnly || remoteCdp) { throw new Error( remoteCdp @@ -284,6 +292,10 @@ function createProfileContext( // We own it but WebSocket failed - restart if (current.resolved.attachOnly || remoteCdp) { + if (opts.onEnsureAttachTarget) { + await opts.onEnsureAttachTarget(profile); + if (await isReachable(1200)) return; + } throw new Error( remoteCdp ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index 4124c956c..bac528cbd 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -707,6 +707,50 @@ describe("browser control server", () => { expect(started.error ?? "").toMatch(/attachOnly/i); }); + it("allows attachOnly servers to ensure reachability via callback", async () => { + cfgAttachOnly = true; + reachable = false; + const { startBrowserBridgeServer } = await import("./bridge-server.js"); + + const ensured = vi.fn(async () => { + reachable = true; + }); + + const bridge = await startBrowserBridgeServer({ + resolved: { + enabled: true, + controlUrl: "http://127.0.0.1:0", + controlHost: "127.0.0.1", + controlPort: 0, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + color: "#FF4500", + headless: true, + noSandbox: false, + attachOnly: true, + defaultProfile: "clawd", + profiles: { + clawd: { cdpPort: testPort + 1, color: "#FF4500" }, + }, + }, + onEnsureAttachTarget: ensured, + }); + + const started = (await realFetch(`${bridge.baseUrl}/start`, { + method: "POST", + }).then((r) => r.json())) as { ok?: boolean; error?: string }; + expect(started.error).toBeUndefined(); + expect(started.ok).toBe(true); + const status = (await realFetch(`${bridge.baseUrl}/`).then((r) => + r.json(), + )) as { running?: boolean }; + expect(status.running).toBe(true); + expect(ensured).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => bridge.server.close(() => resolve())); + }); + it("opens tabs via CDP createTarget path", async () => { const { startBrowserControlServerFromConfig } = await import("./server.js"); await startBrowserControlServerFromConfig(); diff --git a/src/config/types.ts b/src/config/types.ts index 2342da12c..c7c940f4a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -827,6 +827,13 @@ export type SandboxBrowserSettings = { noVncPort?: number; headless?: boolean; enableNoVnc?: boolean; + /** + * When true (default), sandboxed browser control will try to start/reattach to + * the sandbox browser container when a tool call needs it. + */ + autoStart?: boolean; + /** Max time to wait for CDP to become reachable after auto-start (ms). */ + autoStartTimeoutMs?: number; }; export type SandboxPruneSettings = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 36a808889..3f07f7d96 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -697,6 +697,8 @@ const SandboxBrowserSchema = z noVncPort: z.number().int().positive().optional(), headless: z.boolean().optional(), enableNoVnc: z.boolean().optional(), + autoStart: z.boolean().optional(), + autoStartTimeoutMs: z.number().int().positive().optional(), }) .optional();