2026-01-01 16:15:12 +00:00
import fs from "node:fs" ;
2026-02-19 13:43:48 +01:00
import { SsrFBlockedError } from "../infra/net/ssrf.js" ;
2026-02-15 18:27:02 +00:00
import { fetchJson , fetchOk } from "./cdp.helpers.js" ;
import { appendCdpPath , createTargetViaCdp , normalizeCdpWsUrl } from "./cdp.js" ;
2026-02-01 10:03:47 +09:00
import {
isChromeCdpReady ,
isChromeReachable ,
launchOpenClawChrome ,
resolveOpenClawUserDataDir ,
stopOpenClawChrome ,
} from "./chrome.js" ;
2026-02-18 01:34:35 +00:00
import type { ResolvedBrowserProfile } from "./config.js" ;
2026-02-15 05:21:13 +00:00
import { resolveProfile } from "./config.js" ;
2026-01-15 04:50:11 +00:00
import {
ensureChromeExtensionRelayServer ,
stopChromeExtensionRelayServer ,
} from "./extension-relay.js" ;
2026-02-19 14:04:08 +01:00
import {
assertBrowserNavigationAllowed ,
InvalidBrowserNavigationUrlError ,
withBrowserNavigationPolicy ,
} from "./navigation-guard.js" ;
2026-02-18 01:34:35 +00:00
import type { PwAiModule } from "./pw-ai-module.js" ;
2026-01-17 01:28:22 +00:00
import { getPwAiModule } from "./pw-ai-module.js" ;
2026-02-15 05:21:13 +00:00
import {
refreshResolvedBrowserConfigFromDisk ,
resolveBrowserProfileWithHotReload ,
} from "./resolved-config-refresh.js" ;
2026-02-18 01:34:35 +00:00
import type {
BrowserServerState ,
BrowserRouteContext ,
BrowserTab ,
ContextOptions ,
ProfileContext ,
ProfileRuntimeState ,
ProfileStatus ,
} from "./server-context.types.js" ;
2025-12-19 23:57:26 +00:00
import { resolveTargetIdFromTabs } from "./target-id.js" ;
2026-01-04 03:32:40 +00:00
import { movePathToTrash } from "./trash.js" ;
2025-12-19 23:57:26 +00:00
2026-01-14 01:08:15 +00:00
export type {
BrowserRouteContext ,
BrowserServerState ,
BrowserTab ,
ProfileContext ,
ProfileRuntimeState ,
ProfileStatus ,
} from "./server-context.types.js" ;
2025-12-19 23:57:26 +00:00
2026-02-14 00:44:04 +01:00
export function listKnownProfileNames ( state : BrowserServerState ) : string [ ] {
const names = new Set ( Object . keys ( state . resolved . profiles ) ) ;
for ( const name of state . profiles . keys ( ) ) {
names . add ( name ) ;
}
return [ . . . names ] ;
}
2026-01-04 03:32:40 +00:00
/ * *
* Normalize a CDP WebSocket URL to use the correct base URL .
* /
2026-01-14 14:31:43 +00:00
function normalizeWsUrl ( raw : string | undefined , cdpBaseUrl : string ) : string | undefined {
2026-01-31 16:19:20 +09:00
if ( ! raw ) {
return undefined ;
}
2026-01-04 03:32:40 +00:00
try {
return normalizeCdpWsUrl ( raw , cdpBaseUrl ) ;
} catch {
return raw ;
}
}
/ * *
* Create a profile - scoped context for browser operations .
* /
function createProfileContext (
2025-12-19 23:57:26 +00:00
opts : ContextOptions ,
2026-01-04 03:32:40 +00:00
profile : ResolvedBrowserProfile ,
) : ProfileContext {
2025-12-19 23:57:26 +00:00
const state = ( ) = > {
const current = opts . getState ( ) ;
2026-01-31 16:19:20 +09:00
if ( ! current ) {
throw new Error ( "Browser server not started" ) ;
}
2025-12-19 23:57:26 +00:00
return current ;
} ;
2026-01-04 03:32:40 +00:00
const getProfileState = ( ) : ProfileRuntimeState = > {
2025-12-19 23:57:26 +00:00
const current = state ( ) ;
2026-01-04 03:32:40 +00:00
let profileState = current . profiles . get ( profile . name ) ;
if ( ! profileState ) {
2026-01-15 09:36:48 +00:00
profileState = { profile , running : null , lastTargetId : null } ;
2026-01-04 03:32:40 +00:00
current . profiles . set ( profile . name , profileState ) ;
}
return profileState ;
} ;
2026-01-14 01:08:15 +00:00
const setProfileRunning = ( running : ProfileRuntimeState [ "running" ] ) = > {
2026-01-04 03:32:40 +00:00
const profileState = getProfileState ( ) ;
profileState . running = running ;
} ;
const listTabs = async ( ) : Promise < BrowserTab [ ] > = > {
2026-01-17 00:22:27 +00:00
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
if ( ! profile . cdpIsLoopback ) {
2026-01-17 01:28:22 +00:00
const mod = await getPwAiModule ( { mode : "strict" } ) ;
2026-01-17 00:57:35 +00:00
const listPagesViaPlaywright = ( mod as Partial < PwAiModule > | null ) ? . listPagesViaPlaywright ;
if ( typeof listPagesViaPlaywright === "function" ) {
const pages = await listPagesViaPlaywright ( { cdpUrl : profile.cdpUrl } ) ;
2026-01-17 00:22:27 +00:00
return pages . map ( ( p ) = > ( {
targetId : p.targetId ,
title : p.title ,
url : p.url ,
type : p . type ,
} ) ) ;
}
}
2026-01-17 00:57:35 +00:00
2025-12-19 23:57:26 +00:00
const raw = await fetchJson <
Array < {
id? : string ;
title? : string ;
url? : string ;
webSocketDebuggerUrl? : string ;
type ? : string ;
} >
2026-01-16 08:31:51 +00:00
> ( appendCdpPath ( profile . cdpUrl , "/json/list" ) ) ;
2025-12-19 23:57:26 +00:00
return raw
. map ( ( t ) = > ( {
targetId : t.id ? ? "" ,
title : t.title ? ? "" ,
url : t.url ? ? "" ,
2026-01-04 03:32:40 +00:00
wsUrl : normalizeWsUrl ( t . webSocketDebuggerUrl , profile . cdpUrl ) ,
2025-12-19 23:57:26 +00:00
type : t . type ,
} ) )
. filter ( ( t ) = > Boolean ( t . targetId ) ) ;
} ;
const openTab = async ( url : string ) : Promise < BrowserTab > = > {
2026-02-19 14:04:08 +01:00
const ssrfPolicyOpts = withBrowserNavigationPolicy ( state ( ) . resolved . ssrfPolicy ) ;
await assertBrowserNavigationAllowed ( { url , . . . ssrfPolicyOpts } ) ;
2026-02-19 13:43:48 +01:00
2026-01-17 00:22:27 +00:00
// For remote profiles, use Playwright's persistent connection to create tabs
// This ensures the tab persists beyond a single request
if ( ! profile . cdpIsLoopback ) {
2026-01-17 01:28:22 +00:00
const mod = await getPwAiModule ( { mode : "strict" } ) ;
2026-01-17 00:57:35 +00:00
const createPageViaPlaywright = ( mod as Partial < PwAiModule > | null ) ? . createPageViaPlaywright ;
if ( typeof createPageViaPlaywright === "function" ) {
2026-02-19 13:43:48 +01:00
const page = await createPageViaPlaywright ( {
cdpUrl : profile.cdpUrl ,
url ,
2026-02-19 14:04:08 +01:00
. . . ssrfPolicyOpts ,
navigationChecked : true ,
2026-02-19 13:43:48 +01:00
} ) ;
2026-01-17 00:22:27 +00:00
const profileState = getProfileState ( ) ;
profileState . lastTargetId = page . targetId ;
return {
targetId : page.targetId ,
title : page.title ,
url : page.url ,
type : page . type ,
} ;
}
}
2026-01-17 00:57:35 +00:00
2025-12-19 23:57:26 +00:00
const createdViaCdp = await createTargetViaCdp ( {
2026-01-04 03:32:40 +00:00
cdpUrl : profile.cdpUrl ,
2025-12-19 23:57:26 +00:00
url ,
2026-02-19 14:04:08 +01:00
. . . ssrfPolicyOpts ,
navigationChecked : true ,
2025-12-19 23:57:26 +00:00
} )
. then ( ( r ) = > r . targetId )
. catch ( ( ) = > null ) ;
if ( createdViaCdp ) {
2026-01-17 00:57:35 +00:00
const profileState = getProfileState ( ) ;
profileState . lastTargetId = createdViaCdp ;
2025-12-19 23:57:26 +00:00
const deadline = Date . now ( ) + 2000 ;
while ( Date . now ( ) < deadline ) {
const tabs = await listTabs ( ) . catch ( ( ) = > [ ] as BrowserTab [ ] ) ;
const found = tabs . find ( ( t ) = > t . targetId === createdViaCdp ) ;
2026-01-31 16:19:20 +09:00
if ( found ) {
return found ;
}
2025-12-19 23:57:26 +00:00
await new Promise ( ( r ) = > setTimeout ( r , 100 ) ) ;
}
return { targetId : createdViaCdp , title : "" , url , type : "page" } ;
}
const encoded = encodeURIComponent ( url ) ;
type CdpTarget = {
id? : string ;
title? : string ;
url? : string ;
webSocketDebuggerUrl? : string ;
type ? : string ;
} ;
2026-01-16 08:31:51 +00:00
const endpointUrl = new URL ( appendCdpPath ( profile . cdpUrl , "/json/new" ) ) ;
const endpoint = endpointUrl . search
? ( ( ) = > {
endpointUrl . searchParams . set ( "url" , url ) ;
return endpointUrl . toString ( ) ;
} ) ( )
: ` ${ endpointUrl . toString ( ) } ? ${ encoded } ` ;
2025-12-19 23:57:26 +00:00
const created = await fetchJson < CdpTarget > ( endpoint , 1500 , {
method : "PUT" ,
} ) . catch ( async ( err ) = > {
if ( String ( err ) . includes ( "HTTP 405" ) ) {
return await fetchJson < CdpTarget > ( endpoint , 1500 ) ;
}
throw err ;
} ) ;
2026-01-31 16:19:20 +09:00
if ( ! created . id ) {
throw new Error ( "Failed to open tab (missing id)" ) ;
}
2026-01-15 09:36:48 +00:00
const profileState = getProfileState ( ) ;
profileState . lastTargetId = created . id ;
2025-12-19 23:57:26 +00:00
return {
targetId : created.id ,
title : created.title ? ? "" ,
url : created.url ? ? url ,
2026-01-16 08:31:51 +00:00
wsUrl : normalizeWsUrl ( created . webSocketDebuggerUrl , profile . cdpUrl ) ,
2025-12-19 23:57:26 +00:00
type : created . type ,
} ;
} ;
2026-01-16 09:01:25 +00:00
const resolveRemoteHttpTimeout = ( timeoutMs : number | undefined ) = > {
2026-01-31 16:19:20 +09:00
if ( profile . cdpIsLoopback ) {
return timeoutMs ? ? 300 ;
}
2026-01-16 09:01:25 +00:00
const resolved = state ( ) . resolved ;
if ( typeof timeoutMs === "number" && Number . isFinite ( timeoutMs ) ) {
return Math . max ( Math . floor ( timeoutMs ) , resolved . remoteCdpTimeoutMs ) ;
}
return resolved . remoteCdpTimeoutMs ;
} ;
const resolveRemoteWsTimeout = ( timeoutMs : number | undefined ) = > {
if ( profile . cdpIsLoopback ) {
const base = timeoutMs ? ? 300 ;
return Math . max ( 200 , Math . min ( 2000 , base * 2 ) ) ;
}
const resolved = state ( ) . resolved ;
if ( typeof timeoutMs === "number" && Number . isFinite ( timeoutMs ) ) {
return Math . max ( Math . floor ( timeoutMs ) * 2 , resolved . remoteCdpHandshakeTimeoutMs ) ;
}
return resolved . remoteCdpHandshakeTimeoutMs ;
} ;
const isReachable = async ( timeoutMs? : number ) = > {
const httpTimeout = resolveRemoteHttpTimeout ( timeoutMs ) ;
const wsTimeout = resolveRemoteWsTimeout ( timeoutMs ) ;
return await isChromeCdpReady ( profile . cdpUrl , httpTimeout , wsTimeout ) ;
2026-01-01 16:15:12 +00:00
} ;
2026-01-16 09:01:25 +00:00
const isHttpReachable = async ( timeoutMs? : number ) = > {
const httpTimeout = resolveRemoteHttpTimeout ( timeoutMs ) ;
return await isChromeReachable ( profile . cdpUrl , httpTimeout ) ;
2025-12-19 23:57:26 +00:00
} ;
2026-01-14 14:31:43 +00:00
const attachRunning = ( running : NonNullable < ProfileRuntimeState [ "running" ] > ) = > {
2026-01-04 03:32:40 +00:00
setProfileRunning ( running ) ;
2026-01-01 16:15:12 +00:00
running . proc . on ( "exit" , ( ) = > {
2026-01-04 03:32:40 +00:00
// Guard against server teardown (e.g., SIGUSR1 restart)
2026-01-31 16:19:20 +09:00
if ( ! opts . getState ( ) ) {
return ;
}
2026-01-04 03:32:40 +00:00
const profileState = getProfileState ( ) ;
if ( profileState . running ? . pid === running . pid ) {
setProfileRunning ( null ) ;
2026-01-01 16:15:12 +00:00
}
} ) ;
} ;
2025-12-19 23:57:26 +00:00
const ensureBrowserAvailable = async ( ) : Promise < void > = > {
const current = state ( ) ;
2026-01-04 03:32:40 +00:00
const remoteCdp = ! profile . cdpIsLoopback ;
2026-01-15 04:50:11 +00:00
const isExtension = profile . driver === "extension" ;
2026-01-04 03:32:40 +00:00
const profileState = getProfileState ( ) ;
2026-01-01 16:15:12 +00:00
const httpReachable = await isHttpReachable ( ) ;
2026-01-04 03:32:40 +00:00
2026-01-15 04:50:11 +00:00
if ( isExtension && remoteCdp ) {
throw new Error (
` Profile " ${ profile . name } " uses driver=extension but cdpUrl is not loopback ( ${ profile . cdpUrl } ). ` ,
) ;
}
if ( isExtension ) {
if ( ! httpReachable ) {
await ensureChromeExtensionRelayServer ( { cdpUrl : profile.cdpUrl } ) ;
if ( await isHttpReachable ( 1200 ) ) {
// continue: we still need the extension to connect for CDP websocket.
} else {
throw new Error (
` Chrome extension relay for profile " ${ profile . name } " is not reachable at ${ profile . cdpUrl } . ` ,
) ;
}
}
2026-01-31 16:19:20 +09:00
if ( await isReachable ( 600 ) ) {
return ;
}
2026-01-15 04:50:11 +00:00
// Relay server is up, but no attached tab yet. Prompt user to attach.
throw new Error (
2026-01-30 03:15:10 +01:00
` Chrome extension relay is running, but no tab is connected. Click the OpenClaw Chrome extension icon on a tab to attach it (profile " ${ profile . name } "). ` ,
2026-01-15 04:50:11 +00:00
) ;
}
2026-01-01 16:15:12 +00:00
if ( ! httpReachable ) {
2026-01-14 14:31:43 +00:00
if ( ( current . resolved . attachOnly || remoteCdp ) && opts . onEnsureAttachTarget ) {
2026-01-10 02:06:05 +00:00
await opts . onEnsureAttachTarget ( profile ) ;
2026-01-31 16:19:20 +09:00
if ( await isHttpReachable ( 1200 ) ) {
return ;
}
2026-01-10 02:06:05 +00:00
}
2026-01-01 22:44:52 +01:00
if ( current . resolved . attachOnly || remoteCdp ) {
2026-01-01 16:15:12 +00:00
throw new Error (
2026-01-01 22:44:52 +01:00
remoteCdp
2026-01-04 03:32:40 +00:00
? ` Remote CDP for profile " ${ profile . name } " is not reachable at ${ profile . cdpUrl } . `
: ` Browser attachOnly is enabled and profile " ${ profile . name } " is not running. ` ,
2026-01-01 16:15:12 +00:00
) ;
}
2026-01-30 03:15:10 +01:00
const launched = await launchOpenClawChrome ( current . resolved , profile ) ;
2026-01-01 16:15:12 +00:00
attachRunning ( launched ) ;
2026-01-04 03:32:40 +00:00
return ;
2026-01-01 16:15:12 +00:00
}
2026-01-04 03:32:40 +00:00
// Port is reachable - check if we own it
2026-01-31 16:19:20 +09:00
if ( await isReachable ( ) ) {
return ;
}
2026-01-01 16:15:12 +00:00
2026-01-04 03:32:40 +00:00
// HTTP responds but WebSocket fails - port in use by something else
if ( ! profileState . running ) {
2025-12-19 23:57:26 +00:00
throw new Error (
2026-01-30 03:15:10 +01:00
` Port ${ profile . cdpPort } is in use for profile " ${ profile . name } " but not by openclaw. ` +
2026-01-04 03:32:40 +00:00
` Run action=reset-profile profile= ${ profile . name } to kill the process. ` ,
2025-12-19 23:57:26 +00:00
) ;
}
2026-01-04 03:32:40 +00:00
// We own it but WebSocket failed - restart
if ( current . resolved . attachOnly || remoteCdp ) {
2026-01-10 02:06:05 +00:00
if ( opts . onEnsureAttachTarget ) {
await opts . onEnsureAttachTarget ( profile ) ;
2026-01-31 16:19:20 +09:00
if ( await isReachable ( 1200 ) ) {
return ;
}
2026-01-10 02:06:05 +00:00
}
2026-01-01 16:15:12 +00:00
throw new Error (
2026-01-04 03:32:40 +00:00
remoteCdp
? ` Remote CDP websocket for profile " ${ profile . name } " is not reachable. `
: ` Browser attachOnly is enabled and CDP websocket for profile " ${ profile . name } " is not reachable. ` ,
2026-01-01 16:15:12 +00:00
) ;
}
2026-01-30 03:15:10 +01:00
await stopOpenClawChrome ( profileState . running ) ;
2026-01-04 03:32:40 +00:00
setProfileRunning ( null ) ;
2026-01-01 16:15:12 +00:00
2026-01-30 03:15:10 +01:00
const relaunched = await launchOpenClawChrome ( current . resolved , profile ) ;
2026-01-01 16:15:12 +00:00
attachRunning ( relaunched ) ;
if ( ! ( await isReachable ( 600 ) ) ) {
2026-01-04 03:32:40 +00:00
throw new Error (
` Chrome CDP websocket for profile " ${ profile . name } " is not reachable after restart. ` ,
) ;
2026-01-01 16:15:12 +00:00
}
2025-12-19 23:57:26 +00:00
} ;
const ensureTabAvailable = async ( targetId? : string ) : Promise < BrowserTab > = > {
await ensureBrowserAvailable ( ) ;
2026-01-15 09:36:48 +00:00
const profileState = getProfileState ( ) ;
2025-12-19 23:57:26 +00:00
const tabs1 = await listTabs ( ) ;
if ( tabs1 . length === 0 ) {
2026-01-15 09:36:48 +00:00
if ( profile . driver === "extension" ) {
2026-01-15 10:44:11 +00:00
throw new Error (
` tab not found (no attached Chrome tabs for profile " ${ profile . name } "). ` +
2026-01-30 03:15:10 +01:00
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON)." ,
2026-01-15 10:44:11 +00:00
) ;
2026-01-15 09:36:48 +00:00
}
2025-12-19 23:57:26 +00:00
await openTab ( "about:blank" ) ;
}
const tabs = await listTabs ( ) ;
2026-01-17 00:22:27 +00:00
// For remote profiles using Playwright's persistent connection, we don't need wsUrl
// because we access pages directly through Playwright, not via individual WebSocket URLs.
2026-01-17 00:57:35 +00:00
const candidates =
profile . driver === "extension" || ! profile . cdpIsLoopback
? tabs
: tabs . filter ( ( t ) = > Boolean ( t . wsUrl ) ) ;
2026-01-15 09:36:48 +00:00
const resolveById = ( raw : string ) = > {
const resolved = resolveTargetIdFromTabs ( raw , candidates ) ;
if ( ! resolved . ok ) {
2026-01-31 16:19:20 +09:00
if ( resolved . reason === "ambiguous" ) {
return "AMBIGUOUS" as const ;
}
2026-01-15 09:36:48 +00:00
return null ;
}
return candidates . find ( ( t ) = > t . targetId === resolved . targetId ) ? ? null ;
} ;
const pickDefault = ( ) = > {
const last = profileState . lastTargetId ? . trim ( ) || "" ;
const lastResolved = last ? resolveById ( last ) : null ;
2026-01-31 16:19:20 +09:00
if ( lastResolved && lastResolved !== "AMBIGUOUS" ) {
return lastResolved ;
}
2026-01-15 09:36:48 +00:00
// Prefer a real page tab first (avoid service workers/background targets).
const page = candidates . find ( ( t ) = > ( t . type ? ? "page" ) === "page" ) ;
2026-01-15 09:50:18 +00:00
return page ? ? candidates . at ( 0 ) ? ? null ;
2026-01-15 09:36:48 +00:00
} ;
2026-01-15 10:44:11 +00:00
let chosen = targetId ? resolveById ( targetId ) : pickDefault ( ) ;
if ( ! chosen && profile . driver === "extension" && candidates . length === 1 ) {
// If an agent passes a stale/foreign targetId but we only have a single attached tab,
// recover by using that tab instead of failing hard.
chosen = candidates [ 0 ] ? ? null ;
}
2025-12-19 23:57:26 +00:00
if ( chosen === "AMBIGUOUS" ) {
throw new Error ( "ambiguous target id prefix" ) ;
}
2026-01-31 16:19:20 +09:00
if ( ! chosen ) {
throw new Error ( "tab not found" ) ;
}
2026-01-15 09:36:48 +00:00
profileState . lastTargetId = chosen . targetId ;
2025-12-19 23:57:26 +00:00
return chosen ;
} ;
const focusTab = async ( targetId : string ) : Promise < void > = > {
const tabs = await listTabs ( ) ;
const resolved = resolveTargetIdFromTabs ( targetId , tabs ) ;
if ( ! resolved . ok ) {
if ( resolved . reason === "ambiguous" ) {
throw new Error ( "ambiguous target id prefix" ) ;
}
throw new Error ( "tab not found" ) ;
}
2026-01-17 01:28:22 +00:00
if ( ! profile . cdpIsLoopback ) {
const mod = await getPwAiModule ( { mode : "strict" } ) ;
const focusPageByTargetIdViaPlaywright = ( mod as Partial < PwAiModule > | null )
? . focusPageByTargetIdViaPlaywright ;
if ( typeof focusPageByTargetIdViaPlaywright === "function" ) {
await focusPageByTargetIdViaPlaywright ( {
cdpUrl : profile.cdpUrl ,
targetId : resolved.targetId ,
} ) ;
const profileState = getProfileState ( ) ;
profileState . lastTargetId = resolved . targetId ;
return ;
}
}
2026-01-16 08:31:51 +00:00
await fetchOk ( appendCdpPath ( profile . cdpUrl , ` /json/activate/ ${ resolved . targetId } ` ) ) ;
2026-01-15 09:36:48 +00:00
const profileState = getProfileState ( ) ;
profileState . lastTargetId = resolved . targetId ;
2025-12-19 23:57:26 +00:00
} ;
const closeTab = async ( targetId : string ) : Promise < void > = > {
const tabs = await listTabs ( ) ;
const resolved = resolveTargetIdFromTabs ( targetId , tabs ) ;
if ( ! resolved . ok ) {
if ( resolved . reason === "ambiguous" ) {
throw new Error ( "ambiguous target id prefix" ) ;
}
throw new Error ( "tab not found" ) ;
}
2026-01-17 00:57:35 +00:00
2026-01-17 00:22:27 +00:00
// For remote profiles, use Playwright's persistent connection to close tabs
if ( ! profile . cdpIsLoopback ) {
2026-01-17 01:28:22 +00:00
const mod = await getPwAiModule ( { mode : "strict" } ) ;
2026-01-17 00:57:35 +00:00
const closePageByTargetIdViaPlaywright = ( mod as Partial < PwAiModule > | null )
? . closePageByTargetIdViaPlaywright ;
if ( typeof closePageByTargetIdViaPlaywright === "function" ) {
await closePageByTargetIdViaPlaywright ( {
2026-01-17 00:22:27 +00:00
cdpUrl : profile.cdpUrl ,
targetId : resolved.targetId ,
} ) ;
return ;
}
}
2026-01-17 00:57:35 +00:00
2026-01-16 08:31:51 +00:00
await fetchOk ( appendCdpPath ( profile . cdpUrl , ` /json/close/ ${ resolved . targetId } ` ) ) ;
2025-12-19 23:57:26 +00:00
} ;
const stopRunningBrowser = async ( ) : Promise < { stopped : boolean } > = > {
2026-01-15 04:50:11 +00:00
if ( profile . driver === "extension" ) {
const stopped = await stopChromeExtensionRelayServer ( {
cdpUrl : profile.cdpUrl ,
} ) ;
return { stopped } ;
}
2026-01-04 03:32:40 +00:00
const profileState = getProfileState ( ) ;
2026-01-31 16:19:20 +09:00
if ( ! profileState . running ) {
return { stopped : false } ;
}
2026-01-30 03:15:10 +01:00
await stopOpenClawChrome ( profileState . running ) ;
2026-01-04 03:32:40 +00:00
setProfileRunning ( null ) ;
2025-12-19 23:57:26 +00:00
return { stopped : true } ;
} ;
2026-01-01 16:15:12 +00:00
const resetProfile = async ( ) = > {
2026-01-15 04:50:11 +00:00
if ( profile . driver === "extension" ) {
2026-01-15 05:17:03 +00:00
await stopChromeExtensionRelayServer ( { cdpUrl : profile.cdpUrl } ) . catch ( ( ) = > { } ) ;
2026-01-15 04:50:11 +00:00
return { moved : false , from : profile . cdpUrl } ;
}
2026-01-04 03:32:40 +00:00
if ( ! profile . cdpIsLoopback ) {
throw new Error (
` reset-profile is only supported for local profiles (profile " ${ profile . name } " is remote). ` ,
) ;
2026-01-01 22:44:52 +01:00
}
2026-01-30 03:15:10 +01:00
const userDataDir = resolveOpenClawUserDataDir ( profile . name ) ;
2026-01-04 03:32:40 +00:00
const profileState = getProfileState ( ) ;
2026-01-01 16:15:12 +00:00
const httpReachable = await isHttpReachable ( 300 ) ;
2026-01-04 03:32:40 +00:00
if ( httpReachable && ! profileState . running ) {
// Port in use but not by us - kill it
try {
const mod = await import ( "./pw-ai.js" ) ;
await mod . closePlaywrightBrowserConnection ( ) ;
} catch {
// ignore
}
2026-01-01 16:15:12 +00:00
}
2026-01-04 03:32:40 +00:00
if ( profileState . running ) {
2026-01-01 16:15:12 +00:00
await stopRunningBrowser ( ) ;
}
try {
const mod = await import ( "./pw-ai.js" ) ;
await mod . closePlaywrightBrowserConnection ( ) ;
} catch {
// ignore
}
if ( ! fs . existsSync ( userDataDir ) ) {
return { moved : false , from : userDataDir } ;
}
const moved = await movePathToTrash ( userDataDir ) ;
return { moved : true , from : userDataDir , to : moved } ;
} ;
2025-12-19 23:57:26 +00:00
return {
2026-01-04 03:32:40 +00:00
profile ,
2025-12-19 23:57:26 +00:00
ensureBrowserAvailable ,
ensureTabAvailable ,
2026-01-01 16:15:12 +00:00
isHttpReachable ,
2025-12-19 23:57:26 +00:00
isReachable ,
listTabs ,
openTab ,
focusTab ,
closeTab ,
stopRunningBrowser ,
2026-01-01 16:15:12 +00:00
resetProfile ,
2025-12-19 23:57:26 +00:00
} ;
}
2026-01-01 16:15:12 +00:00
2026-01-14 14:31:43 +00:00
export function createBrowserRouteContext ( opts : ContextOptions ) : BrowserRouteContext {
2026-02-14 00:44:04 +01:00
const refreshConfigFromDisk = opts . refreshConfigFromDisk === true ;
2026-01-04 03:32:40 +00:00
const state = ( ) = > {
const current = opts . getState ( ) ;
2026-01-31 16:19:20 +09:00
if ( ! current ) {
throw new Error ( "Browser server not started" ) ;
}
2026-01-04 03:32:40 +00:00
return current ;
} ;
const forProfile = ( profileName? : string ) : ProfileContext = > {
const current = state ( ) ;
const name = profileName ? ? current . resolved . defaultProfile ;
2026-02-15 05:21:13 +00:00
const profile = resolveBrowserProfileWithHotReload ( {
current ,
refreshConfigFromDisk ,
name ,
} ) ;
2026-02-14 00:44:04 +01:00
2026-01-04 03:32:40 +00:00
if ( ! profile ) {
const available = Object . keys ( current . resolved . profiles ) . join ( ", " ) ;
2026-01-14 14:31:43 +00:00
throw new Error ( ` Profile " ${ name } " not found. Available profiles: ${ available || "(none)" } ` ) ;
2026-01-01 16:15:12 +00:00
}
2026-01-04 03:32:40 +00:00
return createProfileContext ( opts , profile ) ;
} ;
const listProfiles = async ( ) : Promise < ProfileStatus [ ] > = > {
const current = state ( ) ;
2026-02-15 05:21:13 +00:00
refreshResolvedBrowserConfigFromDisk ( {
current ,
refreshConfigFromDisk ,
mode : "cached" ,
} ) ;
2026-01-04 03:32:40 +00:00
const result : ProfileStatus [ ] = [ ] ;
for ( const name of Object . keys ( current . resolved . profiles ) ) {
const profileState = current . profiles . get ( name ) ;
const profile = resolveProfile ( current . resolved , name ) ;
2026-01-31 16:19:20 +09:00
if ( ! profile ) {
continue ;
}
2026-01-04 03:32:40 +00:00
let tabCount = 0 ;
let running = false ;
if ( profileState ? . running ) {
running = true ;
try {
const ctx = createProfileContext ( opts , profile ) ;
const tabs = await ctx . listTabs ( ) ;
tabCount = tabs . filter ( ( t ) = > t . type === "page" ) . length ;
} catch {
// Browser might not be responsive
}
} else {
// Check if something is listening on the port
try {
const reachable = await isChromeReachable ( profile . cdpUrl , 200 ) ;
if ( reachable ) {
running = true ;
const ctx = createProfileContext ( opts , profile ) ;
const tabs = await ctx . listTabs ( ) . catch ( ( ) = > [ ] ) ;
tabCount = tabs . filter ( ( t ) = > t . type === "page" ) . length ;
}
} catch {
// Not reachable
}
}
result . push ( {
name ,
cdpPort : profile.cdpPort ,
cdpUrl : profile.cdpUrl ,
color : profile.color ,
running ,
tabCount ,
isDefault : name === current . resolved . defaultProfile ,
isRemote : ! profile . cdpIsLoopback ,
} ) ;
}
return result ;
} ;
// Create default profile context for backward compatibility
const getDefaultContext = ( ) = > forProfile ( ) ;
const mapTabError = ( err : unknown ) = > {
2026-02-19 13:43:48 +01:00
if ( err instanceof SsrFBlockedError ) {
return { status : 400 , message : err.message } ;
}
2026-02-19 14:04:08 +01:00
if ( err instanceof InvalidBrowserNavigationUrlError ) {
return { status : 400 , message : err.message } ;
2026-02-19 13:43:48 +01:00
}
2026-02-19 14:04:08 +01:00
const msg = String ( err ) ;
2026-01-04 03:32:40 +00:00
if ( msg . includes ( "ambiguous target id prefix" ) ) {
return { status : 409 , message : "ambiguous target id prefix" } ;
}
if ( msg . includes ( "tab not found" ) ) {
2026-01-15 10:44:11 +00:00
return { status : 404 , message : msg } ;
2026-01-04 03:32:40 +00:00
}
if ( msg . includes ( "not found" ) ) {
return { status : 404 , message : msg } ;
}
return null ;
} ;
return {
state ,
forProfile ,
listProfiles ,
// Legacy methods delegate to default profile
ensureBrowserAvailable : ( ) = > getDefaultContext ( ) . ensureBrowserAvailable ( ) ,
2026-01-14 14:31:43 +00:00
ensureTabAvailable : ( targetId ) = > getDefaultContext ( ) . ensureTabAvailable ( targetId ) ,
isHttpReachable : ( timeoutMs ) = > getDefaultContext ( ) . isHttpReachable ( timeoutMs ) ,
2026-01-04 03:32:40 +00:00
isReachable : ( timeoutMs ) = > getDefaultContext ( ) . isReachable ( timeoutMs ) ,
listTabs : ( ) = > getDefaultContext ( ) . listTabs ( ) ,
openTab : ( url ) = > getDefaultContext ( ) . openTab ( url ) ,
focusTab : ( targetId ) = > getDefaultContext ( ) . focusTab ( targetId ) ,
closeTab : ( targetId ) = > getDefaultContext ( ) . closeTab ( targetId ) ,
stopRunningBrowser : ( ) = > getDefaultContext ( ) . stopRunningBrowser ( ) ,
resetProfile : ( ) = > getDefaultContext ( ) . resetProfile ( ) ,
mapTabError ,
} ;
2026-01-01 16:15:12 +00:00
}