import { badRequest } from '@hapi/boom' import type { ReclaimClient } from '@reclaimprotocol/client/api' import assert from 'node:assert' import { type CdpHandles, connectCdp, connectCdpUrl, } from '../../../cdp/client.ts' import { dockerAvailable, dockerMissingMessage, startContainer, } from '../../../cdp/container.ts' import { suggestCountryFromIp } from '../../../cdp/geo.ts' import { defaultProfileDir, launchChrome, waitForCdpReady, } from '../../../cdp/launcher.ts' import type { ConfigStore } from '../../../config/store.ts' import { defaultConfigPath } from '../../../config/store.ts' import { defineTool, type RegisteredTool } from '../../server.ts' import { ensureLiveViewUrl } from './live-view-url.ts' export type BrowserMode = | 'dedicated' | 'attach' | 'builder' | 'custom' | 'container' /** * Modes with a live view that `share_browser_view` can put behind a public URL. * * Only `container`: it runs the Reclaim browser runtime, whose viewer already * serves on loopback, so sharing is a tunnel in front of it. `builder` gives a * hosted `liveViewUrl` instead, and a plain local browser has nothing to * show — one on this machine is watched by looking at it. */ export const SHAREABLE_MODES = new Set(['container']) export interface AttachState { handles: CdpHandles mode: BrowserMode port?: number liveViewUrl?: string browserSessionId?: string orgId?: string /** The hosted Builder browser's TEE API base — lets * `run_proof_via_pod` prove through the SAME enclave that holds the * browser's TLS session (and its country-routed egress), instead of the * local in-process attestor SDK. */ apiUrl?: string /** The exit country this remote browser was allocated with, if any. */ countryCode?: string /** Docker's name for a `container`-mode browser, for diagnostics. */ containerName?: string /** Loopback port the container's own live viewer serves on, which is what * `share_browser_view` tunnels. Never the CDP port. */ viewerPort?: number /** Whether the runtime image had to be downloaded on this attach. */ imagePulled?: boolean disposeRemote?: () => Promise } export interface AttachRef { current?: AttachState config: ConfigStore } interface AttachArgs { mode?: BrowserMode /** Backward-compatible local-mode spelling. */ attachMode?: 'dedicated' | 'attach' port?: number orgId?: string cdpUrl?: string liveViewUrl?: string /** Builder mode: the country the remote browser should browse from. */ countryCode?: string /** Builder mode: allocate without geo-routing, skipping the country * prompt. Geolocation is optional — this is how the caller says so. */ skipCountry?: boolean /** Container mode: loopback port for the live viewer. */ viewerPort?: number } /** * The browser sources, written once and used twice: rendered into this tool's * DESCRIPTION (so an agent can put the choice to the developer straight from * the tool list, without a discovery round-trip) and returned verbatim as * structured `options` when the tool is called without a `mode` anyway. * * Being the single source for both is the point — an option list that a model * reads in the description but that disagrees with what the tool returns is * worse than either alone. * * Ordered and labelled BY USE CASE, because that is the question a developer * can actually answer. "Which CDP transport?" is not; "does anyone else need to * get into this browser?" is, and it decides two of the four. `useCase` is the * grouping key, so the rendered list reads as two pairs rather than four * unrelated transports. */ const BROWSER_OPTIONS = [ { mode: 'dedicated', useCase: 'Working on your own machine', label: 'Local Chrome, launched for you', detail: 'Launches Chrome with a dedicated profile under ' + '~/.reclaim/chrome-profile. Nothing to set up, and the profile ' + 'persists, so a site you sign in to stays signed in. The easiest ' + 'choice when you are doing the work yourself.', requires: [], liveView: 'None, and none needed — the browser is on your screen.', }, { mode: 'attach', useCase: 'Working on your own machine', label: 'The Chrome you already have open', detail: 'Connects to a Chrome you started yourself, keeping the tabs, ' + 'extensions and logins already in it. That Chrome must have been ' + 'started with BOTH --remote-debugging-port AND a non-default ' + '--user-data-dir: since Chrome 136 the debug flag is silently ' + 'ignored on the default profile.', requires: ['port (default 9222)'], liveView: 'None, and none needed — the browser is on your screen.', }, { mode: 'container', useCase: 'Someone else has to sign in, or you need a phone', label: 'Reclaim runtime in Docker — free, runs on your machine', detail: 'Runs the Reclaim browser runtime (the same image the remote browser ' + 'uses) as a container on this machine, and gives you a link you can ' + 'send to whoever holds the account. They open it in any browser, ' + 'including a phone, and can see and click in it. Free — it is your ' + 'Docker and your bandwidth. Costs a 432 MB first-run download and ' + 'about 2 GB of RAM. The image is amd64, so Apple Silicon runs it ' + 'translated. OrbStack handles that itself; Docker Desktop needs ' + 'Rosetta enabled.', requires: ['Docker running'], liveView: 'Built in. This call returns a local `viewerUrl`, and ' + 'share_browser_view turns that into a public link for the person ' + 'helping you.', }, { mode: 'builder', useCase: 'Someone else has to sign in, or you need a phone', label: 'Remote browser, hosted by Reclaim — CHARGEABLE', detail: 'A browser allocated in Reclaim\'s cloud, billed against the ' + 'organization\'s quota. Nothing to install, and the shareable link ' + 'comes back immediately. It is also the only way to browse a site ' + 'from a SPECIFIC COUNTRY — its whole egress is routed there. ' + 'Geolocation is optional: omit countryCode to be offered a ' + 'suggestion, or pass skipCountry to allocate without geo-routing.', requires: [ 'Builder authentication', 'orgId (auto-resolved when you have only one)', 'quota — this one costs money', ], liveView: 'Included. This call returns a hosted liveViewUrl to pass on; ' + 'share_browser_view does not apply.', }, { mode: 'custom', useCase: 'Advanced', label: 'A CDP endpoint you supply', detail: 'Connects to any ws:// or wss:// CDP websocket — a browser you run ' + 'elsewhere, or another tool\'s. The URL is treated as a credential: ' + 'never logged, persisted, or returned. Only pick this if you were ' + 'given an endpoint and know why.', requires: ['cdpUrl'], liveView: 'None, unless you pass your own liveViewUrl alongside the cdpUrl.', }, ] as const /** * The options as prose, grouped by use case. * * The grouping is the useful part: it turns "pick one of five transports" into * "answer one question, then pick one of two". */ function renderBrowserOptions(): string { const seen = new Set() const lines: string[] = [] for(const option of BROWSER_OPTIONS) { if(!seen.has(option.useCase)) { seen.add(option.useCase) lines.push(`— ${option.useCase.toUpperCase()} —`) } const needs = option.requires.length ? ` Needs: ${option.requires.join('; ')}.` : '' lines.push( `(${option.mode}) ${option.label}. ${option.detail}${needs}` + ` Live view: ${option.liveView}`, ) } return lines.join(' ') } interface AttachDependencies { connectLocal: typeof connectCdp connectRemote: typeof connectCdpUrl /** Injected so container mode is testable without Docker installed. */ dockerAvailable: typeof dockerAvailable startContainer: typeof startContainer } const DEFAULT_DEPENDENCIES: AttachDependencies = { connectLocal: connectCdp, connectRemote: connectCdpUrl, dockerAvailable, startContainer, } export function attachTool( state: AttachRef, builderClient?: ReclaimClient, beforeReplace?: () => Promise, // Partial so a caller can stub one thing without restating the rest — the // tests only ever care about a couple of these at a time. dependencies: Partial = {}, ): RegisteredTool { const deps: AttachDependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies, } return defineTool( { name: 'attach_browser', description: 'Connect the provider-authoring tools to a browser. Never choose ' + 'for them, never assume a previous session\'s choice still ' + 'applies, and call this ONCE with the `mode` they pick. ' + 'ASK THIS FIRST, before listing anything: "Will you be doing this ' + 'yourself, or does someone else need to sign in — for example the ' + 'person whose account it is?" That answer decides half the list, ' + 'and a developer can answer it without knowing what CDP is. ' + 'THEMSELVES → offer `dedicated` or `attach`. SOMEONE ELSE, or the ' + 'site only works on a phone → offer `container` or `builder`, ' + 'which are the only two that produce a link another person can ' + 'open. Say which of those two costs money: `container` is free ' + 'but needs Docker, `builder` needs no install but is billed ' + 'against the org\'s quota. ' + 'Then relay the matching options with their live-view lines. You ' + 'do not need a discovery call — the full list is here, and calling ' + 'with no `mode` returns it as structured `options` if you would ' + 'rather read it back verbatim. ' + 'OPTIONS — ' + renderBrowserOptions() + ' ' + 'A `container` browser returns a local `viewerUrl`; relay it. When ' + 'someone remote needs in, call share_browser_view and relay the ' + 'FULL public url verbatim, on its own line. `builder` returns a ' + 'hosted `liveViewUrl` — relay that the same way. `dedicated`, ' + '`attach` and `custom` cannot be shared at all: if they ask for a ' + 'link after picking one, say so and offer to re-attach with ' + '`container`. ' + 'Builder and custom CDP credentials are held in memory and are ' + 'never returned. ' + 'More: how_it_works({ topic: "browser" }).', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: [ 'dedicated', 'attach', 'container', 'builder', 'custom', ], }, attachMode: { type: 'string', enum: ['dedicated', 'attach'], description: 'Deprecated local-mode alias for mode.', }, port: { type: 'number', minimum: 1, maximum: 65535 }, orgId: { type: 'string', format: 'uuid' }, cdpUrl: { type: 'string', maxLength: 4000 }, liveViewUrl: { type: 'string', format: 'uri', maxLength: 4000 }, viewerPort: { type: 'number', minimum: 1024, maximum: 60000, description: '`container` mode only. Loopback port for the built-in live ' + 'viewer. Change it only to avoid a clash; the CDP port and ' + 'the container name are derived from it.', }, countryCode: { type: 'string', pattern: '^[A-Za-z]{2}$', description: 'Builder mode only. ISO 3166-1 alpha-2 country the remote ' + 'browser browses from, fixed for the whole session. Omit to ' + 'get a suggested country back (needsChoice) instead of ' + 'allocating a browser.', }, skipCountry: { type: 'boolean', description: 'Builder mode only. Allocate without geo-routing, skipping ' + 'the country prompt. Pass this once the developer has said ' + 'they don\'t need a specific country.', }, }, }, }, async(parsed) => { const persisted = state.config.read()?.browser_agent // Deliberately NOT falling back to the persisted mode: which browser // to drive is the developer's call every time (a remote one costs // quota, a local one exposes their own profile), and silently reusing // last session's answer takes that decision away from them. The // persisted choice is offered back as a default to suggest, not // applied. const mode = parsed.mode ?? parsed.attachMode if(!mode) { return { needsChoice: true, message: 'Ask FIRST whether they will be doing this themselves or ' + 'someone else has to sign in — that answer decides half the ' + 'list. Themselves: `dedicated` or `attach`. Someone else, or ' + 'a phone-only site: `container` (free, needs Docker) or ' + '`builder` (no install, chargeable) — the only two that give ' + 'you a link to send. Then present the matching options and ' + 'call attach_browser again with the `mode` they pick. Do not ' + 'pick one for them.', options: BROWSER_OPTIONS, ...(persisted?.attachMode ? { previouslyUsed: persisted.attachMode } : {}), configPath: defaultConfigPath(), } } let orgId = parsed.orgId if(mode === 'builder' && !orgId) { assert( builderClient, badRequest('Builder browser service is unavailable'), ) const { data } = await builderClient.call('ListOrgs', { query: {} }) const orgs = data.items ?? [] assert( orgs.length, badRequest( 'No organizations available for this account — create one ' + 'first, or authenticate as a user who belongs to one.', ), ) if(orgs.length > 1) { return { needsChoice: true, message: 'This account belongs to more than one organization. Ask ' + 'the developer which one to use, then re-call ' + 'attach_browser with mode "builder" and that orgId.', organizations: orgs.map((o) => ({ id: o.id, name: o.name, kind: o.kind, status: o.status, })), } } orgId = orgs[0].id } // A remote browser's exit country is fixed for the whole session — // Popcorn applies it at allocation and it can't be changed later. So // confirm it with the developer BEFORE spending a quota-accounted // browser on the wrong one, the same way orgId is confirmed above. // Geo-routing is OPTIONAL though, so this asks rather than demands: // `skipCountry` is the "no particular country" answer, and it must be // as reachable as naming one. if(mode === 'builder' && !parsed.countryCode && !parsed.skipCountry) { const suggestion = await suggestCountryFromIp() return { needsChoice: true, message: 'Ask the developer which country this browser should browse ' + 'from — its egress is routed through that country for the ' + 'whole session and cannot be changed afterwards. Geolocation ' + 'is optional; offer all three answers below. Nothing has been ' + 'allocated yet, so this costs nothing.', choices: [ { answer: 'The suggested country', howToCall: `attach_browser({ mode: "builder", countryCode: "${ suggestion.countryCode}" })`, }, { answer: 'A specific country the site must be seen from', howToCall: 'attach_browser({ mode: "builder", countryCode: "" })', }, { answer: 'No particular country', howToCall: 'attach_browser({ mode: "builder", skipCountry: true })', }, ], suggestedCountryCode: suggestion.countryCode, suggestionSource: 'caller-ip', ...(suggestion.country ? { country: suggestion.country } : {}), ...(suggestion.region ? { region: suggestion.region } : {}), ...(suggestion.city ? { city: suggestion.city } : {}), } } const next = await connect( mode, { ...parsed, orgId }, persisted, builderClient, deps, ) next.liveViewUrl = next.liveViewUrl ? ensureLiveViewUrl(next.liveViewUrl) : undefined try { await beforeReplace?.() await disposeAttached(state) } catch(err) { await disposeState(next).catch(() => {}) throw err } state.current = next if(mode === 'dedicated' || mode === 'attach') { state.config.write({ browser_agent: { attachMode: mode, port: next.port! }, }) } // How the developer watches this browser differs per mode, and none of // it is guessable, so every mode says its own piece here. const localView = mode === 'container' const hostedView = Boolean(next.liveViewUrl) && !localView const canShare = SHAREABLE_MODES.has(mode) return { attached: true, mode, ...(next.port ? { port: next.port } : {}), ...(next.browserSessionId ? { browserSessionId: next.browserSessionId } : {}), ...(next.liveViewUrl ? { liveViewUrl: next.liveViewUrl } : {}), ...(next.countryCode ? { countryCode: next.countryCode } : {}), ...(next.containerName ? { containerName: next.containerName } : {}), ...(canShare ? { liveViewAvailable: true } : {}), _notes: [ ...(next.imagePulled ? ['The browser runtime image was downloaded just now (about ' + '432 MB, about 1.3 GB on disk). Mention it — later attaches ' + 'reuse it. RECLAIM_BROWSER_RUNTIME_IMAGE points at a ' + 'different or locally built image.'] : []), ...(localView ? ['This browser has its own live view, already running on this ' + 'machine: relay `liveViewUrl` to the developer verbatim, on ' + 'its own line, so they can watch and drive it. It is ' + 'loopback-only, so only they can reach it. If someone ELSE ' + 'needs to — a login only they can complete — call ' + 'share_browser_view to put that same viewer behind a public ' + 'URL.'] : []), ...(hostedView ? ['This browser has a hosted live view already. Relay ' + '`liveViewUrl` to the developer verbatim, on its own line, ' + 'so they can watch the run — share_browser_view does not ' + 'apply here.'] : []), ...(!localView && !hostedView ? ['This browser has no live view: it runs on the developer\'s ' + 'own machine, so they watch it by looking at it. If someone ' + 'ELSE needs to see or drive it, say that `container` mode ' + 'runs a browser with a shareable live view built in.'] : []), ], } }, ) } export async function disposeAttached(state: AttachRef) { const current = state.current if(!current) { return } delete state.current await disposeState(current) } async function connect( mode: BrowserMode, parsed: AttachArgs, persisted: { attachMode: 'dedicated' | 'attach', port: number } | undefined, builderClient: ReclaimClient | undefined, dependencies: AttachDependencies, ): Promise { if(mode === 'builder') { assert(builderClient, badRequest('Builder browser service is unavailable')) assert(parsed.orgId, badRequest('orgId is required for builder mode')) const countryCode = parsed.countryCode?.trim().toUpperCase() const { data } = await builderClient.call('CreateAgentBrowserSession', { params: { orgId: parsed.orgId }, body: countryCode ? { countryCode } : {}, }) let handles: CdpHandles try { handles = await dependencies.connectRemote(data.cdpUrl) } catch(err) { await builderClient.call('DisposeAgentBrowserSession', { params: { orgId: parsed.orgId, browserSessionId: data.id, }, }).catch(() => {}) throw err } return { handles, mode, liveViewUrl: data.liveViewUrl, browserSessionId: data.id, orgId: parsed.orgId, ...(data.apiUrl ? { apiUrl: data.apiUrl } : {}), ...(countryCode ? { countryCode } : {}), disposeRemote: async() => { await builderClient.call('DisposeAgentBrowserSession', { params: { orgId: parsed.orgId!, browserSessionId: data.id, }, }) }, } } if(mode === 'custom') { assert(parsed.cdpUrl, badRequest('cdpUrl is required for custom mode')) const url = new URL(parsed.cdpUrl) assert( url.protocol === 'ws:' || url.protocol === 'wss:', badRequest('cdpUrl must use ws:// or wss://'), ) return { handles: await dependencies.connectRemote(parsed.cdpUrl), mode, ...(parsed.liveViewUrl ? { liveViewUrl: parsed.liveViewUrl } : {}), } } if(mode === 'container') { assert(await dependencies.dockerAvailable(), badRequest(dockerMissingMessage())) // Ports are allocated per session unless the caller names one. A fixed // default would give two concurrent sessions the SAME container — the // name derives from the viewer port — so session two would silently // drive session one's browser. const container = await dependencies.startContainer({ ...(parsed.viewerPort ? { viewerPort: parsed.viewerPort } : {}), image: process.env.RECLAIM_BROWSER_RUNTIME_IMAGE?.trim() || undefined, }) try { return { handles: await dependencies.connectRemote(container.cdpUrl), mode, liveViewUrl: container.viewerUrl, containerName: container.name, viewerPort: container.viewerPort, imagePulled: container.pulled, disposeRemote: container.stop, } } catch(err) { // Never leave a 1.3 GB container running for a browser we could not // talk to. await container.stop().catch(() => {}) throw err } } const port = parsed.port ?? persisted?.port ?? 9222 if(mode === 'dedicated') { try { await waitForCdpReady(port, 500) } catch{ launchChrome({ port, profileDir: defaultProfileDir() }) await waitForCdpReady(port, 10_000) } } else { await waitForCdpReady(port, 2_000).catch(() => { throw new Error( `attach mode: nothing answering CDP on port ${port}. Chrome must ` + `be launched with --remote-debugging-port=${port} AND ` + '--user-data-dir=. Chrome 136+ ignores ' + 'the debugging flag for the default profile.', ) }) } return { handles: await dependencies.connectLocal(port), mode, port } } async function disposeState(state: AttachState) { try { await state.handles.close() } finally { await state.disposeRemote?.() } }