feat: add browser target selection for sandboxed agents

This commit is contained in:
Peter Steinberger
2026-01-11 01:24:02 +01:00
parent d2098e4492
commit 326fb04d12
16 changed files with 173 additions and 8 deletions

View File

@@ -17,6 +17,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
allowHostBrowserControl?: boolean;
agentSessionKey?: string;
agentProvider?: GatewayMessageProvider;
agentAccountId?: string;
@@ -37,7 +38,10 @@ export function createClawdbotTools(options?: {
agentDir: options?.agentDir,
});
return [
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createBrowserTool({
defaultControlUrl: options?.browserControlUrl,
allowHostControl: options?.allowHostBrowserControl,
}),
createCanvasTool(),
createNodesTool(),
createCronTool(),

View File

@@ -45,6 +45,7 @@ describe("buildEmbeddedSandboxInfo", () => {
allow: ["bash"],
deny: ["browser"],
},
browserAllowHostControl: true,
browser: {
controlUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
@@ -59,6 +60,7 @@ describe("buildEmbeddedSandboxInfo", () => {
agentWorkspaceMount: undefined,
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
hostBrowserAllowed: true,
});
});
@@ -86,6 +88,7 @@ describe("buildEmbeddedSandboxInfo", () => {
allow: ["bash"],
deny: ["browser"],
},
browserAllowHostControl: false,
} satisfies SandboxContext;
expect(
@@ -99,6 +102,7 @@ describe("buildEmbeddedSandboxInfo", () => {
workspaceDir: "/tmp/clawdbot-sandbox",
workspaceAccess: "none",
agentWorkspaceMount: undefined,
hostBrowserAllowed: false,
elevated: { allowed: true, defaultLevel: "on" },
});
});

View File

@@ -479,6 +479,7 @@ type EmbeddedSandboxInfo = {
agentWorkspaceMount?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
@@ -570,6 +571,7 @@ export function buildEmbeddedSandboxInfo(
sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
hostBrowserAllowed: sandbox.browserAllowHostControl,
...(elevatedAllowed
? {
elevated: {

View File

@@ -240,6 +240,7 @@ describe("Agent-specific tool filtering", () => {
allow: ["read", "write", "bash"],
deny: [],
},
browserAllowHostControl: false,
},
});

View File

@@ -25,6 +25,7 @@ describe("createClawdbotCodingTools", () => {
required?: string[];
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
@@ -326,6 +327,7 @@ describe("createClawdbotCodingTools", () => {
allow: ["bash"],
deny: ["browser"],
},
browserAllowHostControl: false,
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
@@ -357,6 +359,7 @@ describe("createClawdbotCodingTools", () => {
allow: ["read", "write", "edit"],
deny: [],
},
browserAllowHostControl: false,
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);

View File

@@ -626,6 +626,9 @@ export function createClawdbotCodingTools(options?: {
createWhatsAppLoginTool(),
...createClawdbotTools({
browserControlUrl: sandbox?.browser?.controlUrl,
allowHostBrowserControl: sandbox
? sandbox.browserAllowHostControl
: true,
agentSessionKey: options?.sessionKey,
agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
agentAccountId: options?.agentAccountId,

View File

@@ -186,6 +186,7 @@ describe("sandboxed workspace paths", () => {
env: { LANG: "C.UTF-8" },
},
tools: { allow: [], deny: [] },
browserAllowHostControl: false,
};
const testFile = "sandbox.txt";

View File

@@ -78,6 +78,7 @@ export type SandboxBrowserConfig = {
noVncPort: number;
headless: boolean;
enableNoVnc: boolean;
allowHostControl: boolean;
autoStart: boolean;
autoStartTimeoutMs: number;
};
@@ -138,6 +139,7 @@ export type SandboxContext = {
containerWorkdir: string;
docker: SandboxDockerConfig;
tools: SandboxToolPolicy;
browserAllowHostControl: boolean;
browser?: SandboxBrowserContext;
};
@@ -333,6 +335,10 @@ export function resolveSandboxBrowserConfig(params: {
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc:
agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
allowHostControl:
agentBrowser?.allowHostControl ??
globalBrowser?.allowHostControl ??
false,
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs:
agentBrowser?.autoStartTimeoutMs ??
@@ -1324,6 +1330,7 @@ export async function resolveSandboxContext(params: {
containerWorkdir: cfg.docker.workdir,
docker: cfg.docker,
tools: cfg.tools,
browserAllowHostControl: cfg.browser.allowHostControl,
browser: browser ?? undefined,
};
}

View File

@@ -32,6 +32,7 @@ export function buildAgentSystemPrompt(params: {
agentWorkspaceMount?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
@@ -243,6 +244,11 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
params.sandboxInfo.hostBrowserAllowed === true
? "Host browser control: allowed."
: params.sandboxInfo.hostBrowserAllowed === false
? "Host browser control: blocked."
: "",
params.sandboxInfo.elevated?.allowed
? "Elevated bash is available for this session."
: "",

View File

@@ -105,6 +105,13 @@ const BrowserToolSchema = Type.Object({
Type.Literal("dialog"),
Type.Literal("act"),
]),
target: Type.Optional(
Type.Union([
Type.Literal("sandbox"),
Type.Literal("host"),
Type.Literal("custom"),
]),
),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
@@ -124,20 +131,60 @@ const BrowserToolSchema = Type.Object({
request: Type.Optional(BrowserActSchema),
});
function resolveBrowserBaseUrl(controlUrl?: string) {
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host" | "custom";
controlUrl?: string;
defaultControlUrl?: string;
allowHostControl?: boolean;
}) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled && !controlUrl?.trim()) {
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
const target =
params.target ??
(normalizedControlUrl
? "custom"
: normalizedDefault
? "sandbox"
: "host");
if (target !== "custom" && params.target && normalizedControlUrl) {
throw new Error(
'controlUrl is only supported with target="custom".',
);
}
if (target === "custom") {
if (!normalizedControlUrl) {
throw new Error('Custom browser target requires controlUrl.');
}
return normalizedControlUrl.replace(/\/$/, "");
}
if (target === "sandbox") {
if (!normalizedDefault) {
throw new Error(
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
);
}
return normalizedDefault.replace(/\/$/, "");
}
if (params.allowHostControl === false) {
throw new Error("Host browser control is disabled by sandbox policy.");
}
if (!resolved.enabled) {
throw new Error(
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
);
}
const url = controlUrl?.trim() ? controlUrl.trim() : resolved.controlUrl;
return url.replace(/\/$/, "");
return resolved.controlUrl.replace(/\/$/, "");
}
export function createBrowserTool(opts?: {
defaultControlUrl?: string;
allowHostControl?: boolean;
}): AnyAgentTool {
return {
label: "Browser",
@@ -149,10 +196,18 @@ export function createBrowserTool(opts?: {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const target = readStringParam(params, "target") as
| "sandbox"
| "host"
| "custom"
| undefined;
const profile = readStringParam(params, "profile");
const baseUrl = resolveBrowserBaseUrl(
controlUrl ?? opts?.defaultControlUrl,
);
const baseUrl = resolveBrowserBaseUrl({
target,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl,
});
switch (action) {
case "status":

View File

@@ -860,6 +860,11 @@ export type SandboxBrowserSettings = {
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
/**
* Allow sandboxed sessions to target the host browser control server.
* Default: false.
*/
allowHostControl?: boolean;
/**
* When true (default), sandboxed browser control will try to start/reattach to
* the sandbox browser container when a tool call needs it.

View File

@@ -724,6 +724,7 @@ const SandboxBrowserSchema = z
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
allowHostControl: z.boolean().optional(),
autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(),
})