2026-01-04 05:07:37 +01:00
import {
browserCloseTab ,
browserFocusTab ,
browserOpenTab ,
2026-01-15 04:50:11 +00:00
browserProfiles ,
2026-01-04 05:07:37 +01:00
browserSnapshot ,
browserStart ,
browserStatus ,
browserStop ,
browserTabs ,
} from "../../browser/client.js" ;
import {
browserAct ,
browserArmDialog ,
browserArmFileChooser ,
browserConsoleMessages ,
browserNavigate ,
browserPdfSave ,
browserScreenshotAction ,
} from "../../browser/client-actions.js" ;
import { resolveBrowserConfig } from "../../browser/config.js" ;
2026-01-12 18:05:10 +00:00
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js" ;
2026-01-04 05:07:37 +01:00
import { loadConfig } from "../../config/config.js" ;
2026-01-14 01:08:15 +00:00
import { BrowserToolSchema } from "./browser-tool.schema.js" ;
2026-01-14 14:31:43 +00:00
import { type AnyAgentTool , imageResultFromFile , jsonResult , readStringParam } from "./common.js" ;
2026-01-04 05:07:37 +01:00
2026-01-11 01:24:02 +01:00
function resolveBrowserBaseUrl ( params : {
target ? : "sandbox" | "host" | "custom" ;
controlUrl? : string ;
defaultControlUrl? : string ;
allowHostControl? : boolean ;
2026-01-11 01:52:23 +01:00
allowedControlUrls? : string [ ] ;
allowedControlHosts? : string [ ] ;
allowedControlPorts? : number [ ] ;
2026-01-11 01:24:02 +01:00
} ) {
2026-01-04 05:07:37 +01:00
const cfg = loadConfig ( ) ;
const resolved = resolveBrowserConfig ( cfg . browser ) ;
2026-01-11 01:24:02 +01:00
const normalizedControlUrl = params . controlUrl ? . trim ( ) ? ? "" ;
const normalizedDefault = params . defaultControlUrl ? . trim ( ) ? ? "" ;
const target =
2026-01-14 14:31:43 +00:00
params . target ? ? ( normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host" ) ;
2026-01-11 01:24:02 +01:00
2026-01-11 01:52:23 +01:00
const assertAllowedControlUrl = ( url : string ) = > {
2026-01-14 14:31:43 +00:00
const allowedUrls = params . allowedControlUrls ? . map ( ( entry ) = > entry . trim ( ) . replace ( /\/$/ , "" ) ) ;
const allowedHosts = params . allowedControlHosts ? . map ( ( entry ) = > entry . trim ( ) . toLowerCase ( ) ) ;
2026-01-11 01:52:23 +01:00
const allowedPorts = params . allowedControlPorts ;
2026-01-14 14:31:43 +00:00
if ( ! allowedUrls ? . length && ! allowedHosts ? . length && ! allowedPorts ? . length ) {
2026-01-11 01:52:23 +01:00
return ;
}
let parsed : URL ;
try {
parsed = new URL ( url ) ;
} catch {
throw new Error ( ` Invalid browser controlUrl: ${ url } ` ) ;
}
const normalizedUrl = parsed . toString ( ) . replace ( /\/$/ , "" ) ;
if ( allowedUrls ? . length && ! allowedUrls . includes ( normalizedUrl ) ) {
throw new Error ( "Browser controlUrl is not in the allowed URL list." ) ;
}
if ( allowedHosts ? . length && ! allowedHosts . includes ( parsed . hostname ) ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "Browser controlUrl hostname is not in the allowed host list." ) ;
2026-01-11 01:52:23 +01:00
}
if ( allowedPorts ? . length ) {
const port =
2026-01-14 14:31:43 +00:00
parsed . port ? . trim ( ) !== "" ? Number ( parsed . port ) : parsed . protocol === "https:" ? 443 : 80 ;
2026-01-11 01:52:23 +01:00
if ( ! Number . isFinite ( port ) || ! allowedPorts . includes ( port ) ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "Browser controlUrl port is not in the allowed port list." ) ;
2026-01-11 01:52:23 +01:00
}
}
} ;
2026-01-11 01:24:02 +01:00
if ( target !== "custom" && params . target && normalizedControlUrl ) {
2026-01-11 01:34:45 +01:00
throw new Error ( 'controlUrl is only supported with target="custom".' ) ;
2026-01-11 01:24:02 +01:00
}
if ( target === "custom" ) {
if ( ! normalizedControlUrl ) {
2026-01-11 01:34:45 +01:00
throw new Error ( "Custom browser target requires controlUrl." ) ;
2026-01-11 01:24:02 +01:00
}
2026-01-11 01:52:23 +01:00
const normalized = normalizedControlUrl . replace ( /\/$/ , "" ) ;
assertAllowedControlUrl ( normalized ) ;
return normalized ;
2026-01-11 01:24:02 +01:00
}
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 ) {
2026-01-04 05:07:37 +01:00
throw new Error (
2026-01-04 14:32:47 +00:00
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json." ,
2026-01-04 05:07:37 +01:00
) ;
}
2026-01-11 01:52:23 +01:00
const normalized = resolved . controlUrl . replace ( /\/$/ , "" ) ;
assertAllowedControlUrl ( normalized ) ;
return normalized ;
2026-01-04 05:07:37 +01:00
}
export function createBrowserTool ( opts ? : {
defaultControlUrl? : string ;
2026-01-11 01:24:02 +01:00
allowHostControl? : boolean ;
2026-01-11 01:52:23 +01:00
allowedControlUrls? : string [ ] ;
allowedControlHosts? : string [ ] ;
allowedControlPorts? : number [ ] ;
2026-01-04 05:07:37 +01:00
} ) : AnyAgentTool {
2026-01-11 01:52:23 +01:00
const targetDefault = opts ? . defaultControlUrl ? "sandbox" : "host" ;
const hostHint =
2026-01-14 14:31:43 +00:00
opts ? . allowHostControl === false ? "Host target blocked by policy." : "Host target allowed." ;
2026-01-11 01:52:23 +01:00
const allowlistHint =
opts ? . allowedControlUrls ? . length ||
opts ? . allowedControlHosts ? . length ||
opts ? . allowedControlPorts ? . length
? "Custom targets are restricted by sandbox allowlists."
: "Custom targets are unrestricted." ;
2026-01-04 05:07:37 +01:00
return {
label : "Browser" ,
name : "browser" ,
2026-01-11 01:52:23 +01:00
description : [
2026-01-15 09:13:07 +00:00
"Control the browser via Clawdbot's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions)." ,
'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="clawd" for the isolated clawd-managed browser.' ,
2026-01-15 10:18:57 +00:00
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).' ,
2026-01-15 09:13:07 +00:00
"Chrome extension relay needs an attached tab: user must click the Clawdbot Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it." ,
2026-01-15 09:36:48 +00:00
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc)." ,
2026-01-15 10:16:33 +00:00
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.' ,
2026-01-11 01:52:23 +01:00
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists." ,
` target selects browser location (sandbox|host|custom). Default: ${ targetDefault } . ` ,
"controlUrl implies target=custom (remote control server)." ,
hostHint ,
allowlistHint ,
] . join ( " " ) ,
2026-01-04 05:07:37 +01:00
parameters : BrowserToolSchema ,
execute : async ( _toolCallId , args ) = > {
const params = args as Record < string , unknown > ;
const action = readStringParam ( params , "action" , { required : true } ) ;
const controlUrl = readStringParam ( params , "controlUrl" ) ;
2026-01-06 09:54:31 -07:00
const profile = readStringParam ( params , "profile" ) ;
2026-01-15 10:34:57 +00:00
let target = readStringParam ( params , "target" ) as "sandbox" | "host" | "custom" | undefined ;
if ( profile === "chrome" && ! target && ! controlUrl ? . trim ( ) ) {
// Chrome extension relay takeover is a host Chrome feature; default to host even in sandboxed sessions.
target = "host" ;
}
2026-01-11 01:24:02 +01:00
const baseUrl = resolveBrowserBaseUrl ( {
target ,
controlUrl ,
defaultControlUrl : opts?.defaultControlUrl ,
allowHostControl : opts?.allowHostControl ,
2026-01-11 01:52:23 +01:00
allowedControlUrls : opts?.allowedControlUrls ,
allowedControlHosts : opts?.allowedControlHosts ,
allowedControlPorts : opts?.allowedControlPorts ,
2026-01-11 01:24:02 +01:00
} ) ;
2026-01-04 05:07:37 +01:00
switch ( action ) {
case "status" :
2026-01-06 09:54:31 -07:00
return jsonResult ( await browserStatus ( baseUrl , { profile } ) ) ;
2026-01-04 05:07:37 +01:00
case "start" :
2026-01-06 09:54:31 -07:00
await browserStart ( baseUrl , { profile } ) ;
return jsonResult ( await browserStatus ( baseUrl , { profile } ) ) ;
2026-01-04 05:07:37 +01:00
case "stop" :
2026-01-06 09:54:31 -07:00
await browserStop ( baseUrl , { profile } ) ;
return jsonResult ( await browserStatus ( baseUrl , { profile } ) ) ;
2026-01-15 04:50:11 +00:00
case "profiles" :
return jsonResult ( { profiles : await browserProfiles ( baseUrl ) } ) ;
2026-01-04 05:07:37 +01:00
case "tabs" :
2026-01-06 09:54:31 -07:00
return jsonResult ( { tabs : await browserTabs ( baseUrl , { profile } ) } ) ;
2026-01-04 05:07:37 +01:00
case "open" : {
const targetUrl = readStringParam ( params , "targetUrl" , {
required : true ,
} ) ;
2026-01-14 14:31:43 +00:00
return jsonResult ( await browserOpenTab ( baseUrl , targetUrl , { profile } ) ) ;
2026-01-04 05:07:37 +01:00
}
case "focus" : {
const targetId = readStringParam ( params , "targetId" , {
required : true ,
} ) ;
2026-01-06 09:54:31 -07:00
await browserFocusTab ( baseUrl , targetId , { profile } ) ;
2026-01-04 05:07:37 +01:00
return jsonResult ( { ok : true } ) ;
}
case "close" : {
const targetId = readStringParam ( params , "targetId" ) ;
2026-01-06 09:54:31 -07:00
if ( targetId ) await browserCloseTab ( baseUrl , targetId , { profile } ) ;
else await browserAct ( baseUrl , { kind : "close" } , { profile } ) ;
2026-01-04 05:07:37 +01:00
return jsonResult ( { ok : true } ) ;
}
case "snapshot" : {
const format =
params . format === "ai" || params . format === "aria"
? ( params . format as "ai" | "aria" )
: "ai" ;
2026-01-15 03:50:48 +00:00
const mode = params . mode === "efficient" ? "efficient" : undefined ;
const labels = typeof params . labels === "boolean" ? params.labels : undefined ;
2026-01-15 10:16:33 +00:00
const refs = params . refs === "aria" || params . refs === "role" ? params.refs : undefined ;
2026-01-13 02:29:48 +00:00
const hasMaxChars = Object . hasOwn ( params , "maxChars" ) ;
2026-01-14 14:31:43 +00:00
const targetId = typeof params . targetId === "string" ? params . targetId . trim ( ) : undefined ;
2026-01-04 05:07:37 +01:00
const limit =
typeof params . limit === "number" && Number . isFinite ( params . limit )
? params . limit
: undefined ;
2026-01-11 21:44:08 -08:00
const maxChars =
typeof params . maxChars === "number" &&
2026-01-12 07:37:14 +00:00
Number . isFinite ( params . maxChars ) &&
params . maxChars > 0
? Math . floor ( params . maxChars )
: undefined ;
const resolvedMaxChars =
2026-01-15 03:50:48 +00:00
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? undefined
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined ;
2026-01-12 08:36:20 +00:00
const interactive =
2026-01-14 14:31:43 +00:00
typeof params . interactive === "boolean" ? params.interactive : undefined ;
const compact = typeof params . compact === "boolean" ? params.compact : undefined ;
2026-01-12 08:36:20 +00:00
const depth =
typeof params . depth === "number" && Number . isFinite ( params . depth )
? params . depth
: undefined ;
2026-01-14 14:31:43 +00:00
const selector = typeof params . selector === "string" ? params . selector . trim ( ) : undefined ;
const frame = typeof params . frame === "string" ? params . frame . trim ( ) : undefined ;
2026-01-04 05:07:37 +01:00
const snapshot = await browserSnapshot ( baseUrl , {
format ,
targetId ,
limit ,
2026-01-14 14:31:43 +00:00
. . . ( typeof resolvedMaxChars === "number" ? { maxChars : resolvedMaxChars } : { } ) ,
2026-01-15 10:16:33 +00:00
refs ,
2026-01-12 08:36:20 +00:00
interactive ,
compact ,
depth ,
selector ,
2026-01-12 17:31:49 +00:00
frame ,
2026-01-15 03:50:48 +00:00
labels ,
mode ,
2026-01-06 09:54:31 -07:00
profile ,
2026-01-04 05:07:37 +01:00
} ) ;
if ( snapshot . format === "ai" ) {
2026-01-15 03:50:48 +00:00
if ( labels && snapshot . imagePath ) {
return await imageResultFromFile ( {
label : "browser:snapshot" ,
path : snapshot.imagePath ,
extraText : snapshot.snapshot ,
details : snapshot ,
} ) ;
}
2026-01-04 05:07:37 +01:00
return {
content : [ { type : "text" , text : snapshot.snapshot } ] ,
details : snapshot ,
} ;
}
return jsonResult ( snapshot ) ;
}
case "screenshot" : {
const targetId = readStringParam ( params , "targetId" ) ;
const fullPage = Boolean ( params . fullPage ) ;
const ref = readStringParam ( params , "ref" ) ;
const element = readStringParam ( params , "element" ) ;
const type = params . type === "jpeg" ? "jpeg" : "png" ;
const result = await browserScreenshotAction ( baseUrl , {
targetId ,
fullPage ,
ref ,
element ,
type ,
2026-01-06 09:54:31 -07:00
profile ,
2026-01-04 05:07:37 +01:00
} ) ;
return await imageResultFromFile ( {
label : "browser:screenshot" ,
path : result.path ,
details : result ,
} ) ;
}
case "navigate" : {
const targetUrl = readStringParam ( params , "targetUrl" , {
required : true ,
} ) ;
const targetId = readStringParam ( params , "targetId" ) ;
return jsonResult (
2026-01-06 11:04:33 -07:00
await browserNavigate ( baseUrl , {
url : targetUrl ,
targetId ,
profile ,
} ) ,
2026-01-04 05:07:37 +01:00
) ;
}
case "console" : {
2026-01-14 14:31:43 +00:00
const level = typeof params . level === "string" ? params . level . trim ( ) : undefined ;
const targetId = typeof params . targetId === "string" ? params . targetId . trim ( ) : undefined ;
return jsonResult ( await browserConsoleMessages ( baseUrl , { level , targetId , profile } ) ) ;
2026-01-04 05:07:37 +01:00
}
case "pdf" : {
2026-01-14 14:31:43 +00:00
const targetId = typeof params . targetId === "string" ? params . targetId . trim ( ) : undefined ;
2026-01-06 09:54:31 -07:00
const result = await browserPdfSave ( baseUrl , { targetId , profile } ) ;
2026-01-04 05:07:37 +01:00
return {
content : [ { type : "text" , text : ` FILE: ${ result . path } ` } ] ,
details : result ,
} ;
}
case "upload" : {
2026-01-14 14:31:43 +00:00
const paths = Array . isArray ( params . paths ) ? params . paths . map ( ( p ) = > String ( p ) ) : [ ] ;
2026-01-04 05:07:37 +01:00
if ( paths . length === 0 ) throw new Error ( "paths required" ) ;
const ref = readStringParam ( params , "ref" ) ;
const inputRef = readStringParam ( params , "inputRef" ) ;
const element = readStringParam ( params , "element" ) ;
2026-01-14 14:31:43 +00:00
const targetId = typeof params . targetId === "string" ? params . targetId . trim ( ) : undefined ;
2026-01-04 05:07:37 +01:00
const timeoutMs =
2026-01-14 14:31:43 +00:00
typeof params . timeoutMs === "number" && Number . isFinite ( params . timeoutMs )
2026-01-04 05:07:37 +01:00
? params . timeoutMs
: undefined ;
return jsonResult (
await browserArmFileChooser ( baseUrl , {
paths ,
ref ,
inputRef ,
element ,
targetId ,
timeoutMs ,
2026-01-06 09:54:31 -07:00
profile ,
2026-01-04 05:07:37 +01:00
} ) ,
) ;
}
case "dialog" : {
const accept = Boolean ( params . accept ) ;
2026-01-14 14:31:43 +00:00
const promptText = typeof params . promptText === "string" ? params.promptText : undefined ;
const targetId = typeof params . targetId === "string" ? params . targetId . trim ( ) : undefined ;
2026-01-04 05:07:37 +01:00
const timeoutMs =
2026-01-14 14:31:43 +00:00
typeof params . timeoutMs === "number" && Number . isFinite ( params . timeoutMs )
2026-01-04 05:07:37 +01:00
? params . timeoutMs
: undefined ;
return jsonResult (
await browserArmDialog ( baseUrl , {
accept ,
promptText ,
targetId ,
timeoutMs ,
2026-01-06 09:54:31 -07:00
profile ,
2026-01-04 05:07:37 +01:00
} ) ,
) ;
}
case "act" : {
const request = params . request as Record < string , unknown > | undefined ;
if ( ! request || typeof request !== "object" ) {
throw new Error ( "request required" ) ;
}
2026-01-15 10:34:57 +00:00
try {
2026-01-15 10:48:04 +00:00
const result = await browserAct ( baseUrl , request as Parameters < typeof browserAct > [ 1 ] , {
profile ,
} ) ;
2026-01-15 10:34:57 +00:00
return jsonResult ( result ) ;
} catch ( err ) {
const msg = String ( err ) ;
if ( msg . includes ( "404:" ) && msg . includes ( "tab not found" ) && profile === "chrome" ) {
const tabs = await browserTabs ( baseUrl , { profile } ) . catch ( ( ) = > [ ] ) ;
if ( ! tabs . length ) {
throw new Error (
2026-01-15 10:48:04 +00:00
"No Chrome tabs are attached via the Clawdbot Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry." ,
2026-01-15 10:34:57 +00:00
) ;
}
throw new Error (
` Chrome tab not found (stale targetId?). Run action=tabs profile="chrome" and use one of the returned targetIds. ` ,
) ;
}
throw err ;
}
2026-01-04 05:07:37 +01:00
}
default :
throw new Error ( ` Unknown action: ${ action } ` ) ;
}
} ,
} ;
}