feat: auto-start sandbox browser

This commit is contained in:
Peter Steinberger
2026-01-10 02:06:05 +00:00
parent 8b579c91a5
commit 2dc7872ad1
9 changed files with 125 additions and 1 deletions

View File

@@ -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<boolean> {
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<SandboxPruneConfig>;
@@ -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) {

View File

@@ -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<void>;
}): Promise<BrowserBridge> {
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);

View File

@@ -85,6 +85,7 @@ export type ProfileStatus = {
type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
};
/**
@@ -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.`

View File

@@ -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<void>((resolve) => bridge.server.close(() => resolve()));
});
it("opens tabs via CDP createTarget path", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();

View File

@@ -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 = {

View File

@@ -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();