/** * `share_browser_view` / `stop_browser_view` — put the attached container * browser's built-in viewer behind a public URL so someone who is not at this * machine can watch it and optionally drive it. * * Scoped to `container` mode on purpose. A `builder`-mode browser already has * a hosted live view at `attach_browser`'s `liveViewUrl`; tunnelling a second * one would duplicate it, so this refuses and points at the existing URL. * * The share is deliberately explicit and single-instance: one live view per * attached browser, started only when asked for, and torn down by * `stop_browser_view`, `dispose_browser`, or `reset_session`. */ import { badRequest, conflict } from '@hapi/boom' import assert from 'node:assert' import { startTunnel, type Tunnel } from '../../../live/tunnel.ts' import { LOGGER } from '../../../logger.ts' import { defineTool, type RegisteredTool } from '../../server.ts' import { type AttachState, SHAREABLE_MODES } from './attach.ts' import { ensureLiveViewUrl } from './live-view-url.ts' export interface LiveShare { tunnel: Tunnel /** The public URL handed to whoever is watching. */ url: string } export interface LiveShareRef { current?: LiveShare } type TunnelStarter = typeof startTunnel /** Tear down whatever share is running. Safe when none is. Exported so the * session tools can call it from `dispose_browser` / `reset_session`. */ export async function stopLiveShare(ref: LiveShareRef): Promise { const share = ref.current if(!share) { return false } ref.current = undefined // Only the tunnel is ours to close. The browser and its viewer belong to the // container, which keeps running until the browser is disposed. await share.tunnel.stop().catch((err) => { LOGGER.warn({ err }, 'live view: tunnel stop failed') }) return true } export function liveViewTools( attachRef: { current?: AttachState }, shareRef: LiveShareRef, openTunnel: TunnelStarter = startTunnel, ): RegisteredTool[] { return [ defineTool>( { name: 'share_browser_view', description: 'Opens a public HTTPS tunnel to the attached `container` browser ' + 'viewer. Use it when someone else must sign in or control it. ' + 'Returns `url` with `liveview.html?magnify=1`; relay it verbatim ' + 'on its own line. ' + 'Only `container` supports this. For `builder`, relay ' + 'the `liveViewUrl` from attach_browser; local modes ' + '(`dedicated`, `attach`, `custom`) ' + 'cannot be shared. The URL is a credential: anyone with it can ' + 'control the signed-in browser. Share it only with the intended ' + 'person and call stop_browser_view when done. `propagating: true` ' + 'means the tunnel remains live while the Cloudflare edge starts; ' + 'reuse the URL and reload after a few seconds rather than creating ' + 'another tunnel.', inputSchema: { type: 'object', properties: {} }, }, async() => { const attached = attachRef.current assert(attached, badRequest( 'No browser attached. Call attach_browser first.', )) const hostedViewUrl = attached.liveViewUrl ? ensureLiveViewUrl(attached.liveViewUrl) : undefined assert(SHAREABLE_MODES.has(attached.mode), badRequest( `share_browser_view does not apply to \`${attached.mode}\` mode. ` + (hostedViewUrl ? 'That browser already has a hosted live view — use the ' + `\`liveViewUrl\` from attach_browser: ${hostedViewUrl}` : 'That browser has no live view to put behind a URL. To let ' + 'someone else in, re-attach with mode "container" (the ' + 'Reclaim runtime in Docker — free, needs Docker) or ' + '"builder" (hosted, no install, chargeable)') + '.', )) assert(!shareRef.current, conflict( 'A live view is already running at ' + `${shareRef.current?.url}. Call stop_browser_view first, or ` + 'reuse that URL.', )) // The container publishes its viewer on loopback and its full CDP // port beside it. Only the VIEWER is ever tunnelled: the CDP port is // unauthenticated, total control of the browser, and putting it on a // public URL would hand that to anyone with the link. const viewerPort = attached.viewerPort assert(viewerPort, badRequest( 'This browser reports no live-view port. Re-attach with mode ' + '"container".', )) const tunnel = await openTunnel(viewerPort, { probePath: '/liveview.html?magnify=1', }) // The path is the runtime's own viewer page; `magnify=1` is what makes // it lay out for the device that opens it. const url = ensureLiveViewUrl(tunnel.url) const share: LiveShare = { tunnel, url, } shareRef.current = share LOGGER.info({ mode: attached.mode }, 'live view shared') return { url, ...(share.tunnel.ready ? {} : { propagating: true, cloudflaredLog: share.tunnel.cloudflaredLog, }), ...(share.tunnel.binary.source === 'downloaded' ? { cloudflaredInstalled: true } : {}), _notes: [ ...(share.tunnel.binary.source === 'downloaded' ? ['cloudflared was not installed, so it was downloaded once ' + 'to ~/.reclaim/bin. Mention that; later shares reuse it.'] : []), ...(share.tunnel.ready ? [] : ['The tunnel hostname exists but the public viewer is still ' + 'propagating. Relay the URL now; if it errors, wait a few ' + 'seconds and reload. Do not start a second tunnel.']), 'Relay `url` to the developer verbatim, on its own line.', 'The URL is a credential: it needs no login, and anyone with it ' + 'can see this browser and act in it as the signed-in user.', 'It shows the whole browser, so a sign-in that opens a separate ' + 'window is visible too — unlike a single-tab stream.', 'Opening it on a phone lays the page out for that phone, with ' + 'touch, pinch-zoom and the soft keyboard.', 'Call stop_browser_view when they are done. That closes the ' + 'tunnel only — the browser keeps running.', ], } }, ), defineTool>( { name: 'stop_browser_view', description: 'Stops the public tunnel opened by share_browser_view; the URL stops ' + 'resolving. Call it when the viewer is done. The browser stays ' + 'attached, its local viewer keeps serving, and capture continues. ' + 'Returns `stopped: false` when no public share is active.', inputSchema: { type: 'object', properties: {} }, }, async() => ({ stopped: await stopLiveShare(shareRef) }), ), ] }