diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index f963e8d0..fba12a90 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -279,6 +279,7 @@ async function main(): Promise { loopStore: createLoopStore(databasePath()), workspaceStore: createWorkspaceStore(databasePath()), previewRoutes, + browserToolsEnabled: process.env.LINKCODE_BROWSER_TOOLS === '1', agentRuntimesReady, assets, // Lets the engine refresh (and push) the runtime snapshot after a managed install lands. diff --git a/apps/desktop/e2e/browser-broker.e2e.mts b/apps/desktop/e2e/browser-broker.e2e.mts new file mode 100644 index 00000000..13b542f3 --- /dev/null +++ b/apps/desktop/e2e/browser-broker.e2e.mts @@ -0,0 +1,216 @@ +/** + * Browser broker E2E (CODE-267): boots an isolated daemon + the built desktop app (which + * registers itself as the browser host), then drives the broker as a raw wire client — + * `browser.execute` ops must round-trip daemon → desktop webview executor → back. Also asserts + * the closed host-unavailable error once the desktop quits. + * Run `pnpm -F @linkcode/desktop e2e:browser-broker` after building daemon and desktop. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication } from 'playwright-core'; +import { _electron } from 'playwright-core'; +import { io } from 'socket.io-client'; +import { WIRE_VERSION } from './wire-version.mts'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 45000 + (process.pid % 1000); +const PROFILE = `e2e-broker-${process.pid}`; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function waitForDaemon(): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + await wait(250); + } + } + fail(`daemon did not come up on port ${PORT}`); +} + +interface WireFrame { + v: number; + id: string; + ts: number; + payload: Record; +} + +function createWireClient(): { + send: (payload: Record) => void; + next: ( + predicate: (payload: Record) => boolean, + ) => Promise>; + close: () => void; +} { + const socket = io(`http://127.0.0.1:${PORT}`, { transports: ['websocket'] }); + let seq = 0; + const backlog: Record[] = []; + const waiters: { + predicate: (payload: Record) => boolean; + resolve: (payload: Record) => void; + }[] = []; + socket.on('frame', (frame: WireFrame) => { + const index = waiters.findIndex((waiter) => waiter.predicate(frame.payload)); + if (index === -1) { + backlog.push(frame.payload); + return; + } + const [waiter] = waiters.splice(index, 1); + waiter.resolve(frame.payload); + }); + return { + send(payload) { + seq += 1; + socket.emit('frame', { v: WIRE_VERSION, id: `e2e-${seq}`, ts: Date.now(), payload }); + }, + next(predicate) { + const buffered = backlog.findIndex((payload) => predicate(payload)); + if (buffered !== -1) return Promise.resolve(backlog.splice(buffered, 1)[0]); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('wire wait timed out')), 20000); + waiters.push({ + predicate, + resolve(payload) { + clearTimeout(timer); + resolve(payload); + }, + }); + }); + }, + close() { + socket.close(); + }, + }; +} + +async function execute( + client: ReturnType, + reqId: string, + op: string, + args: Record, +): Promise> { + client.send({ kind: 'browser.execute', clientReqId: reqId, op, args }); + const reply = await client.next( + (payload) => payload.kind === 'browser.executed' && payload.replyTo === reqId, + ); + return reply.result as Record; +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let client: ReturnType | null = null; + let passed = false; + try { + daemon = spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_PROFILE: PROFILE, + LINKCODE_BROWSER_TOOLS: '1', + }, + stdio: 'ignore', + }); + await waitForDaemon(); + console.log(`daemon up on :${PORT}`); + + app = await _electron.launch({ + executablePath: electronBinary, + args: [desktopDir, '--use-mock-keychain', '--lang=en-US'], + env: { ...process.env, HOME: home, LINKCODE_PROFILE: PROFILE }, + }); + const win = await app.firstWindow(); + await win + .locator('[data-slot="composer-editor"][contenteditable="true"]') + .waitFor({ state: 'visible', timeout: 30000 }); + client = createWireClient(); + client.send({ kind: 'ping' }); + await client.next((payload) => payload.kind === 'pong'); + console.log('wire client handshake ok'); + + // tabs.open drives the desktop store; the seeded tab list must come back with the new tab. + const opened = await execute(client, 'r-open', 'tabs.open', { + url: 'https://example.com/', + }); + if (opened.ok !== true) fail(`tabs.open failed: ${JSON.stringify(opened)}`); + const tabs = opened.data as { id: string; url: string | null; active: boolean }[]; + const active = tabs.find((tab) => tab.active); + if (active?.url !== 'https://example.com/') { + fail(`expected an active example.com tab, got ${JSON.stringify(tabs)}`); + } + const activeTabId = active.id; + console.log(`tabs.open round-tripped through the desktop executor (${tabs.length} tabs)`); + + const invalid = await execute(client, 'r-bad', 'tab.click', { tabId: activeTabId, ref: '@e1' }); + if (invalid.ok !== false) fail('tab.click without a snapshot should fail'); + const error = invalid.error as { code: string }; + if (error.code !== 'stale-ref') fail(`expected stale-ref, got ${JSON.stringify(error)}`); + console.log('ref discipline enforced (stale-ref without a snapshot)'); + + const snapshot = await execute(client, 'r-snapshot', 'tab.snapshot', { tabId: activeTabId }); + if (snapshot.ok !== true) fail(`tab.snapshot failed: ${JSON.stringify(snapshot)}`); + const snapshotData = snapshot.data as { url: string; title: string }; + if (!snapshotData.url.startsWith('https://example.com/')) { + fail(`snapshot did not observe example.com: ${JSON.stringify(snapshotData)}`); + } + console.log(`webview ready (${snapshotData.title})`); + + // Quit the desktop: the broker must degrade to the closed host-unavailable code. + await app.close(); + app = null; + await wait(1000); + const offline = await execute(client, 'r-offline', 'tabs.list', {}); + if (offline.ok !== false) fail('tabs.list should fail once the host quit'); + const offlineError = offline.error as { code: string }; + if (offlineError.code !== 'host-unavailable') { + fail(`expected host-unavailable, got ${JSON.stringify(offlineError)}`); + } + console.log('host disconnect degrades to host-unavailable'); + + passed = true; + console.log('PASS'); + } finally { + client?.close(); + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + rmSync(join(homedir(), 'Library/Application Support', `LinkCode Development (${PROFILE})`), { + recursive: true, + force: true, + }); + if (passed) { + rmSync(home, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/e2e/wire-version.mts b/apps/desktop/e2e/wire-version.mts index 1f530488..f56b8041 100644 --- a/apps/desktop/e2e/wire-version.mts +++ b/apps/desktop/e2e/wire-version.mts @@ -11,4 +11,4 @@ * A stale value does not fail loudly: the socket still completes, then every frame is silently * discarded at the transport, so a driving script looks connected while the device never moves. */ -export const WIRE_VERSION = 59; +export const WIRE_VERSION = 60; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9c9c3782..faee5891 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "e2e:simulator": "node e2e/simulator-panel.e2e.mts", "e2e:window-bounds": "node e2e/window-bounds.e2e.mts", "e2e:browser-tabs": "node e2e/browser-tabs.e2e.mts", + "e2e:browser-broker": "node e2e/browser-broker.e2e.mts", "lint": "pnpm --dir ../.. exec eslint --format=sukka apps/desktop" }, "author": "ArcBox Labs ", diff --git a/apps/desktop/src/renderer/src/__tests__/webview-registry.test.ts b/apps/desktop/src/renderer/src/__tests__/webview-registry.test.ts new file mode 100644 index 00000000..a832a052 --- /dev/null +++ b/apps/desktop/src/renderer/src/__tests__/webview-registry.test.ts @@ -0,0 +1,68 @@ +import type { WebviewTag } from 'electron'; +import { describe, expect, it } from 'vitest'; +import { + advanceBrowserWebviewGeneration, + getBrowserWebviewGeneration, + getReadyBrowserWebview, + markBrowserWebviewReady, + markBrowserWebviewUnready, + registerBrowserWebview, +} from '../shell/browser/webview-registry'; + +describe('browser webview registry', () => { + it('waits for a webview that has not mounted yet', async () => { + const webview: WebviewTag = Object.create(null); + const ready = getReadyBrowserWebview('late-tab'); + registerBrowserWebview('late-tab', webview); + markBrowserWebviewReady('late-tab'); + + await expect(ready).resolves.toEqual({ webview, generation: 0 }); + registerBrowserWebview('late-tab', null); + }); + + it('waits for the current document and tracks navigation generation', async () => { + const webview: WebviewTag = Object.create(null); + registerBrowserWebview('tab', webview); + let settled = false; + const initial = getReadyBrowserWebview('tab').then((value) => { + settled = true; + return value; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + markBrowserWebviewReady('tab'); + await expect(initial).resolves.toEqual({ webview, generation: 0 }); + markBrowserWebviewUnready('tab'); + advanceBrowserWebviewGeneration('tab'); + const navigated = getReadyBrowserWebview('tab'); + markBrowserWebviewReady('tab'); + + await expect(navigated).resolves.toEqual({ webview, generation: 1 }); + expect(getBrowserWebviewGeneration('tab')).toBe(1); + registerBrowserWebview('tab', null); + }); + + it('stops waiting when an unready webview is removed', async () => { + const webview: WebviewTag = Object.create(null); + registerBrowserWebview('removed-tab', webview); + const ready = getReadyBrowserWebview('removed-tab'); + + registerBrowserWebview('removed-tab', null); + + await expect(ready).resolves.toBeUndefined(); + }); + + it('follows a replacement webview until it is ready', async () => { + const previous: WebviewTag = Object.create(null); + const replacement: WebviewTag = Object.create(null); + registerBrowserWebview('replaced-tab', previous); + const ready = getReadyBrowserWebview('replaced-tab'); + + registerBrowserWebview('replaced-tab', replacement); + markBrowserWebviewReady('replaced-tab'); + + await expect(ready).resolves.toEqual({ webview: replacement, generation: 0 }); + registerBrowserWebview('replaced-tab', null); + }); +}); diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts b/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts new file mode 100644 index 00000000..805a163b --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts @@ -0,0 +1,315 @@ +import type { + BrowserCommandArgs, + BrowserCommandError, + BrowserCommandErrorCode, + BrowserCommandResult, + BrowserOp, + BrowserSnapshot, + BrowserTabInfo, +} from '@linkcode/schema'; +import { isAllowedBrowserUrl } from '@linkcode/ui/shell/browser'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { z } from 'zod'; +import { useDesktopShellStore } from '../store/store'; +import { getBrowserWebviewGeneration, getReadyBrowserWebview } from './webview-registry'; + +/** Snapshot ref marker attribute; selectors target it so refs survive without CSS-path math. */ +const REF_ATTRIBUTE = 'data-linkcode-ref'; +const SNAPSHOT_NODE_CAP = 200; + +const TabArgsSchema = z.object({ tabId: z.string().min(1) }); +const OpenArgsSchema = z.object({ url: z.string().min(1) }); +const NavigateArgsSchema = TabArgsSchema.extend({ url: z.string().min(1) }); +const RefArgsSchema = TabArgsSchema.extend({ ref: z.string().min(1) }); +const TypeArgsSchema = RefArgsSchema.extend({ text: z.string() }); +const EvaluateArgsSchema = TabArgsSchema.extend({ js: z.string().min(1) }); + +interface SnapshotRecord { + generation: number; + refs: Set; +} + +const SNAPSHOT_SCRIPT = `(() => { + const SELECTOR = 'a[href], button, input, textarea, select, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="combobox"], [role="menuitem"], [role="tab"], [contenteditable="true"]'; + for (const el of document.querySelectorAll('[${REF_ATTRIBUTE}]')) el.removeAttribute('${REF_ATTRIBUTE}'); + const nodes = []; + let truncated = false; + let n = 0; + for (const el of document.querySelectorAll(SELECTOR)) { + if (nodes.length >= ${SNAPSHOT_NODE_CAP}) { truncated = true; break; } + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + const style = getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') continue; + n += 1; + const ref = '@e' + n; + el.setAttribute('${REF_ATTRIBUTE}', ref); + const tag = el.tagName.toLowerCase(); + const role = el.getAttribute('role') || (tag === 'input' ? 'input:' + (el.getAttribute('type') || 'text') : tag); + const name = (el.getAttribute('aria-label') || el.innerText || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || '').trim().slice(0, 80); + const node = { ref, role, name }; + if (tag === 'input' || tag === 'textarea' || tag === 'select') node.value = String(el.value ?? '').slice(0, 80); + nodes.push(node); + } + return { url: location.href, title: document.title, nodes, truncated }; +})()`; + +class CommandError extends Error { + override name = 'CommandError'; + + constructor( + readonly code: BrowserCommandErrorCode, + message: string, + readonly retryable = false, + ) { + super(message); + } +} + +function listTabs(): BrowserTabInfo[] { + const { browser } = useDesktopShellStore.getState().rightPanel; + return browser.tabs.map((tab) => ({ + id: tab.id, + url: tab.url, + title: tab.title, + active: tab.id === browser.activeTabId, + })); +} + +function findTab(tabId: string): BrowserTabInfo { + const { browser } = useDesktopShellStore.getState().rightPanel; + const tab = browser.tabs.find((candidate) => candidate.id === tabId); + if (!tab) { + throw new CommandError('no-such-tab', `no browser tab ${tabId}`); + } + return { ...tab, active: tab.id === browser.activeTabId }; +} + +function assertTab(tabId: string): void { + findTab(tabId); +} + +function assertPageTab(tabId: string): void { + const tab = findTab(tabId); + if (tab.url === null) { + throw new CommandError( + 'no-such-tab', + `tab ${tabId} has no page loaded yet (navigate it first)`, + ); + } +} + +async function requireReadyWebview(tabId: string) { + assertPageTab(tabId); + const ready = await getReadyBrowserWebview(tabId); + // eslint-disable-next-line sukka/prefer-nullthrow -- the throw must carry a closed BrowserCommandErrorCode, which nullthrow/invariant cannot attach + if (!ready) { + throw new CommandError( + 'no-such-tab', + `tab ${tabId} has no page loaded yet (navigate it first)`, + ); + } + return ready; +} + +/** + * Executes broker-dispatched browser ops against the SAME tabs/webviews the user sees: + * tab management goes through the desktop shell store; page ops run inside the live webview + * via `executeJavaScript`; screenshots use `capturePage`. Snapshot refs (`@eN`) are marker + * attributes in the page plus a per-tab record here — a navigation makes them stale. + */ +export class BrowserCommandExecutor { + private readonly snapshots = new Map(); + + async execute(op: BrowserOp, args: BrowserCommandArgs): Promise { + try { + return { ok: true, data: await this.run(op, args) }; + } catch (err) { + return { ok: false, error: toCommandError(err) }; + } + } + + private async run(op: BrowserOp, args: BrowserCommandArgs): Promise { + switch (op) { + case 'tabs.list': + return listTabs(); + case 'tabs.open': { + const { url } = parseArgs(OpenArgsSchema, args); + assertAllowedUrl(url); + useDesktopShellStore.getState().openBrowserTab(url); + return listTabs(); + } + case 'tabs.select': { + const { tabId } = parseArgs(TabArgsSchema, args); + assertTab(tabId); + const store = useDesktopShellStore.getState(); + store.openRightPanelSection('browser'); + store.setActiveRightBrowserTab(tabId); + return listTabs(); + } + case 'tab.navigate': { + const { tabId, url } = parseArgs(NavigateArgsSchema, args); + assertTab(tabId); + assertAllowedUrl(url); + this.snapshots.delete(tabId); + useDesktopShellStore.getState().setBrowserTabUrl(tabId, url); + return null; + } + case 'tab.back': { + const { tabId } = parseArgs(TabArgsSchema, args); + this.snapshots.delete(tabId); + (await requireReadyWebview(tabId)).webview.goBack(); + return null; + } + case 'tab.reload': { + const { tabId } = parseArgs(TabArgsSchema, args); + this.snapshots.delete(tabId); + (await requireReadyWebview(tabId)).webview.reload(); + return null; + } + case 'tab.close': { + const { tabId } = parseArgs(TabArgsSchema, args); + assertTab(tabId); + this.snapshots.delete(tabId); + useDesktopShellStore.getState().closeRightBrowserTab(tabId); + return listTabs(); + } + case 'tab.snapshot': + return this.snapshot(parseArgs(TabArgsSchema, args).tabId); + case 'tab.click': { + const { tabId, ref } = parseArgs(RefArgsSchema, args); + await this.runOnRef(tabId, ref, clickScript(ref)); + return null; + } + case 'tab.type': { + const { tabId, ref, text } = parseArgs(TypeArgsSchema, args); + await this.runOnRef(tabId, ref, typeScript(ref, text)); + return null; + } + case 'tab.screenshot': + return screenshot(parseArgs(TabArgsSchema, args).tabId); + case 'tab.evaluate': { + const { tabId, js } = parseArgs(EvaluateArgsSchema, args); + const { webview } = await requireReadyWebview(tabId); + try { + return await webview.executeJavaScript(js); + } catch (err) { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'evaluate failed'); + } + } + default: + throw new CommandError('invalid-args', `unsupported op ${op as string}`); + } + } + + private async snapshot(tabId: string): Promise { + const { webview, generation } = await requireReadyWebview(tabId); + const result = (await webview.executeJavaScript(SNAPSHOT_SCRIPT).catch((err: unknown) => { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'snapshot failed'); + })) as BrowserSnapshot; + this.snapshots.set(tabId, { + generation, + refs: new Set(result.nodes.map((node) => node.ref)), + }); + return result; + } + + private async runOnRef(tabId: string, ref: string, script: string): Promise { + const record = this.snapshots.get(tabId); + if (!record?.refs.has(ref)) { + throw new CommandError('stale-ref', `unknown ref ${ref}; take a fresh tab.snapshot first`); + } + const { webview, generation } = await requireReadyWebview(tabId); + if (generation !== record.generation || getBrowserWebviewGeneration(tabId) !== generation) { + this.snapshots.delete(tabId); + throw new CommandError( + 'stale-ref', + 'the page navigated since the last snapshot; take a fresh tab.snapshot', + true, + ); + } + const found = (await webview.executeJavaScript(script).catch((err: unknown) => { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'interaction failed'); + })) as boolean; + if (!found) { + this.snapshots.delete(tabId); + throw new CommandError( + 'stale-ref', + `ref ${ref} is gone from the page; take a fresh tab.snapshot`, + true, + ); + } + } +} + +async function screenshot(tabId: string): Promise<{ mimeType: string; base64: string }> { + // capturePage renders the compositor's view: bring the tab forward first so a hidden + // (visibility-toggled) webview doesn't come back blank. + const store = useDesktopShellStore.getState(); + store.openRightPanelSection('browser'); + store.setActiveRightBrowserTab(tabId); + const { webview } = await requireReadyWebview(tabId); + const image = await webview.capturePage(); + return { mimeType: 'image/png', base64: image.toPNG().toString('base64') }; +} + +function parseArgs(schema: Schema, args: unknown): z.infer { + const parsed = schema.safeParse(args); + if (!parsed.success) { + throw new CommandError('invalid-args', parsed.error.issues[0]?.message ?? 'invalid args'); + } + return parsed.data; +} + +function assertAllowedUrl(url: string): void { + if (!isAllowedBrowserUrl(url)) { + throw new CommandError('not-allowed', 'only http(s) URLs can be opened in the browser'); + } +} + +function toCommandError(err: unknown): BrowserCommandError { + if (err instanceof CommandError) { + return { code: err.code, message: err.message, retryable: err.retryable }; + } + return { + code: 'execution-failed', + message: extractErrorMessage(err) ?? 'Unknown executor error', + retryable: false, + }; +} + +function refSelector(ref: string): string { + return `[${REF_ATTRIBUTE}=${JSON.stringify(ref)}]`; +} + +/** Resolves `true` when the ref still exists (false → stale), throwing nothing page-side. */ +function clickScript(ref: string): string { + return `(() => { + const el = document.querySelector(${JSON.stringify(refSelector(ref))}); + if (!el) return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + return true; +})()`; +} + +function typeScript(ref: string, text: string): string { + return `(() => { + const el = document.querySelector(${JSON.stringify(refSelector(ref))}); + if (!el) return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.focus(); + const tag = el.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') { + const proto = tag === 'input' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; + setter.call(el, ${JSON.stringify(text)}); + el.dispatchEvent(new InputEvent('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + el.textContent = ${JSON.stringify(text)}; + el.dispatchEvent(new InputEvent('input', { bubbles: true })); + } + return true; +})()`; +} diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 85641669..668ee0db 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -5,10 +5,17 @@ import { BrowserPane } from '@linkcode/ui/shell/browser'; import type { WebviewTag } from 'electron'; import { useEffect } from 'foxact/use-abortable-effect'; import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; +import { useSingleton } from 'foxact/use-singleton'; import { noop } from 'foxts/noop'; import { useEffectEvent, useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; +import { + advanceBrowserWebviewGeneration, + markBrowserWebviewReady, + markBrowserWebviewUnready, + registerBrowserWebview, +} from './webview-registry'; /** All in-app pages share one persisted session (cookies/storage survive restarts). */ const BROWSER_PARTITION = 'persist:linkcode-browser'; @@ -81,9 +88,15 @@ export function BrowserWebviewPane({ const [find, setFind] = useState(null); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. - const captureWebview = (element: HTMLWebViewElement | null): void => { - setWebview(element as WebviewTag | null); - }; + const { current: captureWebview } = useSingleton( + () => (element: HTMLWebViewElement | null) => setWebview(element as WebviewTag | null), + ); + + useLayoutEffect(() => { + if (webview === null) return; + registerBrowserWebview(tabId, webview); + return () => registerBrowserWebview(tabId, null); + }, [tabId, webview]); const syncDocumentState = useEffectEvent((currentUrl: string, currentTitle: string) => { if (currentUrl.length > 0) setBrowserTabUrl(tabId, currentUrl); @@ -104,15 +117,22 @@ export function BrowserWebviewPane({ }; const syncDocument = (): void => { ready = true; + markBrowserWebviewReady(tabId); setReadyGuest(webview); sync(); syncDocumentState(webview.getURL(), webview.getTitle()); }; const onNavigate = (event: Electron.DidNavigateEvent): void => { + advanceBrowserWebviewGeneration(tabId); syncDocumentState(event.url, ''); setNav((prev) => ({ ...prev, failure: null })); sync(); }; + const onStartNavigation = (event: Electron.DidStartNavigationEvent): void => { + if (!event.isMainFrame || event.isInPlace) return; + ready = false; + markBrowserWebviewUnready(tabId); + }; const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { syncDocumentState('', event.title); }; @@ -135,24 +155,35 @@ export function BrowserWebviewPane({ ); }; webview.addEventListener('did-start-loading', sync); - webview.addEventListener('did-stop-loading', sync); + // `dom-ready` can fire before React's layout effects subscribe on very fast pages. The later + // `did-stop-loading` is an equivalent safe point for guest methods and closes that race. + webview.addEventListener('did-stop-loading', syncDocument); webview.addEventListener('dom-ready', syncDocument); + webview.addEventListener('did-start-navigation', onStartNavigation); webview.addEventListener('did-navigate', onNavigate); webview.addEventListener('did-navigate-in-page', onNavigate); webview.addEventListener('page-title-updated', onTitleUpdated); webview.addEventListener('did-fail-load', onFail); webview.addEventListener('found-in-page', onFoundInPage); + // A cached page can finish before either readiness listener is attached. Probe only after all + // listeners are installed; an attaching guest may still throw, in which case an event wins. + try { + if (!webview.isLoading() && webview.getURL().length > 0) syncDocument(); + } catch { + noop(); + } return () => { webview.removeEventListener('did-start-loading', sync); - webview.removeEventListener('did-stop-loading', sync); + webview.removeEventListener('did-stop-loading', syncDocument); webview.removeEventListener('dom-ready', syncDocument); + webview.removeEventListener('did-start-navigation', onStartNavigation); webview.removeEventListener('did-navigate', onNavigate); webview.removeEventListener('did-navigate-in-page', onNavigate); webview.removeEventListener('page-title-updated', onTitleUpdated); webview.removeEventListener('did-fail-load', onFail); webview.removeEventListener('found-in-page', onFoundInPage); }; - }, [webview, t]); + }, [webview, t, tabId]); // Pause any playing media when the pane is hidden (panel collapsed or another section shown), // so a preview stops instead of playing audio out of sight. Paused, not resumed — the user diff --git a/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts new file mode 100644 index 00000000..f6747b78 --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts @@ -0,0 +1,78 @@ +import type { WebviewTag } from 'electron'; +import { noop } from 'foxts/noop'; + +/** + * Live `` elements by browser-tab id. Panes register on mount so the browser command + * executor can drive the same webviews the user sees; entries exist only while a tab has a URL + * (an empty tab renders no webview). + */ +interface WebviewEntry { + webview: WebviewTag; + generation: number; + ready: Promise; + resolveReady: () => void; +} + +const webviews = new Map(); +const registrationWaiters = new Map void>>(); + +function unreadyEntry(webview: WebviewTag, generation: number): WebviewEntry { + let resolveReady = noop; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + return { webview, generation, ready, resolveReady }; +} + +export function registerBrowserWebview(tabId: string, webview: WebviewTag | null): void { + const previous = webviews.get(tabId); + const entry = webview === null ? undefined : unreadyEntry(webview, 0); + if (entry) webviews.set(tabId, entry); + else webviews.delete(tabId); + // Wake callers waiting on an entry that was removed or replaced so they can follow the map. + previous?.resolveReady(); + for (const resolve of registrationWaiters.get(tabId) ?? []) resolve(entry); + registrationWaiters.delete(tabId); +} + +export function markBrowserWebviewUnready(tabId: string): void { + const entry = webviews.get(tabId); + if (entry) { + // Wake callers waiting on the superseded readiness promise; they re-read the current entry. + entry.resolveReady(); + webviews.set(tabId, unreadyEntry(entry.webview, entry.generation)); + } +} + +export function markBrowserWebviewReady(tabId: string): void { + webviews.get(tabId)?.resolveReady(); +} + +export function advanceBrowserWebviewGeneration(tabId: string): void { + const entry = webviews.get(tabId); + if (entry) entry.generation += 1; +} + +export function getBrowserWebviewGeneration(tabId: string): number | undefined { + return webviews.get(tabId)?.generation; +} + +export async function getReadyBrowserWebview( + tabId: string, +): Promise<{ webview: WebviewTag; generation: number } | undefined> { + let entry = webviews.get(tabId); + if (!entry) { + entry = await new Promise((resolve) => { + const waiters = registrationWaiters.get(tabId) ?? new Set(); + waiters.add(resolve); + registrationWaiters.set(tabId, waiters); + }); + } + while (entry) { + await entry.ready; + const current = webviews.get(tabId); + if (current === entry) return { webview: entry.webview, generation: entry.generation }; + entry = current; + } + return undefined; +} diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index b570747c..bd374d8f 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -23,6 +23,7 @@ import { SimulatorAutoReveal, suppressSimulatorAutoReveal, TerminalPanel, + useBrowserHostRegistration, useCloudHosts, useSelectedHostStore, WorkspaceServicesMenu, @@ -37,6 +38,7 @@ import { useFormatter, useTranslations } from 'use-intl'; import { useShallow } from 'zustand/react/shallow'; import { DesktopThreadImMenu } from '../cloud-auth/thread-im-menu'; import { useCloudAccount } from '../cloud-auth/use-cloud-account'; +import { BrowserCommandExecutor } from './browser/browser-command-executor'; import { BrowserWebviewPane } from './browser/browser-webview-pane'; import { DesktopChrome } from './chrome/chrome'; import { DiffStatChip } from './chrome/diff-stat-chip'; @@ -246,6 +248,14 @@ export function DesktopShell({ const openBrowserTab = useDesktopShellStore((state) => state.openBrowserTab); useEffect(() => systemBridge.browser.onOpenTab(openBrowserTab), [systemBridge, openBrowserTab]); + // Broker commands drive the same resident webviews the user sees. The connection gate remounts + // this shell for each client generation, so registration follows every reconnect. + const { current: browserExecutor } = useSingleton(() => { + const executor = new BrowserCommandExecutor(); + return executor.execute.bind(executor); + }); + useBrowserHostRegistration(browserExecutor); + const tBrowser = useTranslations('workbench.preview.browser'); useEffect( () => diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 2e1b32ec..5c5fa801 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -64,6 +64,8 @@ import { extractErrorMessage } from 'foxts/extract-error-message'; import { noop } from 'foxts/noop'; import type { AgentLoginHandlers } from './client/agent-login-channel'; import { AgentLoginChannel } from './client/agent-login-channel'; +import type { BrowserCommandExecutor } from './client/browser-host-channel'; +import { BrowserHostChannel } from './client/browser-host-channel'; import type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; import { ControlChannel } from './client/control-channel'; import type { SequencedAgentEvent } from './client/event-buffer'; @@ -74,6 +76,7 @@ import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login-channel'; +export type { BrowserCommandExecutor } from './client/browser-host-channel'; export type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; export type { SequencedAgentEvent } from './client/event-buffer'; @@ -166,6 +169,7 @@ export class LinkCodeClient { private readonly control: ControlChannel; private readonly events = new EventBuffer(); private readonly terminals: TerminalChannel; + private readonly browserHost: BrowserHostChannel; private readonly agentLogin: AgentLoginChannel; private readonly scriptStatusSubs = new Set(); private readonly scheduleEventSubs = new Set(); @@ -197,6 +201,7 @@ export class LinkCodeClient { this.pending = new PendingRegistry(randomUUID); this.control = new ControlChannel(transport, this.pending); this.terminals = new TerminalChannel(transport, this.pending, randomUUID); + this.browserHost = new BrowserHostChannel(transport, this.pending, randomUUID); this.agentLogin = new AgentLoginChannel(transport, this.pending); } @@ -525,6 +530,9 @@ export class LinkCodeClient { case 'terminal.exit': this.terminals.handleMessage(p); break; + case 'browser.command': + this.browserHost.handleMessage(p); + break; case 'agent-login.started': case 'agent-login.url': case 'agent-login.settled': @@ -1068,6 +1076,11 @@ export class LinkCodeClient { return this.events.snapshot(sessionId); } + /** Register this client as THE browser host; `executor` runs broker commands (desktop only). */ + registerBrowserHost(executor: BrowserCommandExecutor): Promise { + return this.browserHost.register(executor); + } + openTerminal(opts: { cols: number; rows: number; diff --git a/packages/client/core/src/client/browser-host-channel.ts b/packages/client/core/src/client/browser-host-channel.ts new file mode 100644 index 00000000..4ec2c672 --- /dev/null +++ b/packages/client/core/src/client/browser-host-channel.ts @@ -0,0 +1,85 @@ +import type { + BrowserCommandArgs, + BrowserCommandResult, + BrowserOp, + WirePayload, +} from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { noop } from 'foxts/noop'; +import type { PendingRegistry, RandomUUID } from './pending-registry'; +import { sendCorrelated } from './pending-registry'; + +export type BrowserCommandExecutor = ( + op: BrowserOp, + args: BrowserCommandArgs, +) => Promise; + +/** + * Registers this client as THE single active browser host (CODE-267) and runs + * broker-dispatched `browser.command` frames through the host-supplied executor. + * Only the desktop client registers; browser/mobile clients never do. + */ +export class BrowserHostChannel { + private executor: BrowserCommandExecutor | null = null; + private hostId: string | null = null; + private hostSecret: string | null = null; + + constructor( + private readonly transport: Transport, + private readonly pending: PendingRegistry, + private readonly randomUUID: RandomUUID, + ) {} + + /** Claim the browser-host role; call again after a reconnect (last registration wins daemon-side). */ + async register(executor: BrowserCommandExecutor): Promise { + this.executor = executor; + this.hostId ??= this.randomUUID(); + this.hostSecret ??= this.randomUUID(); + const hostId = this.hostId; + const hostSecret = this.hostSecret; + await sendCorrelated(this.transport, this.pending, 'ack', (clientReqId) => ({ + kind: 'browser.host.register', + clientReqId, + hostId, + hostSecret, + })); + } + + /** Route a `browser.*` host push. Returns false if `payload` wasn't one. */ + handleMessage(p: WirePayload): boolean { + if (p.kind !== 'browser.command') return false; + void this.run(p.commandId, p.op, p.args); + return true; + } + + private async run(commandId: string, op: BrowserOp, args: BrowserCommandArgs): Promise { + const executor = this.executor; + const result: BrowserCommandResult = executor + ? await executor(op, args).catch((err: unknown) => ({ + ok: false as const, + error: { + code: 'execution-failed' as const, + message: extractErrorMessage(err) ?? 'Unknown executor error', + retryable: false, + }, + })) + : { + ok: false, + error: { + code: 'execution-failed', + message: 'no browser executor is installed on this host', + retryable: false, + }, + }; + try { + // Best-effort settle: if the send is lost the broker's timeout answers instead. + await this.transport.send( + createWireMessage({ kind: 'browser.command.result', commandId, result }), + ); + } catch { + noop(); + } + } +} diff --git a/packages/client/workbench/src/browser/use-browser-host.ts b/packages/client/workbench/src/browser/use-browser-host.ts new file mode 100644 index 00000000..1603272e --- /dev/null +++ b/packages/client/workbench/src/browser/use-browser-host.ts @@ -0,0 +1,18 @@ +import type { BrowserCommandExecutor } from '@linkcode/client-core'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { noop } from 'foxts/noop'; +import { useWorkbenchSdkClient } from '../runtime/provider'; + +/** + * Registers this client as the daemon's browser host while an executor is supplied (desktop + * passes its webview-backed executor; webview/mobile pass nothing and never register). Mounted + * under the connection gate, so a reconnect generation remounts it and re-registers. + */ +export function useBrowserHostRegistration(executor: BrowserCommandExecutor | null): void { + const client = useWorkbenchSdkClient(); + useEffect(() => { + if (executor === null) return; + // Registration failure just leaves agent browser tools reporting host-unavailable. + void client.raw.registerBrowserHost(executor).catch(noop); + }, [client, executor]); +} diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 49bcca6b..cf0fc929 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -13,6 +13,7 @@ export * from './automations/loop/items'; export * from './automations/schedule/hooks'; export * from './automations/schedule/items'; export * from './automations/store'; +export * from './browser/use-browser-host'; export * from './cloud'; export * from './files/hooks'; export * from './files/locate'; diff --git a/packages/foundation/schema/src/model/browser.ts b/packages/foundation/schema/src/model/browser.ts new file mode 100644 index 00000000..d81c459d --- /dev/null +++ b/packages/foundation/schema/src/model/browser.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +/** + * Browser automation contracts (CODE-267): a desktop client registers as THE browser host and + * executes broker-dispatched operations in its Browser-pane webviews. Single active host — + * commands have side effects, so they are never fanned out; the last registration wins. + */ + +/** Closed set of operations the browser host executes. `tab.*` ops take `tabId` in args. */ +export const BrowserOpSchema = z.enum([ + 'tabs.list', + 'tabs.open', + 'tabs.select', + 'tab.navigate', + 'tab.back', + 'tab.reload', + 'tab.close', + 'tab.snapshot', + 'tab.click', + 'tab.type', + 'tab.screenshot', + 'tab.evaluate', +]); +export type BrowserOp = z.infer; + +/** Envelope-level shape only; the executor validates per-op args in depth. */ +export const BrowserCommandArgsSchema = z.record(z.string(), z.unknown()); +export type BrowserCommandArgs = z.infer; + +export const BrowserCommandErrorCodeSchema = z.enum([ + 'host-unavailable', + 'timeout', + 'invalid-args', + 'no-such-tab', + 'stale-ref', + 'navigation-failed', + 'execution-failed', + 'not-allowed', +]); +export type BrowserCommandErrorCode = z.infer; + +export const BrowserCommandErrorSchema = z.object({ + code: BrowserCommandErrorCodeSchema, + message: z.string(), + retryable: z.boolean(), +}); +export type BrowserCommandError = z.infer; + +/** Settlement of one command; `data` is op-specific (tab list, snapshot, screenshot, …). */ +export const BrowserCommandResultSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true), data: z.unknown().optional() }), + z.object({ ok: z.literal(false), error: BrowserCommandErrorSchema }), +]); +export type BrowserCommandResult = z.infer; + +/** Host identity is a client-minted capability (mirrors terminal attachments): it travels + * client → host on registration only and is never echoed in replies or broadcasts. */ +export const BrowserHostCredentialsSchema = z.object({ + hostId: z.string().min(1).max(128), + hostSecret: z.string().min(1).max(256), +}); +export type BrowserHostCredentials = z.infer; + +// Op-payload shapes the desktop executor produces inside `data` (envelope-validated as unknown; +// the executor is first-party, so consumers narrow with these types instead of re-parsing). + +export interface BrowserTabInfo { + id: string; + url: string | null; + title: string | null; + active: boolean; +} + +/** One interactive element captured by `tab.snapshot`; `ref` (`@eN`) keys click/type targets + * and goes stale as soon as the page navigates. */ +export interface BrowserSnapshotNode { + ref: string; + role: string; + name: string; + value?: string; +} + +export interface BrowserSnapshot { + url: string; + title: string; + nodes: BrowserSnapshotNode[]; + /** True when the node list was cut at the collection cap. */ + truncated: boolean; +} diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 54198288..6ad95a60 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -2,6 +2,7 @@ export * from './account'; export * from './agent'; export * from './agent-runtime'; export * from './artifact'; +export * from './browser'; export * from './content'; export * from './daemon-discovery'; export * from './file'; diff --git a/packages/foundation/schema/src/wire/browser.ts b/packages/foundation/schema/src/wire/browser.ts new file mode 100644 index 00000000..c36c53a2 --- /dev/null +++ b/packages/foundation/schema/src/wire/browser.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { + BrowserCommandArgsSchema, + BrowserCommandResultSchema, + BrowserHostCredentialsSchema, + BrowserOpSchema, +} from '../model/browser'; + +/** Browser-broker wire variants (CODE-267): a desktop client registers as THE single active + * browser host (last registration wins); the daemon dispatches commands to that connection only + * and correlates settlements by the daemon-minted `commandId`. */ +export const browserWireVariants = [ + // Client → host: claim the browser-host role for this connection (replies via + // request.succeeded / request.failed; the Hub starts targeting on the success reply). + z.object({ + kind: z.literal('browser.host.register'), + clientReqId: z.string().min(1), + ...BrowserHostCredentialsSchema.shape, + }), + // Synthesized by the Hub when the registered host's connection closes (mirrors the + // synthetic terminal.detach); hostId guards against a stale disconnect clearing a newer host. + z.object({ kind: z.literal('browser.host.detached'), hostId: z.string().min(1) }), + // Host → clients broadcast: whether a browser host is currently registered. + z.object({ kind: z.literal('browser.host.changed'), available: z.boolean() }), + // Host push (delivered ONLY to the registered browser-host connection): execute one op. + z.object({ + kind: z.literal('browser.command'), + commandId: z.string().min(1), + op: BrowserOpSchema, + args: BrowserCommandArgsSchema, + }), + // Browser host → daemon: settle one command. + z.object({ + kind: z.literal('browser.command.result'), + commandId: z.string().min(1), + result: BrowserCommandResultSchema, + }), + // Any client → daemon (the B-2 stdio bridge): run one op through the broker. + z.object({ + kind: z.literal('browser.execute'), + clientReqId: z.string().min(1), + op: BrowserOpSchema, + args: BrowserCommandArgsSchema, + }), + z.object({ + kind: z.literal('browser.executed'), + replyTo: z.string().min(1), + result: BrowserCommandResultSchema, + }), +] as const; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 5610c3b5..84550b46 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -25,7 +25,8 @@ import { WirePayloadSchema } from './payload'; // 53 migrates managed-asset IDs from public strings to discriminated objects. // 54 adds the simulator agent-consent variants (CODE-420). // 55 adds the simulator volume buttons (CODE-414). -export const WIRE_PROTOCOL_VERSION = 59 as const; +// 60 adds the browser broker variants (CODE-267). +export const WIRE_PROTOCOL_VERSION = 60 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/foundation/schema/src/wire/payload.ts b/packages/foundation/schema/src/wire/payload.ts index e6e133e1..8c38e8b9 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -4,6 +4,7 @@ import { agentCatalogWireVariants } from './agent-catalog'; import { agentLoginWireVariants } from './agent-login'; import { agentRuntimeWireVariants } from './agent-runtime'; import { artifactWireVariants } from './artifact'; +import { browserWireVariants } from './browser'; import { configWireVariants } from './config'; import { fileWireVariants } from './file'; import { gitWireVariants } from './git'; @@ -36,6 +37,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [ ...scheduleWireVariants, ...loopWireVariants, ...artifactWireVariants, + ...browserWireVariants, ...agentWireVariants, ...terminalWireVariants, ...simulatorWireVariants, diff --git a/packages/foundation/schema/tests/contract/wire/browser.test.ts b/packages/foundation/schema/tests/contract/wire/browser.test.ts new file mode 100644 index 00000000..790d12e2 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/browser.test.ts @@ -0,0 +1,80 @@ +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +describe('browser wire schema', () => { + it('accepts a host registration with client-minted credentials', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.host.register', + clientReqId: 'request-1', + hostId: 'host-1', + hostSecret: 's'.repeat(32), + }), + ); + expect(parsed.success).toBe(true); + }); + + it('rejects a command with an op outside the closed set', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command', + commandId: 'command-1', + op: 'tab.delete-cookies', + args: {}, + }), + ); + expect(parsed.success).toBe(false); + }); + + it('round-trips a failed command settlement with a closed error code', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command.result', + commandId: 'command-1', + result: { + ok: false, + error: { code: 'host-unavailable', message: 'no host', retryable: true }, + }, + }), + ); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.payload.kind !== 'browser.command.result') return; + expect(parsed.data.payload.result.ok).toBe(false); + }); + + it('rejects a settlement with an unknown error code', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command.result', + commandId: 'command-1', + result: { ok: false, error: { code: 'mystery', message: 'x', retryable: false } }, + }), + ); + expect(parsed.success).toBe(false); + }); + + it('accepts the client-side execute request and its data-carrying reply', () => { + const request = parseWireMessage( + envelope({ + kind: 'browser.execute', + clientReqId: 'request-1', + op: 'tab.snapshot', + args: { tabId: 'right-browser-1' }, + }), + ); + expect(request.success).toBe(true); + + const reply = parseWireMessage( + envelope({ + kind: 'browser.executed', + replyTo: 'request-1', + result: { ok: true, data: { nodes: [] } }, + }), + ); + expect(reply.success).toBe(true); + }); +}); diff --git a/packages/foundation/transport/src/__tests__/hub.test.ts b/packages/foundation/transport/src/__tests__/hub.test.ts index 2af366b5..bc857529 100644 --- a/packages/foundation/transport/src/__tests__/hub.test.ts +++ b/packages/foundation/transport/src/__tests__/hub.test.ts @@ -344,3 +344,162 @@ describe('Hub terminal routing', () => { ]); }); }); + +describe('Hub browser host routing', () => { + function register(clientReqId: string, hostId: string): ValidatedWireMessage { + return createWireMessage({ + kind: 'browser.host.register', + clientReqId, + hostId, + hostSecret: 's'.repeat(32), + }); + } + + function succeeded(replyTo: string): ValidatedWireMessage { + return createWireMessage({ kind: 'request.succeeded', replyTo }); + } + + function command(commandId: string): ValidatedWireMessage { + return createWireMessage({ kind: 'browser.command', commandId, op: 'tabs.list', args: {} }); + } + + it('targets browser.command only at the registered host connection', () => { + const hub = new Hub(); + const host = new FakeConn(); + const other = new FakeConn(); + hub.addConnection(host); + hub.addConnection(other); + + host.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + hub.send(command('c1')); + + expect(host.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded', 'browser.command']); + expect(other.sent).toHaveLength(0); + }); + + it('settles browser.command with host-unavailable when no host is registered', () => { + const hub = new Hub(); + const conn = new FakeConn(); + const forwarded: ValidatedWireMessage[] = []; + hub.addConnection(conn); + hub.onMessage((msg) => forwarded.push(msg)); + + hub.send(command('c1')); + + expect(conn.sent).toHaveLength(0); + expect(forwarded.map((message) => message.payload)).toEqual([ + { + kind: 'browser.command.result', + commandId: 'c1', + result: { + ok: false, + error: { + code: 'host-unavailable', + message: 'no desktop client is registered as the browser host', + retryable: true, + }, + }, + }, + ]); + }); + + it('ignores client-originated detach and results from non-owner connections', () => { + const hub = new Hub(); + const host = new FakeConn(); + const other = new FakeConn(); + const forwarded: ValidatedWireMessage[] = []; + hub.addConnection(host); + hub.addConnection(other); + hub.onMessage((msg) => forwarded.push(msg)); + host.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + forwarded.length = 0; + + other.emit(createWireMessage({ kind: 'browser.host.detached', hostId: 'host-1' })); + other.emit( + createWireMessage({ + kind: 'browser.command.result', + commandId: 'foreign', + result: { ok: true, data: null }, + }), + ); + host.emit( + createWireMessage({ + kind: 'browser.command.result', + commandId: 'owned', + result: { ok: true, data: null }, + }), + ); + hub.send(command('still-owned')); + + expect(forwarded.map((message) => message.payload.kind)).toEqual(['browser.command.result']); + expect(host.sent.map((message) => message.payload.kind)).toEqual([ + 'request.succeeded', + 'browser.command', + ]); + }); + + it('lets the last successful registration win', () => { + const hub = new Hub(); + const first = new FakeConn(); + const second = new FakeConn(); + hub.addConnection(first); + hub.addConnection(second); + + first.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + second.emit(register('r2', 'host-2')); + hub.send(succeeded('r2')); + hub.send(command('c1')); + + expect(first.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded']); + expect(second.sent.map((m) => m.payload.kind)).toEqual([ + 'request.succeeded', + 'browser.command', + ]); + }); + + it('synthesizes browser.host.detached when the host connection drops', () => { + const hub = new Hub(); + const host = new FakeConn(); + hub.addConnection(host); + const forwarded: ValidatedWireMessage[] = []; + hub.onMessage((msg) => forwarded.push(msg)); + + host.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + hub.removeConnection(host); + hub.send(command('c1')); + + expect(forwarded.map((m) => m.payload.kind)).toEqual([ + 'browser.host.register', + 'browser.host.detached', + 'browser.command.result', + ]); + expect(host.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded']); + }); + + it('does not detach a newer host when a superseded connection drops', () => { + const hub = new Hub(); + const first = new FakeConn(); + const second = new FakeConn(); + hub.addConnection(first); + hub.addConnection(second); + const forwarded: ValidatedWireMessage[] = []; + hub.onMessage((msg) => forwarded.push(msg)); + + first.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + second.emit(register('r2', 'host-2')); + hub.send(succeeded('r2')); + hub.removeConnection(first); + hub.send(command('c1')); + + expect(forwarded.some((m) => m.payload.kind === 'browser.host.detached')).toBe(false); + expect(second.sent.map((m) => m.payload.kind)).toEqual([ + 'request.succeeded', + 'browser.command', + ]); + }); +}); diff --git a/packages/foundation/transport/src/hub.ts b/packages/foundation/transport/src/hub.ts index 02347685..924918d4 100644 --- a/packages/foundation/transport/src/hub.ts +++ b/packages/foundation/transport/src/hub.ts @@ -22,6 +22,11 @@ interface PendingTerminalRequest extends TerminalAttachmentCredentials { conn: Transport; } +interface BrowserHostRegistration { + conn: Transport; + hostId: string; +} + /** * Composes many client connections into the single `Transport` the daemon's `Host` consumes. * Correlated replies return only to their request's connection; session events keep their @@ -38,6 +43,8 @@ export class Hub implements Transport { /** Kept through origin disconnect so a late host reply cannot collide with a reused request id. */ private readonly pendingReplies = new Map(); private readonly pendingTerminals = new Map(); + private readonly pendingBrowserHosts = new Map(); + private browserHost: BrowserHostRegistration | null = null; private readonly inbound = new Listeners(); private readonly closed = new Listeners(); @@ -57,6 +64,11 @@ export class Hub implements Transport { this.unsubs.get(conn)?.(); this.unsubs.delete(conn); this.conns.delete(conn); + if (this.browserHost?.conn === conn) { + const { hostId } = this.browserHost; + this.browserHost = null; + this.inbound.emit(createWireMessage({ kind: 'browser.host.detached', hostId })); + } const subscription = this.subscriptions.get(conn); this.subscriptions.delete(conn); if (!subscription) return; @@ -85,6 +97,8 @@ export class Hub implements Transport { private route(conn: Transport, msg: ValidatedWireMessage): void { const p = msg.payload; + if (p.kind === 'browser.host.detached') return; + if (p.kind === 'browser.command.result' && this.browserHost?.conn !== conn) return; const subscription = this.subscriptions.get(conn); if ('clientReqId' in p && this.pendingReplies.has(p.clientReqId)) { bestEffort(() => @@ -129,6 +143,9 @@ export class Hub implements Transport { attachmentSecret: p.attachmentSecret, }); } + if (p.kind === 'browser.host.register') { + this.pendingBrowserHosts.set(p.clientReqId, { conn, hostId: p.hostId }); + } } this.inbound.emit(msg); } @@ -158,10 +175,46 @@ export class Hub implements Transport { ); } } + const pendingBrowserHost = this.pendingBrowserHosts.get(p.replyTo); + this.pendingBrowserHosts.delete(p.replyTo); + if (pendingBrowserHost && p.kind === 'request.succeeded') { + if (this.conns.has(pendingBrowserHost.conn)) { + this.browserHost = pendingBrowserHost; + } else { + this.inbound.emit( + createWireMessage({ + kind: 'browser.host.detached', + hostId: pendingBrowserHost.hostId, + }), + ); + } + } if (conn && this.conns.has(conn)) bestEffort(() => conn.send(msg)); return; } + if (p.kind === 'browser.command') { + const host = this.browserHost; + if (host && this.conns.has(host.conn)) bestEffort(() => host.conn.send(msg)); + else { + this.inbound.emit( + createWireMessage({ + kind: 'browser.command.result', + commandId: p.commandId, + result: { + ok: false, + error: { + code: 'host-unavailable', + message: 'no desktop client is registered as the browser host', + retryable: true, + }, + }, + }), + ); + } + return; + } + if ( p.kind === 'terminal.output' || p.kind === 'terminal.resized' || @@ -224,6 +277,8 @@ export class Hub implements Transport { this.subscriptions.clear(); this.pendingReplies.clear(); this.pendingTerminals.clear(); + this.pendingBrowserHosts.clear(); + this.browserHost = null; this.inbound.clear(); this.closed.emit(); } diff --git a/packages/host/agent-adapter/src/__tests__/pi-approval.test.ts b/packages/host/agent-adapter/src/__tests__/pi-approval.test.ts index 81b12299..0ad3d505 100644 --- a/packages/host/agent-adapter/src/__tests__/pi-approval.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi-approval.test.ts @@ -8,10 +8,19 @@ type Gate = (event: { toolCallId: string; input: unknown; }) => Promise<{ block?: boolean; reason?: string } | undefined>; +interface RegisteredTool { + name: string; + description: string; + execute( + toolCallId: string, + params: { code: string }, + ): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> }>; +} const sdk = vi.hoisted(() => ({ abort: vi.fn<() => Promise>(), gate: null as Gate | null, + tool: null as RegisteredTool | null, })); vi.mock('@earendil-works/pi-coding-agent', async () => { const { asyncNoop, noop } = await import('foxts/noop'); @@ -34,6 +43,9 @@ vi.mock('@earendil-works/pi-coding-agent', async () => { on(_name: string, handler: Gate) { sdk.gate = handler; }, + registerTool(tool: RegisteredTool) { + sdk.tool = tool; + }, }); } return Promise.resolve(); @@ -86,9 +98,44 @@ async function respond(adapter: PiAdapter, events: AgentEvent[], optionId?: stri beforeEach(() => { sdk.abort.mockReset().mockResolvedValue(); sdk.gate = null; + sdk.tool = null; }); describe('Pi approval gate', () => { + it('registers one browser toolset per session through the approval-gated extension API', async () => { + const adapter = new PiAdapter(); + const createToolset = vi.fn(() => ({ + documentation: 'Browser docs', + execute: vi.fn(() => + Promise.resolve({ + ok: true, + value: { mimeType: 'image/png', base64: 'aW1hZ2U=' }, + logs: ['captured'], + }), + ), + })); + adapter.attachBrowserTools(createToolset); + + await adapter.start({ kind: 'pi', cwd: '/tmp/pi' }); + + expect(createToolset).toHaveBeenCalledOnce(); + expect(sdk.tool).toMatchObject({ name: 'browser_execute', description: 'Browser docs' }); + if (!sdk.tool || !sdk.gate) throw new Error('browser extension not registered'); + await expect(sdk.tool.execute('browser-call', { code: 'snapshot()' })).resolves.toEqual({ + content: [ + { type: 'text', text: '[screenshot attached]\nconsole:\ncaptured' }, + { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }, + ], + details: undefined, + }); + + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + const gated = sdk.gate(call('browser-call', 'browser_execute')); + await respond(adapter, events, 'allow'); + await expect(gated).resolves.toBeUndefined(); + }); + it('auto-allows safe tools and supports allow once and option id always', async () => { const { adapter, events, gate } = await setup(); await expect(gate(call('safe', 'read'))).resolves.toBeUndefined(); diff --git a/packages/host/agent-adapter/src/adapter.ts b/packages/host/agent-adapter/src/adapter.ts index 932eaa7a..01fc32e0 100644 --- a/packages/host/agent-adapter/src/adapter.ts +++ b/packages/host/agent-adapter/src/adapter.ts @@ -17,6 +17,20 @@ import type { Unsubscribe } from '@linkcode/transport'; export type AgentStartCatalogOptions = Partial>; +export interface BrowserToolExecuteResult { + ok: boolean; + value?: unknown; + logs: string[]; + error?: string; +} + +export interface BrowserToolset { + readonly documentation: string; + execute(code: string): Promise; +} + +export type BrowserToolsetFactory = () => BrowserToolset; + /** * Unified adapter interface, one per coding agent (docs/ARCHITECTURE.md#key-contracts): no per-SDK * branching in upper layers (docs/ARCHITECTURE.md#core-principles). Implementations normalize @@ -28,6 +42,7 @@ export interface AgentAdapter { readonly capabilities: AgentCapabilities; /** History support advertised by this adapter. Unsupported operations must reject clearly. */ readonly historyCapabilities: AgentHistoryCapabilities; + attachBrowserTools?(createToolset: BrowserToolsetFactory): void; start(opts: StartOptions): Promise; startCatalog(opts?: AgentStartCatalogOptions): Promise; /** List provider-local historical sessions, if supported. */ @@ -42,6 +57,41 @@ export interface AgentAdapter { stop(): Promise; } +const BROWSER_RESULT_STRING_CAP = 4000; + +export interface BrowserToolRendered { + text: string; + image?: { mimeType: string; base64: string }; +} + +export function renderBrowserToolResult(result: BrowserToolExecuteResult): BrowserToolRendered { + const logs = result.logs.length > 0 ? `\nconsole:\n${result.logs.join('\n')}` : ''; + if (!result.ok) return { text: `Error: ${result.error ?? 'unknown error'}${logs}` }; + const image = detectImageValue(result.value); + if (image) return { text: `[screenshot attached]${logs}`, image }; + const value = + result.value === undefined + ? 'undefined' + : (JSON.stringify(result.value, capLongStrings, 2) ?? 'undefined'); + return { text: `${value}${logs}` }; +} + +function capLongStrings(_key: string, value: unknown): unknown { + return typeof value === 'string' && value.length > BROWSER_RESULT_STRING_CAP + ? `${value.slice(0, BROWSER_RESULT_STRING_CAP)}… [${value.length} chars total]` + : value; +} + +function detectImageValue(value: unknown): { mimeType: string; base64: string } | undefined { + if (typeof value !== 'object' || value === null) return undefined; + if (!('mimeType' in value) || !('base64' in value)) return undefined; + return typeof value.mimeType === 'string' && + value.mimeType.startsWith('image/') && + typeof value.base64 === 'string' + ? { mimeType: value.mimeType, base64: value.base64 } + : undefined; +} + let __seq = 0; function nextId(prefix: string): string { __seq += 1; diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index ae686527..4626257c 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -5,6 +5,7 @@ import { env } from 'node:process'; import type { CanUseTool, HookCallback, + McpSdkServerConfigWithInstance, McpServerConfig, PermissionMode, PermissionResult, @@ -57,7 +58,8 @@ import { import { extractErrorMessage } from 'foxts/extract-error-message'; import { nullthrow } from 'foxts/guard'; import { z } from 'zod'; -import { AUTH_FAILED_ERROR_CODE } from '../adapter'; +import type { BrowserToolsetFactory } from '../adapter'; +import { AUTH_FAILED_ERROR_CODE, renderBrowserToolResult } from '../adapter'; import { BaseAgentAdapter } from '../base'; import { claudeCodeEnv, readAgentCredential } from '../credential'; import { @@ -444,6 +446,46 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { /** The last published slash-command catalog — the alias authority for command interception * (`/cost` resolves to `/usage` via the provider's own aliases, not a hardcoded list). */ private commandCatalog: AgentCommand[] = []; + private browserTools: BrowserToolsetFactory | undefined; + + attachBrowserTools(createToolset: BrowserToolsetFactory): void { + this.browserTools = createToolset; + } + + private async buildBrowserMcpServer(): Promise> { + const factory = nullthrow(this.browserTools, 'claude-code: browser tools not attached'); + const { createSdkMcpServer, tool } = await import('@anthropic-ai/claude-agent-sdk'); + const toolset = factory(); + return { + linkcode_browser: createSdkMcpServer({ + name: 'linkcode_browser', + tools: [ + tool( + 'execute', + toolset.documentation, + { code: z.string().describe('JavaScript for the persistent browser REPL') }, + async ({ code }) => { + const rendered = renderBrowserToolResult(await toolset.execute(code)); + return { + content: [ + { type: 'text' as const, text: rendered.text }, + ...(rendered.image + ? [ + { + type: 'image' as const, + data: rendered.image.base64, + mimeType: rendered.image.mimeType, + }, + ] + : []), + ], + }; + }, + ), + ], + }), + }; + } protected async onStart(opts: StartOptions): Promise { this.stopped = false; @@ -661,7 +703,17 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { // The SDK has no apiKey/baseURL option — the resolved account reaches the subprocess via `env` // (see `claudeCodeEnv` for the replace-vs-spread and omit-to-inherit semantics). const credentialEnv = claudeCodeEnv(env, readAgentCredential(opts.config)); - const mcpServers = claudeMcpServers(opts.mcpServers); + const configuredMcpServers = claudeMcpServers(opts.mcpServers); + if (this.browserTools && configuredMcpServers?.linkcode_browser) { + throw new Error( + "claude-code: MCP server name 'linkcode_browser' is reserved for browser tools", + ); + } + const browserMcpServer = this.browserTools ? await this.buildBrowserMcpServer() : undefined; + const mcpServers = + configuredMcpServers || browserMcpServer + ? { ...configuredMcpServers, ...browserMcpServer } + : undefined; let q: Query | null = null; const reflectCurrentQueryEffort: HookCallback = (input, toolUseID, hookOptions) => { if (q === null || this.q !== q) return Promise.resolve({ continue: true }); diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 5007a748..2c321030 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -24,9 +24,11 @@ import type { ToolKind, } from '@linkcode/schema'; import { textBlock } from '@linkcode/schema'; +import { Type } from '@sinclair/typebox'; import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { invariant } from 'foxts/guard'; -import type { AgentStartCatalogOptions } from '../../adapter'; +import type { AgentStartCatalogOptions, BrowserToolsetFactory } from '../../adapter'; +import { renderBrowserToolResult } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { readAgentCredential } from '../../credential'; import { asHistoryId } from '../../history-util'; @@ -188,6 +190,11 @@ export class PiAdapter extends BaseAgentAdapter { }; /** Invalidates SDK and extension callbacks captured by a stopped Pi session. */ private lifecycle = 0; + private browserTools: BrowserToolsetFactory | undefined; + + attachBrowserTools(createToolset: BrowserToolsetFactory): void { + this.browserTools = createToolset; + } override async startCatalog(opts: AgentStartCatalogOptions = {}): Promise { const pi = await this.importSdk(); @@ -266,7 +273,12 @@ export class PiAdapter extends BaseAgentAdapter { const resourceLoader = new pi.DefaultResourceLoader({ cwd, agentDir: piAgentDir(), - extensionFactories: [(extension) => this.registerGate(extension, generation)], + extensionFactories: [ + (extension) => { + this.registerGate(extension, generation); + this.registerBrowserTool(extension); + }, + ], }); await resourceLoader.reload(); @@ -450,6 +462,39 @@ export class PiAdapter extends BaseAgentAdapter { extension.on('tool_call', (event) => this.gateTool(event, generation)); } + private registerBrowserTool(extension: ExtensionAPI): void { + const factory = this.browserTools; + if (!factory) return; + const toolset = factory(); + extension.registerTool({ + name: 'browser_execute', + label: 'Browser', + description: toolset.documentation, + parameters: Type.Object({ + code: Type.String({ description: 'JavaScript for the persistent browser REPL' }), + }), + async execute(_toolCallId, { code }) { + invariant(code, 'pi: browser_execute code is required'); + const rendered = renderBrowserToolResult(await toolset.execute(code)); + return { + content: [ + { type: 'text', text: rendered.text }, + ...(rendered.image + ? [ + { + type: 'image' as const, + data: rendered.image.base64, + mimeType: rendered.image.mimeType, + }, + ] + : []), + ], + details: undefined, + }; + }, + }); + } + private async gateTool( event: ToolCallEvent, generation: number, diff --git a/packages/host/engine/package.json b/packages/host/engine/package.json index 4794e305..2b5cea04 100644 --- a/packages/host/engine/package.json +++ b/packages/host/engine/package.json @@ -16,6 +16,7 @@ "@linkcode/common": "workspace:*", "@linkcode/schema": "workspace:*", "@linkcode/transport": "workspace:*", + "acorn": "^8.17.0", "croner": "^10.0.1", "effect": "4.0.0-beta.98", "foxts": "^5.8.0", diff --git a/packages/host/engine/src/__tests__/browser-broker.test.ts b/packages/host/engine/src/__tests__/browser-broker.test.ts new file mode 100644 index 00000000..79c55423 --- /dev/null +++ b/packages/host/engine/src/__tests__/browser-broker.test.ts @@ -0,0 +1,161 @@ +import type { WireMessage } from '@linkcode/schema'; +import type { Transport, Unsubscribe } from '@linkcode/transport'; +import { noop } from 'foxts/noop'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserBrokerService } from '../browser/broker'; + +class FakeTransport implements Transport { + readonly sent: WireMessage[] = []; + + connect(): Promise { + return Promise.resolve(); + } + + send(msg: WireMessage): void | Promise { + this.sent.push(msg); + } + + onMessage(): Unsubscribe { + return noop; + } + + onClose(): Unsubscribe { + return noop; + } + + close = noop; +} + +class RejectingTransport extends FakeTransport { + override send(msg: WireMessage): Promise { + this.sent.push(msg); + return msg.payload.kind === 'browser.command' + ? Promise.reject(new Error('closed')) + : Promise.resolve(); + } +} + +function lastCommandId(transport: FakeTransport): string { + const command = transport.sent.findLast((m) => m.payload.kind === 'browser.command'); + if (command?.payload.kind !== 'browser.command') throw new Error('no browser.command sent'); + return command.payload.commandId; +} + +describe('BrowserBrokerService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('fails fast with host-unavailable when no host is registered', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + + const result = await broker.dispatch('tabs.list', {}); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + expect(transport.sent).toHaveLength(0); + }); + + it('correlates a settlement back to its dispatch', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.settle(lastCommandId(transport), { ok: true, data: { tabs: [] } }); + + const result = await pending; + expect(result).toEqual({ ok: true, data: { tabs: [] } }); + }); + + it('settles immediately when transport send rejects', async () => { + const transport = new RejectingTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const result = await broker.dispatch('tabs.list', {}); + + expect(result).toEqual({ + ok: false, + error: { + code: 'host-unavailable', + message: 'the browser host connection is closed', + retryable: true, + }, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('times out an unanswered command with a retryable closed code', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tab.snapshot', { tabId: 't1' }); + vi.advanceTimersByTime(15000); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('timeout'); + expect(result.error.retryable).toBe(true); + } + // A settlement arriving after the timeout is ignored, not crashed on. + broker.settle(lastCommandId(transport), { ok: true }); + }); + + it('fails all pending commands when the host detaches', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.detachHost('host-1'); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + expect(broker.available).toBe(false); + }); + + it('ignores a stale detach for a superseded host', () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + broker.registerHost('host-2'); + + broker.detachHost('host-1'); + + expect(broker.available).toBe(true); + }); + + it('fails commands pending against a superseded host on re-registration', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.registerHost('host-2'); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + }); + + it('broadcasts availability transitions', () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + + broker.registerHost('host-1'); + broker.detachHost('host-1'); + + const availability = transport.sent.flatMap((m) => + m.payload.kind === 'browser.host.changed' ? [m.payload.available] : [], + ); + expect(availability).toEqual([true, false]); + }); +}); diff --git a/packages/host/engine/src/__tests__/browser-repl-host.test.ts b/packages/host/engine/src/__tests__/browser-repl-host.test.ts new file mode 100644 index 00000000..121453ca --- /dev/null +++ b/packages/host/engine/src/__tests__/browser-repl-host.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { BrowserReplHost } from '../browser/repl-host'; + +const TIMEOUT_PATTERN = /timed out/i; +const EXECUTE_TIMEOUT_PATTERN = /timeout: execute did not settle within 50ms/; +const HOST_UNAVAILABLE_PATTERN = /host-unavailable: desktop app not running/; +const SYNTAX_ERROR_PATTERN = /SyntaxError|Unexpected/i; + +function dispatch(op: string) { + if (op === 'tabs.list') { + return Promise.resolve({ ok: true as const, data: [{ id: 't1', url: 'https://a.test' }] }); + } + return Promise.resolve({ + ok: false as const, + error: { + code: 'host-unavailable' as const, + message: 'desktop app not running', + retryable: true, + }, + }); +} + +describe('BrowserReplHost', () => { + it('persists const bindings with top-level await across execute calls', async () => { + const host = new BrowserReplHost(dispatch); + + const first = await host.execute('const tabs = await browser.tabs();\nreturn tabs.length;'); + expect(first).toMatchObject({ ok: true, value: 1 }); + + const second = await host.execute('return tabs[0].id;'); + expect(second).toMatchObject({ ok: true, value: 't1' }); + }); + + it('persists destructured and uninitialized bindings', async () => { + const host = new BrowserReplHost(dispatch); + + await host.execute('const [{ id, url }] = await browser.tabs();\nlet note;'); + const result = await host.execute('note = url;\nreturn id + " " + note;'); + expect(result).toMatchObject({ ok: true, value: 't1 https://a.test' }); + }); + + it('persists function and class declarations', async () => { + const host = new BrowserReplHost(dispatch); + + await host.execute( + 'function twice(n) { return n * 2; }\nclass Box { constructor(v) { this.v = v; } }', + ); + const result = await host.execute('return new Box(twice(21)).v;'); + expect(result).toMatchObject({ ok: true, value: 42 }); + }); + + it('interrupts a synchronous infinite loop via the vm timeout', async () => { + const host = new BrowserReplHost(dispatch, { syncTimeoutMs: 100 }); + + const result = await host.execute('while (true) {}'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(TIMEOUT_PATTERN); + }); + + it('fails a never-settling await via the execute timeout', async () => { + const host = new BrowserReplHost(dispatch, { executeTimeoutMs: 50 }); + + const result = await host.execute('await new Promise(() => {});'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(EXECUTE_TIMEOUT_PATTERN); + }); + + it('captures console.log output and surfaces closed-code broker errors', async () => { + const host = new BrowserReplHost(dispatch); + + const result = await host.execute( + 'console.log("before");\nawait browser.open("https://b.test");', + ); + expect(result.ok).toBe(false); + expect(result.logs).toEqual(['before']); + expect(result.error).toMatch(HOST_UNAVAILABLE_PATTERN); + }); + + it('surfaces syntax errors from unparseable code', async () => { + const host = new BrowserReplHost(dispatch); + + const result = await host.execute('const = ;'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(SYNTAX_ERROR_PATTERN); + }); +}); diff --git a/packages/host/engine/src/browser/broker.ts b/packages/host/engine/src/browser/broker.ts new file mode 100644 index 00000000..41454233 --- /dev/null +++ b/packages/host/engine/src/browser/broker.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto'; +import type { BrowserCommandArgs, BrowserCommandResult, BrowserOp } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; + +const COMMAND_TIMEOUT_MS = 15000; + +interface PendingCommand { + resolve: (result: BrowserCommandResult) => void; + timer: NodeJS.Timeout; +} + +/** + * Dispatches browser ops to the single registered desktop host (CODE-267). The Hub owns + * connection-level targeting (`browser.command` frames reach only the host connection); this + * service owns the request/settlement correlation, the timeout, and availability broadcasts. + * `dispatch` never rejects — every failure is a closed-code {@link BrowserCommandResult}. + */ +export class BrowserBrokerService { + private hostId: string | null = null; + private readonly pending = new Map(); + + constructor(private readonly transport: Transport) {} + + get available(): boolean { + return this.hostId !== null; + } + + /** Last registration wins; commands still pending against the previous host are failed. */ + registerHost(hostId: string): void { + if (this.hostId !== null && this.hostId !== hostId) { + this.failAllPending('superseded by a new browser host'); + } + this.hostId = hostId; + this.broadcastAvailability(); + } + + /** Hub-synthesized on host disconnect; a stale (already superseded) hostId is ignored. */ + detachHost(hostId: string): void { + if (this.hostId !== hostId) return; + this.hostId = null; + this.failAllPending('the browser host disconnected'); + this.broadcastAvailability(); + } + + settle(commandId: string, result: BrowserCommandResult): void { + const pending = this.pending.get(commandId); + if (!pending) return; + this.pending.delete(commandId); + clearTimeout(pending.timer); + pending.resolve(result); + } + + dispatch(op: BrowserOp, args: BrowserCommandArgs): Promise { + if (this.hostId === null) { + return Promise.resolve( + failure('host-unavailable', 'no desktop client is registered as the browser host'), + ); + } + const commandId = randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(commandId); + resolve( + failure('timeout', `the browser host did not answer within ${COMMAND_TIMEOUT_MS}ms`), + ); + }, COMMAND_TIMEOUT_MS); + timer.unref(); + this.pending.set(commandId, { resolve, timer }); + try { + void Promise.resolve( + this.transport.send(createWireMessage({ kind: 'browser.command', commandId, op, args })), + ).catch(() => this.failDispatch(commandId)); + } catch { + this.failDispatch(commandId); + } + }); + } + + shutdown(): void { + this.failAllPending('the daemon is shutting down'); + this.hostId = null; + } + + private failAllPending(message: string): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.resolve(failure('host-unavailable', message)); + } + this.pending.clear(); + } + + private failDispatch(commandId: string): void { + const pending = this.pending.get(commandId); + if (!pending) return; + this.pending.delete(commandId); + clearTimeout(pending.timer); + pending.resolve(failure('host-unavailable', 'the browser host connection is closed')); + } + + private broadcastAvailability(): void { + this.transport.send( + createWireMessage({ kind: 'browser.host.changed', available: this.available }), + ); + } +} + +function failure(code: 'host-unavailable' | 'timeout', message: string): BrowserCommandResult { + return { ok: false, error: { code, message, retryable: true } }; +} diff --git a/packages/host/engine/src/browser/repl-host.ts b/packages/host/engine/src/browser/repl-host.ts new file mode 100644 index 00000000..ec581a65 --- /dev/null +++ b/packages/host/engine/src/browser/repl-host.ts @@ -0,0 +1,141 @@ +import type { Context } from 'node:vm'; +import { createContext, Script } from 'node:vm'; +import type { BrowserCommandArgs, BrowserCommandResult, BrowserOp } from '@linkcode/schema'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { noop } from 'foxts/noop'; +import { rewriteForPersistentRepl } from './repl-rewrite'; + +/** Bound on the synchronous portion of an execute call (everything up to the first `await`, + * or the whole script if it never awaits) — enforced by the vm `timeout` option, which can + * interrupt a `while (true) {}` that would otherwise block the daemon event loop. */ +const SYNC_TIMEOUT_MS = 5000; +/** Bound on the whole execute call. Generous enough for several sequential broker ops (each + * already capped at 15s by the broker), tight enough that a never-settling promise fails the + * tool call instead of wedging the session. */ +const EXECUTE_TIMEOUT_MS = 60000; + +export type BrowserOpDispatcher = ( + op: BrowserOp, + args: BrowserCommandArgs, +) => Promise; + +export interface BrowserExecuteResult { + ok: boolean; + value?: unknown; + logs: string[]; + error?: string; +} + +/** Behavior discipline embedded in the execute tool description (the Codex lesson: the model + * must read the rules before driving the page). */ +export const BROWSER_TOOL_DOCUMENTATION = `Run JavaScript in a persistent REPL that controls the user's in-app browser (you and the user share the same visible tabs). Variables persist across calls. Await every browser.* call. console.log output is returned alongside the completion value. + +API (all methods return promises; tab ids come from browser.tabs()): +- browser.tabs() → [{ id, url, title, active }] +- browser.open(url) → open a NEW tab (http/https only) and return the tab list +- browser.select(tabId) / browser.close(tabId) +- browser.navigate(tabId, url) / browser.back(tabId) / browser.reload(tabId) +- browser.snapshot(tabId) → { url, title, nodes: [{ ref, role, name, value? }], truncated } — the interactive elements +- browser.click(tabId, ref) / browser.type(tabId, ref, text) — ref is a "@eN" ref from the LAST snapshot +- browser.screenshot(tabId) → { mimeType, base64 } (also brings the tab forward) +- browser.evaluate(tabId, js) → run JS inside the page and return its JSON-serializable result + +Discipline: +1. ALWAYS take browser.snapshot(tabId) before click/type — refs only exist after a snapshot. +2. Refs go stale on ANY navigation (including ones your click caused). On a stale-ref error, re-snapshot and re-resolve the element; never retry a stale ref. +3. After an action that loads a page, snapshot again before the next interaction. +4. Prefer snapshot+refs over evaluate for interaction; use evaluate for reading page data. +5. Errors are thrown with a closed code prefix (e.g. "host-unavailable: …" when the desktop app is not running). +6. Each execute call must settle within 60s (individual browser.* calls within 15s) — keep scripts short and check intermediate results instead of writing one long script.`; + +export interface BrowserReplHostOptions { + syncTimeoutMs?: number; + executeTimeoutMs?: number; +} + +/** + * Code-mode host (CODE-267, reviving the sky-mcp REPL skeleton): runs model-authored JS in a + * persistent vm context with only a `browser` namespace and a captured `console` injected. + * Top-level declarations are rewritten onto the context global (`rewriteForPersistentRepl`), + * so bindings persist across `execute` calls and the model can bind tab ids once. + * + * SECURITY: `node:vm` is NOT a security boundary — model code is semi-trusted, and effects are + * gated by the execute tool's approval flow plus the default-off feature gate, never here. + * The timeouts bound runaway code, with one in-process gap: a synchronous infinite loop AFTER + * an `await` resumes as a microtask outside the vm timeout and still blocks the event loop; + * closing that needs worker isolation, which the broker topology doesn't justify yet. + */ +export class BrowserReplHost { + /** Embedded verbatim in the execute tool's description (agent-adapter `BrowserToolset`). */ + readonly documentation = BROWSER_TOOL_DOCUMENTATION; + private readonly vmContext: Context; + private readonly logs: string[] = []; + private readonly syncTimeoutMs: number; + private readonly executeTimeoutMs: number; + + constructor(dispatch: BrowserOpDispatcher, options?: BrowserReplHostOptions) { + const capturedConsole = { + log: (...args: unknown[]): void => { + this.logs.push(args.map(String).join(' ')); + }, + }; + this.vmContext = createContext({ + browser: buildBrowserNamespace(dispatch), + console: capturedConsole, + }); + this.syncTimeoutMs = options?.syncTimeoutMs ?? SYNC_TIMEOUT_MS; + this.executeTimeoutMs = options?.executeTimeoutMs ?? EXECUTE_TIMEOUT_MS; + } + + async execute(code: string): Promise { + this.logs.length = 0; + let timer: NodeJS.Timeout | undefined; + try { + const script = new Script(rewriteForPersistentRepl(code)); + const completion = Promise.resolve( + script.runInContext(this.vmContext, { timeout: this.syncTimeoutMs }) as unknown, + ); + // A rejection landing after the race is lost must not become an unhandled rejection. + completion.catch(noop); + const expiry = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`timeout: execute did not settle within ${this.executeTimeoutMs}ms`)); + }, this.executeTimeoutMs); + }); + const value = await Promise.race([completion, expiry]); + return { ok: true, value, logs: [...this.logs] }; + } catch (error) { + return { + ok: false, + logs: [...this.logs], + error: extractErrorMessage(error) ?? 'unknown error', + }; + } finally { + clearTimeout(timer); + } + } +} + +/** The `browser` object injected into the REPL; each method is one broker op. A failed result + * becomes a thrown Error carrying the closed code, so model code can catch or surface it. */ +function buildBrowserNamespace(dispatch: BrowserOpDispatcher) { + const call = async (op: BrowserOp, args: BrowserCommandArgs): Promise => { + const result = await dispatch(op, args); + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`); + return result.data; + }; + return { + tabs: () => call('tabs.list', {}), + open: (url: string) => call('tabs.open', { url }), + select: (tabId: string) => call('tabs.select', { tabId }), + navigate: (tabId: string, url: string) => call('tab.navigate', { tabId, url }), + back: (tabId: string) => call('tab.back', { tabId }), + reload: (tabId: string) => call('tab.reload', { tabId }), + close: (tabId: string) => call('tab.close', { tabId }), + snapshot: (tabId: string) => call('tab.snapshot', { tabId }), + click: (tabId: string, ref: string) => call('tab.click', { tabId, ref }), + type: (tabId: string, ref: string, text: string) => call('tab.type', { tabId, ref, text }), + screenshot: (tabId: string) => call('tab.screenshot', { tabId }), + evaluate: (tabId: string, js: string) => call('tab.evaluate', { tabId, js }), + }; +} diff --git a/packages/host/engine/src/browser/repl-rewrite.ts b/packages/host/engine/src/browser/repl-rewrite.ts new file mode 100644 index 00000000..4e1de599 --- /dev/null +++ b/packages/host/engine/src/browser/repl-rewrite.ts @@ -0,0 +1,97 @@ +import type { Pattern, Program } from 'acorn'; +import { parse } from 'acorn'; + +/** + * Rewrites model-authored REPL code so top-level bindings survive across `execute` calls + * (the Node REPL top-level-await strategy, `internal/repl/await.js`): the code still runs + * inside an async IIFE so `await` works, but top-level `const`/`let`/`var`/`function`/`class` + * declarations are converted to assignments against hoisted `var`s declared OUTSIDE the + * wrapper — script-level `var` in a vm context lands on the context global, so the next + * `execute` call sees the binding. + * + * Code acorn cannot parse is returned wrapped but untouched, so the vm surfaces the real + * syntax error to the model instead of a transform error. + */ +export function rewriteForPersistentRepl(code: string): string { + let program: Program; + try { + program = parse(code, { + ecmaVersion: 'latest', + sourceType: 'script', + allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, + }); + } catch { + return wrap(code); + } + + const hoisted: string[] = []; + const edits: Array<{ start: number; end: number; text: string }> = []; + + for (const node of program.body) { + switch (node.type) { + case 'VariableDeclaration': { + if (node.kind === 'using' || node.kind === 'await using') break; + const assignments = node.declarations.map((d) => { + collectPatternNames(d.id, hoisted); + const target = code.slice(d.id.start, d.id.end); + return d.init + ? `(${target} = ${code.slice(d.init.start, d.init.end)})` + : `(${target} = undefined)`; + }); + edits.push({ start: node.start, end: node.end, text: `void (${assignments.join(', ')});` }); + break; + } + case 'FunctionDeclaration': + case 'ClassDeclaration': { + hoisted.push(node.id.name); + edits.push({ + start: node.start, + end: node.end, + text: `${node.id.name} = (${code.slice(node.start, node.end)});`, + }); + break; + } + default: + break; + } + } + + let rewritten = code; + for (const edit of edits.reverse()) { + rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end); + } + const prelude = hoisted.length > 0 ? `var ${hoisted.join(', ')};\n` : ''; + return prelude + wrap(rewritten); +} + +function wrap(body: string): string { + return `(async () => {\n${body}\n})()`; +} + +function collectPatternNames(pattern: Pattern, names: string[]): void { + switch (pattern.type) { + case 'Identifier': + names.push(pattern.name); + break; + case 'ObjectPattern': + for (const property of pattern.properties) { + collectPatternNames(property.type === 'RestElement' ? property : property.value, names); + } + break; + case 'ArrayPattern': + for (const element of pattern.elements) { + if (element) collectPatternNames(element, names); + } + break; + case 'AssignmentPattern': + collectPatternNames(pattern.left, names); + break; + case 'RestElement': + collectPatternNames(pattern.argument, names); + break; + default: + // MemberExpression targets have no binding to hoist. + break; + } +} diff --git a/packages/host/engine/src/browser/request-handler.ts b/packages/host/engine/src/browser/request-handler.ts new file mode 100644 index 00000000..52057285 --- /dev/null +++ b/packages/host/engine/src/browser/request-handler.ts @@ -0,0 +1,64 @@ +import type { WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { Effect } from 'effect'; +import type { BrowserBrokerService } from './broker'; + +type BrowserRequest = Extract< + WirePayload, + { + kind: + | 'browser.host.register' + | 'browser.host.detached' + | 'browser.command.result' + | 'browser.execute'; + } +>; + +export class BrowserRequestHandler { + constructor( + private readonly transport: Transport, + private readonly broker: BrowserBrokerService, + ) {} + + handle(payload: BrowserRequest): Effect.Effect { + switch (payload.kind) { + case 'browser.host.register': + return Effect.sync(() => { + this.broker.registerHost(payload.hostId); + }).pipe( + Effect.andThen( + Effect.promise(() => + Promise.resolve( + this.transport.send( + createWireMessage({ kind: 'request.succeeded', replyTo: payload.clientReqId }), + ), + ), + ), + ), + ); + case 'browser.host.detached': + return Effect.sync(() => this.broker.detachHost(payload.hostId)); + case 'browser.command.result': + return Effect.sync(() => this.broker.settle(payload.commandId, payload.result)); + case 'browser.execute': + return Effect.promise(() => this.broker.dispatch(payload.op, payload.args)).pipe( + Effect.flatMap((result) => + Effect.promise(() => + Promise.resolve( + this.transport.send( + createWireMessage({ + kind: 'browser.executed', + replyTo: payload.clientReqId, + result, + }), + ), + ), + ), + ), + ); + default: + return Effect.void; + } + } +} diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index fbb7ec71..de3bd447 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -57,4 +57,6 @@ export interface EngineDeps { scheduleStore?: ScheduleStore; /** Durable store for loops; the in-memory default keeps bare engines and tests dependency-free. */ loopStore?: LoopStore; + /** Enable agent browser code-mode tools. Default-off until the desktop host registers. */ + browserToolsEnabled?: boolean; } diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 1074df38..7b200f16 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -16,6 +16,9 @@ import { ScheduleService, } from './automation'; import { AutomationRequestHandler } from './automation/request-handler'; +import { BrowserBrokerService } from './browser/broker'; +import { BrowserReplHost } from './browser/repl-host'; +import { BrowserRequestHandler } from './browser/request-handler'; import type { EngineDeps } from './deps'; import type { EngineFailure, OperationSubsystem } from './failure'; import { toOperationFailure } from './failure'; @@ -88,6 +91,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( // it here; the engine owns the session registry, so it hands the service a session-existence // predicate that gates claims on a live session. const simulators = deps.simulators; + const browserBroker = new BrowserBrokerService(transport); const sessions = new SessionOrchestrator( transport, factory, @@ -100,6 +104,9 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( simulators?.releaseSession(sessionId); deps.simulatorMcp?.release(sessionId); }, + deps.browserToolsEnabled + ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) + : undefined, ); simulators?.setSessionValidator((id) => sessions.has(id)); terminals = deps.ptyBackend @@ -190,6 +197,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( responder, factory, ); + const browserRequests = new BrowserRequestHandler(transport, browserBroker); const requests = new WireRequestRouter(transport, { session: sessionRequests, history: historyRequests, @@ -203,6 +211,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( automation: automationRequests, terminal: terminalRequests, simulator: simulatorRequests, + browser: browserRequests, }); let acceptingRequests = false; let unsubscribeRequests: Unsubscribe | undefined; @@ -303,6 +312,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( yield* finalize('agent-login.shutdown', () => logins?.closeAll()); yield* finalize('translator.shutdown', () => translator?.closeAll()); yield* finalize('assets.shutdown', () => assets.close()); + yield* finalize('browser.shutdown', () => browserBroker.shutdown()); // Session teardown can enqueue best-effort record persistence into the root set. All // producers are closed now, so a final clear leaves no Engine-owned fibers behind. yield* FiberSet.clear(taskSet); diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index ee5844f8..e0625152 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -1,4 +1,4 @@ -import type { AdapterFactory, AgentAdapter } from '@linkcode/agent-adapter'; +import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkcode/agent-adapter'; import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentInput, @@ -34,6 +34,7 @@ export class SessionOrchestrator { private readonly scope: Scope.Scope, reportFailure: (effect: Effect.Effect) => void, private readonly onStopped: (sessionId: SessionId) => void, + private readonly browserTools?: BrowserToolsetFactory, ) { this.events = new SessionEventProcessor(transport, records, runtimes, reportFailure); this.inputs = new SessionInputDispatcher(records, this.events); @@ -162,12 +163,14 @@ export class SessionOrchestrator { startAdapter: (adapter: AgentAdapter) => Effect.Effect, ): Effect.Effect { const { events, factory, records, runtimes, scope: parentScope, sessions, transport } = this; + const { browserTools } = this; const discardFailedStart = (session: LiveSession): Effect.Effect => this.discardFailedStart(record.sessionId, session); return observeOperation( Effect.gen(function* () { const sessionId = record.sessionId; const adapter = factory(record.kind); + if (browserTools) adapter.attachBrowserTools?.(browserTools); const scope = yield* Scope.fork(parentScope); const closed = yield* Deferred.make(); const session = new LiveSession(adapter, sessionId, scope, closed); diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 6c88886e..7fade564 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -5,6 +5,7 @@ import { Effect } from 'effect'; import type { AgentRequestHandler } from '../agent/request-handler'; import type { ManagedAssetService } from '../asset/service'; import type { AutomationRequestHandler } from '../automation/request-handler'; +import type { BrowserRequestHandler } from '../browser/request-handler'; import type { GitRequestHandler } from '../git/request-handler'; import { observeRequest } from '../observability'; import type { ArtifactRequestHandler } from '../preview/request-handler'; @@ -29,6 +30,7 @@ interface RequestHandlers { readonly automation: AutomationRequestHandler; readonly terminal: TerminalRequestHandler; readonly simulator: SimulatorRequestHandler; + readonly browser: BrowserRequestHandler; } export class WireRequestRouter { @@ -159,6 +161,12 @@ export class WireRequestRouter { case 'agent-login.cancel': { return this.handlers.agent.handle(p); } + case 'browser.host.register': + case 'browser.host.detached': + case 'browser.command.result': + case 'browser.execute': { + return this.handlers.browser.handle(p); + } case 'ping': { return Effect.sync(() => this.transport.send(createWireMessage({ kind: 'pong' }))); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a55361bf..4a6b40bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1026,6 +1026,9 @@ importers: '@linkcode/transport': specifier: workspace:* version: link:../../foundation/transport + acorn: + specifier: ^8.17.0 + version: 8.17.0 croner: specifier: ^10.0.1 version: 10.0.1