feat: auto-start sandbox browser
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- 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
|
- 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)
|
- Agent: fast abort on /stop and cancel tool calls between tool boundaries. (#617)
|
||||||
|
|||||||
@@ -1354,7 +1354,9 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
|||||||
vncPort: 5900,
|
vncPort: 5900,
|
||||||
noVncPort: 6080,
|
noVncPort: 6080,
|
||||||
headless: false,
|
headless: false,
|
||||||
enableNoVnc: true
|
enableNoVnc: true,
|
||||||
|
autoStart: true,
|
||||||
|
autoStartTimeoutMs: 12000
|
||||||
},
|
},
|
||||||
prune: {
|
prune: {
|
||||||
idleHours: 24, // 0 disables idle pruning
|
idleHours: 24, // 0 disables idle pruning
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ and process access when the model does something dumb.
|
|||||||
## What gets sandboxed
|
## What gets sandboxed
|
||||||
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
|
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
|
||||||
- Optional sandboxed browser (`agents.defaults.sandbox.browser`).
|
- 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:
|
Not sandboxed:
|
||||||
- The Gateway process itself.
|
- The Gateway process itself.
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export type SandboxBrowserConfig = {
|
|||||||
noVncPort: number;
|
noVncPort: number;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
enableNoVnc: boolean;
|
enableNoVnc: boolean;
|
||||||
|
autoStart: boolean;
|
||||||
|
autoStartTimeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxDockerConfig = {
|
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_CDP_PORT = 9222;
|
||||||
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
||||||
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
|
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
|
||||||
|
const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
|
||||||
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
|
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
|
||||||
|
|
||||||
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
|
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
|
||||||
@@ -307,9 +310,38 @@ export function resolveSandboxBrowserConfig(params: {
|
|||||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||||
enableNoVnc:
|
enableNoVnc:
|
||||||
agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
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: {
|
export function resolveSandboxPruneConfig(params: {
|
||||||
scope: SandboxScope;
|
scope: SandboxScope;
|
||||||
globalPrune?: Partial<SandboxPruneConfig>;
|
globalPrune?: Partial<SandboxPruneConfig>;
|
||||||
@@ -921,12 +953,31 @@ async function ensureSandboxBrowser(params: {
|
|||||||
if (shouldReuse && existing) {
|
if (shouldReuse && existing) {
|
||||||
bridge = existing.bridge;
|
bridge = existing.bridge;
|
||||||
} else {
|
} 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({
|
bridge = await startBrowserBridgeServer({
|
||||||
resolved: buildSandboxBrowserResolvedConfig({
|
resolved: buildSandboxBrowserResolvedConfig({
|
||||||
controlPort: 0,
|
controlPort: 0,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
headless: params.cfg.browser.headless,
|
headless: params.cfg.browser.headless,
|
||||||
}),
|
}),
|
||||||
|
onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!shouldReuse) {
|
if (!shouldReuse) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { registerBrowserRoutes } from "./routes/index.js";
|
|||||||
import {
|
import {
|
||||||
type BrowserServerState,
|
type BrowserServerState,
|
||||||
createBrowserRouteContext,
|
createBrowserRouteContext,
|
||||||
|
type ProfileContext,
|
||||||
} from "./server-context.js";
|
} from "./server-context.js";
|
||||||
|
|
||||||
export type BrowserBridge = {
|
export type BrowserBridge = {
|
||||||
@@ -20,6 +21,7 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
resolved: ResolvedBrowserConfig;
|
resolved: ResolvedBrowserConfig;
|
||||||
host?: string;
|
host?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
|
||||||
}): Promise<BrowserBridge> {
|
}): Promise<BrowserBridge> {
|
||||||
const host = params.host ?? "127.0.0.1";
|
const host = params.host ?? "127.0.0.1";
|
||||||
const port = params.port ?? 0;
|
const port = params.port ?? 0;
|
||||||
@@ -36,6 +38,7 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
|
|
||||||
const ctx = createBrowserRouteContext({
|
const ctx = createBrowserRouteContext({
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
|
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
registerBrowserRoutes(app, ctx);
|
registerBrowserRoutes(app, ctx);
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export type ProfileStatus = {
|
|||||||
|
|
||||||
type ContextOptions = {
|
type ContextOptions = {
|
||||||
getState: () => BrowserServerState | null;
|
getState: () => BrowserServerState | null;
|
||||||
|
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,6 +260,13 @@ function createProfileContext(
|
|||||||
const httpReachable = await isHttpReachable();
|
const httpReachable = await isHttpReachable();
|
||||||
|
|
||||||
if (!httpReachable) {
|
if (!httpReachable) {
|
||||||
|
if (
|
||||||
|
(current.resolved.attachOnly || remoteCdp) &&
|
||||||
|
opts.onEnsureAttachTarget
|
||||||
|
) {
|
||||||
|
await opts.onEnsureAttachTarget(profile);
|
||||||
|
if (await isHttpReachable(1200)) return;
|
||||||
|
}
|
||||||
if (current.resolved.attachOnly || remoteCdp) {
|
if (current.resolved.attachOnly || remoteCdp) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
remoteCdp
|
remoteCdp
|
||||||
@@ -284,6 +292,10 @@ function createProfileContext(
|
|||||||
|
|
||||||
// We own it but WebSocket failed - restart
|
// We own it but WebSocket failed - restart
|
||||||
if (current.resolved.attachOnly || remoteCdp) {
|
if (current.resolved.attachOnly || remoteCdp) {
|
||||||
|
if (opts.onEnsureAttachTarget) {
|
||||||
|
await opts.onEnsureAttachTarget(profile);
|
||||||
|
if (await isReachable(1200)) return;
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
remoteCdp
|
remoteCdp
|
||||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||||
|
|||||||
@@ -707,6 +707,50 @@ describe("browser control server", () => {
|
|||||||
expect(started.error ?? "").toMatch(/attachOnly/i);
|
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 () => {
|
it("opens tabs via CDP createTarget path", async () => {
|
||||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||||
await startBrowserControlServerFromConfig();
|
await startBrowserControlServerFromConfig();
|
||||||
|
|||||||
@@ -827,6 +827,13 @@ export type SandboxBrowserSettings = {
|
|||||||
noVncPort?: number;
|
noVncPort?: number;
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
enableNoVnc?: 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 = {
|
export type SandboxPruneSettings = {
|
||||||
|
|||||||
@@ -697,6 +697,8 @@ const SandboxBrowserSchema = z
|
|||||||
noVncPort: z.number().int().positive().optional(),
|
noVncPort: z.number().int().positive().optional(),
|
||||||
headless: z.boolean().optional(),
|
headless: z.boolean().optional(),
|
||||||
enableNoVnc: z.boolean().optional(),
|
enableNoVnc: z.boolean().optional(),
|
||||||
|
autoStart: z.boolean().optional(),
|
||||||
|
autoStartTimeoutMs: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user