diff --git a/.tmp-agent-handoff.md b/.tmp-agent-handoff.md new file mode 100644 index 00000000000..b96cee2fbcc --- /dev/null +++ b/.tmp-agent-handoff.md @@ -0,0 +1,174 @@ +# Agent Handoff: Ryco In-App Browser Computer-Use + +## Repo / branch + +- **Path:** `/Users/laurinfrank/.ryco/worktrees/0ed0fd07-3a8e-4874-8a7d-f15a17a80f5a/feature-add-in-app-browser-usability__budop` +- **Branch:** `feature/add-in-app-browser-usability` +- **Status:** ~56 modified/new files, **nothing committed yet** + +## Goal + +Finish the in-app browser so agents can do real **computer use** (open browser → observe page → click/type → verify) through Ryco's isolated Electron browser, across providers (Codex, Claude, Copilot, OpenCode, Cursor). + +Design spec: `docs/superpowers/specs/2026-06-22-in-app-browser-design.md` + +**AGENTS.md requirements before done:** `bun fmt`, `bun lint`, `bun typecheck`, `bun run test` (not `bun test`). + +--- + +## What's already implemented + +### Desktop host (`apps/desktop/src/browser/`) + +- `BrowserKernel.ts`: navigation, input (x/y + **DOM ref click**), screenshot (`capturePage`), structured DOM snapshot (refs + bounds), console/network buffers +- `BrowserObservationHelpers.ts`: DOM snapshot script, console parsing +- `BrowserHostConnection.ts`: `screenshots: true` capability +- `BrowserSurfaceManager.ts`: HiDPI bounds via `resolveElectronSurfaceBounds` from `@ryco/shared/browser` + +### Server control plane (`apps/server/src/browser/`) + +- `BrowserService.ts`: observation methods (`snapshotDom`, `screenshot`, `readConsole`, `readNetwork`, `waitFor`), agent session cleanup (`closeAgentSessionsForThread`), origin policy enforcement (`source: "agent"` blocks `"ask"` origins) +- `BrowserArtifactStore.ts`: PNG/JSON blob storage under `{stateDir}/browser-artifacts/` with `readData` +- `BrowserMcpBridge.ts` + `browser-mcp-stdio.ts` + `BrowserRuntimeMcpConfig.ts`: MCP bridge for OpenCode/Cursor subprocesses +- `BrowserThreadCleanup.ts`: stops MCP bridge + closes agent sessions after provider turn +- `server.ts`: wires `BrowserLayerLive` (mergeAll: HostRegistry, Policy, ArtifactStore, McpBridge, BrowserService, ProviderRuntimeToolRegistry) + `ProviderRuntimeEventHubLive` in ProviderLayer + +### Provider tool bridge (`apps/server/src/provider/tools/`) + +- `BrowserRuntimeTool.ts`: **12 tools** — `browser_open`, `browser_navigate`, back/forward/reload/stop, `browser_input`, `browser_snapshot`, `browser_screenshot`, `browser_console`, `browser_network`, `browser_wait_for` +- `ProviderRuntimeToolRegistry.ts`: executes tools, emits `browser_tool_call` lifecycle events via `ProviderRuntimeEventHub` +- `BrowserRuntimeToolHelpers.ts`: result mapping + **PNG image content blocks** for screenshots (`enrichBrowserRuntimeToolCallToolResult`) +- `BrowserToolLifecycleEvents.ts`: canonical `browser_tool_call` started/completed events + +### Provider integrations + +| Provider | Status | How | +|----------|--------|-----| +| **Claude** | ✅ wired | `ClaudeBrowserRuntimeTools.ts` — in-process MCP server `ryco`, auto-allow in `canUseTool` | +| **Copilot** | ✅ wired | `CopilotBrowserRuntimeTools.ts` — `defineTool`, negative system prompt removed | +| **OpenCode** | ✅ wired | MCP bridge at session start | +| **Cursor/ACP** | ✅ wired | Ryco browser MCP injected into `mcpServers` via `AcpSessionRuntime` | +| **Codex** | ❌ blocked | `item/tool/call` handler in `CodexSessionRuntime.ts` exists, but `resolveProviderBrowserToolSupport("codex")` returns **`supported: false`** — Codex app-server has no `dynamicTools` field on thread start yet | + +### Web UI (`apps/web/src/components/`) + +- `BrowserPanel.tsx`: thread profile mode, agent session badge, permission/download toasts, HiDPI surface bounds +- `BrowserPanel.logic.ts`: session adoption helpers, tests extended + +### Contracts (`packages/contracts/src/browser.ts`, `rpc.ts`) + +- `BrowserInputAction` supports `{ type: "click", ref }` in addition to x/y +- Observation result schemas, WS RPC methods for snapshot/screenshot/console/network/waitFor + +### Tests added/updated (passing in targeted runs) + +- `BrowserService.test.ts`, `BrowserArtifactStore.test.ts`, `BrowserRuntimeTool.test.ts`, `BrowserRuntimeToolHelpers.test.ts`, `ClaudeBrowserRuntimeTools.test.ts`, `BrowserPanel.logic.test.ts` +- Adapter test layer mocks via `BrowserRuntimeToolTestLayers.ts` + +--- + +## What's NOT done / known gaps + +1. **`bun typecheck` fails** — production code is clean (0 non-test errors), but ~168 errors in `apps/server/src/server.test.ts` and `apps/server/src/cli.test.ts` (Effect `unknown` in requirements channel from new browser layers). **AGENTS.md requires full typecheck pass.** + +2. **Full test suite not re-verified** after last fixes — targeted browser tests pass; full `bun run test` in `apps/server` was interrupted. A bad `Layer.mergeAll` in `server.test.ts` briefly broke 100 tests; fixed back to `BrowserServiceLive.pipe(Layer.provideMerge(...), Layer.provideMerge(browserRuntimeToolTestLayers))`. + +3. **Codex non-functional** — inbound `item/tool/call` handler is dead code until Codex schema supports advertising tools at thread start. Either regenerate `effect-codex-app-server` when upstream adds `dynamicTools`, or keep `supported: false` with a clear reason. + +4. **Origin approval UX missing** — agent navigation to `"ask"` origins hard-fails with `origin_denied`; no `browser_origin_approval` request.opened/resolved flow in UI. + +5. **Single-tab / no popups** — `BrowserKernel` only drives `tabs[0]`; `window.open` denied. OAuth/popup flows break. + +6. **Session lifecycle** — cleanup on turn end exists, but panel unmount doesn't call `closeSession`; no TTL for abandoned WebContentsViews. + +7. **`runServer` uses `as` cast** in `server.ts` (line ~495) instead of `satisfies` — layer graph leaks `unknown` at type level; runtime works. + +8. **Timeline UI** — `browser_tool_call` events emitted server-side; orchestration/timeline rendering may still be incomplete. + +9. **Nothing committed** — user has not asked for a commit yet. + +--- + +## Your task: finish to merge-ready + +Follow `AGENTS.md`: `bun fmt`, `bun lint`, `bun typecheck`, `bun run test` must all pass before done. + +### Priority 0 — Must do before merge + +- [ ] Fix ~168 test typecheck errors in `server.test.ts` / `cli.test.ts`: + - Likely fix: align `buildAppUnderTest` browser layer with production pattern OR use `browserRuntimeToolTestLayers` mocks consistently + - Do **not** use `Layer.mergeAll` for browser services in tests without proper `Layer.provide` — caused `Service not found: BrowserHostRegistry` +- [ ] Run `bun fmt && bun lint && bun typecheck && bun run test` — all must pass +- [ ] Verify no accidental file loss from git stash/checkout during prior session (confirm `BrowserService.ts` ~994 lines, `BrowserArtifactStore.ts` has `readData` + fs blobs) + +### Priority 1 — Strongly recommended + +- [ ] Re-run full server test suite (1440+ tests) and fix failures +- [ ] Manual smoke: desktop app → open Browser panel → agent on Claude drives `browser_open` → `browser_snapshot` → ref click → `browser_screenshot` +- [ ] Clean up `runServer` typing if possible without breaking launch layer contract in `server.ts:493` + +### Priority 2 — Nice to have / follow-up + +- [ ] Codex: investigate `effect-codex-app-server` for dynamic tool registration; flip `resolveProviderBrowserToolSupport("codex")` when possible +- [ ] Origin approval UI for agent `"ask"` navigations (`browser_origin_approval` request type exists in contracts) +- [ ] Multi-tab / controlled popup support for OAuth +- [ ] Timeline UI for `browser_tool_call` items in chat + +--- + +## Key files to read first + +``` +apps/server/src/server.ts # layer wiring +apps/server/src/browser/BrowserService.ts # observation + policy +apps/server/src/provider/tools/BrowserRuntimeTool.ts +apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts +apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts +apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts +apps/server/src/provider/Layers/CodexSessionRuntime.ts +apps/server/src/browser/BrowserMcpBridge.ts +apps/desktop/src/browser/BrowserKernel.ts +apps/web/src/components/BrowserPanel.tsx +apps/server/src/server.test.ts # fix test layer + typecheck (~line 610 browserLayer, ~line 419 buildAppUnderTest) +docs/superpowers/specs/2026-06-22-in-app-browser-design.md +docs/superpowers/plans/2026-06-22-in-app-browser.md +``` + +--- + +## Architecture (quick ref) + +``` +Agent tool call + → Provider adapter (Claude MCP / Copilot defineTool / OpenCode+Cursor MCP bridge / Codex item/tool/call) + → ProviderRuntimeToolRegistry.executeBrowserTool + → BrowserRuntimeTool → BrowserService + → BrowserHostRegistry → Desktop BrowserKernel (WebContentsView) + → observation back (snapshot/screenshot/console) via BrowserArtifactStore + → browser_tool_call lifecycle events → ProviderRuntimeEventHub +``` + +Two channels: + +- **UI**: `browser.*` WS RPC + `desktopBridge.browser` surface IPC +- **Agents**: provider-specific MCP/tools → shared registry → same `BrowserService` + +--- + +## Pitfalls from prior session + +- **Don't `git stash push -u` carelessly** — lost untracked files twice +- **Don't use `Layer.mergeAll` for browser layer in tests** without dependency ordering — use `BrowserServiceLive.pipe(Layer.provideMerge(...))` pattern +- **`BrowserServiceLive` hard `yield* BrowserArtifactStore` at layer init** caused `unknown` leak — use `Effect.serviceOption` at call time in methods, not layer init (partially done) +- **`server.test.ts` browser layer** should use `browserRuntimeToolTestLayers` mock, not live `BrowserMcpBridgeLive` +- **`runServer satisfies`** fails due to layer `unknown` — production uses `as` cast as workaround + +--- + +## Success criteria + +- [ ] `bun fmt` / `bun lint` / `bun typecheck` / `bun run test` all green +- [ ] Claude (and ideally Copilot/Cursor/OpenCode) can drive browser tools on desktop +- [ ] Codex either works or is explicitly documented/typed as unsupported with clear reason +- [ ] No regressions in `server.test.ts` integration tests +- [ ] User decides when to commit (don't commit unless asked) diff --git a/apps/desktop/src/browser/BrowserHost.ts b/apps/desktop/src/browser/BrowserHost.ts new file mode 100644 index 00000000000..5e7aa81d45b --- /dev/null +++ b/apps/desktop/src/browser/BrowserHost.ts @@ -0,0 +1,22 @@ +import { BrowserHostConnection } from "./BrowserHostConnection.ts"; +import { BrowserKernel } from "./BrowserKernel.ts"; + +export class BrowserHost { + readonly kernel = new BrowserKernel(); + readonly connection: BrowserHostConnection; + + constructor(appRunId: string) { + this.connection = new BrowserHostConnection(appRunId, (command) => + this.kernel.execute(command), + ); + this.kernel.setEventSink((event) => this.connection.publishEvent(event)); + } + + start(input: { readonly wsBaseUrl: string; readonly token: string }): Promise { + return this.connection.start(input); + } + + stop(): Promise { + return this.connection.stop(); + } +} diff --git a/apps/desktop/src/browser/BrowserHostConnection.ts b/apps/desktop/src/browser/BrowserHostConnection.ts new file mode 100644 index 00000000000..5a027f9ee54 --- /dev/null +++ b/apps/desktop/src/browser/BrowserHostConnection.ts @@ -0,0 +1,178 @@ +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + BrowserHostId, + BrowserHostRpcGroup, + BrowserHostRunId, + BROWSER_HOST_METHODS, +} from "@ryco/contracts"; +import type { + BrowserEvent, + BrowserHostCommandEnvelope, + BrowserCommandResult, +} from "@ryco/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +const makeBrowserHostRpcClient = RpcClient.make(BrowserHostRpcGroup); +type BrowserHostRpcClient = + typeof makeBrowserHostRpcClient extends Effect.Effect ? Client : never; + +type CommandHandler = (command: BrowserHostCommandEnvelope) => Promise; + +function browserHostWsUrl(wsBaseUrl: string): string { + const url = new URL(wsBaseUrl); + url.pathname = "/browser-host/ws"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function browserHostProtocolLayer( + wsUrl: string, + token: string, +): Layer.Layer { + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => + new NodeSocket.NodeWS.WebSocket(socketUrl, protocols, { + headers: { + authorization: `Bearer ${token}`, + }, + }) as unknown as globalThis.WebSocket, + ); + + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(wsUrl).pipe(Layer.provide(webSocketConstructorLayer))), + Layer.provide(RpcSerialization.layerJson), + ); +} + +export class BrowserHostConnection { + private runtime: ManagedRuntime.ManagedRuntime | null = null; + private client: BrowserHostRpcClient | null = null; + private clientScope: Scope.Scope | null = null; + private heartbeatTimer: ReturnType | null = null; + private commandStreamAbort = false; + private readonly handleCommand: CommandHandler; + + readonly hostId: BrowserHostId; + readonly runId: BrowserHostRunId; + + constructor(appRunId: string, handleCommand: CommandHandler) { + this.handleCommand = handleCommand; + this.hostId = BrowserHostId.make(`desktop-browser-host:${appRunId}`); + this.runId = BrowserHostRunId.make(`desktop-browser-host-run:${crypto.randomUUID()}`); + } + + async start(input: { readonly wsBaseUrl: string; readonly token: string }): Promise { + await this.stop(); + this.commandStreamAbort = false; + const runtime = ManagedRuntime.make( + browserHostProtocolLayer(browserHostWsUrl(input.wsBaseUrl), input.token), + ); + this.runtime = runtime; + const clientScope = runtime.runSync(Scope.make()); + this.clientScope = clientScope; + const client = await runtime.runPromise(Scope.provide(clientScope)(makeBrowserHostRpcClient)); + this.client = client; + await runtime.runPromise( + client[BROWSER_HOST_METHODS.register]({ + hostId: this.hostId, + runId: this.runId, + capabilities: { + surface: true, + persistentProfiles: true, + temporaryProfiles: true, + screenshots: true, + domSnapshot: true, + input: true, + downloads: false, + devtools: false, + }, + }), + ); + this.startCommandStream(client); + this.heartbeatTimer = setInterval(() => { + void this.heartbeat().catch((error) => { + console.warn("[desktop-browser-host] heartbeat failed", error); + }); + }, 5_000); + this.heartbeatTimer.unref(); + } + + async stop(): Promise { + this.commandStreamAbort = true; + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + const runtime = this.runtime; + const clientScope = this.clientScope; + this.runtime = null; + this.client = null; + this.clientScope = null; + if (runtime) { + if (clientScope) { + await runtime.runPromise(Scope.close(clientScope, Exit.void)).catch(() => undefined); + } + await runtime.dispose(); + } + } + + async publishEvent(event: BrowserEvent): Promise { + const runtime = this.runtime; + const client = this.client; + if (!runtime || !client) return; + await runtime.runPromise( + client[BROWSER_HOST_METHODS.event]({ + hostId: this.hostId, + runId: this.runId, + event, + }), + ); + } + + private async heartbeat(): Promise { + const runtime = this.runtime; + const client = this.client; + if (!runtime || !client) return; + await runtime.runPromise( + client[BROWSER_HOST_METHODS.heartbeat]({ + hostId: this.hostId, + runId: this.runId, + }), + ); + } + + private startCommandStream(client: BrowserHostRpcClient): void { + const runtime = this.runtime; + if (!runtime) return; + void runtime + .runPromise( + Stream.runForEach( + client[BROWSER_HOST_METHODS.subscribeCommands]({ + hostId: this.hostId, + runId: this.runId, + }), + (envelope) => + Effect.promise(async () => { + if (this.commandStreamAbort) return; + const result = await this.handleCommand(envelope); + await runtime.runPromise( + client[BROWSER_HOST_METHODS.commandResult]({ + hostId: this.hostId, + runId: this.runId, + result, + }), + ); + }), + ), + ) + .catch((error) => { + if (!this.commandStreamAbort) { + console.warn("[desktop-browser-host] command stream closed", error); + } + }); + } +} diff --git a/apps/desktop/src/browser/BrowserKernel.ts b/apps/desktop/src/browser/BrowserKernel.ts new file mode 100644 index 00000000000..95fe31f17df --- /dev/null +++ b/apps/desktop/src/browser/BrowserKernel.ts @@ -0,0 +1,784 @@ +import { WebContentsView } from "electron"; +import type { WebContents } from "electron"; +import { BrowserTabId } from "@ryco/contracts"; +import type { + BrowserEvent, + BrowserHostCommandEnvelope, + BrowserCommandResult, + BrowserCommandResultPayload, + BrowserCookieDeleteResult, + BrowserDomBounds, + BrowserDomNode, + BrowserInputAction, + BrowserProfile, + BrowserSessionSnapshot, + BrowserStorageClearResult, + BrowserStorageDataType, + BrowserStorageEntryMetadata, + BrowserStorageInspectionResult, + BrowserTabSnapshot, +} from "@ryco/contracts"; + +import { BrowserProfiles } from "./BrowserProfiles.ts"; +import { + browserCookieMetadata, + browserCookieRemovalUrl, + canUseBrowserCookieUrl, + electronStorageTypes, + sanitizeBrowserStorageEntries, +} from "./BrowserStorageHelpers.ts"; +import { + BROWSER_CONSOLE_BUFFER_LIMIT, + BROWSER_DOM_SNAPSHOT_SCRIPT, + BROWSER_MAX_SCREENSHOT_BYTES, + BROWSER_NETWORK_BUFFER_LIMIT, + type BufferedConsoleEntry, + type BufferedNetworkEntry, + parseConsoleMessage, + pushBounded, +} from "./BrowserObservationHelpers.ts"; + +type EventSink = (event: BrowserEvent) => Promise | void; + +interface HostedSession { + session: BrowserSessionSnapshot; + view: WebContentsView; + consoleEntries: BufferedConsoleEntry[]; + networkEntries: BufferedNetworkEntry[]; + networkRequestStartedAt: Map; +} + +function commandFailure( + commandId: BrowserHostCommandEnvelope["commandId"], + code: string, + message: string, + retryable = false, +): BrowserCommandResult { + return { + ok: false, + commandId, + error: { + code, + message, + retryable, + }, + }; +} + +function commandSuccess( + commandId: BrowserHostCommandEnvelope["commandId"], + session: BrowserSessionSnapshot, + payload: Omit = {}, +): BrowserCommandResult { + return { + ok: true, + commandId, + result: { + session, + ...payload, + }, + }; +} + +function webContentsNavigation(webContents: WebContents) { + const url = webContents.getURL() || "about:blank"; + let origin: string | null = null; + try { + const parsed = new URL(url); + origin = parsed.origin === "null" ? null : parsed.origin; + } catch { + origin = null; + } + return { + url, + origin, + title: webContents.getTitle() || "New Tab", + loadState: webContents.isLoading() ? "loading" : "loaded", + canGoBack: webContents.navigationHistory?.canGoBack() ?? webContents.canGoBack(), + canGoForward: webContents.navigationHistory?.canGoForward() ?? webContents.canGoForward(), + } satisfies BrowserTabSnapshot["navigation"]; +} + +function parseOrigin(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.origin === "null" ? null : parsed.origin; + } catch { + return null; + } +} + +function uniqueStrings(values: ReadonlyArray): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function flattenDomRefBounds(nodes: ReadonlyArray): Map { + const refs = new Map(); + const walk = (node: BrowserDomNode) => { + if (node.bounds) { + refs.set(node.ref, node.bounds); + } + node.children?.forEach(walk); + }; + for (const node of nodes) { + walk(node); + } + return refs; +} + +function resolveClickCoordinates( + action: Extract, + refs: Map | undefined, +): { readonly x: number; readonly y: number } { + if ("ref" in action) { + const bounds = refs?.get(action.ref); + if (!bounds) { + throw new Error(`Unknown DOM ref '${action.ref}'. Run snapshot_dom first.`); + } + return { + x: Math.round(bounds.x + bounds.width / 2), + y: Math.round(bounds.y + bounds.height / 2), + }; + } + return { x: action.x, y: action.y }; +} + +export class BrowserKernel { + private readonly profiles = new BrowserProfiles(); + private readonly sessions = new Map(); + private readonly domRefCache = new Map>(); + private eventSink: EventSink = () => undefined; + + setEventSink(eventSink: EventSink): void { + this.eventSink = eventSink; + } + + getView(sessionId: string, tabId: string): WebContentsView | null { + const hosted = this.sessions.get(sessionId); + if (!hosted || hosted.session.selectedTabId !== tabId) return null; + return hosted.view; + } + + async execute(envelope: BrowserHostCommandEnvelope): Promise { + const { command, commandId } = envelope; + try { + switch (command.kind) { + case "open_session": + return commandSuccess( + commandId, + await this.openSession(command.session, command.profile, command.initialUrl), + ); + case "close_session": + return commandSuccess(commandId, await this.closeSession(command.sessionId)); + case "navigate": + return commandSuccess( + commandId, + await this.withHosted(command.sessionId, command.tabId, async (hosted) => { + await hosted.view.webContents.loadURL(command.url); + return this.updateSnapshotFromWebContents(hosted); + }), + ); + case "back": + return commandSuccess( + commandId, + await this.navigationAction(command.sessionId, command.tabId, "back"), + ); + case "forward": + return commandSuccess( + commandId, + await this.navigationAction(command.sessionId, command.tabId, "forward"), + ); + case "reload": + return commandSuccess( + commandId, + await this.navigationAction(command.sessionId, command.tabId, "reload"), + ); + case "stop": + return commandSuccess( + commandId, + await this.navigationAction(command.sessionId, command.tabId, "stop"), + ); + case "input": + return commandSuccess( + commandId, + await this.input(command.sessionId, command.tabId, command.action), + ); + case "snapshot_dom": + return commandSuccess( + commandId, + await this.snapshot(command.sessionId, command.tabId), + await this.snapshotDomPayload(command.sessionId, command.tabId), + ); + case "inspect_storage": + return commandSuccess(commandId, await this.snapshot(command.sessionId, command.tabId), { + storageInspection: await this.inspectStorage(command.sessionId, command.tabId), + }); + case "clear_storage": + return commandSuccess(commandId, await this.snapshot(command.sessionId, command.tabId), { + storageClear: await this.clearStorage( + command.sessionId, + command.tabId, + command.scope, + command.dataTypes, + ), + }); + case "delete_cookie": + return commandSuccess(commandId, await this.snapshot(command.sessionId, command.tabId), { + cookieDelete: await this.deleteCookie(command.sessionId, command.tabId, { + ...(command.url ? { url: command.url } : {}), + name: command.name, + ...(command.domain ? { domain: command.domain } : {}), + ...(command.path ? { path: command.path } : {}), + ...(command.secure !== undefined ? { secure: command.secure } : {}), + }), + }); + case "screenshot": + return commandSuccess( + commandId, + await this.snapshot(command.sessionId, command.tabId), + await this.screenshotPayload(command.sessionId, command.tabId), + ); + case "read_console": + return commandSuccess( + commandId, + await this.snapshot(command.sessionId, command.tabId), + await this.consolePayload(command.sessionId, command.tabId), + ); + case "read_network": + return commandSuccess( + commandId, + await this.snapshot(command.sessionId, command.tabId), + await this.networkPayload(command.sessionId, command.tabId), + ); + } + } catch (error) { + return commandFailure( + commandId, + "unsupported_capability", + error instanceof Error ? error.message : "Browser host command failed.", + ); + } + } + + private async openSession( + session: BrowserSessionSnapshot, + profile: BrowserProfile, + initialUrl: string | undefined, + ): Promise { + const tab = session.tabs.find((candidate) => candidate.tabId === session.selectedTabId); + if (!tab) throw new Error("Browser session has no selected tab."); + const browserSession = this.profiles.resolve(profile); + const view = new WebContentsView({ + webPreferences: { + session: browserSession, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + backgroundThrottling: false, + webSecurity: true, + }, + }); + view.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + const hosted: HostedSession = { + session: { ...session, status: "ready" }, + view, + consoleEntries: [], + networkEntries: [], + networkRequestStartedAt: new Map(), + }; + this.sessions.set(session.sessionId, hosted); + this.installWebContentsListeners(hosted); + await view.webContents.loadURL(initialUrl ?? "about:blank").catch(() => undefined); + return this.updateSnapshotFromWebContents(hosted); + } + + private async closeSession(sessionId: string): Promise { + const hosted = this.sessions.get(sessionId); + if (!hosted) throw new Error("Browser session not found."); + this.sessions.delete(sessionId); + for (const key of this.domRefCache.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.domRefCache.delete(key); + } + } + hosted.view.webContents.close({ waitForBeforeUnload: false }); + const closed = { + ...hosted.session, + status: "closed" as const, + updatedAt: new Date().toISOString(), + }; + await this.emit({ + type: "session.closed", + sessionId: closed.sessionId, + createdAt: closed.updatedAt, + }); + return closed; + } + + private async navigationAction( + sessionId: string, + tabId: string, + action: "back" | "forward" | "reload" | "stop", + ): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + if (action === "back" && webContents.canGoBack()) webContents.goBack(); + if (action === "forward" && webContents.canGoForward()) webContents.goForward(); + if (action === "reload") webContents.reload(); + if (action === "stop") webContents.stop(); + return this.updateSnapshotFromWebContents(hosted); + }); + } + + private async input( + sessionId: string, + tabId: string, + action: BrowserInputAction, + ): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + if (action.type === "click") { + const { x, y } = resolveClickCoordinates( + action, + this.domRefCache.get(`${sessionId}:${tabId}`), + ); + webContents.sendInputEvent({ + type: "mouseDown", + x, + y, + button: "left", + clickCount: 1, + }); + webContents.sendInputEvent({ + type: "mouseUp", + x, + y, + button: "left", + clickCount: 1, + }); + } else if (action.type === "type") { + webContents.insertText(action.text); + } else if (action.type === "key") { + webContents.sendInputEvent({ type: "keyDown", keyCode: action.key }); + webContents.sendInputEvent({ type: "keyUp", keyCode: action.key }); + } else if (action.type === "scroll") { + webContents.sendInputEvent({ + type: "mouseWheel", + x: 0, + y: 0, + deltaX: action.deltaX, + deltaY: action.deltaY, + }); + } + return this.updateSnapshotFromWebContents(hosted); + }); + } + + private async snapshot(sessionId: string, tabId: string): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => + this.updateSnapshotFromWebContents(hosted), + ); + } + + private async snapshotDomPayload(sessionId: string, tabId: string) { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + const snapshot = await webContents.executeJavaScript(BROWSER_DOM_SNAPSHOT_SCRIPT, true); + if (snapshot && typeof snapshot === "object" && Array.isArray(snapshot.tree)) { + this.domRefCache.set( + `${sessionId}:${tabId}`, + flattenDomRefBounds(snapshot.tree as ReadonlyArray), + ); + } + const text = await this.readTextFromWebContents(webContents); + return { + data: { + kind: "dom_snapshot" as const, + snapshot, + text, + }, + }; + }); + } + + private async screenshotPayload(sessionId: string, tabId: string) { + return this.withHosted(sessionId, tabId, async (hosted) => { + const image = await hosted.view.webContents.capturePage(); + const png = image.toPNG(); + if (png.byteLength > BROWSER_MAX_SCREENSHOT_BYTES) { + throw new Error("Screenshot exceeds the maximum allowed size."); + } + return { + data: { + kind: "screenshot" as const, + base64: png.toString("base64"), + }, + }; + }); + } + + private async consolePayload(sessionId: string, tabId: string) { + return this.withHosted(sessionId, tabId, async (hosted) => ({ + data: { + kind: "console" as const, + entries: [...hosted.consoleEntries], + }, + })); + } + + private async networkPayload(sessionId: string, tabId: string) { + return this.withHosted(sessionId, tabId, async (hosted) => ({ + data: { + kind: "network" as const, + entries: [...hosted.networkEntries], + }, + })); + } + + private async readTextFromWebContents(webContents: WebContents): Promise { + const text = await webContents.executeJavaScript( + "document.body ? document.body.innerText.slice(0, 20000) : ''", + false, + ); + return typeof text === "string" ? text : ""; + } + + private async readText(sessionId: string, tabId: string): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => + this.readTextFromWebContents(hosted.view.webContents), + ); + } + + private async inspectStorage( + sessionId: string, + tabId: string, + ): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + const url = webContents.getURL() || "about:blank"; + const origin = parseOrigin(url); + const profileCookies = await webContents.session.cookies.get({}); + const currentCookies = canUseBrowserCookieUrl(url) + ? await webContents.session.cookies.get({ url }) + : []; + const storage = await this.readActiveWebStorage(webContents); + const session = this.updateSnapshotFromWebContents(hosted); + + return { + session, + tabId: session.selectedTabId ?? BrowserTabId.make(tabId), + profileId: session.profileId, + url, + origin, + cookies: currentCookies.map(browserCookieMetadata), + localStorage: storage.localStorage, + sessionStorage: storage.sessionStorage, + cookieCounts: { + currentOrigin: currentCookies.length, + profile: profileCookies.length, + }, + inspectedAt: new Date().toISOString(), + }; + }); + } + + private async clearStorage( + sessionId: string, + tabId: string, + scope: "current_origin" | "profile", + dataTypes: ReadonlyArray, + ): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + const browserSession = webContents.session; + const url = webContents.getURL() || "about:blank"; + const origin = parseOrigin(url); + const cleared = new Set(); + + if (scope === "current_origin" && !origin) { + throw new Error("Current browser page does not have a clearable origin."); + } + + if ( + scope === "current_origin" && + dataTypes.includes("cookies") && + canUseBrowserCookieUrl(url) + ) { + const cookies = await browserSession.cookies.get({ url }); + await Promise.all( + cookies.map((cookie) => { + const removalUrl = browserCookieRemovalUrl({ + fallbackUrl: url, + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.path ? { path: cookie.path } : {}), + ...(cookie.secure !== undefined ? { secure: cookie.secure } : {}), + }); + return removalUrl + ? browserSession.cookies.remove(removalUrl, cookie.name).catch(() => undefined) + : Promise.resolve(); + }), + ); + cleared.add("cookies"); + } + + if (scope === "profile" && dataTypes.includes("cookies")) { + await browserSession.clearStorageData({ storages: ["cookies"] }); + cleared.add("cookies"); + } + + const storageTypes = electronStorageTypes(dataTypes); + if (storageTypes.length > 0) { + await browserSession.clearStorageData( + scope === "current_origin" && origin + ? { origin, storages: storageTypes } + : { storages: storageTypes }, + ); + for (const type of dataTypes) { + if (type !== "cookies" && type !== "sessionStorage" && type !== "httpCache") { + cleared.add(type); + } + } + } + + if (dataTypes.includes("sessionStorage")) { + const clearedActiveStorage = await this.clearActiveWebStorage( + webContents, + ["sessionStorage"], + scope === "current_origin" ? origin : null, + ); + if (clearedActiveStorage) cleared.add("sessionStorage"); + } + + if (dataTypes.includes("localStorage")) { + const clearedActiveStorage = await this.clearActiveWebStorage( + webContents, + ["localStorage"], + scope === "current_origin" ? origin : null, + ); + if (clearedActiveStorage) cleared.add("localStorage"); + } + + if (dataTypes.includes("httpCache")) { + if (scope === "current_origin" && origin) { + await browserSession.clearData({ origins: [origin], dataTypes: ["cache"] }); + } else { + await browserSession.clearCache(); + } + cleared.add("httpCache"); + } + + const session = this.updateSnapshotFromWebContents(hosted); + return { + session, + scope, + origin, + clearedDataTypes: [...cleared], + clearedAt: new Date().toISOString(), + }; + }); + } + + private async deleteCookie( + sessionId: string, + tabId: string, + cookie: { + readonly url?: string; + readonly name: string; + readonly domain?: string; + readonly path?: string; + readonly secure?: boolean; + }, + ): Promise { + return this.withHosted(sessionId, tabId, async (hosted) => { + const { webContents } = hosted.view; + const currentUrl = webContents.getURL() || "about:blank"; + const browserSession = webContents.session; + const filter = { + name: cookie.name, + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.path ? { path: cookie.path } : {}), + ...(cookie.secure !== undefined ? { secure: cookie.secure } : {}), + }; + const before = await browserSession.cookies.get(filter); + const candidates = uniqueStrings([ + cookie.url && canUseBrowserCookieUrl(cookie.url) ? cookie.url : null, + browserCookieRemovalUrl({ + fallbackUrl: cookie.url ?? currentUrl, + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.path ? { path: cookie.path } : {}), + ...(cookie.secure !== undefined ? { secure: cookie.secure } : {}), + }), + canUseBrowserCookieUrl(currentUrl) ? currentUrl : null, + ]); + + for (const candidate of candidates) { + await browserSession.cookies.remove(candidate, cookie.name).catch(() => undefined); + } + + const after = await browserSession.cookies.get(filter); + const session = this.updateSnapshotFromWebContents(hosted); + return { + session, + deleted: after.length < before.length, + cookie: { + name: cookie.name, + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.path ? { path: cookie.path } : {}), + ...(cookie.secure !== undefined ? { secure: cookie.secure } : {}), + }, + deletedAt: new Date().toISOString(), + }; + }); + } + + private async readActiveWebStorage(webContents: WebContents): Promise<{ + readonly localStorage: BrowserStorageEntryMetadata[]; + readonly sessionStorage: BrowserStorageEntryMetadata[]; + }> { + try { + const result = await webContents.executeJavaScript( + `(() => { + const read = (storage) => { + const entries = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (typeof key !== "string") continue; + const value = storage.getItem(key) ?? ""; + entries.push({ key, valueBytes: new Blob([value]).size }); + } + return entries; + }; + return { + localStorage: read(window.localStorage), + sessionStorage: read(window.sessionStorage), + }; + })()`, + false, + ); + return { + localStorage: sanitizeBrowserStorageEntries(result?.localStorage), + sessionStorage: sanitizeBrowserStorageEntries(result?.sessionStorage), + }; + } catch { + return { localStorage: [], sessionStorage: [] }; + } + } + + private async clearActiveWebStorage( + webContents: WebContents, + areas: ReadonlyArray<"localStorage" | "sessionStorage">, + expectedOrigin: string | null, + ): Promise { + if (areas.length === 0) return true; + const script = `(() => { + const areas = ${JSON.stringify(areas)}; + const expectedOrigin = ${JSON.stringify(expectedOrigin)}; + if (expectedOrigin !== null && window.location.origin !== expectedOrigin) return false; + if (areas.includes("localStorage")) window.localStorage.clear(); + if (areas.includes("sessionStorage")) window.sessionStorage.clear(); + return true; + })()`; + const result = await webContents.executeJavaScript(script, false).catch(() => false); + return result === true; + } + + private async withHosted( + sessionId: string, + tabId: string, + operation: (hosted: HostedSession) => Promise, + ): Promise { + const hosted = this.sessions.get(sessionId); + if (!hosted || hosted.session.selectedTabId !== tabId) + throw new Error("Browser tab not found."); + return operation(hosted); + } + + private installWebContentsListeners(hosted: HostedSession): void { + const update = () => { + void this.emit({ + type: "session.updated", + session: this.updateSnapshotFromWebContents(hosted), + createdAt: new Date().toISOString(), + }); + }; + const { webContents } = hosted.view; + const browserSession = webContents.session; + webContents.on("did-start-loading", update); + webContents.on("did-stop-loading", update); + webContents.on("page-title-updated", update); + webContents.on("did-navigate", update); + webContents.on("did-navigate-in-page", update); + webContents.on("console-message", (...args: unknown[]) => { + const entry = parseConsoleMessage(args); + if (entry) pushBounded(hosted.consoleEntries, entry, BROWSER_CONSOLE_BUFFER_LIMIT); + }); + const requestFilter = { urls: [""] }; + browserSession.webRequest.onBeforeRequest(requestFilter, (details) => { + hosted.networkRequestStartedAt.set(String(details.id), new Date().toISOString()); + }); + const recordNetworkEntry = (details: { + readonly id: number; + readonly url: string; + readonly method: string; + readonly statusCode?: number; + readonly resourceType?: string; + }) => { + const requestId = String(details.id); + const startedAt = hosted.networkRequestStartedAt.get(requestId) ?? new Date().toISOString(); + hosted.networkRequestStartedAt.delete(requestId); + pushBounded( + hosted.networkEntries, + { + requestId, + url: details.url.slice(0, 8_192), + method: details.method.slice(0, 32), + ...("statusCode" in details && typeof details.statusCode === "number" + ? { status: details.statusCode } + : {}), + ...(details.resourceType + ? { resourceType: String(details.resourceType).slice(0, 128) } + : {}), + startedAt, + completedAt: new Date().toISOString(), + } satisfies BufferedNetworkEntry, + BROWSER_NETWORK_BUFFER_LIMIT, + ); + }; + browserSession.webRequest.onCompleted(requestFilter, recordNetworkEntry); + browserSession.webRequest.onErrorOccurred(requestFilter, recordNetworkEntry); + webContents.on("render-process-gone", (_event, details) => { + const tab = hosted.session.tabs[0]; + if (!tab) return; + void this.emit({ + type: "tab.crashed", + sessionId: hosted.session.sessionId, + tabId: tab.tabId, + reason: details.reason, + createdAt: new Date().toISOString(), + }); + }); + } + + private updateSnapshotFromWebContents(hosted: HostedSession): BrowserSessionSnapshot { + const timestamp = new Date().toISOString(); + const tab = hosted.session.tabs[0]; + if (!tab) throw new Error("Browser session has no tab."); + const nextTab = { + ...tab, + navigation: webContentsNavigation(hosted.view.webContents), + updatedAt: timestamp, + } satisfies BrowserTabSnapshot; + hosted.session = { + ...hosted.session, + status: "ready", + selectedTabId: nextTab.tabId, + tabs: [nextTab], + updatedAt: timestamp, + }; + return hosted.session; + } + + private async emit(event: BrowserEvent): Promise { + await this.eventSink(event); + } +} diff --git a/apps/desktop/src/browser/BrowserObservationHelpers.test.ts b/apps/desktop/src/browser/BrowserObservationHelpers.test.ts new file mode 100644 index 00000000000..32d9ff5603a --- /dev/null +++ b/apps/desktop/src/browser/BrowserObservationHelpers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mapConsoleLevel, parseConsoleMessage, pushBounded } from "./BrowserObservationHelpers.ts"; + +describe("BrowserObservationHelpers", () => { + it("maps legacy console-message arguments", () => { + const entry = parseConsoleMessage([{}, 2, "warn message", 12, "app.js"]); + expect(entry).toMatchObject({ + level: "warning", + message: "warn message", + line: 12, + source: "app.js", + }); + }); + + it("maps structured console-message events", () => { + const entry = parseConsoleMessage([ + { + level: 3, + message: "boom", + lineNumber: 4, + sourceId: "bundle.js", + }, + ]); + expect(entry).toMatchObject({ + level: "error", + message: "boom", + line: 4, + source: "bundle.js", + }); + }); + + it("keeps bounded buffers at the configured limit", () => { + const buffer: number[] = []; + pushBounded(buffer, 1, 2); + pushBounded(buffer, 2, 2); + pushBounded(buffer, 3, 2); + expect(buffer).toEqual([2, 3]); + }); + + it("maps numeric console levels", () => { + expect(mapConsoleLevel(0)).toBe("debug"); + expect(mapConsoleLevel(3)).toBe("error"); + }); +}); diff --git a/apps/desktop/src/browser/BrowserObservationHelpers.ts b/apps/desktop/src/browser/BrowserObservationHelpers.ts new file mode 100644 index 00000000000..df3a9fa107f --- /dev/null +++ b/apps/desktop/src/browser/BrowserObservationHelpers.ts @@ -0,0 +1,225 @@ +export const BROWSER_CONSOLE_BUFFER_LIMIT = 200; +export const BROWSER_NETWORK_BUFFER_LIMIT = 200; +export const BROWSER_DOM_NODE_LIMIT = 400; +export const BROWSER_MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024; + +export const BROWSER_DOM_SNAPSHOT_SCRIPT = `(() => { + const MAX_NODES = ${BROWSER_DOM_NODE_LIMIT}; + let nodeCount = 0; + let truncated = false; + let nextRef = 1; + + const isVisible = (element) => { + if (!(element instanceof Element)) return false; + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 || rect.height > 0; + }; + + const implicitRole = (element) => { + const tag = element.tagName.toLowerCase(); + switch (tag) { + case "a": + return element.hasAttribute("href") ? "link" : "generic"; + case "button": + return "button"; + case "input": { + const type = (element.getAttribute("type") || "text").toLowerCase(); + if (type === "button" || type === "submit" || type === "reset") return "button"; + if (type === "checkbox") return "checkbox"; + if (type === "radio") return "radio"; + return "textbox"; + } + case "select": + return "combobox"; + case "textarea": + return "textbox"; + case "img": + return "img"; + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + return "heading"; + case "ul": + case "ol": + return "list"; + case "li": + return "listitem"; + case "nav": + return "navigation"; + case "main": + return "main"; + case "form": + return "form"; + default: + return tag; + } + }; + + const nodeName = (element) => { + const ariaLabel = element.getAttribute("aria-label"); + if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim().slice(0, 200); + const title = element.getAttribute("title"); + if (title && title.trim()) return title.trim().slice(0, 200); + const alt = element.getAttribute("alt"); + if (alt && alt.trim()) return alt.trim().slice(0, 200); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + const value = element.value?.trim(); + if (value) return value.slice(0, 200); + const placeholder = element.getAttribute("placeholder"); + if (placeholder && placeholder.trim()) return placeholder.trim().slice(0, 200); + } + const text = (element.innerText || element.textContent || "").trim(); + if (text) return text.slice(0, 200); + return undefined; + }; + + const buildNode = (element) => { + if (!(element instanceof Element) || !isVisible(element)) return null; + if (nodeCount >= MAX_NODES) { + truncated = true; + return null; + } + nodeCount += 1; + const ref = "e" + nextRef++; + const rect = element.getBoundingClientRect(); + const node = { + ref, + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role") || implicitRole(element), + name: nodeName(element), + bounds: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + children: [], + }; + for (const child of element.children) { + if (nodeCount >= MAX_NODES) { + truncated = true; + break; + } + const childNode = buildNode(child); + if (childNode) node.children.push(childNode); + } + if (node.children.length === 0) delete node.children; + return node; + }; + + const roots = []; + const body = document.body; + if (body) { + for (const child of body.children) { + const built = buildNode(child); + if (built) roots.push(built); + } + } + + return { + url: location.href, + title: document.title || "", + viewport: { + width: Math.max(1, Math.round(window.innerWidth || 0)), + height: Math.max(1, Math.round(window.innerHeight || 0)), + }, + tree: roots, + truncated, + nodeCount, + }; +})()`; + +export type BrowserConsoleLevel = "debug" | "info" | "warning" | "error" | "verbose"; + +export interface BufferedConsoleEntry { + readonly level: BrowserConsoleLevel; + readonly message: string; + readonly timestamp: string; + readonly source?: string; + readonly line?: number; +} + +export interface BufferedNetworkEntry { + readonly requestId: string; + readonly url: string; + readonly method: string; + readonly status?: number; + readonly resourceType?: string; + readonly startedAt: string; + readonly completedAt?: string; +} + +export function mapConsoleLevel(level: number | string): BrowserConsoleLevel { + if (typeof level === "string") { + const normalized = level.toLowerCase(); + if ( + normalized === "debug" || + normalized === "info" || + normalized === "warning" || + normalized === "error" || + normalized === "verbose" + ) { + return normalized; + } + } + switch (level) { + case 0: + return "debug"; + case 1: + return "info"; + case 2: + return "warning"; + case 3: + return "error"; + default: + return "verbose"; + } +} + +export function parseConsoleMessage(args: ReadonlyArray): BufferedConsoleEntry | null { + const timestamp = new Date().toISOString(); + if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) { + const event = args[0] as { + readonly level?: number | string; + readonly message?: string; + readonly lineNumber?: number; + readonly sourceId?: string; + }; + if (typeof event.message !== "string") return null; + return { + level: mapConsoleLevel(event.level ?? 1), + message: event.message.slice(0, 16_384), + timestamp, + ...(typeof event.sourceId === "string" ? { source: event.sourceId.slice(0, 4_096) } : {}), + ...(typeof event.lineNumber === "number" ? { line: event.lineNumber } : {}), + }; + } + if (args.length >= 3 && typeof args[2] === "string") { + const level = typeof args[1] === "number" ? args[1] : 1; + const message = args[2]; + const line = typeof args[3] === "number" ? args[3] : undefined; + const source = typeof args[4] === "string" ? args[4] : undefined; + return { + level: mapConsoleLevel(level), + message: message.slice(0, 16_384), + timestamp, + ...(source ? { source: source.slice(0, 4_096) } : {}), + ...(line !== undefined ? { line } : {}), + }; + } + return null; +} + +export function pushBounded(buffer: T[], entry: T, limit: number): void { + buffer.push(entry); + if (buffer.length > limit) { + buffer.splice(0, buffer.length - limit); + } +} diff --git a/apps/desktop/src/browser/BrowserProfiles.ts b/apps/desktop/src/browser/BrowserProfiles.ts new file mode 100644 index 00000000000..2076600b42e --- /dev/null +++ b/apps/desktop/src/browser/BrowserProfiles.ts @@ -0,0 +1,44 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import { app, session as electronSession } from "electron"; +import type { Session } from "electron"; +import type { BrowserProfile } from "@ryco/contracts"; +import { sanitizeBrowserProfileKey } from "@ryco/shared/browser"; + +export class BrowserProfiles { + private readonly sessionsByProfile = new Map(); + + resolve(profile: BrowserProfile): Session { + const existing = this.sessionsByProfile.get(profile.profileId); + if (existing) return existing; + + const browserSession = profile.persistent + ? this.resolvePersistentSession(profile) + : electronSession.fromPartition(`ryco-browser-temp:${profile.profileId}:${Date.now()}`); + + browserSession.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + + this.sessionsByProfile.set(profile.profileId, browserSession); + return browserSession; + } + + async cleanupTemporary(profile: BrowserProfile): Promise { + if (profile.persistent) return; + const browserSession = this.sessionsByProfile.get(profile.profileId); + this.sessionsByProfile.delete(profile.profileId); + await browserSession?.clearStorageData().catch(() => undefined); + } + + private resolvePersistentSession(profile: BrowserProfile): Session { + const profilePath = Path.join( + app.getPath("userData"), + "browser-profiles", + sanitizeBrowserProfileKey(profile.profileId), + ); + FS.mkdirSync(profilePath, { recursive: true, mode: 0o700 }); + return electronSession.fromPath(profilePath); + } +} diff --git a/apps/desktop/src/browser/BrowserStorageHelpers.test.ts b/apps/desktop/src/browser/BrowserStorageHelpers.test.ts new file mode 100644 index 00000000000..51a1b853cd2 --- /dev/null +++ b/apps/desktop/src/browser/BrowserStorageHelpers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + browserCookieMetadata, + browserCookieRemovalUrl, + canUseBrowserCookieUrl, + electronStorageTypes, + sanitizeBrowserStorageEntries, +} from "./BrowserStorageHelpers.ts"; + +describe("BrowserStorageHelpers", () => { + it("redacts cookie values while preserving safe metadata", () => { + const metadata = browserCookieMetadata({ + name: "sid", + value: "secret-value", + domain: ".example.test", + path: "/app", + secure: true, + httpOnly: true, + session: false, + sameSite: "lax", + expirationDate: 1_783_000_000, + } as Parameters[0]); + + expect(metadata).toMatchObject({ + name: "sid", + domain: ".example.test", + path: "/app", + secure: true, + httpOnly: true, + session: false, + sameSite: "lax", + expirationDate: 1_783_000_000, + }); + expect(JSON.stringify(metadata)).not.toContain("secret-value"); + expect(metadata.sizeBytes).toBeGreaterThan("sid".length); + }); + + it("builds cookie removal URLs from cookie metadata", () => { + expect( + browserCookieRemovalUrl({ + fallbackUrl: "http://example.test/app/page", + domain: ".example.test", + path: "/app", + secure: true, + }), + ).toBe("https://example.test/app"); + expect(browserCookieRemovalUrl({ fallbackUrl: "about:blank" })).toBeNull(); + expect(canUseBrowserCookieUrl("https://example.test/")).toBe(true); + expect(canUseBrowserCookieUrl("about:blank")).toBe(false); + }); + + it("maps Ryco storage types to Electron storage names", () => { + expect( + electronStorageTypes(["localStorage", "indexedDB", "cacheStorage", "serviceWorkers"]), + ).toEqual(["localstorage", "indexdb", "filesystem", "cachestorage", "serviceworkers"]); + expect(electronStorageTypes(["cookies", "httpCache", "sessionStorage"])).toEqual([]); + }); + + it("sanitizes active-page storage entries", () => { + const [entry] = sanitizeBrowserStorageEntries([ + { key: "a".repeat(5_000), valueBytes: 3.8 }, + { key: "bad", valueBytes: -10 }, + { key: 42, valueBytes: 2 }, + ]); + + expect(entry?.key).toHaveLength(4_096); + expect(entry?.valueBytes).toBe(3); + expect(sanitizeBrowserStorageEntries([{ key: "bad", valueBytes: -10 }])).toEqual([ + { key: "bad", valueBytes: 0 }, + ]); + }); +}); diff --git a/apps/desktop/src/browser/BrowserStorageHelpers.ts b/apps/desktop/src/browser/BrowserStorageHelpers.ts new file mode 100644 index 00000000000..db6edb5af7f --- /dev/null +++ b/apps/desktop/src/browser/BrowserStorageHelpers.ts @@ -0,0 +1,93 @@ +import type { Cookie } from "electron"; +import type { + BrowserCookieMetadata, + BrowserStorageDataType, + BrowserStorageEntryMetadata, +} from "@ryco/contracts"; + +export type ElectronStorageType = + | "filesystem" + | "indexdb" + | "localstorage" + | "serviceworkers" + | "cachestorage"; + +function byteLength(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +export function browserCookieMetadata(cookie: Cookie): BrowserCookieMetadata { + return { + name: cookie.name, + domain: cookie.domain ?? "", + path: cookie.path ?? "/", + secure: cookie.secure === true, + httpOnly: cookie.httpOnly === true, + session: cookie.session === true, + sameSite: cookie.sameSite, + ...(cookie.expirationDate !== undefined ? { expirationDate: cookie.expirationDate } : {}), + sizeBytes: byteLength(cookie.name) + byteLength(cookie.value), + }; +} + +export function canUseBrowserCookieUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function browserCookieRemovalUrl(input: { + readonly fallbackUrl: string; + readonly domain?: string; + readonly path?: string; + readonly secure?: boolean; +}): string | null { + let fallback: URL; + try { + fallback = new URL(input.fallbackUrl); + } catch { + return null; + } + if (fallback.protocol !== "http:" && fallback.protocol !== "https:") return null; + + const hostname = (input.domain ?? fallback.hostname).replace(/^\./, ""); + if (!hostname) return null; + const protocol = input.secure === true || fallback.protocol === "https:" ? "https:" : "http:"; + const rawPath = input.path && input.path.startsWith("/") ? input.path : "/"; + return `${protocol}//${hostname}${rawPath}`; +} + +export function electronStorageTypes( + dataTypes: ReadonlyArray, +): ElectronStorageType[] { + const storages = new Set(); + for (const type of dataTypes) { + if (type === "localStorage") storages.add("localstorage"); + if (type === "indexedDB") { + storages.add("indexdb"); + storages.add("filesystem"); + } + if (type === "cacheStorage") storages.add("cachestorage"); + if (type === "serviceWorkers") storages.add("serviceworkers"); + } + return [...storages]; +} + +export function sanitizeBrowserStorageEntries(value: unknown): BrowserStorageEntryMetadata[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const candidate = entry as { key?: unknown; valueBytes?: unknown }; + if (typeof candidate.key !== "string") return null; + const size = typeof candidate.valueBytes === "number" ? candidate.valueBytes : 0; + return { + key: candidate.key.slice(0, 4_096), + valueBytes: Math.max(0, Math.floor(size)), + } satisfies BrowserStorageEntryMetadata; + }) + .filter((entry): entry is BrowserStorageEntryMetadata => entry !== null); +} diff --git a/apps/desktop/src/browser/BrowserSurfaceManager.ts b/apps/desktop/src/browser/BrowserSurfaceManager.ts new file mode 100644 index 00000000000..6af3f65ac59 --- /dev/null +++ b/apps/desktop/src/browser/BrowserSurfaceManager.ts @@ -0,0 +1,166 @@ +import { BrowserWindow, screen } from "electron"; +import type { IpcMainInvokeEvent } from "electron"; +import { + DesktopBrowserSurfaceAttachInput as DesktopBrowserSurfaceAttachInputSchema, + DesktopBrowserSurfaceDetachInput as DesktopBrowserSurfaceDetachInputSchema, + DesktopBrowserSurfaceFocusInput as DesktopBrowserSurfaceFocusInputSchema, + DesktopBrowserSurfaceUpdateInput as DesktopBrowserSurfaceUpdateInputSchema, + type DesktopBrowserSurfaceAttachInput, + type DesktopBrowserSurfaceBounds, + type DesktopBrowserSurfaceDetachInput, + type DesktopBrowserSurfaceFocusInput, + type DesktopBrowserSurfaceUpdateInput, +} from "@ryco/contracts"; +import { Schema } from "effect"; + +import { resolveElectronSurfaceBounds } from "@ryco/shared/browser"; + +import { BrowserKernel } from "./BrowserKernel.ts"; + +const decodeAttachInput = Schema.decodeUnknownSync(DesktopBrowserSurfaceAttachInputSchema); +const decodeUpdateInput = Schema.decodeUnknownSync(DesktopBrowserSurfaceUpdateInputSchema); +const decodeDetachInput = Schema.decodeUnknownSync(DesktopBrowserSurfaceDetachInputSchema); +const decodeFocusInput = Schema.decodeUnknownSync(DesktopBrowserSurfaceFocusInputSchema); + +function decodeSurfaceInput(decode: (input: unknown) => A, input: unknown): A | null { + try { + return decode(input); + } catch { + return null; + } +} + +function validBounds(bounds: DesktopBrowserSurfaceBounds): boolean { + return ( + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0 + ); +} + +function nativeDeviceScaleFactor( + window: BrowserWindow, + bounds: DesktopBrowserSurfaceBounds, +): number { + try { + return screen.getDisplayMatching(window.getBounds()).scaleFactor || 1; + } catch { + return bounds.deviceScaleFactor ?? 1; + } +} + +function clippedBounds(window: BrowserWindow, bounds: DesktopBrowserSurfaceBounds) { + const resolved = resolveElectronSurfaceBounds(bounds, nativeDeviceScaleFactor(window, bounds)); + if (!resolved) { + return { + x: 0, + y: 0, + width: 1, + height: 1, + }; + } + + const content = window.getContentBounds(); + const x = Math.max(0, Math.min(resolved.x, content.width)); + const y = Math.max(0, Math.min(resolved.y, content.height)); + const maxWidth = Math.max(0, content.width - x); + const maxHeight = Math.max(0, content.height - y); + return { + x, + y, + width: Math.max(1, Math.min(resolved.width, maxWidth)), + height: Math.max(1, Math.min(resolved.height, maxHeight)), + }; +} + +export class BrowserSurfaceManager { + private readonly attached = new Map(); + private readonly kernel: BrowserKernel; + + constructor(kernel: BrowserKernel) { + this.kernel = kernel; + } + + attach(event: IpcMainInvokeEvent, unsafeInput: unknown): boolean { + const input = decodeSurfaceInput( + decodeAttachInput, + unsafeInput, + ); + if (!input) return false; + const window = BrowserWindow.fromWebContents(event.sender); + if (!window || window.isDestroyed() || !validBounds(input.bounds)) return false; + const view = this.kernel.getView(input.sessionId, input.tabId); + if (!view) return false; + + const key = this.key(input.sessionId, input.tabId); + const currentWindow = this.attached.get(key); + if (currentWindow && currentWindow !== window && !currentWindow.isDestroyed()) { + currentWindow.contentView.removeChildView(view); + } + if (currentWindow !== window) { + window.contentView.addChildView(view); + } + view.setBounds(clippedBounds(window, input.bounds)); + this.attached.set(key, window); + return true; + } + + update(event: IpcMainInvokeEvent, unsafeInput: unknown): boolean { + const input = decodeSurfaceInput( + decodeUpdateInput, + unsafeInput, + ); + if (!input) return false; + const window = BrowserWindow.fromWebContents(event.sender); + if (!window || window.isDestroyed() || !validBounds(input.bounds)) return false; + const view = this.kernel.getView(input.sessionId, input.tabId); + if (!view) return false; + const attachedWindow = this.attached.get(this.key(input.sessionId, input.tabId)); + if (attachedWindow !== window) return this.attach(event, input); + view.setBounds(clippedBounds(window, input.bounds)); + return true; + } + + detach(event: IpcMainInvokeEvent, unsafeInput: unknown): void { + const input = decodeSurfaceInput( + decodeDetachInput, + unsafeInput, + ); + if (!input) return; + const window = BrowserWindow.fromWebContents(event.sender); + const view = this.kernel.getView(input.sessionId, input.tabId); + if (!window || !view) return; + const key = this.key(input.sessionId, input.tabId); + if (this.attached.get(key) === window) { + window.contentView.removeChildView(view); + this.attached.delete(key); + } + } + + focus(_event: IpcMainInvokeEvent, unsafeInput: unknown): boolean { + const input = decodeSurfaceInput( + decodeFocusInput, + unsafeInput, + ); + if (!input) return false; + const view = this.kernel.getView(input.sessionId, input.tabId); + if (!view) return false; + view.webContents.focus(); + return true; + } + + detachAllForWindow(window: BrowserWindow): void { + for (const [key, attachedWindow] of this.attached.entries()) { + if (attachedWindow === window) { + this.attached.delete(key); + } + } + } + + private key(sessionId: string, tabId: string): string { + return `${sessionId}:${tabId}`; + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index db24abc986d..20701da43d1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -102,6 +102,8 @@ import { shouldUseUnsignedMacUpdateInstaller, type MacCodeSignatureKind, } from "./unsignedMacUpdateInstaller.ts"; +import { BrowserHost } from "./browser/BrowserHost.ts"; +import { BrowserSurfaceManager } from "./browser/BrowserSurfaceManager.ts"; const desktopStartupTiming = createStartupTiming(); desktopStartupTiming.mark("desktop.launch"); @@ -134,6 +136,10 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +const BROWSER_ATTACH_SURFACE_CHANNEL = "desktop:browser:attach-surface"; +const BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL = "desktop:browser:update-surface-bounds"; +const BROWSER_DETACH_SURFACE_CHANNEL = "desktop:browser:detach-surface"; +const BROWSER_FOCUS_SURFACE_CHANNEL = "desktop:browser:focus-surface"; const BASE_DIR = readEnv("RYCO_HOME")?.trim() || Path.join(OS.homedir(), ".ryco"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -261,6 +267,7 @@ let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; +let backendBrowserHostToken = ""; let backendHttpUrl = ""; let backendWsUrl = ""; let backendEndpointUrl: string | null = null; @@ -283,6 +290,8 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; +const browserHost = new BrowserHost(APP_RUN_ID); +const browserSurfaceManager = new BrowserSurfaceManager(browserHost.kernel); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -727,6 +736,7 @@ function ensureDevelopmentInitialWindowOpen(): void { .then((source) => { markDesktopStartupPhase("desktop.backend.listening", `source=${source}`); writeDesktopLogHeader(`bootstrap development resources ready backendSource=${source}`); + startDesktopBrowserHost("development-ready"); }) .catch((error) => { if (isBackendReadinessAborted(error)) { @@ -796,6 +806,7 @@ function ensureInitialBackendWindowOpen(): void { .then((source) => { markDesktopStartupPhase("desktop.backend.listening", `source=${source}`); writeDesktopLogHeader(`bootstrap backend ready source=${source}`); + startDesktopBrowserHost("packaged-ready"); const window = ensurePackagedBootstrapWindowOpen("backend-ready"); if (window) { loadPackagedBackendAppWindow(window, "backend-ready"); @@ -835,6 +846,31 @@ function writeDesktopStreamChunk( } } +function startDesktopBrowserHost(reason: string): void { + if (!backendWsUrl || !backendBrowserHostToken) { + return; + } + void browserHost + .start({ + wsBaseUrl: backendWsUrl, + token: backendBrowserHostToken, + }) + .then(() => { + writeDesktopLogHeader(`browser host connected reason=${reason}`); + }) + .catch((error) => { + console.warn("[desktop-browser-host] failed to connect", error); + writeDesktopLogHeader( + `browser host failed reason=${reason} message=${formatErrorMessage(error)}`, + ); + }); +} + +function stopDesktopBrowserHost(reason: string): void { + void browserHost.stop().catch(() => undefined); + writeDesktopLogHeader(`browser host stopped reason=${reason}`); +} + function installStdIoCapture(): void { if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { return; @@ -1976,6 +2012,7 @@ function startBackend(): void { host: backendBindHost, ...(isDevelopment ? { devUrl: resolveDesktopDevServerUrl() } : {}), desktopBootstrapToken: backendBootstrapToken, + desktopBrowserHostToken: backendBrowserHostToken, tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, tailscaleServePort: desktopSettings.tailscaleServePort, ...(backendObservabilitySettings.otlpTracesUrl @@ -2020,6 +2057,7 @@ function startBackend(): void { if (backendProcess === child) { backendProcess = null; } + stopDesktopBrowserHost("backend-error"); closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); if (wasExpected) { return; @@ -2040,6 +2078,7 @@ function startBackend(): void { if (backendProcess === child) { backendProcess = null; } + stopDesktopBrowserHost("backend-exit"); closeBackendSession( `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, ); @@ -2053,6 +2092,7 @@ function startBackend(): void { function stopBackend(): void { cancelBackendReadinessWait(); + stopDesktopBrowserHost("stop-backend"); backendListeningDetector = null; if (restartTimer) { clearTimeout(restartTimer); @@ -2076,6 +2116,7 @@ function stopBackend(): void { async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { cancelBackendReadinessWait(); + stopDesktopBrowserHost("stop-backend-wait"); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -2225,6 +2266,26 @@ function registerIpcHandlers(): void { desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); + ipcMain.removeHandler(BROWSER_ATTACH_SURFACE_CHANNEL); + ipcMain.handle(BROWSER_ATTACH_SURFACE_CHANNEL, (event, input) => + browserSurfaceManager.attach(event, input), + ); + + ipcMain.removeHandler(BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL); + ipcMain.handle(BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL, (event, input) => + browserSurfaceManager.update(event, input), + ); + + ipcMain.removeHandler(BROWSER_DETACH_SURFACE_CHANNEL); + ipcMain.handle(BROWSER_DETACH_SURFACE_CHANNEL, (event, input) => { + browserSurfaceManager.detach(event, input); + }); + + ipcMain.removeHandler(BROWSER_FOCUS_SURFACE_CHANNEL); + ipcMain.handle(BROWSER_FOCUS_SURFACE_CHANNEL, (event, input) => + browserSurfaceManager.focus(event, input), + ); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); @@ -2682,6 +2743,7 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + browserSurfaceManager.detachAllForWindow(window); desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( "SSH authentication was cancelled because the app window closed.", ); @@ -2720,6 +2782,7 @@ async function bootstrap(): Promise { : `using configured backend port port=${backendPort}`, ); backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); + backendBrowserHostToken = Crypto.randomBytes(32).toString("hex"); if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { writeDesktopLogHeader( `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, @@ -2769,6 +2832,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + stopDesktopBrowserHost("before-quit"); void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 04da2255850..112b7fbd71d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,6 +41,10 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +const BROWSER_ATTACH_SURFACE_CHANNEL = "desktop:browser:attach-surface"; +const BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL = "desktop:browser:update-surface-bounds"; +const BROWSER_DETACH_SURFACE_CHANNEL = "desktop:browser:detach-surface"; +const BROWSER_FOCUS_SURFACE_CHANNEL = "desktop:browser:focus-surface"; const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; function unwrapEnsureSshEnvironmentResult(result: unknown) { @@ -124,6 +128,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + browser: { + attachSurface: (input) => ipcRenderer.invoke(BROWSER_ATTACH_SURFACE_CHANNEL, input), + updateSurfaceBounds: (input) => + ipcRenderer.invoke(BROWSER_UPDATE_SURFACE_BOUNDS_CHANNEL, input), + detachSurface: (input) => ipcRenderer.invoke(BROWSER_DETACH_SURFACE_CHANNEL, input), + focusSurface: (input) => ipcRenderer.invoke(BROWSER_FOCUS_SURFACE_CHANNEL, input), + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c19e32e8a02..b2ec14b359e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -40,6 +40,8 @@ import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSes import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; +import { browserRuntimeToolTestLayers } from "../src/provider/tools/BrowserRuntimeToolTestLayers.ts"; +import { ProviderRuntimeEventHubLive } from "../src/provider/tools/ProviderRuntimeEventHub.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, @@ -285,6 +287,8 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(browserRuntimeToolTestLayers), + Layer.provideMerge(ProviderRuntimeEventHubLive), ); const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const providerLayer = useRealCodex @@ -293,12 +297,14 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(realCodexRegistry), Layer.provide(AnalyticsService.layerTest), Layer.provide(providerEventLoggersLayer), + Layer.provide(ProviderRuntimeEventHubLive), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), Layer.provide(providerEventLoggersLayer), + Layer.provide(ProviderRuntimeEventHubLive), ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 904df9f291f..09a3f0536ee 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -21,6 +21,7 @@ import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderRuntimeEventHubLive } from "../src/provider/tools/ProviderRuntimeEventHub.ts"; import { makeTestProviderAdapterHarness, @@ -67,6 +68,7 @@ const makeIntegrationFixture = Effect.gen(function* () { ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ProviderRuntimeEventHubLive, ).pipe(Layer.provide(SqlitePersistenceMemory)); const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); diff --git a/apps/server/package.json b/apps/server/package.json index cc8f82e5291..43d6bc0e880 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@github/copilot-sdk": "0.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/auth/Layers/BrowserHostAuth.test.ts b/apps/server/src/auth/Layers/BrowserHostAuth.test.ts new file mode 100644 index 00000000000..23733215c21 --- /dev/null +++ b/apps/server/src/auth/Layers/BrowserHostAuth.test.ts @@ -0,0 +1,99 @@ +import { Effect, Layer } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { describe, expect, it } from "vite-plus/test"; + +import { ServerConfig, type ServerConfigShape } from "../../config.ts"; +import { makeTestServerConfig } from "../../test/serverConfigFixtures.ts"; +import { AuthError } from "../Services/ServerAuth.ts"; +import { BrowserHostAuth } from "../Services/BrowserHostAuth.ts"; +import { BrowserHostAuthLive } from "./BrowserHostAuth.ts"; + +const makeRequest = (headers: Record): HttpServerRequest.HttpServerRequest => + ({ headers }) as unknown as HttpServerRequest.HttpServerRequest; + +const runAuth = ( + effect: Effect.Effect, + config: ServerConfigShape = makeTestServerConfig(), +) => { + const layer = BrowserHostAuthLive.pipe(Layer.provide(Layer.succeed(ServerConfig, config))); + return Effect.runPromiseExit(effect.pipe(Effect.provide(layer))); +}; + +describe("BrowserHostAuth", () => { + it("accepts the desktop host token from loopback", async () => { + const exit = await runAuth( + Effect.gen(function* () { + const auth = yield* BrowserHostAuth; + return yield* auth.authenticateWebSocketUpgrade( + makeRequest({ + host: "127.0.0.1:3773", + authorization: "Bearer secret-token", + }), + ); + }), + ); + + expect(exit._tag).toBe("Success"); + if (exit._tag === "Success") { + expect(exit.value.role).toBe("desktop-browser-host"); + } + }); + + it("accepts local browser host upgrades when the desktop backend binds all interfaces", async () => { + const exit = await runAuth( + Effect.gen(function* () { + const auth = yield* BrowserHostAuth; + return yield* auth.authenticateWebSocketUpgrade( + makeRequest({ + host: "127.0.0.1:3773", + authorization: "Bearer secret-token", + }), + ); + }), + makeTestServerConfig({ host: "0.0.0.0" }), + ); + + expect(exit._tag).toBe("Success"); + }); + + it("rejects invalid host tokens", async () => { + const exit = await runAuth( + Effect.gen(function* () { + const auth = yield* BrowserHostAuth; + return yield* auth.authenticateWebSocketUpgrade( + makeRequest({ + host: "127.0.0.1:3773", + authorization: "Bearer wrong-token", + }), + ); + }), + ); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail"); + expect(failure?.error).toBeInstanceOf(AuthError); + expect(failure?.error).toMatchObject({ status: 401 }); + } + }); + + it("rejects non-loopback browser host upgrades", async () => { + const exit = await runAuth( + Effect.gen(function* () { + const auth = yield* BrowserHostAuth; + return yield* auth.authenticateWebSocketUpgrade( + makeRequest({ + host: "192.0.2.10:3773", + authorization: "Bearer secret-token", + }), + ); + }), + ); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail"); + expect(failure?.error).toMatchObject({ status: 403 }); + } + }); +}); diff --git a/apps/server/src/auth/Layers/BrowserHostAuth.ts b/apps/server/src/auth/Layers/BrowserHostAuth.ts new file mode 100644 index 00000000000..ae0b45c67d3 --- /dev/null +++ b/apps/server/src/auth/Layers/BrowserHostAuth.ts @@ -0,0 +1,74 @@ +import { Effect, Layer } from "effect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +import { ServerConfig } from "../../config.ts"; +import { isLoopbackHost } from "../../startupAccess.ts"; +import { BrowserHostAuth, type BrowserHostAuthShape } from "../Services/BrowserHostAuth.ts"; +import { AuthError } from "../Services/ServerAuth.ts"; + +const AUTHORIZATION_PREFIX = "Bearer "; + +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + +function hostnameFromHostHeader(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(`http://${value}`).hostname.toLowerCase(); + } catch { + return value.split(":")[0]?.toLowerCase() ?? null; + } +} + +export const BrowserHostAuthLive = Layer.effect( + BrowserHostAuth, + Effect.gen(function* () { + const config = yield* ServerConfig; + + const authenticateWebSocketUpgrade: BrowserHostAuthShape["authenticateWebSocketUpgrade"] = ( + request, + ) => + Effect.gen(function* () { + if (config.mode !== "desktop") { + return yield* new AuthError({ + message: "BrowserHost route is only available for desktop runtime.", + status: 403, + }); + } + + const requestHost = hostnameFromHostHeader(request.headers.host); + if (!isLoopbackHost(requestHost ?? undefined)) { + return yield* new AuthError({ + message: "BrowserHost route requires loopback desktop access.", + status: 403, + }); + } + + const expectedToken = config.desktopBrowserHostToken; + if (!expectedToken) { + return yield* new AuthError({ + message: "BrowserHost token is not configured.", + status: 403, + }); + } + + const token = parseBearerToken(request); + if (token !== expectedToken) { + return yield* new AuthError({ + message: "Invalid BrowserHost token.", + status: 401, + }); + } + + return { role: "desktop-browser-host" as const }; + }); + + return { authenticateWebSocketUpgrade } satisfies BrowserHostAuthShape; + }), +); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index e5971267508..3d1a32b8c84 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -27,6 +27,7 @@ import { SessionCredentialService, } from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; +import { deriveAuthClientMetadata } from "../utils.ts"; type BootstrapExchangeResult = { readonly response: AuthBootstrapResult; @@ -138,6 +139,11 @@ function isAcceptedWebSocketOrigin(input: { ); } +function isLoopbackRequest(request: HttpServerRequest.HttpServerRequest): boolean { + const ipAddress = deriveAuthClientMetadata({ request }).ipAddress; + return ipAddress !== undefined && isLoopbackHost(ipAddress); +} + export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { if (cause.status === 500) { return new AuthError({ @@ -473,6 +479,7 @@ export const makeServerAuth = Effect.gen(function* () { if (Option.isSome(requestUrl)) { const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); if (websocketToken && websocketToken.trim().length > 0) { + const isLoopback = isLoopbackRequest(request); return yield* sessions.verifyWebSocketToken(websocketToken).pipe( Effect.map((session) => ({ sessionId: session.sessionId, @@ -480,6 +487,7 @@ export const makeServerAuth = Effect.gen(function* () { method: session.method, role: session.role, ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + isLoopback, })), Effect.mapError( (cause) => diff --git a/apps/server/src/auth/Services/BrowserHostAuth.ts b/apps/server/src/auth/Services/BrowserHostAuth.ts new file mode 100644 index 00000000000..af3196f838c --- /dev/null +++ b/apps/server/src/auth/Services/BrowserHostAuth.ts @@ -0,0 +1,19 @@ +import { Context } from "effect"; +import type { Effect } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +import { AuthError } from "./ServerAuth.ts"; + +export interface AuthenticatedBrowserHost { + readonly role: "desktop-browser-host"; +} + +export interface BrowserHostAuthShape { + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} + +export class BrowserHostAuth extends Context.Service()( + "ryco/auth/Services/BrowserHostAuth", +) {} diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index 22d965b82ac..c47180e1634 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -23,6 +23,7 @@ export interface AuthenticatedSession { readonly method: ServerAuthSessionMethod; readonly role: SessionRole; readonly expiresAt?: DateTime.DateTime; + readonly isLoopback?: boolean; } export class AuthError extends Data.TaggedError("AuthError")<{ diff --git a/apps/server/src/auth/wsAuthorization.test.ts b/apps/server/src/auth/wsAuthorization.test.ts index ab26f1deed0..719e9ae45c2 100644 --- a/apps/server/src/auth/wsAuthorization.test.ts +++ b/apps/server/src/auth/wsAuthorization.test.ts @@ -5,11 +5,15 @@ import { AuthSessionId } from "@ryco/contracts"; import { authorizeWsRpc } from "./wsAuthorization.ts"; import type { AuthenticatedSession } from "./Services/ServerAuth.ts"; -const makeSession = (role: AuthenticatedSession["role"]): AuthenticatedSession => ({ +const makeSession = ( + role: AuthenticatedSession["role"], + input?: { readonly isLoopback?: boolean }, +): AuthenticatedSession => ({ sessionId: AuthSessionId.make(`session-${role}`), subject: role, method: "browser-session-cookie", role, + ...(input?.isLoopback !== undefined ? { isLoopback: input.isLoopback } : {}), }); it.effect("allows owner sessions to call owner websocket RPC methods", () => @@ -41,3 +45,28 @@ it.effect("allows authenticated client sessions to call authenticated websocket expect(result._tag).toBe("Success"); }), ); + +it.effect("allows local owner sessions to call local desktop websocket RPC methods", () => + Effect.gen(function* () { + const result = yield* authorizeWsRpc( + makeSession("owner", { isLoopback: true }), + "local-desktop-owner", + "browser.openSession", + ).pipe(Effect.result); + expect(result._tag).toBe("Success"); + }), +); + +it.effect("rejects remote owner sessions from local desktop websocket RPC methods", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + authorizeWsRpc( + makeSession("owner", { isLoopback: false }), + "local-desktop-owner", + "browser.openSession", + ), + ); + expect(error.message).toBe("Only local desktop sessions can call browser.openSession."); + expect(error.status).toBe(403); + }), +); diff --git a/apps/server/src/auth/wsAuthorization.ts b/apps/server/src/auth/wsAuthorization.ts index d2c4af9ff17..89bb680ab67 100644 --- a/apps/server/src/auth/wsAuthorization.ts +++ b/apps/server/src/auth/wsAuthorization.ts @@ -3,14 +3,14 @@ import { Effect } from "effect"; import type { AuthenticatedSession } from "./Services/ServerAuth.ts"; -export type WsRpcAccess = "owner" | "authenticated"; +export type WsRpcAccess = "owner" | "authenticated" | "local-desktop-owner"; export function authorizeWsRpc( session: AuthenticatedSession, access: WsRpcAccess, method: string, ): Effect.Effect { - if (access === "owner" && session.role !== "owner") { + if ((access === "owner" || access === "local-desktop-owner") && session.role !== "owner") { return Effect.fail( new AuthRpcError({ message: `Only owner sessions can call ${method}.`, @@ -19,5 +19,14 @@ export function authorizeWsRpc( ); } + if (access === "local-desktop-owner" && session.isLoopback !== true) { + return Effect.fail( + new AuthRpcError({ + message: `Only local desktop sessions can call ${method}.`, + status: 403, + }), + ); + } + return Effect.void; } diff --git a/apps/server/src/browser/BrowserArtifactStore.test.ts b/apps/server/src/browser/BrowserArtifactStore.test.ts new file mode 100644 index 00000000000..d76e1ed42f6 --- /dev/null +++ b/apps/server/src/browser/BrowserArtifactStore.test.ts @@ -0,0 +1,78 @@ +import { + BrowserArtifactId, + BrowserProfileId, + BrowserSessionId, + BrowserTabId, +} from "@ryco/contracts"; +import { Effect, Layer } from "effect"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { ServerConfig } from "../config.ts"; +import { makeTestServerConfig } from "../test/serverConfigFixtures.ts"; +import { BrowserArtifactStore, BrowserArtifactStoreLayerLive } from "./BrowserArtifactStore.ts"; + +function makeStoreLayer(stateDir: string) { + return BrowserArtifactStoreLayerLive.pipe( + Layer.provide( + Layer.succeed( + ServerConfig, + makeTestServerConfig({ + stateDir, + baseDir: stateDir, + }), + ), + ), + ); +} + +describe("BrowserArtifactStore", () => { + it("writes screenshot blobs under app state and tracks metadata", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ryco-browser-artifacts-")); + try { + const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* BrowserArtifactStore; + const ref = yield* store.put({ + kind: "screenshot", + mimeType: "image/png", + data: pngBytes, + profileId: BrowserProfileId.make("browser-profile:test"), + sessionId: BrowserSessionId.make("browser-session:test"), + tabId: BrowserTabId.make("browser-tab:test"), + url: "https://example.test/", + origin: "https://example.test", + }); + const record = yield* store.get(ref.artifactId); + const data = yield* store.readData(ref.artifactId); + return { ref, record, data }; + }).pipe(Effect.provide(makeStoreLayer(stateDir))), + ); + + expect(result.ref.byteSize).toBe(pngBytes.byteLength); + expect(result.record?.filePath).toContain("browser-artifacts"); + expect(fs.existsSync(result.record!.filePath)).toBe(true); + expect(Buffer.from(result.data ?? [])).toEqual(Buffer.from(pngBytes)); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("returns null for unknown artifact ids", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ryco-browser-artifacts-")); + try { + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* BrowserArtifactStore; + return yield* store.get(BrowserArtifactId.make("browser-artifact:missing")); + }).pipe(Effect.provide(makeStoreLayer(stateDir))), + ); + expect(result).toBeNull(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/browser/BrowserArtifactStore.ts b/apps/server/src/browser/BrowserArtifactStore.ts new file mode 100644 index 00000000000..8ec5e145276 --- /dev/null +++ b/apps/server/src/browser/BrowserArtifactStore.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Context, Effect, Layer, Ref } from "effect"; + +import { + BrowserArtifactId, + type BrowserArtifactKind, + type BrowserArtifactRef, + type BrowserProfileId, + type BrowserSessionId, + type BrowserTabId, +} from "@ryco/contracts"; + +import { ServerConfig } from "../config.ts"; + +interface BrowserArtifactRecord extends BrowserArtifactRef { + readonly profileId: BrowserProfileId; + readonly sessionId: BrowserSessionId; + readonly tabId: BrowserTabId; + readonly filePath: string; +} + +const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000; + +function artifactExtension(mimeType: string): string { + switch (mimeType) { + case "image/png": + return ".png"; + case "image/jpeg": + return ".jpg"; + case "application/json": + return ".json"; + default: + return ".bin"; + } +} + +export interface BrowserArtifactStoreShape { + readonly put: (input: { + readonly kind: BrowserArtifactKind; + readonly mimeType: string; + readonly data: Uint8Array; + readonly profileId: BrowserProfileId; + readonly sessionId: BrowserSessionId; + readonly tabId: BrowserTabId; + readonly url?: string; + readonly origin?: string | null; + readonly retentionMs?: number; + }) => Effect.Effect; + readonly get: (artifactId: BrowserArtifactId) => Effect.Effect; + readonly readData: (artifactId: BrowserArtifactId) => Effect.Effect; +} + +export class BrowserArtifactStore extends Context.Service< + BrowserArtifactStore, + BrowserArtifactStoreShape +>()("ryco/browser/BrowserArtifactStore") {} + +export const BrowserArtifactStoreLive = Layer.effect( + BrowserArtifactStore, + Effect.gen(function* () { + const config = yield* ServerConfig; + const artifactsDir = path.join(config.stateDir, "browser-artifacts"); + yield* Effect.tryPromise({ + try: () => fs.mkdir(artifactsDir, { recursive: true }), + catch: (cause) => new Error(String(cause)), + }).pipe(Effect.orDie); + const records = yield* Ref.make(new Map()); + + return { + put: (input) => + Effect.gen(function* () { + const now = new Date(); + const expiresAt = new Date(now.getTime() + (input.retentionMs ?? DEFAULT_RETENTION_MS)); + const artifactId = BrowserArtifactId.make(`browser-artifact:${crypto.randomUUID()}`); + const extension = artifactExtension(input.mimeType); + const fileName = `${artifactId.replace(/:/g, "-")}${extension}`; + const filePath = path.join(artifactsDir, fileName); + yield* Effect.tryPromise({ + try: () => fs.writeFile(filePath, input.data), + catch: (cause) => new Error(String(cause)), + }); + const ref = { + artifactId, + kind: input.kind, + mimeType: input.mimeType, + byteSize: input.data.byteLength, + ...(input.url ? { url: input.url } : {}), + ...(input.origin ? { origin: input.origin } : {}), + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + } satisfies BrowserArtifactRef; + const record = { + ...ref, + profileId: input.profileId, + sessionId: input.sessionId, + tabId: input.tabId, + filePath, + } satisfies BrowserArtifactRecord; + yield* Ref.update(records, (current) => { + const next = new Map(current); + next.set(ref.artifactId, record); + return next; + }); + return ref; + }), + get: (artifactId) => + Ref.get(records).pipe(Effect.map((current) => current.get(artifactId) ?? null)), + readData: (artifactId) => + Effect.gen(function* () { + const record = yield* Ref.get(records).pipe( + Effect.map((current) => current.get(artifactId) ?? null), + ); + if (!record) return null; + const data = yield* Effect.tryPromise({ + try: () => fs.readFile(record.filePath), + catch: (cause) => new Error(String(cause)), + }); + return new Uint8Array(data); + }), + } satisfies BrowserArtifactStoreShape; + }), +); + +export const BrowserArtifactStoreLayerLive = BrowserArtifactStoreLive; diff --git a/apps/server/src/browser/BrowserHostRegistry.test.ts b/apps/server/src/browser/BrowserHostRegistry.test.ts new file mode 100644 index 00000000000..ef560bf8a9f --- /dev/null +++ b/apps/server/src/browser/BrowserHostRegistry.test.ts @@ -0,0 +1,177 @@ +import { + BrowserHostId, + BrowserHostRunId, + BrowserProfileId, + BrowserServiceError, + BrowserSessionId, + BrowserTabId, + ThreadId, + type BrowserHostCapabilities, + type BrowserHostCommand, + type BrowserProfile, + type BrowserSessionSnapshot, +} from "@ryco/contracts"; +import { Effect, Exit, Fiber, Option, Scope, Stream } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { BrowserHostRegistry, BrowserHostRegistryLive } from "./BrowserHostRegistry.ts"; + +const capabilities = { + surface: true, + persistentProfiles: true, + temporaryProfiles: true, + screenshots: false, + domSnapshot: true, + input: true, + downloads: false, + devtools: false, +} satisfies BrowserHostCapabilities; + +const hostId = BrowserHostId.make("browser-host:test"); +const runId = BrowserHostRunId.make("browser-host-run:1"); +const now = "2026-06-24T10:00:00.000Z"; + +function makeProfile(): BrowserProfile { + return { + profileId: BrowserProfileId.make("browser-profile:test"), + displayName: "Test profile", + mode: "thread", + scope: { mode: "thread", threadId: ThreadId.make("thread-1") }, + persistent: true, + createdAt: now, + updatedAt: now, + }; +} + +function makeSession(profile: BrowserProfile): BrowserSessionSnapshot { + const sessionId = BrowserSessionId.make("browser-session:test"); + const tabId = BrowserTabId.make("browser-tab:test"); + return { + sessionId, + profileId: profile.profileId, + threadId: ThreadId.make("thread-1"), + selectedTabId: tabId, + tabs: [ + { + tabId, + sessionId, + profileId: profile.profileId, + selected: true, + crashed: false, + navigation: { + url: "about:blank", + origin: null, + loadState: "idle", + canGoBack: false, + canGoForward: false, + }, + createdAt: now, + updatedAt: now, + }, + ], + status: "ready", + createdAt: now, + updatedAt: now, + }; +} + +type BrowserOpenSessionCommand = Extract; + +function makeOpenSessionCommand(): BrowserOpenSessionCommand { + const profile = makeProfile(); + return { + kind: "open_session", + profile, + session: makeSession(profile), + }; +} + +const runRegistry = (effect: Effect.Effect) => + Effect.runPromise(Effect.scoped(effect.pipe(Effect.provide(BrowserHostRegistryLive)))); + +const findFailure = (exit: Exit.Exit) => + exit._tag === "Failure" ? exit.cause.reasons.find((reason) => reason._tag === "Fail") : undefined; + +describe("BrowserHostRegistry", () => { + it("queues host commands and resolves them from command results", async () => { + const result = await runRegistry( + Effect.gen(function* () { + const registry = yield* BrowserHostRegistry; + yield* registry.register({ hostId, runId, capabilities }); + const command = makeOpenSessionCommand(); + const fiber = yield* registry + .sendCommand({ command, timeoutMs: 1_000 }) + .pipe(Effect.forkScoped); + const envelopeOption = yield* registry + .commandStream({ hostId, runId }) + .pipe(Stream.runHead); + + if (Option.isNone(envelopeOption)) { + return yield* Effect.die(new Error("Expected queued browser host command.")); + } + + const envelope = envelopeOption.value; + yield* registry.completeCommand({ + hostId, + runId, + result: { + ok: true, + commandId: envelope.commandId, + result: { session: command.session }, + }, + }); + + return yield* Fiber.join(fiber); + }), + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.session?.sessionId).toBe(BrowserSessionId.make("browser-session:test")); + } + }); + + it("fails pending commands when a new host run registers", async () => { + const exit = await runRegistry( + Effect.gen(function* () { + const registry = yield* BrowserHostRegistry; + yield* registry.register({ hostId, runId, capabilities }); + const fiber = yield* registry + .sendCommand({ command: makeOpenSessionCommand(), timeoutMs: 1_000 }) + .pipe(Effect.forkScoped); + const envelopeOption = yield* registry + .commandStream({ hostId, runId }) + .pipe(Stream.runHead); + if (Option.isNone(envelopeOption)) { + return yield* Effect.die(new Error("Expected queued browser host command.")); + } + yield* registry.register({ + hostId, + runId: BrowserHostRunId.make("browser-host-run:2"), + capabilities, + }); + return yield* Effect.exit(Fiber.join(fiber)); + }), + ); + + const failure = findFailure(exit); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "host_disconnected", retryable: true }); + }); + + it("times out commands that are not completed by the host", async () => { + const exit = await runRegistry( + Effect.gen(function* () { + const registry = yield* BrowserHostRegistry; + yield* registry.register({ hostId, runId, capabilities }); + return yield* Effect.exit( + registry.sendCommand({ command: makeOpenSessionCommand(), timeoutMs: 20 }), + ); + }), + ); + + const failure = findFailure(exit); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "command_timeout", retryable: true }); + }); +}); diff --git a/apps/server/src/browser/BrowserHostRegistry.ts b/apps/server/src/browser/BrowserHostRegistry.ts new file mode 100644 index 00000000000..6fa52f9a5bb --- /dev/null +++ b/apps/server/src/browser/BrowserHostRegistry.ts @@ -0,0 +1,302 @@ +import { + BrowserCommandId, + BrowserServiceError, + type BrowserCommandResult, + type BrowserEvent, + type BrowserHostCommand, + type BrowserHostCommandEnvelope, + type BrowserHostHeartbeatInput, + type BrowserHostId, + type BrowserHostRegisterInput, + type BrowserHostRegisterResult, + type BrowserHostRunId, + type BrowserHostSnapshot, + type BrowserSessionSnapshot, +} from "@ryco/contracts"; +import { Context, Deferred, Effect, Layer, PubSub, Queue, Ref, Stream } from "effect"; + +interface PendingCommand { + readonly runId: BrowserHostRunId; + readonly deferred: Deferred.Deferred; + readonly timeout: ReturnType; +} + +interface BrowserHostRegistryState { + readonly host: BrowserHostSnapshot | null; + readonly sessions: ReadonlyMap; + readonly pending: ReadonlyMap; +} + +export interface BrowserHostRegistryShape { + readonly register: ( + input: BrowserHostRegisterInput, + ) => Effect.Effect; + readonly heartbeat: ( + input: BrowserHostHeartbeatInput, + ) => Effect.Effect; + readonly disconnect: (input: { + readonly hostId: BrowserHostId; + readonly runId: BrowserHostRunId; + }) => Effect.Effect; + readonly snapshot: Effect.Effect<{ + readonly host: BrowserHostSnapshot | null; + readonly sessions: ReadonlyArray; + }>; + readonly sendCommand: (input: { + readonly command: BrowserHostCommand; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly completeCommand: (input: { + readonly hostId: BrowserHostId; + readonly runId: BrowserHostRunId; + readonly result: BrowserCommandResult; + }) => Effect.Effect; + readonly publishHostEvent: (input: { + readonly hostId: BrowserHostId; + readonly runId: BrowserHostRunId; + readonly event: BrowserEvent; + }) => Effect.Effect; + readonly commandStream: (input: { + readonly hostId: BrowserHostId; + readonly runId: BrowserHostRunId; + }) => Stream.Stream; + readonly eventStream: Stream.Stream; +} + +export class BrowserHostRegistry extends Context.Service< + BrowserHostRegistry, + BrowserHostRegistryShape +>()("ryco/browser/BrowserHostRegistry") {} + +const DEFAULT_COMMAND_TIMEOUT_MS = 15_000; +const COMMAND_QUEUE_SIZE = 64; + +const staleHostError = () => + new BrowserServiceError({ + code: "host_disconnected", + message: "Browser host registration is stale.", + retryable: true, + }); + +function isCurrentHost( + host: BrowserHostSnapshot | null, + hostId: BrowserHostId, + runId: BrowserHostRunId, +): boolean { + return host?.hostId === hostId && host.runId === runId && host.connected; +} + +export const BrowserHostRegistryLive = Layer.effect( + BrowserHostRegistry, + Effect.gen(function* () { + const state = yield* Ref.make({ + host: null, + sessions: new Map(), + pending: new Map(), + }); + const commandQueue = yield* Queue.bounded(COMMAND_QUEUE_SIZE); + const eventPubSub = yield* PubSub.unbounded(); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const failPending = ( + pending: ReadonlyMap, + error: BrowserServiceError, + ) => + Effect.forEach( + [...pending.values()], + (command) => + Effect.sync(() => clearTimeout(command.timeout)).pipe( + Effect.andThen(Deferred.fail(command.deferred, error)), + Effect.ignore, + ), + { discard: true }, + ); + + const publish = (event: BrowserEvent) => PubSub.publish(eventPubSub, event).pipe(Effect.asVoid); + + return { + register: (input) => + Effect.gen(function* () { + const now = new Date().toISOString(); + const host = { + hostId: input.hostId, + runId: input.runId, + connected: true, + capabilities: input.capabilities, + registeredAt: now, + heartbeatAt: now, + } satisfies BrowserHostSnapshot; + const previous = yield* Ref.get(state); + yield* failPending(previous.pending, staleHostError()); + yield* Ref.set(state, { + host, + sessions: previous.sessions, + pending: new Map(), + }); + yield* publish({ + type: "host.connected", + host, + createdAt: now, + }); + return { accepted: true, host } satisfies BrowserHostRegisterResult; + }), + heartbeat: (input) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + const host = current.host; + if (!host || !isCurrentHost(host, input.hostId, input.runId)) { + return yield* staleHostError(); + } + const heartbeatAt = new Date().toISOString(); + const sessions = new Map(current.sessions); + for (const session of input.sessions ?? []) { + sessions.set(session.sessionId, session); + } + yield* Ref.set(state, { + ...current, + host: { ...host, heartbeatAt }, + sessions, + }); + }), + disconnect: (input) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + const host = current.host; + if (!host || !isCurrentHost(host, input.hostId, input.runId)) return; + yield* failPending( + current.pending, + new BrowserServiceError({ + code: "host_disconnected", + message: "Browser host disconnected while commands were in flight.", + retryable: true, + }), + ); + yield* Ref.set(state, { + ...current, + host: { ...host, connected: false }, + pending: new Map(), + }); + yield* publish({ + type: "host.disconnected", + hostId: input.hostId, + runId: input.runId, + createdAt: new Date().toISOString(), + }); + }), + snapshot: Ref.get(state).pipe( + Effect.map((current) => ({ + host: current.host, + sessions: [...current.sessions.values()], + })), + ), + sendCommand: (input) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + const host = current.host; + if (!host?.connected) { + return yield* new BrowserServiceError({ + code: "host_unavailable", + message: "No desktop browser host is connected.", + retryable: true, + }); + } + + const deferred = yield* Deferred.make(); + const commandId = BrowserCommandId.make(`browser-command:${crypto.randomUUID()}`); + const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const timeout = setTimeout(() => { + runFork( + Deferred.fail( + deferred, + new BrowserServiceError({ + code: "command_timeout", + message: "Browser command timed out.", + retryable: true, + }), + ).pipe( + Effect.andThen( + Ref.update(state, (latest) => { + const pending = new Map(latest.pending); + pending.delete(commandId); + return { ...latest, pending }; + }), + ), + Effect.ignore, + ), + ); + }, timeoutMs); + + const envelope = { + commandId, + hostId: host.hostId, + runId: host.runId, + command: input.command, + issuedAt: new Date().toISOString(), + timeoutMs, + } satisfies BrowserHostCommandEnvelope; + + yield* Ref.update(state, (latest) => { + const pending = new Map(latest.pending); + pending.set(commandId, { + runId: host.runId, + deferred, + timeout, + }); + return { ...latest, pending }; + }); + yield* Queue.offer(commandQueue, envelope); + return yield* Deferred.await(deferred); + }), + completeCommand: (input) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (!isCurrentHost(current.host, input.hostId, input.runId)) { + return yield* staleHostError(); + } + const pending = current.pending.get(input.result.commandId); + if (!pending || pending.runId !== input.runId) { + return; + } + yield* Ref.update(state, (latest) => { + const nextPending = new Map(latest.pending); + nextPending.delete(input.result.commandId); + return { ...latest, pending: nextPending }; + }); + yield* Effect.sync(() => clearTimeout(pending.timeout)); + yield* Deferred.succeed(pending.deferred, input.result).pipe(Effect.ignore); + }), + publishHostEvent: (input) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (!isCurrentHost(current.host, input.hostId, input.runId)) { + return yield* staleHostError(); + } + const event = input.event; + if (event.type === "session.updated" && "session" in event) { + yield* Ref.update(state, (latest) => { + const sessions = new Map(latest.sessions); + sessions.set(event.session.sessionId, event.session); + return { ...latest, sessions }; + }); + } else if (event.type === "session.closed" && "sessionId" in event) { + yield* Ref.update(state, (latest) => { + const sessions = new Map(latest.sessions); + sessions.delete(event.sessionId); + return { ...latest, sessions }; + }); + } + yield* publish(event); + }), + commandStream: (input) => + Stream.fromQueue(commandQueue).pipe( + Stream.filter( + (envelope) => envelope.hostId === input.hostId && envelope.runId === input.runId, + ), + ), + get eventStream() { + return Stream.fromPubSub(eventPubSub); + }, + } satisfies BrowserHostRegistryShape; + }), +); diff --git a/apps/server/src/browser/BrowserMcpBridge.ts b/apps/server/src/browser/BrowserMcpBridge.ts new file mode 100644 index 00000000000..2b6b9495055 --- /dev/null +++ b/apps/server/src/browser/BrowserMcpBridge.ts @@ -0,0 +1,262 @@ +import { createConnection } from "node:net"; +import { mkdir, rm } from "node:fs/promises"; +import { createServer, type Server, type Socket } from "node:net"; +import * as nodePath from "node:path"; + +import type { ThreadId } from "@ryco/contracts"; +import { Context, Effect, Layer, Ref } from "effect"; + +import { + isBrowserRuntimeToolName, + parseBrowserRuntimeToolCallInput, +} from "../provider/tools/BrowserRuntimeTool.ts"; +import { formatBrowserRuntimeToolCallError } from "../provider/tools/BrowserRuntimeToolHelpers.ts"; +import type { ProviderRuntimeToolRegistryShape } from "../provider/tools/ProviderRuntimeToolRegistry.ts"; + +const SOCKET_DIR = nodePath.join(process.env.TMPDIR ?? "/tmp", "ryco-browser-mcp"); + +interface BrowserMcpBridgeRequest { + readonly id: string; + readonly toolName: string; + readonly arguments: unknown; +} + +interface BrowserMcpBridgeResponse { + readonly id: string; + readonly ok: boolean; + readonly result?: unknown; + readonly message?: string; +} + +interface ActiveBridge { + readonly threadId: ThreadId; + readonly socketPath: string; + readonly server: Server; +} + +export interface BrowserMcpBridgeShape { + readonly start: (input: { + readonly threadId: ThreadId; + readonly executeBrowserTool: ProviderRuntimeToolRegistryShape["executeBrowserTool"]; + readonly runPromise: (effect: Effect.Effect) => Promise; + }) => Effect.Effect<{ readonly socketPath: string }, Error>; + readonly stop: (threadId: ThreadId) => Effect.Effect; +} + +export class BrowserMcpBridge extends Context.Service()( + "ryco/provider/tools/BrowserMcpBridge", +) {} + +function readJsonLine(socket: Socket): Effect.Effect { + return Effect.callback((resume) => { + let buffer = ""; + const onData = (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + socket.off("data", onData); + if (!line) { + resume(Effect.succeed(undefined)); + return; + } + try { + resume(Effect.succeed(JSON.parse(line) as BrowserMcpBridgeRequest)); + } catch { + resume(Effect.succeed(undefined)); + } + }; + socket.on("data", onData); + socket.on("error", () => resume(Effect.succeed(undefined))); + socket.on("close", () => resume(Effect.succeed(undefined))); + return Effect.sync(() => { + socket.off("data", onData); + }); + }); +} + +function writeJsonLine(socket: Socket, payload: BrowserMcpBridgeResponse): Effect.Effect { + return Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + socket.write(`${JSON.stringify(payload)}\n`, (error) => { + if (error) reject(error); + else resolve(); + }); + }), + catch: () => new Error("Failed to write browser MCP bridge response."), + }).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ); +} + +export const BrowserMcpBridgeLive = Layer.effect( + BrowserMcpBridge, + Effect.gen(function* () { + const bridgesRef = yield* Ref.make(new Map()); + + const stop = (threadId: ThreadId) => + Effect.gen(function* () { + const bridge = yield* Ref.modify(bridgesRef, (bridges) => { + const existing = bridges.get(threadId); + const next = new Map(bridges); + next.delete(threadId); + return [existing, next] as const; + }); + if (!bridge) return; + yield* Effect.tryPromise({ + try: () => + new Promise((resolve) => { + bridge.server.close(() => resolve()); + }), + catch: () => new Error("Failed to close browser MCP bridge server."), + }).pipe(Effect.ignore); + yield* Effect.tryPromise({ + try: () => rm(bridge.socketPath, { force: true }), + catch: () => new Error("Failed to remove browser MCP bridge socket."), + }).pipe(Effect.ignore); + }); + + const start: BrowserMcpBridgeShape["start"] = (input) => + Effect.gen(function* () { + yield* stop(input.threadId); + yield* Effect.tryPromise({ + try: () => mkdir(SOCKET_DIR, { recursive: true }), + catch: () => new Error("Failed to create browser MCP bridge socket directory."), + }).pipe(Effect.ignore); + const socketId = crypto.randomUUID(); + const socketPath = nodePath.join(SOCKET_DIR, `${input.threadId}-${socketId}.sock`); + yield* Effect.tryPromise({ + try: () => rm(socketPath, { force: true }), + catch: () => new Error("Failed to remove stale browser MCP bridge socket."), + }).pipe(Effect.ignore); + + const server = yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const nextServer = createServer((socket) => { + void (async () => { + try { + const request = await input.runPromise(readJsonLine(socket)); + if (!request || !isBrowserRuntimeToolName(request.toolName)) { + await input.runPromise( + writeJsonLine(socket, { + id: request?.id ?? "unknown", + ok: false, + message: "Unsupported browser MCP request.", + }), + ); + socket.end(); + return; + } + const parsed = await input.runPromise( + parseBrowserRuntimeToolCallInput({ + toolName: request.toolName, + threadId: input.threadId, + arguments: request.arguments, + }).pipe( + Effect.catch((error) => + Effect.succeed({ + error: formatBrowserRuntimeToolCallError(error), + }), + ), + ), + ); + if ("error" in parsed) { + await input.runPromise( + writeJsonLine(socket, { + id: request.id, + ok: false, + message: parsed.error, + }), + ); + socket.end(); + return; + } + try { + const value = await input.runPromise(input.executeBrowserTool(parsed)); + await input.runPromise( + writeJsonLine(socket, { + id: request.id, + ok: true, + result: value, + }), + ); + } catch (cause) { + await input.runPromise( + writeJsonLine(socket, { + id: request.id, + ok: false, + message: formatBrowserRuntimeToolCallError(cause), + }), + ); + } + socket.end(); + } catch { + socket.end(); + } + })(); + }); + nextServer.on("error", reject); + nextServer.listen(socketPath, () => resolve(nextServer)); + }), + catch: (cause) => cause as Error, + }); + + yield* Ref.update(bridgesRef, (bridges) => { + const next = new Map(bridges); + next.set(input.threadId, { + threadId: input.threadId, + socketPath, + server, + }); + return next; + }); + + return { socketPath }; + }); + + return { start, stop } satisfies BrowserMcpBridgeShape; + }), +); + +export function browserMcpBridgeRequest(input: { + readonly socketPath: string; + readonly toolName: string; + readonly arguments: unknown; +}): Promise { + return new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + const socket = createConnection(input.socketPath); + let buffer = ""; + socket.on("connect", () => { + socket.write( + `${JSON.stringify({ + id, + toolName: input.toolName, + arguments: input.arguments, + })}\n`, + ); + }); + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + const line = buffer.slice(0, newlineIndex).trim(); + socket.end(); + try { + const response = JSON.parse(line) as BrowserMcpBridgeResponse; + if (!response.ok) { + reject(new Error(response.message ?? "Browser MCP bridge request failed.")); + return; + } + resolve(response.result); + } catch (cause) { + reject(cause); + } + }); + socket.on("error", reject); + }); +} diff --git a/apps/server/src/browser/BrowserObservationHelpers.ts b/apps/server/src/browser/BrowserObservationHelpers.ts new file mode 100644 index 00000000000..8f03163b792 --- /dev/null +++ b/apps/server/src/browser/BrowserObservationHelpers.ts @@ -0,0 +1,60 @@ +import type { + BrowserHostConsoleData, + BrowserHostDomSnapshotData, + BrowserHostNetworkData, + BrowserHostScreenshotData, +} from "@ryco/contracts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function decodeBrowserHostScreenshotData(value: unknown): BrowserHostScreenshotData | null { + if (!isRecord(value) || value.kind !== "screenshot" || typeof value.base64 !== "string") { + return null; + } + return { kind: "screenshot", base64: value.base64 }; +} + +export function decodeBrowserHostDomSnapshotData( + value: unknown, +): BrowserHostDomSnapshotData | null { + if (!isRecord(value) || value.kind !== "dom_snapshot" || !isRecord(value.snapshot)) { + return null; + } + const snapshot = value.snapshot; + if ( + typeof snapshot.url !== "string" || + typeof snapshot.title !== "string" || + !isRecord(snapshot.viewport) || + typeof snapshot.viewport.width !== "number" || + typeof snapshot.viewport.height !== "number" || + !Array.isArray(snapshot.tree) + ) { + return null; + } + return { + kind: "dom_snapshot", + snapshot: snapshot as BrowserHostDomSnapshotData["snapshot"], + ...(typeof value.text === "string" ? { text: value.text } : {}), + }; +} + +export function decodeBrowserHostConsoleData(value: unknown): BrowserHostConsoleData | null { + if (!isRecord(value) || value.kind !== "console" || !Array.isArray(value.entries)) { + return null; + } + return { kind: "console", entries: value.entries as BrowserHostConsoleData["entries"] }; +} + +export function decodeBrowserHostNetworkData(value: unknown): BrowserHostNetworkData | null { + if (!isRecord(value) || value.kind !== "network" || !Array.isArray(value.entries)) { + return null; + } + return { kind: "network", entries: value.entries as BrowserHostNetworkData["entries"] }; +} + +export function base64ToUint8Array(base64: string): Uint8Array { + const binary = Buffer.from(base64, "base64"); + return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength); +} diff --git a/apps/server/src/browser/BrowserPolicy.test.ts b/apps/server/src/browser/BrowserPolicy.test.ts new file mode 100644 index 00000000000..a4e44948423 --- /dev/null +++ b/apps/server/src/browser/BrowserPolicy.test.ts @@ -0,0 +1,51 @@ +import { BrowserServiceError } from "@ryco/contracts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { BrowserPolicy, BrowserPolicyLive } from "./BrowserPolicy.ts"; + +const runPolicy = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(BrowserPolicyLive))); + +describe("BrowserPolicy", () => { + it("allows loopback navigation without approval", async () => { + const decision = await runPolicy( + Effect.gen(function* () { + const policy = yield* BrowserPolicy; + return yield* policy.decideNavigation({ rawUrl: "localhost:5173" }); + }), + ); + + expect(decision.url).toBe("http://localhost:5173/"); + expect(decision.origin).toBe("http://localhost:5173"); + expect(decision.decision.decision).toBe("allow"); + }); + + it("requires approval for public origins", async () => { + const decision = await runPolicy( + Effect.gen(function* () { + const policy = yield* BrowserPolicy; + return yield* policy.decideNavigation({ rawUrl: "https://example.com/docs" }); + }), + ); + + expect(decision.origin).toBe("https://example.com"); + expect(decision.decision.decision).toBe("ask"); + }); + + it("rejects blocked schemes as navigation_blocked", async () => { + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const policy = yield* BrowserPolicy; + return yield* policy.decideNavigation({ rawUrl: "javascript:alert(1)" }); + }).pipe(Effect.provide(BrowserPolicyLive)), + ); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail"); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "navigation_blocked", retryable: false }); + } + }); +}); diff --git a/apps/server/src/browser/BrowserPolicy.ts b/apps/server/src/browser/BrowserPolicy.ts new file mode 100644 index 00000000000..9eadf0911d1 --- /dev/null +++ b/apps/server/src/browser/BrowserPolicy.ts @@ -0,0 +1,52 @@ +import { normalizeBrowserNavigationUrl } from "@ryco/shared/browser"; +import { Context, Effect, Layer } from "effect"; + +import { BrowserServiceError, type BrowserToolAccessDecision } from "@ryco/contracts"; + +export interface BrowserPolicyShape { + readonly decideNavigation: (input: { readonly rawUrl: string }) => Effect.Effect< + { + readonly url: string; + readonly origin: string | null; + readonly decision: BrowserToolAccessDecision; + }, + BrowserServiceError + >; +} + +export class BrowserPolicy extends Context.Service()( + "ryco/browser/BrowserPolicy", +) {} + +export const BrowserPolicyLive = Layer.succeed(BrowserPolicy, { + decideNavigation: ({ rawUrl }) => + Effect.sync(() => normalizeBrowserNavigationUrl(rawUrl)).pipe( + Effect.flatMap((parsed) => { + if (!parsed.ok) { + return Effect.fail( + new BrowserServiceError({ + code: parsed.reason === "blocked-scheme" ? "navigation_blocked" : "invalid_url", + message: parsed.message, + retryable: false, + }), + ); + } + + const { value } = parsed; + const decision = + value.kind === "loopback" || value.kind === "about" || value.kind === "file" + ? "allow" + : "ask"; + return Effect.succeed({ + url: value.url, + origin: value.origin, + decision: { + decision, + ...(decision === "ask" + ? { reason: "Origin requires explicit approval for provider-driven browser use." } + : {}), + }, + }); + }), + ), +} satisfies BrowserPolicyShape); diff --git a/apps/server/src/browser/BrowserRuntimeMcpConfig.ts b/apps/server/src/browser/BrowserRuntimeMcpConfig.ts new file mode 100644 index 00000000000..c52358e54a3 --- /dev/null +++ b/apps/server/src/browser/BrowserRuntimeMcpConfig.ts @@ -0,0 +1,56 @@ +import { fileURLToPath } from "node:url"; +import * as nodePath from "node:path"; + +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { RYCO_BROWSER_MCP_SERVER_NAME } from "../provider/tools/BrowserRuntimeToolHelpers.ts"; + +const browserMcpStdioEntry = fileURLToPath(new URL("./browser-mcp-stdio.ts", import.meta.url)); + +export function resolveBrowserMcpStdioCommand(): { + readonly command: string; + readonly args: ReadonlyArray; +} { + const runtime = process.execPath; + const usesBun = nodePath.basename(runtime).includes("bun"); + return usesBun + ? { command: runtime, args: [browserMcpStdioEntry] } + : { command: "bun", args: [browserMcpStdioEntry] }; +} + +export function makeAcpBrowserMcpServer(input: { + readonly socketPath: string; +}): EffectAcpSchema.McpServer { + const spawn = resolveBrowserMcpStdioCommand(); + return { + name: RYCO_BROWSER_MCP_SERVER_NAME, + command: spawn.command, + args: [...spawn.args], + env: [{ name: "RYCO_BROWSER_MCP_SOCKET", value: input.socketPath }], + }; +} + +export function makeOpenCodeBrowserMcpConfig(input: { readonly socketPath: string }) { + const spawn = resolveBrowserMcpStdioCommand(); + return { + type: "local" as const, + command: [spawn.command, ...spawn.args], + enabled: true, + environment: { + RYCO_BROWSER_MCP_SOCKET: input.socketPath, + }, + }; +} + +export function makeCopilotBrowserMcpServerConfig(input: { readonly socketPath: string }) { + const spawn = resolveBrowserMcpStdioCommand(); + return { + type: "stdio" as const, + command: spawn.command, + args: [...spawn.args], + tools: ["*"] as const, + env: { + RYCO_BROWSER_MCP_SOCKET: input.socketPath, + }, + }; +} diff --git a/apps/server/src/browser/BrowserService.test.ts b/apps/server/src/browser/BrowserService.test.ts new file mode 100644 index 00000000000..e774fad44b0 --- /dev/null +++ b/apps/server/src/browser/BrowserService.test.ts @@ -0,0 +1,561 @@ +import { + BrowserCommandId, + BrowserServiceError, + BrowserTabId, + ProjectId, + ThreadId, + type BrowserCommandResult, + type BrowserHostCommand, + type BrowserSessionSnapshot, +} from "@ryco/contracts"; +import { Effect, Exit, Layer, Stream } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { ServerConfig } from "../config.ts"; +import { makeTestServerConfig } from "../test/serverConfigFixtures.ts"; +import { BrowserArtifactStoreLayerLive } from "./BrowserArtifactStore.ts"; +import { BrowserHostRegistry, type BrowserHostRegistryShape } from "./BrowserHostRegistry.ts"; +import { BrowserPolicy, type BrowserPolicyShape } from "./BrowserPolicy.ts"; +import { BrowserService, BrowserServiceLive } from "./BrowserService.ts"; + +const now = "2026-06-24T10:00:00.000Z"; +type BrowserCommandSuccessPayload = Extract["result"]; + +function makeRegistryLayer( + commands: Array = [], + resultForCommand?: (command: BrowserHostCommand) => BrowserCommandSuccessPayload | undefined, +) { + const registry = { + register: () => Effect.die("register not implemented in BrowserService tests"), + heartbeat: () => Effect.void, + disconnect: () => Effect.void, + snapshot: Effect.succeed({ host: null, sessions: [] }), + sendCommand: ({ command }) => + Effect.sync(() => { + commands.push(command); + const result = + resultForCommand?.(command) ?? + (command.kind === "open_session" + ? { + session: { + ...command.session, + status: "ready" as const, + updatedAt: now, + }, + } + : {}); + + return { + ok: true, + commandId: BrowserCommandId.make(`browser-command:test-${commands.length}`), + result, + } satisfies BrowserCommandResult; + }), + completeCommand: () => Effect.void, + publishHostEvent: () => Effect.void, + commandStream: () => Stream.empty, + eventStream: Stream.empty, + } satisfies BrowserHostRegistryShape; + + return Layer.succeed(BrowserHostRegistry, registry); +} + +const allowPolicyLayer = Layer.succeed(BrowserPolicy, { + decideNavigation: ({ rawUrl }) => + Effect.succeed({ + url: rawUrl, + origin: rawUrl.startsWith("http") ? new URL(rawUrl).origin : null, + decision: { decision: "allow" as const }, + }), +} satisfies BrowserPolicyShape); + +const denyPolicyLayer = Layer.succeed(BrowserPolicy, { + decideNavigation: ({ rawUrl }) => + Effect.succeed({ + url: rawUrl, + origin: "https://denied.example", + decision: { decision: "deny" as const, reason: "blocked by test policy" }, + }), +} satisfies BrowserPolicyShape); + +const askPolicyLayer = Layer.succeed(BrowserPolicy, { + decideNavigation: ({ rawUrl }) => + Effect.succeed({ + url: rawUrl, + origin: rawUrl.startsWith("http") ? new URL(rawUrl).origin : null, + decision: { + decision: "ask" as const, + reason: "Origin requires explicit approval for provider-driven browser use.", + }, + }), +} satisfies BrowserPolicyShape); + +function makeServiceLayer(input?: { + readonly commands?: Array; + readonly resultForCommand?: ( + command: BrowserHostCommand, + ) => BrowserCommandSuccessPayload | undefined; + readonly policy?: Layer.Layer; + readonly mode?: "desktop" | "web"; +}) { + return BrowserServiceLive.pipe( + Layer.provide(makeRegistryLayer(input?.commands, input?.resultForCommand)), + Layer.provide(input?.policy ?? allowPolicyLayer), + Layer.provide(BrowserArtifactStoreLayerLive), + Layer.provide( + Layer.succeed(ServerConfig, makeTestServerConfig({ mode: input?.mode ?? "desktop" })), + ), + ); +} + +const findFailure = (exit: Exit.Exit) => + exit._tag === "Failure" ? exit.cause.reasons.find((reason) => reason._tag === "Fail") : undefined; + +function latestReadySession(commands: ReadonlyArray): BrowserSessionSnapshot { + const open = commands.find( + (command): command is Extract => + command.kind === "open_session", + ); + if (!open) throw new Error("Expected open_session command."); + return { + ...open.session, + status: "ready", + updatedAt: now, + }; +} + +describe("BrowserService", () => { + it("keeps browser sessions desktop-local", async () => { + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const service = yield* BrowserService; + return yield* service.openSession({ + threadId: ThreadId.make("thread-remote"), + }); + }).pipe(Effect.provide(makeServiceLayer({ mode: "web" }))), + ); + + const failure = findFailure(exit); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "unsupported_capability" }); + }); + + it("locks persistent project profiles to one active thread session", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const first = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + profileMode: "project", + }); + const second = yield* Effect.exit( + service.openSession({ + threadId: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-1"), + profileMode: "project", + }), + ); + return { first, second }; + }).pipe(Effect.provide(makeServiceLayer({ commands }))), + ); + + expect(result.first.status).toBe("ready"); + expect(commands).toHaveLength(1); + const failure = findFailure(result.second); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "profile_locked" }); + }); + + it("does not queue navigation commands denied by policy", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const beforeNavigateCommands = commands.length; + const denied = yield* Effect.exit( + service.navigate({ + sessionId: session.sessionId, + url: "https://denied.example/", + }), + ); + return { denied, beforeNavigateCommands, afterNavigateCommands: commands.length }; + }).pipe(Effect.provide(makeServiceLayer({ commands, policy: denyPolicyLayer }))), + ); + + expect(result.beforeNavigateCommands).toBe(1); + expect(result.afterNavigateCommands).toBe(1); + const failure = findFailure(result.denied); + expect(failure?.error).toMatchObject({ code: "origin_denied", retryable: false }); + }); + + it("blocks agent-driven navigation to ask-policy origins while allowing manual UI navigation", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const agentDenied = yield* Effect.exit( + service.navigate({ + sessionId: session.sessionId, + url: "https://example.com/", + source: "agent", + }), + ); + const uiAllowed = yield* service.navigate({ + sessionId: session.sessionId, + url: "https://example.com/docs", + source: "ui", + }); + return { agentDenied, uiAllowed, commandCount: commands.length }; + }).pipe(Effect.provide(makeServiceLayer({ commands, policy: askPolicyLayer }))), + ); + + expect(result.commandCount).toBe(2); + const failure = findFailure(result.agentDenied); + expect(failure?.error).toMatchObject({ code: "origin_denied" }); + expect(result.uiAllowed.tabs[0]?.navigation.url).toBe("https://example.com/docs"); + }); + + it("does not open browser sessions with initial URLs denied by policy", async () => { + const commands: Array = []; + const result = await Effect.runPromiseExit( + Effect.gen(function* () { + const service = yield* BrowserService; + return yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + initialUrl: "https://denied.example/", + }); + }).pipe(Effect.provide(makeServiceLayer({ commands, policy: denyPolicyLayer }))), + ); + + expect(commands).toHaveLength(0); + const failure = findFailure(result); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "origin_denied", retryable: false }); + }); + + it("dispatches storage inspection through the browser host", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const inspection = yield* service.inspectStorage({ sessionId: session.sessionId }); + return { session, inspection }; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + if (command.kind !== "inspect_storage") return undefined; + const session = latestReadySession(commands); + return { + storageInspection: { + session, + tabId: command.tabId, + profileId: session.profileId, + url: "https://example.test/", + origin: "https://example.test", + cookies: [], + localStorage: [{ key: "theme", valueBytes: 4 }], + sessionStorage: [], + cookieCounts: { currentOrigin: 0, profile: 2 }, + inspectedAt: now, + }, + }; + }, + }), + ), + ), + ); + + expect(result.inspection.cookieCounts.profile).toBe(2); + expect(commands.map((command) => command.kind)).toEqual(["open_session", "inspect_storage"]); + expect(commands[1]).toMatchObject({ + kind: "inspect_storage", + sessionId: result.session.sessionId, + tabId: result.session.selectedTabId, + }); + }); + + it("dispatches scoped storage clear through the browser host", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const clear = yield* service.clearStorage({ + sessionId: session.sessionId, + scope: "profile", + dataTypes: ["cookies", "localStorage", "httpCache"], + }); + return { clear, session }; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + if (command.kind !== "clear_storage") return undefined; + return { + storageClear: { + session: latestReadySession(commands), + scope: command.scope, + origin: null, + clearedDataTypes: command.dataTypes, + clearedAt: now, + }, + }; + }, + }), + ), + ), + ); + + expect(result.clear.clearedDataTypes).toEqual(["cookies", "localStorage", "httpCache"]); + expect(commands[1]).toMatchObject({ + kind: "clear_storage", + scope: "profile", + dataTypes: ["cookies", "localStorage", "httpCache"], + tabId: result.session.selectedTabId, + }); + }); + + it("dispatches individual cookie deletion through the browser host", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const deleted = yield* service.deleteCookie({ + sessionId: session.sessionId, + name: "sid", + domain: ".example.test", + path: "/app", + secure: true, + }); + return { deleted, session }; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + if (command.kind !== "delete_cookie") return undefined; + return { + cookieDelete: { + session: latestReadySession(commands), + deleted: true, + cookie: { + name: command.name, + domain: command.domain, + path: command.path, + secure: command.secure, + }, + deletedAt: now, + }, + }; + }, + }), + ), + ), + ); + + expect(result.deleted.deleted).toBe(true); + expect(commands[1]).toMatchObject({ + kind: "delete_cookie", + name: "sid", + domain: ".example.test", + path: "/app", + secure: true, + tabId: result.session.selectedTabId, + }); + }); + + it("does not queue storage commands for missing tabs", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const beforeInspectCommands = commands.length; + const failed = yield* Effect.exit( + service.inspectStorage({ + sessionId: session.sessionId, + tabId: BrowserTabId.make("browser-tab:missing"), + }), + ); + return { failed, beforeInspectCommands, afterInspectCommands: commands.length }; + }).pipe(Effect.provide(makeServiceLayer({ commands }))), + ); + + expect(result.beforeInspectCommands).toBe(1); + expect(result.afterInspectCommands).toBe(1); + const failure = findFailure(result.failed); + expect(failure?.error).toBeInstanceOf(BrowserServiceError); + expect(failure?.error).toMatchObject({ code: "tab_not_found" }); + }); + + it("dispatches DOM snapshot observation through the browser host", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const snapshot = yield* service.snapshotDom({ sessionId: session.sessionId }); + return { session, snapshot }; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + if (command.kind !== "snapshot_dom") return undefined; + const session = latestReadySession(commands); + return { + session, + data: { + kind: "dom_snapshot", + snapshot: { + url: "https://example.test/", + title: "Example", + viewport: { width: 1280, height: 720 }, + tree: [{ ref: "e1", tag: "body", role: "document" }], + nodeCount: 1, + }, + text: "Hello", + }, + }; + }, + }), + ), + ), + ); + + expect(result.snapshot.snapshot.title).toBe("Example"); + expect(result.snapshot.text).toBe("Hello"); + expect(commands.map((command) => command.kind)).toEqual(["open_session", "snapshot_dom"]); + }); + + it("stores screenshot payloads as browser artifacts", async () => { + const commands: Array = []; + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"); + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const screenshot = yield* service.screenshot({ sessionId: session.sessionId }); + return screenshot; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + if (command.kind !== "screenshot") return undefined; + return { + session: latestReadySession(commands), + data: { kind: "screenshot", base64: png }, + }; + }, + }), + ), + ), + ); + + expect(result.artifact.kind).toBe("screenshot"); + expect(result.artifact.byteSize).toBe(8); + expect(commands[1]?.kind).toBe("screenshot"); + }); + + it("returns console and network observations from the browser host", async () => { + const commands: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* BrowserService; + const session = yield* service.openSession({ + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }); + const consoleResult = yield* service.readConsole({ sessionId: session.sessionId }); + const networkResult = yield* service.readNetwork({ sessionId: session.sessionId }); + return { consoleResult, networkResult }; + }).pipe( + Effect.provide( + makeServiceLayer({ + commands, + resultForCommand: (command) => { + const session = latestReadySession(commands); + if (command.kind === "read_console") { + return { + session, + data: { + kind: "console", + entries: [ + { + level: "error", + message: "failed to fetch", + timestamp: now, + }, + ], + }, + }; + } + if (command.kind === "read_network") { + return { + session, + data: { + kind: "network", + entries: [ + { + requestId: "req-1", + url: "https://example.test/app.js", + method: "GET", + status: 200, + startedAt: now, + completedAt: now, + }, + ], + }, + }; + } + return undefined; + }, + }), + ), + ), + ); + + expect(result.consoleResult.entries[0]?.message).toBe("failed to fetch"); + expect(result.networkResult.entries[0]?.status).toBe(200); + expect(commands.map((command) => command.kind)).toEqual([ + "open_session", + "read_console", + "read_network", + ]); + }); +}); diff --git a/apps/server/src/browser/BrowserService.ts b/apps/server/src/browser/BrowserService.ts new file mode 100644 index 00000000000..778d79c14df --- /dev/null +++ b/apps/server/src/browser/BrowserService.ts @@ -0,0 +1,994 @@ +import { sanitizeBrowserProfileKey } from "@ryco/shared/browser"; +import { Context, Effect, Layer, Option, PubSub, Ref, Stream } from "effect"; + +import { + BrowserProfileId, + BrowserServiceError, + BrowserSessionId, + BrowserTabId, + type BrowserControlInput, + type BrowserCookieDeleteInput, + type BrowserCookieDeleteResult, + type BrowserConsoleResult, + type BrowserDomSnapshotResult, + type BrowserEvent, + type BrowserInputCommandInput, + type BrowserListProfilesResult, + type BrowserNavigateInput, + type BrowserNetworkResult, + type BrowserOpenSessionInput, + type BrowserProfile, + type BrowserProfileMode, + type BrowserScreenshotResult, + type BrowserSessionInput, + type BrowserSessionSnapshot, + type BrowserStorageClearInput, + type BrowserStorageClearResult, + type BrowserStorageInspectInput, + type BrowserStorageInspectionResult, + type BrowserStatusSnapshot, + type BrowserTabSnapshot, + type BrowserWaitForInput, + type BrowserWaitForResult, + type ThreadId, +} from "@ryco/contracts"; + +import { ServerConfig } from "../config.ts"; +import { BrowserArtifactStore } from "./BrowserArtifactStore.ts"; +import { BrowserHostRegistry } from "./BrowserHostRegistry.ts"; +import { + base64ToUint8Array, + decodeBrowserHostConsoleData, + decodeBrowserHostDomSnapshotData, + decodeBrowserHostNetworkData, + decodeBrowserHostScreenshotData, +} from "./BrowserObservationHelpers.ts"; +import { BrowserPolicy } from "./BrowserPolicy.ts"; + +interface BrowserServiceState { + readonly profiles: ReadonlyMap; + readonly sessions: ReadonlyMap; + readonly sessionOwners: ReadonlyMap; +} + +export interface BrowserServiceShape { + readonly getStatus: Effect.Effect; + readonly listProfiles: Effect.Effect; + readonly openSession: ( + input: BrowserOpenSessionInput, + ) => Effect.Effect; + readonly closeSession: ( + input: BrowserSessionInput, + ) => Effect.Effect; + readonly getSnapshot: ( + input: BrowserSessionInput, + ) => Effect.Effect; + readonly navigate: ( + input: BrowserNavigateInput, + ) => Effect.Effect; + readonly back: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly forward: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly reload: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly stop: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly input: ( + input: BrowserInputCommandInput, + ) => Effect.Effect; + readonly inspectStorage: ( + input: BrowserStorageInspectInput, + ) => Effect.Effect; + readonly clearStorage: ( + input: BrowserStorageClearInput, + ) => Effect.Effect; + readonly deleteCookie: ( + input: BrowserCookieDeleteInput, + ) => Effect.Effect; + readonly snapshotDom: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly screenshot: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly readConsole: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly readNetwork: ( + input: BrowserControlInput, + ) => Effect.Effect; + readonly waitFor: ( + input: BrowserWaitForInput, + ) => Effect.Effect; + readonly closeAgentSessionsForThread: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly events: Stream.Stream; +} + +export class BrowserService extends Context.Service()( + "ryco/browser/BrowserService", +) {} + +function nowIso(): string { + return new Date().toISOString(); +} + +function defaultMode(input: BrowserOpenSessionInput): BrowserProfileMode { + return input.profileMode ?? (input.projectId ? "project" : "thread"); +} + +function profileIdFor(input: BrowserOpenSessionInput): BrowserProfileId { + const mode = defaultMode(input); + const basis = + input.profileName ?? + (mode === "project" && input.projectId + ? `project:${input.projectId}` + : `thread:${input.threadId}`); + return BrowserProfileId.make(`browser-profile:${sanitizeBrowserProfileKey(`${mode}:${basis}`)}`); +} + +function profileFor(input: BrowserOpenSessionInput): BrowserProfile { + const timestamp = nowIso(); + const mode = defaultMode(input); + const profileId = profileIdFor(input); + return { + profileId, + displayName: input.profileName ?? (mode === "project" ? "Project" : "Thread"), + mode, + persistent: mode !== "temporary", + scope: { + mode, + threadId: input.threadId, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.profileName ? { name: input.profileName } : {}), + }, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +function blankNavigation() { + return { + url: "about:blank", + origin: null, + title: "New Tab", + loadState: "idle" as const, + canGoBack: false, + canGoForward: false, + }; +} + +function makeSession( + input: BrowserOpenSessionInput, + profile: BrowserProfile, +): BrowserSessionSnapshot { + const timestamp = nowIso(); + const sessionId = BrowserSessionId.make(`browser-session:${crypto.randomUUID()}`); + const tabId = BrowserTabId.make(`browser-tab:${crypto.randomUUID()}`); + const tab = { + tabId, + sessionId, + profileId: profile.profileId, + selected: true, + crashed: false, + navigation: blankNavigation(), + createdAt: timestamp, + updatedAt: timestamp, + } satisfies BrowserTabSnapshot; + + return { + sessionId, + profileId: profile.profileId, + threadId: input.threadId, + ...(input.projectId ? { projectId: input.projectId } : {}), + selectedTabId: tabId, + tabs: [tab], + status: "opening", + createdAt: timestamp, + updatedAt: timestamp, + } satisfies BrowserSessionSnapshot; +} + +type BrowserServiceErrorCode = + | "host_unavailable" + | "profile_locked" + | "session_not_found" + | "tab_not_found" + | "navigation_blocked" + | "origin_denied" + | "permission_denied" + | "command_timeout" + | "unsupported_capability" + | "invalid_url" + | "queue_full" + | "host_disconnected"; + +function browserError( + code: BrowserServiceErrorCode, + message: string, + retryable = false, +): BrowserServiceError { + return new BrowserServiceError({ code, message, retryable }); +} + +function selectedTab( + session: BrowserSessionSnapshot, + explicitTabId?: BrowserTabId, +): Effect.Effect { + const tabId = explicitTabId ?? session.selectedTabId; + const tab = session.tabs.find((candidate) => candidate.tabId === tabId); + if (!tab) { + return Effect.fail(browserError("tab_not_found", "Browser tab not found.")); + } + return Effect.succeed(tab); +} + +function mapCommandFailure(code: string, message: string, retryable: boolean): BrowserServiceError { + const knownCodes = new Set([ + "host_unavailable", + "profile_locked", + "session_not_found", + "tab_not_found", + "navigation_blocked", + "origin_denied", + "permission_denied", + "command_timeout", + "unsupported_capability", + "invalid_url", + "queue_full", + "host_disconnected", + ]); + const serviceCode = knownCodes.has(code as BrowserServiceErrorCode) + ? (code as BrowserServiceErrorCode) + : "unsupported_capability"; + return browserError(serviceCode, message, retryable); +} + +function enforceNavigationAccess(input: { + readonly decision: import("@ryco/contracts").BrowserToolAccessDecision; + readonly source?: "ui" | "agent"; +}): Effect.Effect { + if (input.decision.decision === "deny") { + return Effect.fail( + browserError( + "origin_denied", + input.decision.reason ?? "Navigation denied by browser policy.", + ), + ); + } + if (input.decision.decision === "ask" && input.source === "agent") { + return Effect.fail( + browserError( + "origin_denied", + input.decision.reason ?? + "Origin requires explicit approval before provider-driven browser navigation.", + false, + ), + ); + } + return Effect.void; +} + +export const BrowserServiceLive = Layer.effect( + BrowserService, + Effect.gen(function* () { + const config = yield* ServerConfig; + const registry = yield* BrowserHostRegistry; + const policy = yield* BrowserPolicy; + const artifactStoreOption = yield* Effect.serviceOption(BrowserArtifactStore); + const state = yield* Ref.make({ + profiles: new Map(), + sessions: new Map(), + sessionOwners: new Map(), + }); + const serviceEvents = yield* PubSub.unbounded(); + + const requireArtifactStore = Effect.gen(function* () { + const store = Option.getOrUndefined(artifactStoreOption); + if (!store) { + return yield* Effect.fail( + browserError("unsupported_capability", "Browser artifact store is unavailable."), + ); + } + return store; + }); + + const publish = (event: BrowserEvent) => + PubSub.publish(serviceEvents, event).pipe(Effect.asVoid); + + const failIfUnsupported = Effect.fail( + browserError( + "unsupported_capability", + "Built-in browser is available only for the local Ryco desktop backend.", + ), + ); + + const requireDesktop = config.mode === "desktop" ? Effect.void : failIfUnsupported; + + const loadSession = ( + input: + | BrowserSessionInput + | BrowserControlInput + | BrowserNavigateInput + | BrowserStorageInspectInput + | BrowserStorageClearInput + | BrowserCookieDeleteInput + | BrowserWaitForInput, + ) => + Ref.get(state).pipe( + Effect.flatMap((current) => { + const session = current.sessions.get(input.sessionId); + if (!session || session.status === "closed") { + return Effect.fail(browserError("session_not_found", "Browser session not found.")); + } + return Effect.succeed(session); + }), + ); + + const saveSession = (session: BrowserSessionSnapshot) => + Effect.gen(function* () { + yield* Ref.update(state, (current) => { + const sessions = new Map(current.sessions); + sessions.set(session.sessionId, session); + return { ...current, sessions }; + }); + yield* publish({ + type: "session.updated", + session, + createdAt: nowIso(), + }); + return session; + }); + + const executeSessionCommand = (input: { + readonly command: Parameters[0]["command"]; + readonly fallbackSession: BrowserSessionSnapshot; + }) => + registry.sendCommand({ command: input.command }).pipe( + Effect.flatMap((result) => { + if (!result.ok) { + return Effect.fail( + mapCommandFailure(result.error.code, result.error.message, result.error.retryable), + ); + } + return saveSession(result.result.session ?? input.fallbackSession); + }), + ); + + const executeInspectionCommand = (input: { + readonly command: Parameters[0]["command"]; + }) => + registry.sendCommand({ command: input.command }).pipe( + Effect.flatMap((result) => { + if (!result.ok) { + return Effect.fail( + mapCommandFailure(result.error.code, result.error.message, result.error.retryable), + ); + } + const inspection = result.result.storageInspection; + if (!inspection) { + return Effect.fail( + browserError( + "unsupported_capability", + "Browser host did not return a storage inspection payload.", + ), + ); + } + return saveSession(inspection.session).pipe(Effect.as(inspection)); + }), + ); + + const executeClearStorageCommand = (input: { + readonly command: Parameters[0]["command"]; + }) => + registry.sendCommand({ command: input.command }).pipe( + Effect.flatMap((result) => { + if (!result.ok) { + return Effect.fail( + mapCommandFailure(result.error.code, result.error.message, result.error.retryable), + ); + } + const clear = result.result.storageClear; + if (!clear) { + return Effect.fail( + browserError( + "unsupported_capability", + "Browser host did not return a storage clear payload.", + ), + ); + } + return saveSession(clear.session).pipe(Effect.as(clear)); + }), + ); + + const executeDeleteCookieCommand = (input: { + readonly command: Parameters[0]["command"]; + }) => + registry.sendCommand({ command: input.command }).pipe( + Effect.flatMap((result) => { + if (!result.ok) { + return Effect.fail( + mapCommandFailure(result.error.code, result.error.message, result.error.retryable), + ); + } + const deleted = result.result.cookieDelete; + if (!deleted) { + return Effect.fail( + browserError( + "unsupported_capability", + "Browser host did not return a cookie delete payload.", + ), + ); + } + return saveSession(deleted.session).pipe(Effect.as(deleted)); + }), + ); + + const executeHostDataCommand = (input: { + readonly command: Parameters[0]["command"]; + }) => + registry.sendCommand({ command: input.command }).pipe( + Effect.flatMap((result) => { + if (!result.ok) { + return Effect.fail( + mapCommandFailure(result.error.code, result.error.message, result.error.retryable), + ); + } + const session = result.result.session; + if (!session) { + return Effect.fail( + browserError( + "unsupported_capability", + "Browser host did not return an updated session snapshot.", + ), + ); + } + return saveSession(session).pipe(Effect.as(result.result)); + }), + ); + + const dispatchObservationCommand = (input: { + readonly session: BrowserSessionSnapshot; + readonly tab: BrowserTabSnapshot; + readonly kind: "snapshot_dom" | "screenshot" | "read_console" | "read_network"; + }) => + executeHostDataCommand({ + command: { + kind: input.kind, + sessionId: input.session.sessionId, + tabId: input.tab.tabId, + }, + }); + + const matchesWaitCondition = (input: { + readonly wait: BrowserWaitForInput; + readonly session: BrowserSessionSnapshot; + readonly tab: BrowserTabSnapshot; + readonly visibleText?: string; + }) => { + const { wait, tab, visibleText } = input; + if (wait.url !== undefined && tab.navigation.url !== wait.url) return false; + if (wait.title !== undefined && tab.navigation.title !== wait.title) return false; + if (wait.loadState !== undefined && tab.navigation.loadState !== wait.loadState) return false; + if (wait.text !== undefined && !(visibleText ?? "").includes(wait.text)) return false; + if (wait.textGone !== undefined && (visibleText ?? "").includes(wait.textGone)) return false; + return true; + }; + + const updateTabNavigation = (input: { + readonly session: BrowserSessionSnapshot; + readonly tab: BrowserTabSnapshot; + readonly url?: string; + readonly origin?: string | null; + readonly loadState?: BrowserTabSnapshot["navigation"]["loadState"]; + }) => { + const timestamp = nowIso(); + const nextTab = { + ...input.tab, + navigation: { + ...input.tab.navigation, + ...(input.url ? { url: input.url } : {}), + ...(input.origin !== undefined ? { origin: input.origin } : {}), + ...(input.loadState ? { loadState: input.loadState } : {}), + }, + updatedAt: timestamp, + } satisfies BrowserTabSnapshot; + return { + ...input.session, + status: "ready" as const, + tabs: input.session.tabs.map((tab) => (tab.tabId === nextTab.tabId ? nextTab : tab)), + selectedTabId: nextTab.tabId, + updatedAt: timestamp, + } satisfies BrowserSessionSnapshot; + }; + + return { + getStatus: Effect.gen(function* () { + const [current, host] = yield* Effect.all([Ref.get(state), registry.snapshot]); + if (config.mode !== "desktop") { + return { + supported: false, + reason: "remote_unsupported", + host: null, + sessions: [...current.sessions.values()], + } satisfies BrowserStatusSnapshot; + } + return { + supported: host.host?.connected === true, + ...(host.host?.connected === true ? {} : { reason: "desktop_host_missing" as const }), + host: host.host, + sessions: [...current.sessions.values()], + } satisfies BrowserStatusSnapshot; + }), + listProfiles: Ref.get(state).pipe( + Effect.map( + (current) => + ({ + profiles: [...current.profiles.values()], + }) satisfies BrowserListProfilesResult, + ), + ), + openSession: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const profile = profileFor(input); + const current = yield* Ref.get(state); + const existing = [...current.sessions.values()].find( + (session) => session.profileId === profile.profileId && session.status !== "closed", + ); + if (existing) { + if (existing.threadId === input.threadId) return existing; + return yield* browserError( + "profile_locked", + "Browser profile is already attached to another session.", + ); + } + + const sessionOwner = input.source === "agent" ? "agent" : "ui"; + const initialNavigation = input.initialUrl + ? yield* policy.decideNavigation({ rawUrl: input.initialUrl }) + : null; + if (initialNavigation) { + yield* enforceNavigationAccess({ + decision: initialNavigation.decision, + source: input.source ?? "ui", + }); + } + + const baseSession = makeSession(input, profile); + const initialTab = baseSession.tabs[0]; + const session = + initialNavigation && initialTab + ? updateTabNavigation({ + session: baseSession, + tab: initialTab, + url: initialNavigation.url, + origin: initialNavigation.origin, + loadState: "loading", + }) + : baseSession; + yield* Ref.update(state, (latest) => { + const profiles = new Map(latest.profiles); + profiles.set(profile.profileId, profile); + const sessions = new Map(latest.sessions); + sessions.set(session.sessionId, session); + const sessionOwners = new Map(latest.sessionOwners); + sessionOwners.set(session.sessionId, sessionOwner); + return { profiles, sessions, sessionOwners }; + }); + + return yield* executeSessionCommand({ + command: { + kind: "open_session", + session, + profile, + ...(initialNavigation ? { initialUrl: initialNavigation.url } : {}), + }, + fallbackSession: { + ...session, + status: "ready", + updatedAt: nowIso(), + }, + }); + }), + closeSession: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const closedSession = { + ...session, + status: "closed" as const, + updatedAt: nowIso(), + }; + const snapshot = yield* executeSessionCommand({ + command: { + kind: "close_session", + sessionId: session.sessionId, + }, + fallbackSession: closedSession, + }); + yield* Ref.update(state, (current) => { + const sessionOwners = new Map(current.sessionOwners); + sessionOwners.delete(session.sessionId); + return { ...current, sessionOwners }; + }); + yield* publish({ + type: "session.closed", + sessionId: snapshot.sessionId, + createdAt: nowIso(), + }); + return snapshot; + }), + closeAgentSessionsForThread: (threadId) => + Effect.gen(function* () { + yield* requireDesktop; + const current = yield* Ref.get(state); + const agentSessions = [...current.sessions.values()].filter( + (session) => + session.threadId === threadId && + session.status !== "closed" && + current.sessionOwners.get(session.sessionId) === "agent", + ); + yield* Effect.forEach( + agentSessions, + (session) => + Effect.gen(function* () { + const closedSession = { + ...session, + status: "closed" as const, + updatedAt: nowIso(), + }; + const snapshot = yield* executeSessionCommand({ + command: { + kind: "close_session", + sessionId: session.sessionId, + }, + fallbackSession: closedSession, + }).pipe( + Effect.catch(() => saveSession(closedSession).pipe(Effect.as(closedSession))), + ); + yield* Ref.update(state, (latest) => { + const sessionOwners = new Map(latest.sessionOwners); + sessionOwners.delete(session.sessionId); + return { ...latest, sessionOwners }; + }); + yield* publish({ + type: "session.closed", + sessionId: snapshot.sessionId, + createdAt: nowIso(), + }); + }), + { concurrency: 1, discard: true }, + ); + }), + getSnapshot: loadSession, + navigate: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const decision = yield* policy.decideNavigation({ rawUrl: input.url }); + yield* enforceNavigationAccess({ + decision: decision.decision, + source: input.source ?? "ui", + }); + const fallbackSession = updateTabNavigation({ + session, + tab, + url: decision.url, + origin: decision.origin, + loadState: "loading", + }); + return yield* executeSessionCommand({ + command: { + kind: "navigate", + sessionId: session.sessionId, + tabId: tab.tabId, + url: decision.url, + }, + fallbackSession, + }); + }), + back: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeSessionCommand({ + command: { kind: "back", sessionId: session.sessionId, tabId: tab.tabId }, + fallbackSession: session, + }); + }), + forward: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeSessionCommand({ + command: { kind: "forward", sessionId: session.sessionId, tabId: tab.tabId }, + fallbackSession: session, + }); + }), + reload: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeSessionCommand({ + command: { kind: "reload", sessionId: session.sessionId, tabId: tab.tabId }, + fallbackSession: session, + }); + }), + stop: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeSessionCommand({ + command: { kind: "stop", sessionId: session.sessionId, tabId: tab.tabId }, + fallbackSession: session, + }); + }), + input: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeSessionCommand({ + command: { + kind: "input", + sessionId: session.sessionId, + tabId: tab.tabId, + action: input.action, + }, + fallbackSession: session, + }); + }), + inspectStorage: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeInspectionCommand({ + command: { + kind: "inspect_storage", + sessionId: session.sessionId, + tabId: tab.tabId, + }, + }); + }), + clearStorage: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeClearStorageCommand({ + command: { + kind: "clear_storage", + sessionId: session.sessionId, + tabId: tab.tabId, + scope: input.scope, + dataTypes: input.dataTypes, + }, + }); + }), + deleteCookie: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + return yield* executeDeleteCookieCommand({ + command: { + kind: "delete_cookie", + sessionId: session.sessionId, + tabId: tab.tabId, + ...(input.url ? { url: input.url } : {}), + name: input.name, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.path ? { path: input.path } : {}), + ...(input.secure !== undefined ? { secure: input.secure } : {}), + }, + }); + }), + snapshotDom: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const payload = yield* dispatchObservationCommand({ + session, + tab, + kind: "snapshot_dom", + }); + const data = decodeBrowserHostDomSnapshotData(payload.data); + if (!data) { + return yield* browserError( + "unsupported_capability", + "Browser host did not return a DOM snapshot payload.", + ); + } + const updatedSession = payload.session ?? session; + const snapshotJson = JSON.stringify(data.snapshot); + if (snapshotJson.length > 65_536) { + const artifactStore = yield* requireArtifactStore; + const artifact = yield* artifactStore + .put({ + kind: "dom_snapshot", + mimeType: "application/json", + data: new TextEncoder().encode(snapshotJson), + profileId: updatedSession.profileId, + sessionId: updatedSession.sessionId, + tabId: tab.tabId, + url: data.snapshot.url, + origin: tab.navigation.origin, + }) + .pipe( + Effect.mapError((error) => + browserError( + "unsupported_capability", + error instanceof Error ? error.message : "Failed to store browser artifact.", + true, + ), + ), + ); + return { + session: updatedSession, + snapshot: { + url: data.snapshot.url, + title: data.snapshot.title, + viewport: data.snapshot.viewport, + tree: [], + truncated: true, + nodeCount: data.snapshot.nodeCount, + }, + ...(data.text ? { text: data.text } : {}), + artifact, + } satisfies BrowserDomSnapshotResult; + } + return { + session: updatedSession, + snapshot: data.snapshot, + ...(data.text ? { text: data.text } : {}), + } satisfies BrowserDomSnapshotResult; + }), + screenshot: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const payload = yield* dispatchObservationCommand({ + session, + tab, + kind: "screenshot", + }); + const data = decodeBrowserHostScreenshotData(payload.data); + if (!data) { + return yield* browserError( + "unsupported_capability", + "Browser host did not return a screenshot payload.", + ); + } + const updatedSession = payload.session ?? session; + const bytes = base64ToUint8Array(data.base64); + const artifactStore = yield* requireArtifactStore; + const artifact = yield* artifactStore + .put({ + kind: "screenshot", + mimeType: "image/png", + data: bytes, + profileId: updatedSession.profileId, + sessionId: updatedSession.sessionId, + tabId: tab.tabId, + url: tab.navigation.url, + origin: tab.navigation.origin, + }) + .pipe( + Effect.mapError((error) => + browserError( + "unsupported_capability", + error instanceof Error ? error.message : "Failed to store browser screenshot.", + true, + ), + ), + ); + return { + session: updatedSession, + artifact, + } satisfies BrowserScreenshotResult; + }), + readConsole: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const payload = yield* dispatchObservationCommand({ + session, + tab, + kind: "read_console", + }); + const data = decodeBrowserHostConsoleData(payload.data); + if (!data) { + return yield* browserError( + "unsupported_capability", + "Browser host did not return a console payload.", + ); + } + return { + session: payload.session ?? session, + entries: data.entries, + } satisfies BrowserConsoleResult; + }), + readNetwork: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const payload = yield* dispatchObservationCommand({ + session, + tab, + kind: "read_network", + }); + const data = decodeBrowserHostNetworkData(payload.data); + if (!data) { + return yield* browserError( + "unsupported_capability", + "Browser host did not return a network payload.", + ); + } + return { + session: payload.session ?? session, + entries: data.entries, + } satisfies BrowserNetworkResult; + }), + waitFor: (input) => + Effect.gen(function* () { + yield* requireDesktop; + const timeoutMs = input.timeoutMs ?? 30_000; + const startedAt = Date.now(); + const needsVisibleText = input.text !== undefined || input.textGone !== undefined; + while (Date.now() - startedAt < timeoutMs) { + const session = yield* loadSession(input); + const tab = yield* selectedTab(session, input.tabId); + const visibleText = needsVisibleText + ? yield* dispatchObservationCommand({ session, tab, kind: "snapshot_dom" }).pipe( + Effect.flatMap((payload) => { + const data = decodeBrowserHostDomSnapshotData(payload.data); + return Effect.succeed(data?.text ?? payload.text ?? ""); + }), + ) + : undefined; + if ( + matchesWaitCondition({ + wait: input, + session, + tab, + ...(visibleText !== undefined ? { visibleText } : {}), + }) + ) { + return { + session, + matched: true, + waitedMs: Date.now() - startedAt, + } satisfies BrowserWaitForResult; + } + yield* Effect.sleep(250); + } + const session = yield* loadSession(input); + return { + session, + matched: false, + waitedMs: Date.now() - startedAt, + } satisfies BrowserWaitForResult; + }), + get events() { + return Stream.merge(registry.eventStream, Stream.fromPubSub(serviceEvents)); + }, + } satisfies BrowserServiceShape; + }), +); diff --git a/apps/server/src/browser/BrowserThreadCleanup.ts b/apps/server/src/browser/BrowserThreadCleanup.ts new file mode 100644 index 00000000000..2219f069bea --- /dev/null +++ b/apps/server/src/browser/BrowserThreadCleanup.ts @@ -0,0 +1,32 @@ +import type { ThreadId } from "@ryco/contracts"; +import { Effect, Option } from "effect"; + +import { BrowserMcpBridge } from "./BrowserMcpBridge.ts"; +import { BrowserService } from "./BrowserService.ts"; + +export function cleanupBrowserThreadAfterProviderTurn( + threadId: ThreadId, + options?: { + readonly stopBridge?: boolean; + readonly closeAgentSessions?: boolean; + }, +): Effect.Effect { + return Effect.gen(function* () { + if (options?.stopBridge !== false) { + const bridge = yield* Effect.serviceOption(BrowserMcpBridge); + yield* Option.match(bridge, { + onNone: () => Effect.void, + onSome: (service) => service.stop(threadId).pipe(Effect.ignore), + }); + } + + if (options?.closeAgentSessions !== false) { + const browser = yield* Effect.serviceOption(BrowserService); + yield* Option.match(browser, { + onNone: () => Effect.void, + onSome: (service) => + service.closeAgentSessionsForThread(threadId).pipe(Effect.catch(() => Effect.void)), + }); + } + }); +} diff --git a/apps/server/src/browser/browser-mcp-stdio.ts b/apps/server/src/browser/browser-mcp-stdio.ts new file mode 100644 index 00000000000..40e09562519 --- /dev/null +++ b/apps/server/src/browser/browser-mcp-stdio.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { BROWSER_RUNTIME_TOOL_DEFINITIONS } from "../provider/tools/BrowserRuntimeTool.ts"; +import { browserMcpBridgeRequest } from "./BrowserMcpBridge.ts"; + +const socketPath = process.env.RYCO_BROWSER_MCP_SOCKET; +if (!socketPath) { + console.error("RYCO_BROWSER_MCP_SOCKET is required."); + process.exit(1); +} + +const server = new McpServer({ + name: "ryco", + version: "1.0.0", +}); + +for (const definition of BROWSER_RUNTIME_TOOL_DEFINITIONS) { + server.registerTool( + definition.name, + { + description: definition.description, + inputSchema: definition.input as never, + }, + // MCP SDK tool handler typing is overly strict for JSON-schema inputs. + (async (args: Record) => { + const result = await browserMcpBridgeRequest({ + socketPath, + toolName: definition.name, + arguments: args, + }); + return { + content: [{ type: "text", text: JSON.stringify(result) }], + structuredContent: + result !== null && typeof result === "object" + ? (result as Record) + : { result }, + }; + }) as never, + ); +} + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/apps/server/src/browserHost/browserHostRpc.ts b/apps/server/src/browserHost/browserHostRpc.ts new file mode 100644 index 00000000000..b1f1aede2d3 --- /dev/null +++ b/apps/server/src/browserHost/browserHostRpc.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect"; +import { BrowserHostRpcGroup, BROWSER_HOST_METHODS } from "@ryco/contracts"; + +import { BrowserHostRegistry } from "../browser/BrowserHostRegistry.ts"; + +export const makeBrowserHostRpcLayer = () => + BrowserHostRpcGroup.toLayer( + Effect.gen(function* () { + const registry = yield* BrowserHostRegistry; + return BrowserHostRpcGroup.of({ + [BROWSER_HOST_METHODS.register]: (input) => registry.register(input), + [BROWSER_HOST_METHODS.heartbeat]: (input) => registry.heartbeat(input).pipe(Effect.as({})), + [BROWSER_HOST_METHODS.subscribeCommands]: (input) => registry.commandStream(input), + [BROWSER_HOST_METHODS.commandResult]: (input) => + registry + .completeCommand({ + hostId: input.hostId, + runId: input.runId, + result: input.result, + }) + .pipe(Effect.as({})), + [BROWSER_HOST_METHODS.event]: (input) => + registry + .publishHostEvent({ + hostId: input.hostId, + runId: input.runId, + event: input.event, + }) + .pipe(Effect.as({})), + }); + }), + ); diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index ad96e690b69..ba0f4755216 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -74,6 +74,7 @@ const makeCliTestServerConfig = (baseDir: string) => noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + desktopBrowserHostToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 5cf93f0f475..f47ce687c80 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -77,6 +77,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ devUrl: Schema.optional(Schema.URLFromString), noBrowser: Schema.optional(Schema.Boolean), desktopBootstrapToken: Schema.optional(Schema.String), + desktopBrowserHostToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), tailscaleServeEnabled: Schema.optional(Schema.Boolean), @@ -343,6 +344,7 @@ export const resolveServerConfig = ( () => mode === "desktop", ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const desktopBrowserHostToken = bootstrap?.desktopBrowserHostToken; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), @@ -422,6 +424,7 @@ export const resolveServerConfig = ( noBrowser, startupPresentation, desktopBootstrapToken, + desktopBrowserHostToken, autoBootstrapProjectFromCwd, logWebSocketEvents, tailscaleServeEnabled, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d725979d4ed..867efa82e00 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -63,6 +63,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; + readonly desktopBrowserHostToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; @@ -166,6 +167,7 @@ export class ServerConfig extends Context.Service { noBrowser: true, startupPresentation: "headless", desktopBootstrapToken: undefined, + desktopBrowserHostToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index fb42c2f95f1..916e3037264 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -35,6 +35,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { port: 0, host: undefined, desktopBootstrapToken: undefined, + desktopBrowserHostToken: undefined, staticDir: undefined, devUrl: undefined, noBrowser: false, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e90c7b36fec..ace1be5daee 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -29,6 +29,11 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { + CLAUDE_BROWSER_MCP_SERVER_NAME, + claudeBrowserMcpToolName, +} from "../tools/ClaudeBrowserRuntimeTools.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. @@ -140,6 +145,10 @@ class FakeClaudeQuery implements AsyncIterable { } } +function makeTestProviderRuntimeToolRegistryLayer() { + return browserRuntimeToolTestLayers; +} + function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; @@ -190,6 +199,7 @@ function makeHarness(config?: { ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(makeTestProviderRuntimeToolRegistryLayer()), ), query, getLastCreateQueryInput: () => createInput, @@ -1397,6 +1407,7 @@ describe("ClaudeAdapterLive", () => { Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(makeTestProviderRuntimeToolRegistryLayer()), ); return Effect.gen(function* () { @@ -1488,6 +1499,7 @@ describe("ClaudeAdapterLive", () => { Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(makeTestProviderRuntimeToolRegistryLayer()), ); return Effect.gen(function* () { @@ -2708,6 +2720,49 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("registers Ryco browser MCP tools and auto-allows them through canUseTool", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal( + createInput?.options.mcpServers?.[CLAUDE_BROWSER_MCP_SERVER_NAME] !== undefined, + true, + ); + + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionResult = yield* Effect.promise(() => + canUseTool( + claudeBrowserMcpToolName("browser_open"), + { initialUrl: "https://example.com/" }, + { + signal: new AbortController().signal, + toolUseID: "tool-browser-open-1", + }, + ), + ); + + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b56c3a94ba8..4835673662a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -62,6 +62,7 @@ import { Exit, FileSystem, Fiber, + Option, Path, Queue, Random, @@ -91,6 +92,15 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { + CLAUDE_BROWSER_MCP_SERVER_NAME, + isClaudeBrowserMcpToolName, + makeClaudeBrowserMcpServer, +} from "../tools/ClaudeBrowserRuntimeTools.ts"; +import { resolveProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; +import { cleanupBrowserThreadAfterProviderTurn } from "../../browser/BrowserThreadCleanup.ts"; +import { BrowserArtifactStore } from "../../browser/BrowserArtifactStore.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("claudeAgent"); @@ -1006,6 +1016,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const providerRuntimeToolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); + const browserArtifactStore = yield* Effect.serviceOption(BrowserArtifactStore); const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); @@ -1609,6 +1621,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), }; yield* updateResumeCursor(context); + yield* cleanupBrowserThreadAfterProviderTurn(context.session.threadId, { + stopBridge: false, + }).pipe(Effect.ignore); }); const handleStreamEvent = Effect.fn("handleStreamEvent")(function* ( @@ -2778,6 +2793,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } + if (isClaudeBrowserMcpToolName(toolName)) { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + const runtimeMode = input.runtimeMode ?? "full-access"; if (runtimeMode === "full-access") { return { @@ -2934,6 +2956,35 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( .map((part) => part?.trim()) .filter((part): part is string => part !== undefined && part.length > 0) .join("\n\n"); + const browserMcpServer = Option.match(providerRuntimeToolRegistry, { + onNone: () => undefined, + onSome: (registry) => + makeClaudeBrowserMcpServer({ + threadId, + executeBrowserTool: (toolInput, execOptions) => + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + return yield* registry.executeBrowserTool(toolInput, { + ...execOptions, + lifecycle: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + ...(context?.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + ...execOptions?.lifecycle, + }, + }); + }), + runPromise, + ...(Option.isSome(browserArtifactStore) + ? { + readArtifactData: (artifactId) => + runPromise(browserArtifactStore.value.readData(artifactId)), + } + : {}), + }), + }); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -2955,6 +3006,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + ...(browserMcpServer + ? { + mcpServers: { + [CLAUDE_BROWSER_MCP_SERVER_NAME]: browserMcpServer, + }, + } + : {}), env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), @@ -3316,6 +3374,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + runtimeTools: { + browser: resolveProviderBrowserToolSupport(PROVIDER), + }, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 61c9fdb5c61..ab3e509a134 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -40,12 +40,14 @@ import { type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; import { makeCodexAdapter } from "./CodexAdapter.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; -// Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. class CodexAdapter extends Context.Service()( "test/CodexAdapter", ) {} +const providerRuntimeToolRegistryTestLayer = browserRuntimeToolTestLayers; + const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -221,6 +223,7 @@ function makeCodexAdapterTestLayer(input: { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ); } @@ -248,6 +251,7 @@ const validationLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ), ); @@ -323,6 +327,7 @@ const sessionErrorLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ), ); @@ -395,6 +400,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ); return Effect.gen(function* () { @@ -593,6 +599,7 @@ const lifecycleLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ), ); @@ -1172,6 +1179,7 @@ const scopedLifecycleLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ), ); @@ -1216,6 +1224,7 @@ const scopedFailureLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ), ); @@ -1266,15 +1275,18 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerRuntimeToolRegistryTestLayer), ); const context = yield* Layer.buildWithScope(layer, scope); const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-logger"), - runtimeMode: "full-access", - }); + yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-logger"), + runtimeMode: "full-access", + }) + .pipe(Effect.provide(context)); const runtime = runtimeFactory.lastRuntime; assert.ok(runtime); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 47c0ec3f174..8a69cc3bf97 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -28,7 +28,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@ryco/contracts"; -import { Effect, Exit, Fiber, FileSystem, Queue, Schema, Scope, Stream } from "effect"; +import { Effect, Exit, Fiber, FileSystem, Option, Queue, Schema, Scope, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -48,6 +48,8 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { resolveProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { makeServerQueueMetrics } from "../../observability/QueueMetrics.ts"; @@ -1397,6 +1399,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, providerInstanceId: boundInstanceId, }); + const providerRuntimeToolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); const sessions = new Map(); const startSession: CodexAdapterShape["startSession"] = (input) => @@ -1444,9 +1447,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), ); const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; - const runtime = yield* createRuntime(runtimeInput).pipe( + const runtimeWithServices = createRuntime(runtimeInput).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + const runtime = yield* Option.match(providerRuntimeToolRegistry, { + onNone: () => runtimeWithServices, + onSome: (registry) => + runtimeWithServices.pipe(Effect.provideService(ProviderRuntimeToolRegistry, registry)), + }).pipe( Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -1810,6 +1819,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + runtimeTools: { + browser: resolveProviderBrowserToolSupport(PROVIDER), + }, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b9cee096c65..3c17666f816 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,7 +18,19 @@ import { TurnId, } from "@ryco/contracts"; import { normalizeModelSlug } from "@ryco/shared/model"; -import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Random, Schema, Stream } from "effect"; +import { + Deferred, + Effect, + Exit, + Layer, + Option, + Queue, + Ref, + Scope, + Random, + Schema, + Stream, +} from "effect"; import * as SchemaIssue from "effect/SchemaIssue"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -33,6 +45,12 @@ import { CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; import { appendProjectCustomSystemPrompt } from "../ProjectCustomSystemPrompt.ts"; +import { cleanupBrowserThreadAfterProviderTurn } from "../../browser/BrowserThreadCleanup.ts"; +import { + parseBrowserRuntimeToolCallInput, + type BrowserRuntimeToolCallError, +} from "../tools/BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; const PROVIDER = ProviderDriverKind.make("codex"); @@ -44,6 +62,10 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ "state db missing rollout path for thread", "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", ]; +// Codex exposes `DynamicToolSpec` in effect-codex-app-server, but generated +// `V2ThreadStartParams` / `V2TurnStartParams` do not yet include a `dynamicTools` +// field. Re-run schema generation once upstream wires tool registration into +// thread/start; until then Ryco only handles inbound `item/tool/call` requests. const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "not found", "missing thread", @@ -663,6 +685,31 @@ function toProtocolParseError( }); } +function formatBrowserRuntimeToolCallError(error: BrowserRuntimeToolCallError): string { + return error.message; +} + +function toDynamicToolCallResponse( + input: + | { + readonly success: true; + readonly result: unknown; + } + | { + readonly success: false; + readonly message: string; + }, +): EffectCodexSchema.DynamicToolCallResponse { + const text = + input.success === true + ? JSON.stringify(input.result) + : JSON.stringify({ error: input.message }); + return { + success: input.success, + contentItems: [{ type: "inputText", text }], + }; +} + function currentProviderThreadId(session: ProviderSession): string | undefined { return readResumeCursorThreadId(session.resumeCursor); } @@ -700,6 +747,7 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; + const toolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); const events = yield* Queue.unbounded(); const pendingApprovalsRef = yield* Ref.make(new Map()); const approvalCorrelationsRef = yield* Ref.make(new Map()); @@ -904,7 +952,13 @@ export const makeCodexSessionRuntime = ( status: payload.turn.status === "failed" ? "error" : "ready", activeTurnId: undefined, ...(lastError ? { lastError } : {}), - }); + }).pipe( + Effect.andThen( + cleanupBrowserThreadAfterProviderTurn(options.threadId, { + stopBridge: false, + }).pipe(Effect.ignore), + ), + ); }), ), ); @@ -1088,6 +1142,78 @@ export const makeCodexSessionRuntime = ( }), ); + yield* client.handleServerRequest("item/tool/call", (payload) => + Effect.gen(function* () { + if (!payload.tool.startsWith("browser_")) { + return yield* CodexErrors.CodexAppServerRequestError.methodNotFound( + `item/tool/call:${payload.tool}`, + ); + } + + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); + const turnId = TurnId.make(payload.turnId); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/tool/call", + requestId, + turnId, + payload, + }); + + const toolInput = yield* parseBrowserRuntimeToolCallInput({ + toolName: payload.tool, + threadId: options.threadId, + arguments: payload.arguments, + }).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidParams(error.message, { + tool: payload.tool, + code: error.code, + }), + ), + ); + + const result = yield* Option.match(toolRegistry, { + onNone: () => + Effect.succeed( + toDynamicToolCallResponse({ + success: false, + message: "Browser runtime tools are unavailable.", + }), + ), + onSome: (registry) => + registry + .executeBrowserTool(toolInput, { + lifecycle: { + provider: PROVIDER, + ...(options.providerInstanceId + ? { providerInstanceId: options.providerInstanceId } + : {}), + turnId, + }, + }) + .pipe( + Effect.match({ + onFailure: (error) => + toDynamicToolCallResponse({ + success: false, + message: formatBrowserRuntimeToolCallError(error), + }), + onSuccess: (snapshot) => + toDynamicToolCallResponse({ + success: true, + result: snapshot, + }), + }), + ), + }); + + return result satisfies EffectCodexSchema.DynamicToolCallResponse; + }), + ); + yield* client.handleUnknownServerRequest((method) => Effect.fail(CodexErrors.CodexAppServerRequestError.methodNotFound(method)), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts b/apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts index 6a97376b9b3..a6d0eb531b7 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.mapEvent.ts @@ -215,6 +215,7 @@ export const mapEvent = ( ]; case "tool.execution_start": { const toolName = event.data.toolName ?? ""; + const isBrowserTool = toolName.startsWith("browser_"); const isMcpTool = event.data.mcpToolName !== undefined; return [ { @@ -229,7 +230,11 @@ export const mapEvent = ( }), type: "item.started", payload: { - itemType: isMcpTool ? "mcp_tool_call" : "dynamic_tool_call", + itemType: isBrowserTool + ? "browser_tool_call" + : isMcpTool + ? "mcp_tool_call" + : "dynamic_tool_call", status: "inProgress", title: toolName, ...(event.data.arguments ? { data: event.data.arguments } : {}), @@ -238,6 +243,11 @@ export const mapEvent = ( ]; } case "tool.execution_complete": { + const toolName = + "toolName" in event.data && typeof event.data.toolName === "string" + ? event.data.toolName + : ""; + const isBrowserTool = toolName.startsWith("browser_"); const isMcpTool = "mcpToolName" in event.data && event.data.mcpToolName !== undefined; return [ { @@ -252,7 +262,11 @@ export const mapEvent = ( }), type: "item.completed", payload: { - itemType: isMcpTool ? "mcp_tool_call" : "dynamic_tool_call", + itemType: isBrowserTool + ? "browser_tool_call" + : isMcpTool + ? "mcp_tool_call" + : "dynamic_tool_call", status: event.data.success ? "completed" : "failed", title: "Tool call", ...((event.data.result?.detailedContent ?? diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 18555fc795f..37eeb84fd26 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -11,7 +11,7 @@ import { type UserInputQuestion, } from "@ryco/contracts"; import type { PermissionRequestResult, SessionConfig, SessionEvent } from "@github/copilot-sdk"; -import { Effect, Queue, Random, Stream } from "effect"; +import { Effect, Option, Queue, Random, Stream } from "effect"; import { ServerConfig } from "../../config.ts"; import { makeServerQueueMetrics } from "../../observability/QueueMetrics.ts"; @@ -34,6 +34,11 @@ import { selectionTargetsCopilotInstance, } from "./CopilotAdapter.types.ts"; import { mapEvent, type MapEventDeps } from "./CopilotAdapter.mapEvent.ts"; +import { cleanupBrowserThreadAfterProviderTurn } from "../../browser/BrowserThreadCleanup.ts"; +import { BrowserArtifactStore } from "../../browser/BrowserArtifactStore.ts"; +import { resolveProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; +import { makeCopilotBrowserTools } from "../tools/CopilotBrowserRuntimeTools.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; import { type SessionOpsDeps, makeHasSession, @@ -57,6 +62,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( options?: CopilotAdapterLiveOptions, ) { const serverConfig = yield* ServerConfig; + const providerRuntimeToolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); + const browserArtifactStore = yield* Effect.serviceOption(BrowserArtifactStore); const instanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); const nativeEventLogger = options?.nativeEventLogger ?? @@ -192,6 +199,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } if (clearsActiveTurn) { + yield* cleanupBrowserThreadAfterProviderTurn(session.threadId, { + stopBridge: false, + }).pipe(Effect.ignore); session.activeTurnId = undefined; session.activeMessageId = undefined; } @@ -224,15 +234,36 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : {}), ...(input.cwd ? { workingDirectory: input.cwd } : {}), streaming: true, - systemMessage: { - mode: "append", - content: - "You have access to a Chromium browser in this environment. " + - "Use it when the task requires live web interaction, navigation, UI verification, login flows, repros, scraping, or screenshots. " + - "Prefer codebase inspection first when the task is local-only. " + - "Summarize what was verified, including URL and important observations. " + - "Avoid unnecessary browser use when terminal or file tools are sufficient.", - }, + tools: Option.match(providerRuntimeToolRegistry, { + onNone: () => [], + onSome: (registry) => + makeCopilotBrowserTools({ + threadId: input.threadId, + executeBrowserTool: (toolInput, execOptions) => { + const activeTurn = activeTurnId(); + return registry.executeBrowserTool(toolInput, { + ...execOptions, + lifecycle: activeTurn + ? { + provider: COPILOT_DRIVER_KIND, + providerInstanceId: instanceId, + turnId: activeTurn, + } + : { + provider: COPILOT_DRIVER_KIND, + providerInstanceId: instanceId, + }, + }); + }, + runPromise: (effect) => Effect.runPromise(effect), + ...(Option.isSome(browserArtifactStore) + ? { + readArtifactData: (artifactId) => + Effect.runPromise(browserArtifactStore.value.readData(artifactId)), + } + : {}), + }), + }), onPermissionRequest: (request) => new Promise((resolve) => { const requestId = randomUUID(); @@ -420,6 +451,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( provider: COPILOT_DRIVER_KIND, capabilities: { sessionModelSwitch: "in-session", + runtimeTools: { + browser: resolveProviderBrowserToolSupport(COPILOT_DRIVER_KIND), + }, }, startSession: makeStartSession(sessionDeps), sendTurn: makeSendTurn(sessionDeps), diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index aa962b4cdf5..08d97a26e81 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -21,6 +21,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. class CursorAdapter extends Context.Service()( @@ -135,6 +136,7 @@ const cursorAdapterTestLayer = it.layer( }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(browserRuntimeToolTestLayers), ), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7406453f8e4..d397ef30b6d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { BrowserMcpBridge } from "../../browser/BrowserMcpBridge.ts"; +import { cleanupBrowserThreadAfterProviderTurn } from "../../browser/BrowserThreadCleanup.ts"; +import { makeAcpBrowserMcpServer } from "../../browser/BrowserRuntimeMcpConfig.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderAdapterProcessError, @@ -75,6 +78,8 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { resolveProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -309,6 +314,13 @@ export function makeCursorAdapter( const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const providerRuntimeToolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); + const browserMcpBridge = yield* Effect.serviceOption(BrowserMcpBridge); + const stopBrowserBridgeForThread = (threadId: ThreadId) => + Option.match(browserMcpBridge, { + onNone: () => Effect.void, + onSome: (bridge) => bridge.stop(threadId), + }); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -432,6 +444,7 @@ export function makeCursorAdapter( yield* Fiber.interrupt(ctx.notificationFiber); } yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + yield* stopBrowserBridgeForThread(ctx.threadId); sessions.delete(ctx.threadId); yield* offerRuntimeEvent({ type: "session.exited", @@ -497,12 +510,59 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const ctxHolder: { current?: CursorSessionContext } = {}; + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + const browserMcpServers: Array = []; + if (serverConfig.mode === "desktop") { + yield* Option.match(browserMcpBridge, { + onNone: () => Effect.void, + onSome: (bridge) => + Option.match(providerRuntimeToolRegistry, { + onNone: () => Effect.void, + onSome: (registry) => + Effect.gen(function* () { + const bridgeHandle = yield* bridge + .start({ + threadId: input.threadId, + runPromise, + executeBrowserTool: (toolInput) => + registry.executeBrowserTool(toolInput, { + lifecycle: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + ...(ctxHolder.current?.activeTurnId + ? { turnId: ctxHolder.current.activeTurnId } + : {}), + }, + }), + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + browserMcpServers.push( + makeAcpBrowserMcpServer({ socketPath: bridgeHandle.socketPath }), + ); + }), + }), + }); + } + const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), + ...(browserMcpServers.length > 0 ? { mcpServers: browserMcpServers } : {}), clientInfo: { name: "ryco", version: "0.0.0" }, ...acpNativeLoggers, }).pipe( @@ -718,6 +778,7 @@ export function makeCursorAdapter( activeTurnId: undefined, stopped: false, }; + ctxHolder.current = ctx; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => @@ -949,6 +1010,10 @@ export function makeCursorAdapter( }, }); + yield* cleanupBrowserThreadAfterProviderTurn(input.threadId, { + stopBridge: false, + }).pipe(Effect.ignore); + return { threadId: input.threadId, turnId, @@ -1059,7 +1124,12 @@ export function makeCursorAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { + sessionModelSwitch: "in-session", + runtimeTools: { + browser: resolveProviderBrowserToolSupport(PROVIDER), + }, + }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 78962400f0f..dffa2606529 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -24,9 +24,10 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, - makeOpenCodeAdapter, mergeOpenCodeAssistantText, + makeOpenCodeAdapter, } from "./OpenCodeAdapter.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. class OpenCodeAdapter extends Context.Service()( @@ -210,6 +211,7 @@ const OpenCodeAdapterTestLayer = Layer.effect( }), ), Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(browserRuntimeToolTestLayers), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index cc16030fa75..d17e5581629 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -13,7 +13,7 @@ import { TurnId, type UserInputQuestion, } from "@ryco/contracts"; -import { Cause, Effect, Exit, Queue, Random, Ref, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Option, Queue, Random, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@ryco/shared/model"; import { formatSourceControlContextsForAgent } from "@ryco/shared/sourceControlContextFormatter"; @@ -30,6 +30,12 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { resolveProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "../tools/ProviderRuntimeToolRegistry.ts"; +import { BrowserMcpBridge } from "../../browser/BrowserMcpBridge.ts"; +import { cleanupBrowserThreadAfterProviderTurn } from "../../browser/BrowserThreadCleanup.ts"; +import { makeOpenCodeBrowserMcpConfig } from "../../browser/BrowserRuntimeMcpConfig.ts"; +import { RYCO_BROWSER_MCP_SERVER_NAME } from "../tools/BrowserRuntimeToolHelpers.ts"; import { buildOpenCodePermissionRules, OpenCodeRuntime, @@ -165,6 +171,9 @@ const buildEventBase = (input: { function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); + if (normalized.startsWith("browser_") || normalized.includes("ryco__browser")) { + return "browser_tool_call"; + } if (normalized.includes("bash") || normalized.includes("command")) { return "command_execution"; } @@ -458,6 +467,64 @@ export function makeOpenCodeAdapter( const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; + const providerRuntimeToolRegistry = yield* Effect.serviceOption(ProviderRuntimeToolRegistry); + const browserMcpBridge = yield* Effect.serviceOption(BrowserMcpBridge); + const stopBrowserBridgeForThread = (threadId: ThreadId) => + Option.match(browserMcpBridge, { + onNone: () => Effect.void, + onSome: (bridge) => bridge.stop(threadId), + }); + + const connectOpenCodeBrowserMcp = ( + context: OpenCodeSessionContext, + directory: string, + ): Effect.Effect => + Effect.gen(function* () { + if (serverConfig.mode !== "desktop") return; + yield* Option.match(browserMcpBridge, { + onNone: () => Effect.void, + onSome: (bridge) => + Option.match(providerRuntimeToolRegistry, { + onNone: () => Effect.void, + onSome: (registry) => + Effect.gen(function* () { + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + const bridgeHandle = yield* bridge + .start({ + threadId: context.session.threadId, + runPromise, + executeBrowserTool: (toolInput) => + registry.executeBrowserTool(toolInput, { + lifecycle: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + }, + }), + }) + .pipe( + Effect.mapError((cause) => toProcessError(context.session.threadId, cause)), + ); + yield* runOpenCodeSdk("mcp.add", () => + context.client.mcp.add({ + name: RYCO_BROWSER_MCP_SERVER_NAME, + config: makeOpenCodeBrowserMcpConfig({ + socketPath: bridgeHandle.socketPath, + }), + directory, + }), + ).pipe(Effect.mapError(toRequestError)); + yield* runOpenCodeSdk("mcp.connect", () => + context.client.mcp.connect({ + name: RYCO_BROWSER_MCP_SERVER_NAME, + directory, + }), + ).pipe(Effect.mapError(toRequestError), Effect.ignore); + }), + }), + }); + }); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -493,7 +560,13 @@ export function makeOpenCodeAdapter( // the remaining cleanups. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => + Effect.ignoreCause( + Effect.gen(function* () { + yield* stopBrowserBridgeForThread(context.session.threadId); + yield* stopOpenCodeContext(context); + }), + ), { concurrency: "unbounded", discard: true }, ); // Close the logger AFTER session teardown so any final lifecycle @@ -914,6 +987,9 @@ export function makeOpenCodeAdapter( state: "completed", }, }); + yield* cleanupBrowserThreadAfterProviderTurn(context.session.threadId).pipe( + Effect.ignore, + ); } break; } @@ -943,6 +1019,9 @@ export function makeOpenCodeAdapter( errorMessage: message, }, }); + yield* cleanupBrowserThreadAfterProviderTurn(context.session.threadId).pipe( + Effect.ignore, + ); } yield* emit({ ...(yield* buildEventBase({ @@ -1036,6 +1115,7 @@ export function makeOpenCodeAdapter( const directory = input.cwd ?? serverConfig.cwd; const existing = sessions.get(input.threadId); if (existing) { + yield* stopBrowserBridgeForThread(input.threadId); yield* stopOpenCodeContext(existing); sessions.delete(input.threadId); } @@ -1136,6 +1216,10 @@ export function makeOpenCodeAdapter( sessions.set(input.threadId, context); yield* startEventPump(context); + if (serverConfig.mode === "desktop") { + yield* connectOpenCodeBrowserMcp(context, directory); + } + yield* emit({ ...(yield* buildEventBase({ threadId: input.threadId })), type: "session.started", @@ -1224,6 +1308,8 @@ export function makeOpenCodeAdapter( }, }); + yield* connectOpenCodeBrowserMcp(context, context.directory).pipe(Effect.ignore); + yield* runOpenCodeSdk("session.promptAsync", () => context.client.session.promptAsync({ sessionID: context.openCodeSessionId, @@ -1344,6 +1430,7 @@ export function makeOpenCodeAdapter( }); } const stopped = yield* stopOpenCodeContext(context); + yield* stopBrowserBridgeForThread(threadId); sessions.delete(threadId); if (!stopped) { return; @@ -1424,7 +1511,13 @@ export function makeOpenCodeAdapter( // interrupt the sibling fibers. Same pattern as the layer finalizer. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => + Effect.ignoreCause( + Effect.gen(function* () { + yield* stopBrowserBridgeForThread(context.session.threadId); + yield* stopOpenCodeContext(context); + }), + ), { concurrency: "unbounded", discard: true }, ); }); @@ -1433,6 +1526,9 @@ export function makeOpenCodeAdapter( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + runtimeTools: { + browser: resolveProviderBrowserToolSupport(PROVIDER), + }, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 665e6111f9f..f45e48506c8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -44,6 +44,7 @@ import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -99,6 +100,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(NodeServices.layer), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(browserRuntimeToolTestLayers), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -236,6 +238,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(infraLayer), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(browserRuntimeToolTestLayers), ); it.live("boots one instance of every shipped driver from a single config map", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 345989a4496..cd4d0797db6 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -38,6 +38,7 @@ import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { browserRuntimeToolTestLayers } from "../tools/BrowserRuntimeToolTestLayers.ts"; const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); @@ -259,223 +260,313 @@ function makeMutableServerSettingsService( }); } -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))( - "ProviderRegistry", - (it) => { - describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, "1.0.0"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "chatgpt"); - assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); - assert.strictEqual(status.auth.email, "test@example.com"); - assert.deepStrictEqual(status.models, [ - { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, - }, - ]); - assert.deepStrictEqual(status.skills, [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ]); - }), - ); - - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: true, +it.layer( + Layer.mergeAll( + NodeServices.layer, + ServerSettingsService.layerTest(), + TestHttpClientLive, + browserRuntimeToolTestLayers, + ), +)("ProviderRegistry", (it) => { + describe("checkCodexProviderStatus", () => { + it.effect("uses the app-server account and model list for provider status", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", }, - }), - ), - ); - - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }), - ); + ], + }), + ), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }), + ); + + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), + ); - it.effect( - "returns ready with unknown auth when app-server does not require OpenAI auth", - () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), - ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }), + ); + + it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "unknown"); - }), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); + + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); + }), + ); + + it.effect("forwards rateLimits from the probe snapshot onto the provider", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + rateLimits: { + limitName: "ChatGPT Pro", + planType: "pro", + primary: { + usedPercent: 42, + resetsAt: 1_700_000_000, + windowDurationMins: 300, }, - }), - ), - ); + secondary: { + usedPercent: 7, + windowDurationMins: 7 * 24 * 60, + }, + }, + }), + ), + ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual(status.rateLimits, { + limitName: "ChatGPT Pro", + planType: "pro", + primary: { + usedPercent: 42, + resetsAt: 1_700_000_000, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 7, + windowDurationMins: 7 * 24 * 60, + }, + }); + }), + ); - it.effect("forwards rateLimits from the probe snapshot onto the provider", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - rateLimits: { - limitName: "ChatGPT Pro", - planType: "pro", - primary: { - usedPercent: 42, - resetsAt: 1_700_000_000, - windowDurationMins: 300, - }, - secondary: { - usedPercent: 7, - windowDurationMins: 7 * 24 * 60, - }, - }, - }), - ), - ); + it.effect("omits rateLimits when the probe snapshot has none", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed(makeCodexProbeSnapshot()), + ); - assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual(status.rateLimits, { - limitName: "ChatGPT Pro", - planType: "pro", - primary: { - usedPercent: 42, - resetsAt: 1_700_000_000, - windowDurationMins: 300, - }, - secondary: { - usedPercent: 7, - windowDurationMins: 7 * 24 * 60, - }, - }); - }), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.rateLimits, undefined); + }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: "codex app-server", + cause: new Error("spawn codex ENOENT"), + }), + ), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }), + ); + }); - it.effect("omits rateLimits when the probe snapshot has none", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed(makeCodexProbeSnapshot()), - ); + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.rateLimits, undefined); - }), - ); + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); - it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: "codex app-server", - cause: new Error("spawn codex ENOENT"), - }), - ), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); - }), - ); + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); }); - describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), }, - ] as const satisfies ReadonlyArray; + ], + } satisfies ServerProvider; - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); - it("preserves previously discovered provider models when a refresh returns none", () => { - const previousProvider = { + it("persists merged provider snapshots for the providers that were refreshed", () => { + const previousProviders = [ + { instanceId: ProviderInstanceId.make("cursor"), driver: ProviderDriverKind.make("cursor"), status: "ready", @@ -502,22 +593,48 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ], slashCommands: [], skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", models: [], - } satisfies ServerProvider; + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + const refreshedCursor = { + ...previousProviders[0], + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); + const persistedProviders = selectProvidersByKind( + mergedProviders, + new Set([ProviderDriverKind.make("cursor")]), + ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); + assert.deepStrictEqual(persistedProviders, [ + { + ...refreshedCursor, + models: [...previousProviders[0].models], + }, + ]); + }); - it("fills missing capabilities from the previous provider snapshot", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), + it.effect("persists the merged snapshot when a live update has empty models", () => + Effect.gen(function* () { + const cursorDriver = ProviderDriverKind.make("cursor"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const initialProvider = { + instanceId: cursorInstanceId, + driver: cursorDriver, status: "ready", enabled: true, installed: true, @@ -534,8 +651,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T selectDescriptor("reasoning", "Reasoning", [ { id: "high", label: "High", isDefault: true }, ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), ], }), }, @@ -544,1363 +659,881 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T skills: [], } as const satisfies ServerProvider; const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [], - }), - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], + ...initialProvider, checkedAt: "2026-04-14T00:01:00.000Z", models: [], } satisfies ServerProvider; - - const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); - const persistedProviders = selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - - it.effect("persists the merged snapshot when a live update has empty models", () => - Effect.gen(function* () { - const cursorDriver = ProviderDriverKind.make("cursor"); - const cursorInstanceId = ProviderInstanceId.make("cursor"); - const initialProvider = { - instanceId: cursorInstanceId, - driver: cursorDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - ], - }), - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...initialProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - const changes = yield* PubSub.unbounded(); - const instance = { - instanceId: cursorInstanceId, + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: cursorInstanceId, + driverKind: cursorDriver, + continuationIdentity: { driverKind: cursorDriver, - continuationIdentity: { - driverKind: cursorDriver, - continuationKey: "cursor:instance:cursor", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: cursorDriver, - packageName: null, + continuationKey: "cursor:instance:cursor", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: cursorDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(refreshedProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-merged-persist-", }), - getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(refreshedProvider), - streamChanges: Stream.fromPubSub(changes), - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), ), - }); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-merged-persist-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const config = yield* ServerConfig; - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: cursorInstanceId, - }); - - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ - ...initialProvider.models, - ]); - yield* PubSub.publish(changes, refreshedProvider); - - let cachedProvider = yield* readProviderStatusCache(filePath); - for ( - let attempt = 0; - attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - cachedProvider = yield* readProviderStatusCache(filePath); - } + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); - assert.deepStrictEqual(cachedProvider, { - ...refreshedProvider, - models: [...initialProvider.models], - }); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const config = yield* ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cursorInstanceId, + }); - it.effect("returns the cached provider list when a manual refresh fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const cachedProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const instance = { - instanceId: codexInstanceId, + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + ...initialProvider.models, + ]); + yield* PubSub.publish(changes, refreshedProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider, { + ...refreshedProvider, + models: [...initialProvider.models], + }); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("returns the cached provider list when a manual refresh fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const cachedProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(cachedProvider), - refresh: Effect.die(new Error("simulated refresh failure")), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-refresh-failure-", - }), - ), - Layer.provideMerge(NodeServices.layer), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-refresh-failure-", + }), ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); - assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); - assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - cachedProvider, - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; - it.effect("does not block registry startup on initial provider refresh", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const initialProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...initialProvider, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - } as const satisfies ServerProvider; - const releaseRefresh = yield* Deferred.make(); - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(initialProvider), - refresh: Deferred.await(releaseRefresh).pipe(Effect.as(refreshedProvider)), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-initial-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); - - yield* Deferred.succeed(releaseRefresh, undefined); - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && providers[0]?.checkedAt !== refreshedProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual(providers, [refreshedProvider]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("keeps consuming registry changes after one sync fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const claudeProvider = { - instanceId: claudeInstanceId, - driver: claudeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const makeInstance = (provider: ServerProvider): ProviderInstance => ({ - instanceId: provider.instanceId, - driverKind: provider.driver, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = (millis: number) => - Effect.promise(() => new Promise((resolve) => setTimeout(resolve, millis))); - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); + assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + cachedProvider, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("does not block registry startup on initial provider refresh", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const initialProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "warning", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...initialProvider, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + } as const satisfies ServerProvider; + const releaseRefresh = yield* Deferred.make(); + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-sync-failure-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); - - yield* Ref.set(failNextList, true); - yield* PubSub.publish(changes, undefined); - - yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); - yield* PubSub.publish(changes, undefined); - - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && - !providers.some((provider) => provider.instanceId === claudeInstanceId); - attempt += 1 - ) { - yield* wait(10); - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual( - providers.map((provider) => provider.instanceId).toSorted(), - [codexInstanceId, claudeInstanceId].toSorted(), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // This test intentionally avoids `mockCommandSpawnerLayer` so the real - // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. - // We point `binaryPath` at a name that cannot exist on any machine so - // the real `ChildProcessSpawner` deterministically returns ENOENT; the - // probe wraps that as `CodexAppServerSpawnError` and - // `checkCodexProviderStatus` turns it into the user-visible "not - // installed" error snapshot. If the aggregator's `syncLiveSources` - // breaks — the `codex_personal`-never-probes bug we are guarding - // against — that snapshot never lands in `getProviders` and the - // assertions below fail. - it.effect("propagates real Codex probe failures to the aggregator at boot", () => - Effect.gen(function* () { - const missingBinary = `ryco_codex_missing_${process.pid}_${Date.now()}`; - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { - providers: { - // Disable every built-in probe that would otherwise spawn - // on the CI host. `enabled: false` short-circuits each - // driver's probe *before* it touches the spawner, so the - // test environment stays isolated from the dev - // machine's PATH. - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - opencode: { enabled: false }, - }, - // `providerInstances` keys are branded `ProviderInstanceId`; - // the branded index signature rejects plain string literals - // at the TS level even though the runtime schema happily - // accepts + decodes them. Cast the patch to `unknown` so - // the `Schema.decodeSync` below does the real validation. - providerInstances: { - // Matches the shape the user had in `.ryco/dev/settings.json` - // when the bug was reported: a custom enabled Codex instance - // pointing at a binary the server has to actually spawn. - codex_personal: { - driver: "codex", - displayName: "Codex Personal", - enabled: true, - config: { - binaryPath: missingBinary, - homePath: `/tmp/${missingBinary}_home`, - }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-", - }), - ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), - // NO spawner mock — `ChildProcessSpawner` is supplied by the - // outer `NodeServices.layer` on `it.layer(...)` and will - // genuinely spawn a subprocess. The missing-binary ENOENT is - // what exercises the same failure mode as a misconfigured - // production `binaryPath`. - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const providers = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 100; attempts += 1) { - const current = yield* registry.getProviders; - const codex = current.find((provider) => provider.instanceId === "codex_personal"); - if (codex?.status === "error") { - return current; - } - yield* Effect.yieldNow; - } - return yield* registry.getProviders; - }); - const codexPersonal = providers.find( - (provider) => provider.instanceId === "codex_personal", - ); - assert.notStrictEqual( - codexPersonal, - undefined, - `Expected the aggregator to know about codex_personal; instead saw: ${providers - .map((provider) => provider.instanceId) - .join(", ")}`, - ); - assert.strictEqual( - codexPersonal?.status, - "error", - "Real Codex probe against a missing binary should surface as 'error' in the aggregator", - ); - assert.strictEqual(codexPersonal?.installed, false); - assert.strictEqual( - codexPersonal?.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // Guards the second half of the reported bug: changing - // `providers.codex.binaryPath` in settings must tear down the live - // instance and rebuild it so a fresh probe runs with the new binary. - // This test drives the real settings stream → registry reconcile → - // aggregator sync pipeline and asserts that `getProviders` reflects - // the new probe's outcome. If `syncLiveSources` stops awaiting the - // rebuilt instance's refresh (previous bug mode), the aggregator - // keeps the old snapshot and this test fails. - // - // `live` (imported from `@effect/vitest`) is used instead of - // `it.effect` so real timers coordinate the fibres that drive the - // settings → reconcile → sync pipeline. Under `it.effect`'s - // TestClock, `Effect.sleep` blocks until `TestClock.adjust`, which - // would require this test to reach into the internals of the - // reconcile pipeline to advance it step by step. - // - // The nested `it` handed to `it.layer(…, (it) => …)` is the - // `MethodsNonLive` variant and therefore lacks `.live`; the - // top-level `live` export from `@effect/vitest` is the equivalent. - live("re-probes when settings change the codex binaryPath", () => - Effect.gen(function* () { - const firstMissing = `ryco_codex_first_${process.pid}_${Date.now()}`; - const secondMissing = `ryco_codex_second_${process.pid}_${Date.now()}`; - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { - providers: { - codex: { enabled: true, binaryPath: firstMissing }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - opencode: { enabled: false }, - }, - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + getSnapshot: Effect.succeed(initialProvider), + refresh: Deferred.await(releaseRefresh).pipe(Effect.as(refreshedProvider)), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-", + prefix: "ryco-provider-registry-initial-refresh-", }), ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), - // `it.live` does not inherit layers from the outer `it.layer` - // wrapper, so provide `NodeServices.layer` inline. This is the - // same real `ChildProcessSpawner` + `FileSystem` + `Path` - // services that production uses. Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - // Boot-time probe: the default codex instance is enabled with - // `firstMissing`, so the real spawner yields ENOENT and the - // background refresh eventually reports `status: "error"`. - // What *distinguishes* the two probe runs is `checkedAt` — - // each probe stamps a fresh DateTime, so we capture it and - // assert it advances after the settings mutation. - const initialProviders = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { - const providers = yield* registry.getProviders; - const codex = providers.find((provider) => provider.instanceId === "codex"); - if (codex?.status === "error") { - return providers; - } - yield* Effect.sleep("50 millis"); - } - return yield* registry.getProviders; - }); - const initialCodex = initialProviders.find( - (provider) => provider.instanceId === "codex", - ); - assert.strictEqual(initialCodex?.status, "error"); - assert.strictEqual(initialCodex?.installed, false); - const initialCheckedAt = initialCodex?.checkedAt; - assert.notStrictEqual(initialCheckedAt, undefined); - - // Drive a settings change. The Hydration layer's - // `SettingsWatcherLive` consumes this via `streamChanges`, - // calls `reconcile`, which rebuilds the codex instance (the - // envelope changed because `binaryPath` differs → `entryEqual` - // is false). The registry's `Stream.runForEach( - // instanceRegistry.streamChanges, () => syncLiveSources)` - // fires `syncLiveSources`, which subscribes + awaits a fresh - // refresh on the rebuilt instance. - yield* serverSettings.updateSettings({ - providers: { - codex: { enabled: true, binaryPath: secondMissing }, - }, - }); - - // Poll with real timers (via `it.live`) until `checkedAt` - // advances or we hit a generous 3-second ceiling. Anything - // slower than that is a regression — the real probe fails - // fast on ENOENT, and the reconcile + sync pipeline is - // purely in-process. - const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { - const providers = yield* registry.getProviders; - const codex = providers.find((provider) => provider.instanceId === "codex"); - if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { - return providers; - } - yield* Effect.sleep("50 millis"); - } - return yield* registry.getProviders; - }); - - const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.notStrictEqual( - reprobedCodex?.checkedAt, - initialCheckedAt, - "Expected a fresh probe after settings change, got the stale snapshot", - ); - assert.strictEqual(reprobedCodex?.status, "error"); - assert.strictEqual(reprobedCodex?.installed, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("includes unavailable instance snapshots in getProviders", () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { - providers: { - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - opencode: { enabled: false }, - }, - providerInstances: { - ghost_main: { - driver: "ghostDriver", - displayName: "A fork-only driver we don't ship", - enabled: false, - config: { arbitrary: "payload" }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); + + yield* Deferred.succeed(releaseRefresh, undefined); + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && providers[0]?.checkedAt !== refreshedProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + providers = yield* registry.getProviders; + } + + assert.deepStrictEqual(providers, [refreshedProvider]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps consuming registry changes after one sync fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const claudeProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const makeInstance = (provider: ServerProvider): ProviderInstance => ({ + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { + driverKind: provider.driver, + continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const codexInstance = makeInstance(codexProvider); + const claudeInstance = makeInstance(claudeProvider); + const changes = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([codexInstance]); + const failNextList = yield* Ref.make(false); + const wait = (millis: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, millis))); + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-", + prefix: "ryco-provider-registry-sync-failure-", }), ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const providers = yield* registry.getProviders; - const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + ), + ).pipe(Scope.provide(scope)); - assert.notStrictEqual(ghost, undefined); - assert.strictEqual(ghost?.driver, "ghostDriver"); - assert.strictEqual(ghost?.availability, "unavailable"); - assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); - it.effect( - "keeps cursor disabled and skips probing when the provider setting is disabled", - () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { - providers: { - codex: { - enabled: false, - }, - cursor: { - enabled: false, - }, - }, - }), - ), - ); - let cursorSpawned = false; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "ryco-provider-registry-", - }), - ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { - stdout: `${command} 1.0.0\n`, - stderr: "", - code: 0, - }; - } - if (joined === "auth status") { - return { - stdout: '{"authenticated":true}\n', - stderr: "", - code: 0, - }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + yield* Ref.set(failNextList, true); + yield* PubSub.publish(changes, undefined); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const providers = yield* registry.getProviders; - const cursorProvider = providers.find( - (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), - ); - - assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ - "claudeAgent", - "codex", - "copilot", - "cursor", - "opencode", - ]); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual(cursorProvider?.message, "Cursor is disabled in Ryco settings."); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); + yield* PubSub.publish(changes, undefined); - it.effect("skips codex probes entirely when the provider is disabled", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( - Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), - ); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in Ryco settings."); - }), - ); - }); + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); + attempt += 1 + ) { + yield* wait(10); + providers = yield* registry.getProviders; + } - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), + assert.deepStrictEqual( + providers.map((provider) => provider.instanceId).toSorted(), + [codexInstanceId, claudeInstanceId].toSorted(), ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude OAuth usage limits when the usage probe succeeds", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - process.env, - () => - Effect.succeed({ - limitId: "claude-oauth", - limitName: "Claude Max Subscription", - planType: "Claude Max Subscription", - primary: { - usedPercent: 42, - windowDurationMins: 300, - }, - secondary: { - usedPercent: 7, - windowDurationMins: 10_080, + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // This test intentionally avoids `mockCommandSpawnerLayer` so the real + // `probeCodexAppServerProvider` path runs — including the full + // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // We point `binaryPath` at a name that cannot exist on any machine so + // the real `ChildProcessSpawner` deterministically returns ENOENT; the + // probe wraps that as `CodexAppServerSpawnError` and + // `checkCodexProviderStatus` turns it into the user-visible "not + // installed" error snapshot. If the aggregator's `syncLiveSources` + // breaks — the `codex_personal`-never-probes bug we are guarding + // against — that snapshot never lands in `getProviders` and the + // assertions below fail. + it.effect("propagates real Codex probe failures to the aggregator at boot", () => + Effect.gen(function* () { + const missingBinary = `ryco_codex_missing_${process.pid}_${Date.now()}`; + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + // `providerInstances` keys are branded `ProviderInstanceId`; + // the branded index signature rejects plain string literals + // at the TS level even though the runtime schema happily + // accepts + decodes them. Cast the patch to `unknown` so + // the `Schema.decodeSync` below does the real validation. + providerInstances: { + // Matches the shape the user had in `.ryco/dev/settings.json` + // when the bug was reported: a custom enabled Codex instance + // pointing at a binary the server has to actually spawn. + codex_personal: { + driver: "codex", + displayName: "Codex Personal", + enabled: true, + config: { + binaryPath: missingBinary, + homePath: `/tmp/${missingBinary}_home`, + }, }, - }), - ); - - assert.deepStrictEqual(status.rateLimits, { - limitId: "claude-oauth", - limitName: "Claude Max Subscription", - planType: "Claude Max Subscription", - primary: { - usedPercent: 42, - windowDurationMins: 300, - }, - secondary: { - usedPercent: 7, - windowDurationMins: 10_080, - }, - }); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); + } as unknown as ContractServerSettings["providerInstances"], }), ), - ), - ); - - it.effect( - "includes Claude Opus 4.7 with fast mode and xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); - if (!opus47) { - assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } - const effortDescriptor = opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "xhigh", label: "Extra High", isDefault: true }, - ); - assert.deepStrictEqual( - opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", - ), - { id: "fastMode", label: "Fast Mode", type: "boolean" }, - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-8"), - false, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(browserRuntimeToolTestLayers), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-", + }), ), - ); - - it.effect( - "includes Claude Opus 4.8 with fast mode and high as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus48 = status.models.find((model) => model.slug === "claude-opus-4-8"); - if (!opus48) { - assert.fail("Expected Claude Opus 4.8 to be present for Claude Code v2.1.154."); - } - if (!opus48.capabilities) { - assert.fail( - "Expected Claude Opus 4.8 capabilities to be present for Claude Code v2.1.154.", - ); + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // NO spawner mock — `ChildProcessSpawner` is supplied by the + // outer `NodeServices.layer` on `it.layer(...)` and will + // genuinely spawn a subprocess. The missing-binary ENOENT is + // what exercises the same failure mode as a misconfigured + // production `binaryPath`. + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll(providerRegistryLayer, browserRuntimeToolTestLayers), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* Effect.gen(function* () { + for (let attempts = 0; attempts < 100; attempts += 1) { + const current = yield* registry.getProviders; + const codex = current.find((provider) => provider.instanceId === "codex_personal"); + if (codex?.status === "error") { + return current; + } + yield* Effect.yieldNow; } - const effortDescriptor = opus48.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "high", label: "High", isDefault: true }, - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.id === "xhigh") - : undefined, - { id: "xhigh", label: "Extra High" }, - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.id === "ultracode") - : undefined, - { id: "ultracode", label: "Ultracode" }, - ); - assert.deepStrictEqual( - opus48.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", - ), - { id: "fastMode", label: "Fast Mode", type: "boolean" }, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.154\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 4.8 on Claude Code versions before v2.1.154", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), + return yield* registry.getProviders; + }); + const codexPersonal = providers.find( + (provider) => provider.instanceId === "codex_personal", ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-8"), - false, + assert.notStrictEqual( + codexPersonal, + undefined, + `Expected the aggregator to know about codex_personal; instead saw: ${providers + .map((provider) => provider.instanceId) + .join(", ")}`, ); assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - true, + codexPersonal?.status, + "error", + "Real Codex probe against a missing binary should surface as 'error' in the aggregator", ); + assert.strictEqual(codexPersonal?.installed, false); assert.strictEqual( - status.message, - "Claude Code v2.1.153 is too old for Claude Opus 4.8. Upgrade to v2.1.154 or newer to access it.", + codexPersonal?.message, + "Codex CLI (`codex`) is not installed or not on PATH.", ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.153\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // Guards the second half of the reported bug: changing + // `providers.codex.binaryPath` in settings must tear down the live + // instance and rebuild it so a fresh probe runs with the new binary. + // This test drives the real settings stream → registry reconcile → + // aggregator sync pipeline and asserts that `getProviders` reflects + // the new probe's outcome. If `syncLiveSources` stops awaiting the + // rebuilt instance's refresh (previous bug mode), the aggregator + // keeps the old snapshot and this test fails. + // + // `live` (imported from `@effect/vitest`) is used instead of + // `it.effect` so real timers coordinate the fibres that drive the + // settings → reconcile → sync pipeline. Under `it.effect`'s + // TestClock, `Effect.sleep` blocks until `TestClock.adjust`, which + // would require this test to reach into the internals of the + // reconcile pipeline to advance it step by step. + // + // The nested `it` handed to `it.layer(…, (it) => …)` is the + // `MethodsNonLive` variant and therefore lacks `.live`; the + // top-level `live` export from `@effect/vitest` is the equivalent. + live("re-probes when settings change the codex binaryPath", () => + Effect.gen(function* () { + const firstMissing = `ryco_codex_first_${process.pid}_${Date.now()}`; + const secondMissing = `ryco_codex_second_${process.pid}_${Date.now()}`; + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { enabled: true, binaryPath: firstMissing }, + claudeAgent: { enabled: false }, + copilot: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, }), ), - ), - ); - - it.effect("hides Claude Opus 4.7 and 4.8 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-8"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it. Claude Code v2.1.110 is too old for Claude Opus 4.8. Upgrade to v2.1.154 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(browserRuntimeToolTestLayers), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-", }), ), - ), - ); + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // `it.live` does not inherit layers from the outer `it.layer` + // wrapper, so provide `NodeServices.layer` inline. This is the + // same real `ChildProcessSpawner` + `FileSystem` + `Path` + // services that production uses. + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); - it.effect("returns a display label for claude subscription types", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ subscriptionType: "maxplan" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + // Boot-time probe: the default codex instance is enabled with + // `firstMissing`, so the real spawner yields ENOENT and the + // background refresh eventually reports `status: "error"`. + // What *distinguishes* the two probe runs is `checkedAt` — + // each probe stamps a fresh DateTime, so we capture it and + // assert it advances after the settings mutation. + const initialProviders = yield* Effect.gen(function* () { + for (let attempts = 0; attempts < 60; attempts += 1) { + const providers = yield* registry.getProviders; + const codex = providers.find((provider) => provider.instanceId === "codex"); + if (codex?.status === "error") { + return providers; + } + yield* Effect.sleep("50 millis"); + } + return yield* registry.getProviders; + }); + const initialCodex = initialProviders.find((provider) => provider.instanceId === "codex"); + assert.strictEqual(initialCodex?.status, "error"); + assert.strictEqual(initialCodex?.installed, false); + const initialCheckedAt = initialCodex?.checkedAt; + assert.notStrictEqual(initialCheckedAt, undefined); + + // Drive a settings change. The Hydration layer's + // `SettingsWatcherLive` consumes this via `streamChanges`, + // calls `reconcile`, which rebuilds the codex instance (the + // envelope changed because `binaryPath` differs → `entryEqual` + // is false). The registry's `Stream.runForEach( + // instanceRegistry.streamChanges, () => syncLiveSources)` + // fires `syncLiveSources`, which subscribes + awaits a fresh + // refresh on the rebuilt instance. + yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true, binaryPath: secondMissing }, + }, + }); - it.effect("does not duplicate Claude in full subscription labels", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max Subscription", - }), + // Poll with real timers (via `it.live`) until `checkedAt` + // advances or we hit a generous 3-second ceiling. Anything + // slower than that is a regression — the real probe fails + // fast on ENOENT, and the reconcile + sync pipeline is + // purely in-process. + const refreshed = yield* Effect.gen(function* () { + for (let attempts = 0; attempts < 60; attempts += 1) { + const providers = yield* registry.getProviders; + const codex = providers.find((provider) => provider.instanceId === "codex"); + if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { + return providers; + } + yield* Effect.sleep("50 millis"); + } + return yield* registry.getProviders; + }); + + const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); + assert.notStrictEqual( + reprobedCodex?.checkedAt, + initialCheckedAt, + "Expected a fresh probe after settings change, got the stale snapshot", ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max Subscription"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); + assert.strictEqual(reprobedCodex?.status, "error"); + assert.strictEqual(reprobedCodex?.installed, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("includes unavailable instance snapshots in getProviders", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + providerInstances: { + ghost_main: { + driver: "ghostDriver", + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload" }, + }, + } as unknown as ContractServerSettings["providerInstances"], }), ), - ), - ); - - it.effect("does not duplicate Claude in provider-prefixed subscription names", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max", + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(browserRuntimeToolTestLayers), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-", }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll(providerRegistryLayer, browserRuntimeToolTestLayers), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + + assert.notStrictEqual(ghost, undefined); + assert.strictEqual(ghost?.driver, "ghostDriver"); + assert.strictEqual(ghost?.availability, "unavailable"); + assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps cursor disabled and skips probing when the provider setting is disabled", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { + enabled: false, + }, + cursor: { + enabled: false, + }, + }, }), ), - ), - ); - - it.effect("returns claude auth email from initialization result", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.email, "claude@example.com"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { + ); + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(browserRuntimeToolTestLayers), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "ryco-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "agent") { + cursorSpawned = true; + } const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") + if (joined === "--version") { return { - stdout: - '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', + stdout: `${command} 1.0.0\n`, stderr: "", code: 0, }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/ryco-claude-home"; - const recorded = recordingMockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }); - - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - { - ...defaultClaudeSettings, - homePath: claudeHome, - }, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.HOME), - [claudeHome], - ); - }).pipe(Effect.provide(recorded.layer)); - }); - - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") + } + if (joined === "auth status") { return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: '{"authenticated":true}\n', stderr: "", code: 0, }; - throw new Error(`Unexpected args: ${joined}`); + } + throw new Error(`Unexpected args: ${command} ${joined}`); }), ), - ), - ); + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "ui", - description: "Explore and refine UI", - }, - { - name: "ui", - input: { hint: "component-or-screen" }, - }, - ], - }), + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), ); - assert.deepStrictEqual(status.slashCommands, [ - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "claudeAgent", + "codex", + "copilot", + "cursor", + "opencode", ]); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual(cursorProvider?.message, "Cursor is disabled in Ryco settings."); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), + ); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in Ryco settings."); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("includes Claude OAuth usage limits when the usage probe succeeds", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + process.env, + () => + Effect.succeed({ + limitId: "claude-oauth", + limitName: "Claude Max Subscription", + planType: "Claude Max Subscription", + primary: { + usedPercent: 42, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 7, + windowDurationMins: 10_080, + }, }), - ), + ); + + assert.deepStrictEqual(status.rateLimits, { + limitId: "claude-oauth", + limitName: "Claude Max Subscription", + planType: "Claude Max Subscription", + primary: { + usedPercent: 42, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 7, + windowDurationMins: 10_080, + }, + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), ), - ); + ), + ); - it.effect("returns an api key label for claude api key auth", () => + it.effect( + "includes Claude Opus 4.7 with fast mode and xhigh as the default effort on supported versions", + () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + claudeCapabilities(), + ); + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, + ); + assert.deepStrictEqual( + opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ), + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', stderr: "", code: 0, }; @@ -1908,77 +1541,444 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ), ), - ); + ); - it.effect("returns unavailable when claude is missing", () => + it.effect( + "includes Claude Opus 4.8 with fast mode and high as the default effort on supported versions", + () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", + const opus48 = status.models.find((model) => model.slug === "claude-opus-4-8"); + if (!opus48) { + assert.fail("Expected Claude Opus 4.8 to be present for Claude Code v2.1.154."); + } + if (!opus48.capabilities) { + assert.fail( + "Expected Claude Opus 4.8 capabilities to be present for Claude Code v2.1.154.", + ); + } + const effortDescriptor = opus48.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "high", label: "High", isDefault: true }, ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { - stdout: "", - stderr: "Something went wrong", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when the Claude initialization result is unavailable", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.id === "xhigh") + : undefined, + { id: "xhigh", label: "Extra High" }, ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.id === "ultracode") + : undefined, + { id: "ultracode", label: "Ultracode" }, + ); + assert.deepStrictEqual( + opus48.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ), + { id: "fastMode", label: "Fast Mode", type: "boolean" }, ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "--version") return { stdout: "2.1.154\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":false}\n', + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', stderr: "", - code: 1, + code: 0, }; throw new Error(`Unexpected args: ${joined}`); }), ), ), - ); + ); + + it.effect("hides Claude Opus 4.8 on Claude Code versions before v2.1.154", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + true, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.153 is too old for Claude Opus 4.8. Upgrade to v2.1.154 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.153\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 4.7 and 4.8 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it. Claude Code v2.1.110 is too old for Claude Opus 4.8. Upgrade to v2.1.154 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns a display label for claude subscription types", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "maxplan" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in full subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max Subscription"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns claude auth email from initialization result", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "claude@example.com"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("runs Claude status probes with the configured Claude HOME", () => { + const claudeHome = "/tmp/ryco-claude-home"; + const recorded = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeHome, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual( + recorded.commands.map((command) => command.env?.HOME), + [claudeHome], + ); + }).pipe(Effect.provide(recorded.layer)); }); - }, -); + + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns an api key label for claude api key auth", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "Claude API Key"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { + stdout: "", + stderr: "Something went wrong", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when the Claude initialization result is unavailable", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Could not verify Claude authentication status from initialization result.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 36c0f50bfd6..65b37182007 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -51,6 +51,7 @@ import { import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; +import { ProviderRuntimeEventHubLive } from "../tools/ProviderRuntimeEventHub.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -275,6 +276,9 @@ function makeFakeCodexAdapter( const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const makeTestProviderServiceLive = (options?: Parameters[0]) => + makeProviderServiceLive(options).pipe(Layer.provideMerge(ProviderRuntimeEventHubLive)); + function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); @@ -293,7 +297,7 @@ function makeProviderServiceLayer() { const layer = it.layer( Layer.mergeAll( - makeProviderServiceLive().pipe( + makeTestProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -336,7 +340,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = Layer.mergeAll( - makeProviderServiceLive().pipe( + makeTestProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -389,7 +393,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeTestProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -441,7 +445,7 @@ it.effect( const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive({ + const providerLayer = makeTestProviderServiceLive({ providerStartupAdmission: { maxConcurrentStartsPerInstance: 1, maxPendingStartsPerInstance: 1, @@ -554,7 +558,7 @@ it.effect( const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeTestProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), @@ -616,7 +620,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeTestProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -656,7 +660,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); - const providerLayer = makeProviderServiceLive({ + const providerLayer = makeTestProviderServiceLive({ canonicalEventLogger: { filePath: "memory://provider-canonical-events", write: (event, threadId) => { @@ -721,7 +725,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }); }).pipe(Effect.provide(directoryLayer)); - const providerLayer = makeProviderServiceLive().pipe( + const providerLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -780,7 +784,7 @@ it.effect( const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -832,7 +836,7 @@ it.effect( const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1334,7 +1338,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1365,7 +1369,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1424,7 +1428,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = makeProviderServiceLive().pipe( + const firstProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1450,7 +1454,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = makeProviderServiceLive().pipe( + const secondProviderLayer = makeTestProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 26007517697..7138f68c9e4 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -31,7 +31,6 @@ import { Layer, Metric, Option, - PubSub, Ref, Schema, SchemaIssue, @@ -58,6 +57,7 @@ import { import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { ProviderRuntimeEventHub } from "../tools/ProviderRuntimeEventHub.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory, @@ -233,7 +233,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; - const runtimeEventPubSub = yield* PubSub.unbounded(); + const runtimeEventHub = yield* ProviderRuntimeEventHub; const maxConcurrentProviderStartsPerInstance = normalizePositiveInt( options?.providerStartupAdmission?.maxConcurrentStartsPerInstance, DEFAULT_MAX_CONCURRENT_PROVIDER_STARTS_PER_INSTANCE, @@ -258,7 +258,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) : Effect.void, ), - Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), + Effect.flatMap((canonicalEvent) => runtimeEventHub.publish(canonicalEvent)), Effect.asVoid, ); @@ -1252,7 +1252,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. get streamEvents(): ProviderServiceShape["streamEvents"] { - return Stream.fromPubSub(runtimeEventPubSub); + return runtimeEventHub.stream; }, } satisfies ProviderServiceShape; }); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c04801a314b..38d10195314 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -23,6 +23,8 @@ import type { import type { Effect } from "effect"; import type { Stream } from "effect"; +import type { ProviderBrowserToolSupport } from "../tools/BrowserRuntimeTool.ts"; + export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { @@ -30,6 +32,9 @@ export interface ProviderAdapterCapabilities { * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; + readonly runtimeTools?: { + readonly browser: ProviderBrowserToolSupport; + }; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 37b15a29712..5dde40fad37 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -28,6 +28,7 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly mcpServers?: ReadonlyArray; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; @@ -380,11 +381,12 @@ const makeAcpSessionRuntime = ( | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; + const mcpServers = options.mcpServers ?? []; if (options.resumeSessionId) { const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers, } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -397,7 +399,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers, } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -410,7 +412,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers, } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", diff --git a/apps/server/src/provider/tools/BrowserRuntimeTool.test.ts b/apps/server/src/provider/tools/BrowserRuntimeTool.test.ts new file mode 100644 index 00000000000..cb10f290133 --- /dev/null +++ b/apps/server/src/provider/tools/BrowserRuntimeTool.test.ts @@ -0,0 +1,175 @@ +import { + BrowserProfileId, + BrowserSessionId, + BrowserTabId, + ProjectId, + ProviderDriverKind, + ThreadId, + type BrowserSessionSnapshot, +} from "@ryco/contracts"; +import { Effect, Exit } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import type { BrowserServiceShape } from "../../browser/BrowserService.ts"; +import { + BROWSER_RUNTIME_TOOL_DEFINITIONS, + BrowserRuntimeToolError, + executeBrowserRuntimeToolCall, + isBrowserRuntimeToolName, + parseBrowserRuntimeToolCallInput, + resolveProviderBrowserToolSupport, +} from "./BrowserRuntimeTool.ts"; + +function makeSessionSnapshot(threadId: ThreadId): BrowserSessionSnapshot { + const now = "2026-06-24T10:00:00.000Z"; + const sessionId = BrowserSessionId.make("browser-session:test"); + const profileId = BrowserProfileId.make("browser-profile:test"); + const tabId = BrowserTabId.make("browser-tab:test"); + return { + sessionId, + profileId, + threadId, + projectId: ProjectId.make("project-1"), + hostId: undefined, + selectedTabId: tabId, + tabs: [ + { + tabId, + sessionId, + profileId, + selected: true, + crashed: false, + navigation: { + url: "about:blank", + origin: null, + loadState: "idle", + canGoBack: false, + canGoForward: false, + }, + createdAt: now, + updatedAt: now, + }, + ], + status: "ready", + createdAt: now, + updatedAt: now, + }; +} + +describe("BrowserRuntimeTool", () => { + it("reports browser tools as supported for wired providers", () => { + const codex = resolveProviderBrowserToolSupport(ProviderDriverKind.make("codex")); + const cursor = resolveProviderBrowserToolSupport(ProviderDriverKind.make("cursor")); + const claude = resolveProviderBrowserToolSupport(ProviderDriverKind.make("claudeAgent")); + const copilot = resolveProviderBrowserToolSupport(ProviderDriverKind.make("copilot")); + const opencode = resolveProviderBrowserToolSupport(ProviderDriverKind.make("opencode")); + + expect(codex.supported).toBe(false); + expect(codex.reason).toContain("dynamicTools"); + expect(codex.definitions).toEqual([]); + expect(cursor.supported).toBe(true); + expect(cursor.definitions.map((definition) => definition.name)).toEqual( + BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => definition.name), + ); + expect(claude.supported).toBe(true); + expect(claude.definitions.map((definition) => definition.name)).toEqual( + BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => definition.name), + ); + expect(copilot.supported).toBe(true); + expect(opencode.supported).toBe(true); + }); + + it("recognizes supported browser runtime tool names", () => { + expect(isBrowserRuntimeToolName("browser_open")).toBe(true); + expect(isBrowserRuntimeToolName("browser_snapshot")).toBe(true); + expect(isBrowserRuntimeToolName("browser_unknown")).toBe(false); + }); + + it("maps Codex dynamic tool arguments into browser runtime tool input", async () => { + const threadId = ThreadId.make("thread-1"); + const parsed = await Effect.runPromise( + parseBrowserRuntimeToolCallInput({ + toolName: "browser_navigate", + threadId, + arguments: { + sessionId: "browser-session:test", + url: "https://example.com/", + }, + }), + ); + + expect(parsed).toMatchObject({ + name: "browser_navigate", + threadId, + sessionId: BrowserSessionId.make("browser-session:test"), + url: "https://example.com/", + }); + }); + + it("opens a thread-scoped browser session through the shared BrowserService executor", async () => { + const threadId = ThreadId.make("thread-1"); + const snapshot = makeSessionSnapshot(threadId); + const browser = { + openSession: () => Effect.succeed(snapshot), + } as unknown as BrowserServiceShape; + + await expect( + Effect.runPromise( + executeBrowserRuntimeToolCall(browser, { + name: "browser_open", + threadId, + projectId: ProjectId.make("project-1"), + }), + ), + ).resolves.toEqual(snapshot); + }); + + it("fails navigation without a session id before reaching BrowserService", async () => { + const exit = await Effect.runPromise( + Effect.exit( + executeBrowserRuntimeToolCall({} as BrowserServiceShape, { + name: "browser_navigate", + threadId: ThreadId.make("thread-1"), + url: "https://example.com/", + }), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail"); + expect(failure?.error).toBeInstanceOf(BrowserRuntimeToolError); + expect(failure?.error).toMatchObject({ code: "missing_session" }); + } + }); + + it("executes browser_snapshot through BrowserService", async () => { + const threadId = ThreadId.make("thread-1"); + const browser = { + snapshotDom: () => + Effect.succeed({ + session: makeSessionSnapshot(threadId), + snapshot: { + url: "https://example.test/", + title: "Example", + viewport: { width: 800, height: 600 }, + tree: [], + }, + }), + } as unknown as BrowserServiceShape; + + const toolInput = await Effect.runPromise( + parseBrowserRuntimeToolCallInput({ + toolName: "browser_snapshot", + threadId, + arguments: { sessionId: "browser-session:test" }, + }), + ); + + await expect( + Effect.runPromise(executeBrowserRuntimeToolCall(browser, toolInput)), + ).resolves.toMatchObject({ + snapshot: { title: "Example" }, + }); + }); +}); diff --git a/apps/server/src/provider/tools/BrowserRuntimeTool.ts b/apps/server/src/provider/tools/BrowserRuntimeTool.ts new file mode 100644 index 00000000000..3bc89d30800 --- /dev/null +++ b/apps/server/src/provider/tools/BrowserRuntimeTool.ts @@ -0,0 +1,499 @@ +import type { BrowserLoadState, ProviderDriverKind, ThreadId } from "@ryco/contracts"; +import { + BrowserInputAction, + BrowserServiceError, + BrowserSessionId, + BrowserTabId, + ProjectId, +} from "@ryco/contracts"; +import { Effect, Schema } from "effect"; + +import type { BrowserServiceShape } from "../../browser/BrowserService.ts"; + +export type BrowserRuntimeToolName = + | "browser_open" + | "browser_navigate" + | "browser_back" + | "browser_forward" + | "browser_reload" + | "browser_stop" + | "browser_input" + | "browser_snapshot" + | "browser_screenshot" + | "browser_console" + | "browser_network" + | "browser_wait_for"; + +export type BrowserRuntimeToolCallResult = unknown; + +export interface BrowserRuntimeToolDefinition { + readonly name: BrowserRuntimeToolName; + readonly description: string; + readonly input: Record; +} + +export interface ProviderBrowserToolSupport { + readonly supported: boolean; + readonly reason?: string; + readonly definitions: ReadonlyArray; +} + +export class BrowserRuntimeToolError extends Schema.TaggedErrorClass()( + "BrowserRuntimeToolError", + { + code: Schema.Literals(["unsupported_tool", "missing_session", "missing_url"]), + message: Schema.String, + }, +) {} + +export interface BrowserRuntimeToolCallInput { + readonly name: BrowserRuntimeToolName; + readonly threadId: ThreadId; + readonly projectId?: ProjectId; + readonly sessionId?: BrowserSessionId; + readonly tabId?: BrowserTabId; + readonly url?: string; + readonly action?: BrowserInputAction; + readonly timeoutMs?: number; + readonly title?: string; + readonly text?: string; + readonly textGone?: string; + readonly loadState?: BrowserLoadState; + readonly source?: "ui" | "agent"; +} + +export type BrowserRuntimeToolCallError = BrowserRuntimeToolError | BrowserServiceError; + +export const BROWSER_RUNTIME_TOOL_DEFINITIONS: ReadonlyArray = [ + { + name: "browser_open", + description: "Open or focus the isolated Ryco browser session for the current thread.", + input: { + type: "object", + properties: { + initialUrl: { type: "string" }, + }, + additionalProperties: false, + }, + }, + { + name: "browser_navigate", + description: "Navigate the active Ryco browser tab to an HTTP(S), file, or about:blank URL.", + input: { + type: "object", + required: ["sessionId", "url"], + properties: { + sessionId: { type: "string" }, + tabId: { type: "string" }, + url: { type: "string" }, + }, + additionalProperties: false, + }, + }, + { + name: "browser_back", + description: "Go back in the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_forward", + description: "Go forward in the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_reload", + description: "Reload the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_stop", + description: "Stop the active Ryco browser tab from loading.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_input", + description: + "Send a bounded click (x/y or DOM ref from browser_snapshot), type, key, or scroll action to the Ryco browser tab.", + input: { + type: "object", + required: ["sessionId", "action"], + properties: { + sessionId: { type: "string" }, + tabId: { type: "string" }, + action: { type: "object" }, + }, + additionalProperties: false, + }, + }, + { + name: "browser_snapshot", + description: + "Capture a structured DOM snapshot with URL, title, viewport, visible text, and accessibility-like node refs.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_screenshot", + description: "Capture a bounded PNG screenshot reference for the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_console", + description: "Read recent console messages from the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_network", + description: "Read recent network request summaries from the active Ryco browser tab.", + input: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string" }, tabId: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "browser_wait_for", + description: + "Wait until the browser tab matches URL, title, load state, or visible text conditions.", + input: { + type: "object", + required: ["sessionId"], + properties: { + sessionId: { type: "string" }, + tabId: { type: "string" }, + timeoutMs: { type: "number" }, + url: { type: "string" }, + title: { type: "string" }, + text: { type: "string" }, + textGone: { type: "string" }, + loadState: { type: "string" }, + }, + additionalProperties: false, + }, + }, +]; + +const UNSUPPORTED_PROVIDER_REASON = + "Browser runtime tools are defined server-side, but this provider adapter does not yet have a tool-injection path wired to Ryco."; + +const CODEX_BROWSER_UNSUPPORTED_REASON = + "Codex app-server schema does not yet expose dynamicTools on thread/start, so Ryco cannot advertise browser tools to the model."; + +const BROWSER_RUNTIME_TOOL_NAMES = new Set( + BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => definition.name), +); + +export function isBrowserRuntimeToolName(toolName: string): toolName is BrowserRuntimeToolName { + return ( + toolName.startsWith("browser_") && + BROWSER_RUNTIME_TOOL_NAMES.has(toolName as BrowserRuntimeToolName) + ); +} + +function readBrowserToolArgumentRecord(argumentsValue: unknown): Record { + if (argumentsValue && typeof argumentsValue === "object" && !Array.isArray(argumentsValue)) { + return argumentsValue as Record; + } + return {}; +} + +function readBrowserToolArgumentString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readBrowserToolArgumentNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readBrowserToolLoadState(value: unknown): BrowserLoadState | undefined { + if (value === "idle" || value === "loading" || value === "loaded" || value === "failed") { + return value; + } + return undefined; +} + +export function parseBrowserRuntimeToolCallInput(input: { + readonly toolName: string; + readonly threadId: ThreadId; + readonly arguments: unknown; +}): Effect.Effect { + if (!input.toolName.startsWith("browser_")) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "unsupported_tool", + message: `Unsupported tool '${input.toolName}'.`, + }), + ); + } + if (!isBrowserRuntimeToolName(input.toolName)) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "unsupported_tool", + message: `Unknown browser tool '${input.toolName}'.`, + }), + ); + } + + const args = readBrowserToolArgumentRecord(input.arguments); + const sessionId = readBrowserToolArgumentString(args.sessionId); + const tabId = readBrowserToolArgumentString(args.tabId); + const url = + readBrowserToolArgumentString(args.url) ?? readBrowserToolArgumentString(args.initialUrl); + const projectId = readBrowserToolArgumentString(args.projectId); + const timeoutMs = readBrowserToolArgumentNumber(args.timeoutMs); + const title = readBrowserToolArgumentString(args.title); + const text = readBrowserToolArgumentString(args.text); + const textGone = readBrowserToolArgumentString(args.textGone); + const loadState = readBrowserToolLoadState(args.loadState); + + const callInput = { + name: input.toolName, + threadId: input.threadId, + source: "agent" as const, + ...(projectId ? { projectId: ProjectId.make(projectId) } : {}), + ...(sessionId ? { sessionId: BrowserSessionId.make(sessionId) } : {}), + ...(tabId ? { tabId: BrowserTabId.make(tabId) } : {}), + ...(url ? { url } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(title ? { title } : {}), + ...(text ? { text } : {}), + ...(textGone ? { textGone } : {}), + ...(loadState ? { loadState } : {}), + } satisfies BrowserRuntimeToolCallInput; + + if (input.toolName === "browser_input") { + const action = args.action; + if (!Schema.is(BrowserInputAction)(action)) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "unsupported_tool", + message: "browser_input requires a valid input action.", + }), + ); + } + return Effect.succeed({ + ...callInput, + action, + }); + } + + return Effect.succeed(callInput); +} + +export function resolveProviderBrowserToolSupport( + provider: ProviderDriverKind, +): ProviderBrowserToolSupport { + if (String(provider) === "codex") { + return { + supported: false, + reason: CODEX_BROWSER_UNSUPPORTED_REASON, + definitions: [], + }; + } + + if (String(provider) === "claudeAgent") { + return { + supported: true, + definitions: BROWSER_RUNTIME_TOOL_DEFINITIONS, + }; + } + + if (String(provider) === "copilot") { + return { + supported: true, + definitions: BROWSER_RUNTIME_TOOL_DEFINITIONS, + }; + } + + if (String(provider) === "opencode") { + return { + supported: true, + definitions: BROWSER_RUNTIME_TOOL_DEFINITIONS, + }; + } + + if (String(provider) === "cursor") { + return { + supported: true, + definitions: BROWSER_RUNTIME_TOOL_DEFINITIONS, + }; + } + + return { + supported: false, + reason: UNSUPPORTED_PROVIDER_REASON, + definitions: [], + }; +} + +export function executeBrowserRuntimeToolCall( + browser: BrowserServiceShape, + input: BrowserRuntimeToolCallInput, +): Effect.Effect { + const requireSession = (toolName: BrowserRuntimeToolName) => { + if (!input.sessionId) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "missing_session", + message: `${toolName} requires a browser session id.`, + }), + ); + } + return Effect.succeed(input.sessionId); + }; + + switch (input.name) { + case "browser_open": + return browser.openSession({ + threadId: input.threadId, + ...(input.projectId ? { projectId: input.projectId } : {}), + profileMode: "thread", + ...(input.url ? { initialUrl: input.url } : {}), + ...(input.source ? { source: input.source } : {}), + }); + case "browser_navigate": + if (!input.sessionId) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "missing_session", + message: "browser_navigate requires a browser session id.", + }), + ); + } + if (!input.url) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "missing_url", + message: "browser_navigate requires a URL.", + }), + ); + } + return browser.navigate({ + sessionId: input.sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + url: input.url, + ...(input.source ? { source: input.source } : {}), + }); + case "browser_back": + case "browser_forward": + case "browser_reload": + case "browser_stop": { + if (!input.sessionId) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "missing_session", + message: `${input.name} requires a browser session id.`, + }), + ); + } + const command = input.name.replace("browser_", "") as "back" | "forward" | "reload" | "stop"; + return browser[command]({ + sessionId: input.sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + }); + } + case "browser_input": + if (!input.sessionId) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "missing_session", + message: "browser_input requires a browser session id.", + }), + ); + } + if (!input.action) { + return Effect.fail( + new BrowserRuntimeToolError({ + code: "unsupported_tool", + message: "browser_input requires an input action.", + }), + ); + } + return browser.input({ + sessionId: input.sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + action: input.action, + }); + case "browser_snapshot": + return Effect.gen(function* () { + const sessionId = yield* requireSession("browser_snapshot"); + return yield* browser.snapshotDom({ + sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + }); + }); + case "browser_screenshot": + return Effect.gen(function* () { + const sessionId = yield* requireSession("browser_screenshot"); + return yield* browser.screenshot({ + sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + }); + }); + case "browser_console": + return Effect.gen(function* () { + const sessionId = yield* requireSession("browser_console"); + return yield* browser.readConsole({ + sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + }); + }); + case "browser_network": + return Effect.gen(function* () { + const sessionId = yield* requireSession("browser_network"); + return yield* browser.readNetwork({ + sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + }); + }); + case "browser_wait_for": + return Effect.gen(function* () { + const sessionId = yield* requireSession("browser_wait_for"); + return yield* browser.waitFor({ + sessionId, + ...(input.tabId ? { tabId: input.tabId } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.title ? { title: input.title } : {}), + ...(input.text ? { text: input.text } : {}), + ...(input.textGone ? { textGone: input.textGone } : {}), + ...(input.loadState ? { loadState: input.loadState } : {}), + }); + }); + } +} diff --git a/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.test.ts b/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.test.ts new file mode 100644 index 00000000000..d2fda4497cc --- /dev/null +++ b/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.test.ts @@ -0,0 +1,50 @@ +import { BrowserArtifactId, ProviderDriverKind } from "@ryco/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProviderBrowserToolSupport } from "./BrowserRuntimeTool.ts"; +import { + enrichBrowserRuntimeToolCallToolResult, + mapBrowserRuntimeToolResultToCallToolResult, +} from "./BrowserRuntimeToolHelpers.ts"; + +describe("BrowserRuntimeToolHelpers", () => { + it("reports Codex browser tools as unsupported until dynamicTools is in thread/start", () => { + const codex = resolveProviderBrowserToolSupport(ProviderDriverKind.make("codex")); + expect(codex.supported).toBe(false); + expect(codex.reason).toContain("dynamicTools"); + expect(codex.definitions).toEqual([]); + }); + + it("adds screenshot image content when artifact bytes are available", async () => { + const artifactId = BrowserArtifactId.make("browser-artifact:test"); + const pngBytes = new Uint8Array([137, 80, 78, 71]); + const mapped = mapBrowserRuntimeToolResultToCallToolResult({ + success: true, + result: { + session: { sessionId: "browser-session:test" }, + artifact: { + artifactId, + kind: "screenshot", + mimeType: "image/png", + byteSize: pngBytes.byteLength, + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + }, + }); + + const enriched = await enrichBrowserRuntimeToolCallToolResult({ + toolName: "browser_screenshot", + result: mapped, + readArtifactData: async (id) => (id === artifactId ? pngBytes : null), + }); + + expect(enriched.content).toHaveLength(2); + expect(enriched.content[0]).toMatchObject({ type: "text" }); + expect(enriched.content[1]).toEqual({ + type: "image", + data: Buffer.from(pngBytes).toString("base64"), + mimeType: "image/png", + }); + }); +}); diff --git a/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts b/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts new file mode 100644 index 00000000000..930b043a9b0 --- /dev/null +++ b/apps/server/src/provider/tools/BrowserRuntimeToolHelpers.ts @@ -0,0 +1,171 @@ +import type { BrowserArtifactId, BrowserArtifactRef, ThreadId } from "@ryco/contracts"; +import { Effect } from "effect"; + +import { BrowserArtifactStore } from "../../browser/BrowserArtifactStore.ts"; +import { + type BrowserRuntimeToolCallError, + type BrowserRuntimeToolDefinition, + type BrowserRuntimeToolName, + parseBrowserRuntimeToolCallInput, +} from "./BrowserRuntimeTool.ts"; +import type { ProviderRuntimeToolRegistryShape } from "./ProviderRuntimeToolRegistry.ts"; + +export const RYCO_BROWSER_MCP_SERVER_NAME = "ryco"; +export const CLAUDE_BROWSER_MCP_SERVER_NAME = RYCO_BROWSER_MCP_SERVER_NAME; + +export function formatBrowserRuntimeToolCallError(error: unknown): string { + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" && + error.message.length > 0 + ) { + return error.message; + } + return "Browser runtime tool call failed."; +} + +export type BrowserRuntimeToolCallToolResultContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "image"; readonly data: string; readonly mimeType: string }; + +export type BrowserRuntimeToolCallToolResult = { + readonly content: ReadonlyArray; + readonly isError?: boolean; + readonly structuredContent?: Record; +}; + +function readScreenshotArtifact(result: unknown): BrowserArtifactRef | undefined { + if (!result || typeof result !== "object" || !("artifact" in result)) { + return undefined; + } + const artifact = (result as { readonly artifact?: unknown }).artifact; + if ( + !artifact || + typeof artifact !== "object" || + !("artifactId" in artifact) || + !("mimeType" in artifact) || + typeof artifact.artifactId !== "string" || + typeof artifact.mimeType !== "string" + ) { + return undefined; + } + return artifact as BrowserArtifactRef; +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +export function readBrowserArtifactDataEffect( + artifactId: BrowserArtifactId, +): Effect.Effect { + return Effect.gen(function* () { + const store = yield* BrowserArtifactStore; + return yield* store.readData(artifactId); + }); +} + +export async function enrichBrowserRuntimeToolCallToolResult(input: { + readonly toolName: BrowserRuntimeToolName; + readonly result: BrowserRuntimeToolCallToolResult; + readonly readArtifactData?: (artifactId: BrowserArtifactId) => Promise; +}): Promise { + if ( + input.toolName !== "browser_screenshot" || + input.result.isError || + !input.readArtifactData || + input.result.content.length === 0 + ) { + return input.result; + } + + const structured = input.result.structuredContent; + const artifact = readScreenshotArtifact(structured ?? null); + if (!artifact) { + return input.result; + } + + const bytes = await input.readArtifactData(artifact.artifactId); + if (!bytes || bytes.byteLength === 0) { + return input.result; + } + + return { + ...input.result, + content: [ + ...input.result.content, + { + type: "image", + data: uint8ArrayToBase64(bytes), + mimeType: artifact.mimeType, + }, + ], + }; +} + +export function mapBrowserRuntimeToolResultToCallToolResult( + input: + | { + readonly success: true; + readonly result: unknown; + } + | { + readonly success: false; + readonly message: string; + }, +): BrowserRuntimeToolCallToolResult { + if (input.success) { + return { + content: [{ type: "text", text: JSON.stringify(input.result) }], + structuredContent: + input.result !== null && typeof input.result === "object" + ? (input.result as Record) + : { result: input.result }, + }; + } + + return { + content: [{ type: "text", text: JSON.stringify({ error: input.message }) }], + isError: true, + }; +} + +export function makeBrowserRuntimeToolHandler(options: { + readonly toolName: BrowserRuntimeToolName; + readonly threadId: ThreadId; + readonly executeBrowserTool: ProviderRuntimeToolRegistryShape["executeBrowserTool"]; + readonly runPromise: (effect: Effect.Effect) => Promise; + readonly readArtifactData?: (artifactId: BrowserArtifactId) => Promise; +}): (args: Record) => Promise { + return async (args) => { + try { + const toolInput = await options.runPromise( + parseBrowserRuntimeToolCallInput({ + toolName: options.toolName, + threadId: options.threadId, + arguments: args, + }), + ); + const result = await options.runPromise(options.executeBrowserTool(toolInput)); + const mapped = mapBrowserRuntimeToolResultToCallToolResult({ success: true, result }); + return await enrichBrowserRuntimeToolCallToolResult({ + toolName: options.toolName, + result: mapped, + ...(options.readArtifactData ? { readArtifactData: options.readArtifactData } : {}), + }); + } catch (cause) { + return mapBrowserRuntimeToolResultToCallToolResult({ + success: false, + message: formatBrowserRuntimeToolCallError(cause as BrowserRuntimeToolCallError), + }); + } + }; +} + +export function browserRuntimeToolJsonSchema( + definition: BrowserRuntimeToolDefinition, +): Record { + return definition.input; +} diff --git a/apps/server/src/provider/tools/BrowserRuntimeToolTestLayers.ts b/apps/server/src/provider/tools/BrowserRuntimeToolTestLayers.ts new file mode 100644 index 00000000000..8ace9602885 --- /dev/null +++ b/apps/server/src/provider/tools/BrowserRuntimeToolTestLayers.ts @@ -0,0 +1,21 @@ +import { Effect, Layer } from "effect"; + +import { BrowserMcpBridge } from "../../browser/BrowserMcpBridge.ts"; +import { resolveProviderBrowserToolSupport } from "./BrowserRuntimeTool.ts"; +import { ProviderRuntimeToolRegistry } from "./ProviderRuntimeToolRegistry.ts"; + +export const providerRuntimeToolRegistryTestLayer = Layer.succeed(ProviderRuntimeToolRegistry, { + getBrowserSupport: resolveProviderBrowserToolSupport, + executeBrowserTool: () => + Effect.die("ProviderRuntimeToolRegistry.executeBrowserTool is not used in test"), +}); + +export const browserMcpBridgeTestLayer = Layer.succeed(BrowserMcpBridge, { + start: () => Effect.succeed({ socketPath: "/tmp/ryco-browser-mcp-test.sock" }), + stop: () => Effect.void, +}); + +export const browserRuntimeToolTestLayers = Layer.mergeAll( + providerRuntimeToolRegistryTestLayer, + browserMcpBridgeTestLayer, +); diff --git a/apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts b/apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts new file mode 100644 index 00000000000..05ae531b565 --- /dev/null +++ b/apps/server/src/provider/tools/BrowserToolLifecycleEvents.ts @@ -0,0 +1,101 @@ +import { + EventId, + ProviderDriverKind, + ProviderInstanceId, + RuntimeItemId, + type ProviderRuntimeEvent, + type TurnId, +} from "@ryco/contracts"; +import { Effect, Random } from "effect"; + +import type { BrowserRuntimeToolCallInput } from "./BrowserRuntimeTool.ts"; + +export interface BrowserToolLifecycleContext { + readonly provider: ProviderDriverKind; + readonly providerInstanceId?: ProviderInstanceId; + readonly turnId?: TurnId; +} + +const makeItemId = (toolName: string, suffix: string) => + RuntimeItemId.make(`browser-tool:${toolName}:${suffix}`); + +export function makeBrowserToolStartedEvent(input: { + readonly call: BrowserRuntimeToolCallInput; + readonly context: BrowserToolLifecycleContext; + readonly itemId?: RuntimeItemId; + readonly createdAt?: string; +}): Effect.Effect { + return Effect.gen(function* () { + const eventId = EventId.make(yield* Random.nextUUIDv4); + const itemId = input.itemId ?? makeItemId(input.call.name, yield* Random.nextUUIDv4); + const createdAt = input.createdAt ?? new Date().toISOString(); + return { + eventId, + provider: input.context.provider, + ...(input.context.providerInstanceId + ? { providerInstanceId: input.context.providerInstanceId } + : {}), + threadId: input.call.threadId, + createdAt, + ...(input.context.turnId ? { turnId: input.context.turnId } : {}), + itemId, + raw: { + source: "ryco.browser.tool", + payload: { + toolName: input.call.name, + arguments: input.call, + }, + }, + type: "item.started", + payload: { + itemType: "browser_tool_call", + status: "inProgress", + title: input.call.name, + data: input.call, + }, + } satisfies ProviderRuntimeEvent; + }); +} + +export function makeBrowserToolCompletedEvent(input: { + readonly call: BrowserRuntimeToolCallInput; + readonly context: BrowserToolLifecycleContext; + readonly itemId: RuntimeItemId; + readonly success: boolean; + readonly result?: unknown; + readonly message?: string; + readonly createdAt?: string; +}): Effect.Effect { + return Effect.gen(function* () { + const eventId = EventId.make(yield* Random.nextUUIDv4); + const createdAt = input.createdAt ?? new Date().toISOString(); + return { + eventId, + provider: input.context.provider, + ...(input.context.providerInstanceId + ? { providerInstanceId: input.context.providerInstanceId } + : {}), + threadId: input.call.threadId, + createdAt, + ...(input.context.turnId ? { turnId: input.context.turnId } : {}), + itemId: input.itemId, + raw: { + source: "ryco.browser.tool", + payload: { + toolName: input.call.name, + success: input.success, + ...(input.result !== undefined ? { result: input.result } : {}), + ...(input.message ? { message: input.message } : {}), + }, + }, + type: "item.completed", + payload: { + itemType: "browser_tool_call", + status: input.success ? "completed" : "failed", + title: input.call.name, + ...(input.message ? { detail: input.message } : {}), + ...(input.result !== undefined ? { data: input.result } : {}), + }, + } satisfies ProviderRuntimeEvent; + }); +} diff --git a/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.test.ts b/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.test.ts new file mode 100644 index 00000000000..7f7af8ccbdc --- /dev/null +++ b/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.test.ts @@ -0,0 +1,69 @@ +import { ProviderDriverKind } from "@ryco/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + claudeBrowserMcpToolName, + isClaudeBrowserMcpToolName, + listClaudeBrowserMcpToolNames, + mapBrowserRuntimeToolResultToCallToolResult, + parseClaudeBrowserMcpToolName, +} from "./ClaudeBrowserRuntimeTools.ts"; +import { + BROWSER_RUNTIME_TOOL_DEFINITIONS, + resolveProviderBrowserToolSupport, +} from "./BrowserRuntimeTool.ts"; + +describe("ClaudeBrowserRuntimeTools", () => { + it("reports Claude browser tools as supported with shared definitions", () => { + const claude = resolveProviderBrowserToolSupport(ProviderDriverKind.make("claudeAgent")); + + expect(claude.supported).toBe(true); + expect(claude.definitions.map((definition) => definition.name)).toEqual( + BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => definition.name), + ); + }); + + it("builds MCP tool names for the Ryco browser server", () => { + expect(claudeBrowserMcpToolName("browser_open")).toBe("mcp__ryco__browser_open"); + expect(listClaudeBrowserMcpToolNames()).toContain("mcp__ryco__browser_navigate"); + }); + + it("recognizes and parses Claude MCP browser tool names", () => { + expect(isClaudeBrowserMcpToolName("mcp__ryco__browser_open")).toBe(true); + expect(parseClaudeBrowserMcpToolName("mcp__ryco__browser_open")).toBe("browser_open"); + expect(isClaudeBrowserMcpToolName("browser_open")).toBe(false); + expect(parseClaudeBrowserMcpToolName("mcp__ryco__browser_not_a_tool")).toBeUndefined(); + }); + + it("maps browser runtime results into Claude MCP CallToolResult payloads", () => { + const snapshot = { + sessionId: "browser-session:test", + status: "ready", + }; + + expect( + mapBrowserRuntimeToolResultToCallToolResult({ + success: true, + result: snapshot, + }), + ).toEqual({ + content: [{ type: "text", text: JSON.stringify(snapshot) }], + structuredContent: snapshot, + }); + + expect( + mapBrowserRuntimeToolResultToCallToolResult({ + success: false, + message: "browser_navigate requires a browser session id.", + }), + ).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ error: "browser_navigate requires a browser session id." }), + }, + ], + isError: true, + }); + }); +}); diff --git a/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts b/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts new file mode 100644 index 00000000000..06d0667cc6a --- /dev/null +++ b/apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts @@ -0,0 +1,133 @@ +import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; +import type { ThreadId } from "@ryco/contracts"; +import { Effect } from "effect"; +import { z } from "zod"; + +import { + BROWSER_RUNTIME_TOOL_DEFINITIONS, + type BrowserRuntimeToolDefinition, + type BrowserRuntimeToolName, + isBrowserRuntimeToolName, +} from "./BrowserRuntimeTool.ts"; +import { + CLAUDE_BROWSER_MCP_SERVER_NAME, + makeBrowserRuntimeToolHandler, +} from "./BrowserRuntimeToolHelpers.ts"; +import type { ProviderRuntimeToolRegistryShape } from "./ProviderRuntimeToolRegistry.ts"; + +export { + CLAUDE_BROWSER_MCP_SERVER_NAME, + RYCO_BROWSER_MCP_SERVER_NAME, +} from "./BrowserRuntimeToolHelpers.ts"; + +const sessionFields = { + sessionId: z.string().describe("Browser session id returned by browser_open."), + tabId: z.string().optional().describe("Optional browser tab id."), +}; + +export function claudeBrowserMcpToolName(toolName: BrowserRuntimeToolName): string { + return `mcp__${CLAUDE_BROWSER_MCP_SERVER_NAME}__${toolName}`; +} + +export function listClaudeBrowserMcpToolNames(): ReadonlyArray { + return BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => + claudeBrowserMcpToolName(definition.name), + ); +} + +export function isClaudeBrowserMcpToolName(toolName: string): boolean { + return parseClaudeBrowserMcpToolName(toolName) !== undefined; +} + +export function parseClaudeBrowserMcpToolName( + toolName: string, +): BrowserRuntimeToolName | undefined { + const prefix = `mcp__${CLAUDE_BROWSER_MCP_SERVER_NAME}__`; + if (!toolName.startsWith(prefix)) { + return undefined; + } + const shortName = toolName.slice(prefix.length); + return isBrowserRuntimeToolName(shortName) ? shortName : undefined; +} + +export { mapBrowserRuntimeToolResultToCallToolResult } from "./BrowserRuntimeToolHelpers.ts"; + +function zodSchemaForBrowserTool(name: BrowserRuntimeToolName) { + switch (name) { + case "browser_open": + return { + initialUrl: z.string().optional(), + projectId: z.string().optional(), + }; + case "browser_navigate": + return { + ...sessionFields, + url: z.string(), + }; + case "browser_input": + return { + ...sessionFields, + action: z.record(z.string(), z.unknown()), + }; + case "browser_wait_for": + return { + ...sessionFields, + timeoutMs: z.number().optional(), + url: z.string().optional(), + title: z.string().optional(), + text: z.string().optional(), + textGone: z.string().optional(), + loadState: z.enum(["idle", "loading", "loaded", "failed"]).optional(), + }; + default: + return sessionFields; + } +} + +function makeBrowserToolDefinition( + definition: BrowserRuntimeToolDefinition, + options: { + readonly threadId: ThreadId; + readonly executeBrowserTool: ProviderRuntimeToolRegistryShape["executeBrowserTool"]; + readonly runPromise: (effect: Effect.Effect) => Promise; + readonly readArtifactData?: ( + artifactId: import("@ryco/contracts").BrowserArtifactId, + ) => Promise; + }, +) { + const handler = makeBrowserRuntimeToolHandler({ + toolName: definition.name, + threadId: options.threadId, + executeBrowserTool: options.executeBrowserTool, + runPromise: options.runPromise, + ...(options.readArtifactData ? { readArtifactData: options.readArtifactData } : {}), + }); + return tool( + definition.name, + definition.description, + zodSchemaForBrowserTool(definition.name), + handler as never, + { alwaysLoad: true }, + ); +} + +export function makeClaudeBrowserMcpServer(options: { + readonly threadId: ThreadId; + readonly executeBrowserTool: ProviderRuntimeToolRegistryShape["executeBrowserTool"]; + readonly runPromise: (effect: Effect.Effect) => Promise; + readonly readArtifactData?: ( + artifactId: import("@ryco/contracts").BrowserArtifactId, + ) => Promise; +}) { + return createSdkMcpServer({ + name: CLAUDE_BROWSER_MCP_SERVER_NAME, + version: "1.0.0", + alwaysLoad: true, + instructions: + "Use these tools to inspect and control the Ryco in-app browser session for the current thread. " + + "Call browser_open first to create or focus the thread browser, then pass the returned sessionId to other browser tools.", + tools: BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => + makeBrowserToolDefinition(definition, options), + ), + }); +} diff --git a/apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts b/apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts new file mode 100644 index 00000000000..c21cb3b84b2 --- /dev/null +++ b/apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts @@ -0,0 +1,89 @@ +import { convertMcpCallToolResult, defineTool } from "@github/copilot-sdk"; +import type { ThreadId } from "@ryco/contracts"; +import { Effect } from "effect"; +import { z } from "zod"; + +import { + BROWSER_RUNTIME_TOOL_DEFINITIONS, + type BrowserRuntimeToolName, +} from "./BrowserRuntimeTool.ts"; +import { + browserRuntimeToolJsonSchema, + makeBrowserRuntimeToolHandler, +} from "./BrowserRuntimeToolHelpers.ts"; +import type { ProviderRuntimeToolRegistryShape } from "./ProviderRuntimeToolRegistry.ts"; + +const sessionFields = { + sessionId: z.string().describe("Browser session id returned by browser_open."), + tabId: z.string().optional().describe("Optional browser tab id."), +}; + +function zodSchemaForBrowserTool(name: BrowserRuntimeToolName) { + switch (name) { + case "browser_open": + return { + initialUrl: z.string().optional(), + projectId: z.string().optional(), + }; + case "browser_navigate": + return { + ...sessionFields, + url: z.string(), + }; + case "browser_input": + return { + ...sessionFields, + action: z.record(z.string(), z.unknown()), + }; + case "browser_wait_for": + return { + ...sessionFields, + timeoutMs: z.number().optional(), + url: z.string().optional(), + title: z.string().optional(), + text: z.string().optional(), + textGone: z.string().optional(), + loadState: z.enum(["idle", "loading", "loaded", "failed"]).optional(), + }; + default: + return sessionFields; + } +} + +export function makeCopilotBrowserTools(options: { + readonly threadId: ThreadId; + readonly executeBrowserTool: ProviderRuntimeToolRegistryShape["executeBrowserTool"]; + readonly runPromise: (effect: Effect.Effect) => Promise; + readonly readArtifactData?: ( + artifactId: import("@ryco/contracts").BrowserArtifactId, + ) => Promise; +}) { + return BROWSER_RUNTIME_TOOL_DEFINITIONS.map((definition) => { + const handler = makeBrowserRuntimeToolHandler({ + toolName: definition.name, + threadId: options.threadId, + executeBrowserTool: options.executeBrowserTool, + runPromise: options.runPromise, + ...(options.readArtifactData ? { readArtifactData: options.readArtifactData } : {}), + }); + return defineTool(definition.name, { + description: definition.description, + parameters: zodSchemaForBrowserTool(definition.name), + skipPermission: true, + handler: async (args) => { + const result = await handler(args as Record); + return convertMcpCallToolResult({ + content: [...result.content], + ...(result.isError === true ? { isError: true as const } : {}), + }); + }, + }); + }); +} + +export function copilotBrowserToolParameters( + name: BrowserRuntimeToolName, +): Record { + const definition = BROWSER_RUNTIME_TOOL_DEFINITIONS.find((entry) => entry.name === name); + return definition ? browserRuntimeToolJsonSchema(definition) : { type: "object" }; +} diff --git a/apps/server/src/provider/tools/ProviderRuntimeEventHub.ts b/apps/server/src/provider/tools/ProviderRuntimeEventHub.ts new file mode 100644 index 00000000000..62c5fbba7ca --- /dev/null +++ b/apps/server/src/provider/tools/ProviderRuntimeEventHub.ts @@ -0,0 +1,23 @@ +import type { ProviderRuntimeEvent } from "@ryco/contracts"; +import { Context, Effect, Layer, PubSub, Stream } from "effect"; + +export interface ProviderRuntimeEventHubShape { + readonly publish: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly stream: Stream.Stream; +} + +export class ProviderRuntimeEventHub extends Context.Service< + ProviderRuntimeEventHub, + ProviderRuntimeEventHubShape +>()("ryco/provider/tools/ProviderRuntimeEventHub") {} + +export const ProviderRuntimeEventHubLive = Layer.effect( + ProviderRuntimeEventHub, + Effect.gen(function* () { + const pubSub = yield* PubSub.unbounded(); + return { + publish: (event) => PubSub.publish(pubSub, event).pipe(Effect.asVoid), + stream: Stream.fromPubSub(pubSub), + } satisfies ProviderRuntimeEventHubShape; + }), +); diff --git a/apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts b/apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts new file mode 100644 index 00000000000..197fbb5c56b --- /dev/null +++ b/apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts @@ -0,0 +1,110 @@ +import type { ProviderDriverKind, ProviderRuntimeEvent, RuntimeItemId } from "@ryco/contracts"; +import { Context, Effect, Layer, Option } from "effect"; + +import { BrowserService } from "../../browser/BrowserService.ts"; +import { + type BrowserRuntimeToolCallError, + type BrowserRuntimeToolCallInput, + type BrowserRuntimeToolCallResult, + type ProviderBrowserToolSupport, + executeBrowserRuntimeToolCall, + resolveProviderBrowserToolSupport, +} from "./BrowserRuntimeTool.ts"; +import { + type BrowserToolLifecycleContext, + makeBrowserToolCompletedEvent, + makeBrowserToolStartedEvent, +} from "./BrowserToolLifecycleEvents.ts"; +import { ProviderRuntimeEventHub } from "./ProviderRuntimeEventHub.ts"; + +export interface BrowserRuntimeToolExecutionOptions { + readonly lifecycle?: BrowserToolLifecycleContext; +} + +export interface ProviderRuntimeToolRegistryShape { + readonly getBrowserSupport: (provider: ProviderDriverKind) => ProviderBrowserToolSupport; + readonly executeBrowserTool: ( + input: BrowserRuntimeToolCallInput, + options?: BrowserRuntimeToolExecutionOptions, + ) => Effect.Effect; +} + +export class ProviderRuntimeToolRegistry extends Context.Service< + ProviderRuntimeToolRegistry, + ProviderRuntimeToolRegistryShape +>()("ryco/provider/tools/ProviderRuntimeToolRegistry") {} + +export const ProviderRuntimeToolRegistryLive = Layer.effect( + ProviderRuntimeToolRegistry, + Effect.gen(function* () { + const browser = yield* BrowserService; + const eventHub = yield* Effect.serviceOption(ProviderRuntimeEventHub); + + const publishEvents = (events: ReadonlyArray) => + Option.match(eventHub, { + onNone: () => Effect.void, + onSome: (hub) => + Effect.forEach(events, (event) => hub.publish(event), { + concurrency: 1, + discard: true, + }), + }); + + return { + getBrowserSupport: resolveProviderBrowserToolSupport, + executeBrowserTool: (input, options) => + Effect.gen(function* () { + let itemId: RuntimeItemId | undefined; + if (options?.lifecycle) { + const started = yield* makeBrowserToolStartedEvent({ + call: input, + context: options.lifecycle, + }); + itemId = started.itemId; + yield* publishEvents([started]); + } + + const result = yield* executeBrowserRuntimeToolCall(browser, input).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.gen(function* () { + if (options?.lifecycle && itemId) { + const completed = yield* makeBrowserToolCompletedEvent({ + call: input, + context: options.lifecycle, + itemId, + success: false, + message: + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ? error.message + : "Browser runtime tool call failed.", + }); + yield* publishEvents([completed]); + } + return yield* Effect.fail(error); + }), + onSuccess: (value) => + Effect.gen(function* () { + if (options?.lifecycle && itemId) { + const completed = yield* makeBrowserToolCompletedEvent({ + call: input, + context: options.lifecycle, + itemId, + success: true, + result: value, + }); + yield* publishEvents([completed]); + } + return value; + }), + }), + ); + + return result; + }), + } satisfies ProviderRuntimeToolRegistryShape; + }), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 812035f0a15..f1c5506872b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -129,6 +129,12 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { BrowserHostAuthLive } from "./auth/Layers/BrowserHostAuth.ts"; +import { BrowserArtifactStoreLive } from "./browser/BrowserArtifactStore.ts"; +import { BrowserHostRegistryLive } from "./browser/BrowserHostRegistry.ts"; +import { BrowserPolicyLive } from "./browser/BrowserPolicy.ts"; +import { BrowserServiceLive } from "./browser/BrowserService.ts"; +import { browserRuntimeToolTestLayers } from "./provider/tools/BrowserRuntimeToolTestLayers.ts"; import { AtlassianConnectionService, type AtlassianConnectionServiceShape, @@ -466,6 +472,7 @@ const buildAppUnderTest = (options?: { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, + desktopBrowserHostToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -600,8 +607,19 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + // BrowserServiceLive requires BrowserHostRegistry/BrowserPolicy (and optionally + // BrowserArtifactStore). Wire the dependencies via provideMerge so the merged layer + // is self-contained: Layer.mergeAll here would leave those services in the + // requirements channel (leaking `unknown` into every test effect and failing at + // runtime with "Service not found: BrowserHostRegistry"). + const browserLayer = BrowserServiceLive.pipe( + Layer.provideMerge(BrowserHostRegistryLive), + Layer.provideMerge(BrowserPolicyLive), + Layer.provideMerge(BrowserArtifactStoreLive), + Layer.provideMerge(browserRuntimeToolTestLayers), + ); - const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { + const baseRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, }).pipe( @@ -713,6 +731,10 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), ), + Layer.provideMerge(browserLayer), + ); + + const servedRoutesLayer = baseRoutesLayer.pipe( Layer.provide( Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, @@ -843,12 +865,26 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(makeAuthTestLayer()), Layer.provideMerge(LocalDiagnosticsMetricsLive), Layer.provideMerge(AdvertisedEndpointRegistryLive), + Layer.provideMerge(BrowserHostAuthLive), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), ); - yield* Layer.build(appLayer); + // `makeRoutesLayer` includes the websocket RPC route, whose + // `RpcServer.toHttpEffectWebsocket` call leaks `unknown` into the requirements + // channel — the same type-level artifact that `runServer` casts away in production + // (see server.ts). `unknown` then absorbs the real requirements of every test effect + // that builds the app. Everything `appLayer` actually needs is provided above or by + // the enclosing `NodeServices` test layer, so narrow the requirements back to their + // real type here. The error channel is preserved so tests still see build failures. + yield* Layer.build( + appLayer as Layer.Layer< + never, + Layer.Error, + Layer.Success + >, + ); return config; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fdf81c859af..26494808a4e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -15,7 +15,7 @@ import { } from "./http.ts"; import { ProjectAvatarStoreLive } from "./project/Layers/ProjectAvatarStore.ts"; import { fixPath } from "./os-jank.ts"; -import { websocketRpcRouteLayer } from "./ws.ts"; +import { browserHostRpcRouteLayer, websocketRpcRouteLayer } from "./ws.ts"; import { OpenLive } from "./open.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; @@ -79,6 +79,7 @@ import { authWebSocketTokenRouteLayer, } from "./auth/http.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { BrowserHostAuthLive } from "./auth/Layers/BrowserHostAuth.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import * as AtlassianConnectionService from "./atlassian/AtlassianConnectionService.ts"; import * as JiraWorkItemService from "./atlassian/JiraWorkItemService.ts"; @@ -98,6 +99,13 @@ import { } from "./orchestration/http.ts"; import { NetService } from "@ryco/shared/Net"; import { disableTailscaleServe, ensureTailscaleServe } from "@ryco/tailscale"; +import { BrowserArtifactStoreLive } from "./browser/BrowserArtifactStore.ts"; +import { BrowserHostRegistryLive } from "./browser/BrowserHostRegistry.ts"; +import { BrowserMcpBridgeLive } from "./browser/BrowserMcpBridge.ts"; +import { BrowserPolicyLive } from "./browser/BrowserPolicy.ts"; +import { BrowserServiceLive } from "./browser/BrowserService.ts"; +import { ProviderRuntimeEventHubLive } from "./provider/tools/ProviderRuntimeEventHub.ts"; +import { ProviderRuntimeToolRegistryLive } from "./provider/tools/ProviderRuntimeToolRegistry.ts"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -176,6 +184,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( const ProviderLayerLive = ProviderServiceLive.pipe( Layer.provide(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), + Layer.provideMerge(ProviderRuntimeEventHubLive), ); const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); @@ -239,6 +248,26 @@ const CheckpointingLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +// The browser services have real dependencies on each other: +// ProviderRuntimeToolRegistry needs BrowserService, and BrowserService needs +// BrowserHostRegistry/BrowserPolicy (and optionally BrowserArtifactStore). A flat +// Layer.mergeAll builds them in parallel WITHOUT satisfying those dependencies, which +// compiles (the requirements just leak, masked by the `as` cast on `runServer`) but +// crashes at runtime with "Service not found: BrowserHostRegistry". Wire the +// dependencies with provideMerge (dependents first, providers last) so every service is +// still exported while the internal graph is actually connected. +const BrowserLayerLive = ProviderRuntimeToolRegistryLive.pipe( + Layer.provideMerge(BrowserServiceLive), + Layer.provideMerge( + Layer.mergeAll( + BrowserHostRegistryLive, + BrowserPolicyLive, + BrowserArtifactStoreLive, + BrowserMcpBridgeLive, + ), + ), +); + const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -284,6 +313,7 @@ const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(BrowserLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(ProjectionWorktreeRepositoryLive), Layer.provideMerge(KeybindingsLive), @@ -317,6 +347,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreBaseDependenciesLive.pipe( Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AdvertisedEndpointRegistryLive), Layer.provideMerge(AuthLayerLive), + Layer.provideMerge(BrowserHostAuthLive), Layer.provideMerge(AtlassianLayerLive), ); @@ -362,6 +393,7 @@ export const makeRoutesLayer = Layer.mergeAll( legacyServerEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, + browserHostRpcRouteLayer, ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( @@ -471,8 +503,4 @@ export const makeServerLayer = Layer.unwrap( ); // Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. -export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect< - never, - any, - ServerConfig ->; +export const runServer = Layer.launch(makeServerLayer) as Effect.Effect; diff --git a/apps/server/src/test/serverConfigFixtures.ts b/apps/server/src/test/serverConfigFixtures.ts new file mode 100644 index 00000000000..24ef4cb43a8 --- /dev/null +++ b/apps/server/src/test/serverConfigFixtures.ts @@ -0,0 +1,49 @@ +import type { ServerConfigShape } from "../config.ts"; + +export const makeTestServerConfig = ( + overrides: Partial = {}, +): ServerConfigShape => ({ + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "ryco-server-test", + mode: "desktop", + port: 3773, + host: "127.0.0.1", + cwd: "/tmp/ryco-test", + baseDir: "/tmp/ryco-test", + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + desktopBrowserHostToken: "secret-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + stateDir: "/tmp/ryco-test/state", + dbPath: "/tmp/ryco-test/state/state.sqlite", + keybindingsConfigPath: "/tmp/ryco-test/state/keybindings.json", + settingsPath: "/tmp/ryco-test/state/settings.json", + providerStatusCacheDir: "/tmp/ryco-test/caches", + worktreesDir: "/tmp/ryco-test/worktrees", + attachmentsDir: "/tmp/ryco-test/state/attachments", + logsDir: "/tmp/ryco-test/state/logs", + serverLogPath: "/tmp/ryco-test/state/logs/server.log", + serverTracePath: "/tmp/ryco-test/state/logs/server.trace.ndjson", + providerLogsDir: "/tmp/ryco-test/state/logs/provider", + providerEventLogPath: "/tmp/ryco-test/state/logs/provider/events.log", + terminalLogsDir: "/tmp/ryco-test/state/logs/terminals", + anonymousIdPath: "/tmp/ryco-test/state/anonymous-id", + environmentIdPath: "/tmp/ryco-test/state/environment-id", + serverRuntimeStatePath: "/tmp/ryco-test/state/server-runtime.json", + secretsDir: "/tmp/ryco-test/state/secrets", + ...overrides, +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 85dccd48a4e..b9bc54f4b9d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,5 @@ import { Effect, Layer } from "effect"; -import { WsRpcGroup } from "@ryco/contracts"; +import { BrowserHostRpcGroup, WsRpcGroup } from "@ryco/contracts"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -22,6 +22,8 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import { respondToAuthError } from "./auth/http.ts"; import { SessionCredentialService } from "./auth/Services/SessionCredentialService.ts"; import { makeWsRpcLayer } from "./ws/index.ts"; +import { BrowserHostAuth } from "./auth/Services/BrowserHostAuth.ts"; +import { makeBrowserHostRpcLayer } from "./browserHost/browserHostRpc.ts"; export const websocketRpcRouteLayer = Layer.unwrap( Effect.succeed( @@ -81,3 +83,30 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ), ); + +export const browserHostRpcRouteLayer = Layer.unwrap( + Effect.succeed( + HttpRouter.add( + "GET", + "/browser-host/ws", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const browserHostAuth = yield* BrowserHostAuth; + yield* browserHostAuth.authenticateWebSocketUpgrade(request); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(BrowserHostRpcGroup, { + spanPrefix: "browser-host.rpc", + spanAttributes: { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", + "rpc.aggregate": "browserHost", + }, + }).pipe( + Effect.provide( + makeBrowserHostRpcLayer().pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + ); + return yield* rpcWebSocketHttpEffect; + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), + ), + ), +); diff --git a/apps/server/src/ws/browserRpc.ts b/apps/server/src/ws/browserRpc.ts new file mode 100644 index 00000000000..43c60da759f --- /dev/null +++ b/apps/server/src/ws/browserRpc.ts @@ -0,0 +1,132 @@ +import { WS_METHODS } from "@ryco/contracts"; + +import { observeRpcEffect, observeRpcStream } from "../observability/RpcInstrumentation.ts"; +import { defineWsHandlers, type WsRpcContext } from "./context.ts"; + +export const makeBrowserHandlers = (ctx: WsRpcContext) => { + const { localDesktopOwnerEffect, localDesktopOwnerStream } = ctx; + const browser = ctx.browserService; + + return defineWsHandlers({ + [WS_METHODS.browserGetStatus]: (_input) => + observeRpcEffect( + WS_METHODS.browserGetStatus, + localDesktopOwnerEffect(WS_METHODS.browserGetStatus, browser.getStatus), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserListProfiles]: (_input) => + observeRpcEffect( + WS_METHODS.browserListProfiles, + localDesktopOwnerEffect(WS_METHODS.browserListProfiles, browser.listProfiles), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserOpenSession]: (input) => + observeRpcEffect( + WS_METHODS.browserOpenSession, + localDesktopOwnerEffect(WS_METHODS.browserOpenSession, browser.openSession(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserCloseSession]: (input) => + observeRpcEffect( + WS_METHODS.browserCloseSession, + localDesktopOwnerEffect(WS_METHODS.browserCloseSession, browser.closeSession(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserGetSnapshot]: (input) => + observeRpcEffect( + WS_METHODS.browserGetSnapshot, + localDesktopOwnerEffect(WS_METHODS.browserGetSnapshot, browser.getSnapshot(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserNavigate]: (input) => + observeRpcEffect( + WS_METHODS.browserNavigate, + localDesktopOwnerEffect(WS_METHODS.browserNavigate, browser.navigate(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserBack]: (input) => + observeRpcEffect( + WS_METHODS.browserBack, + localDesktopOwnerEffect(WS_METHODS.browserBack, browser.back(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserForward]: (input) => + observeRpcEffect( + WS_METHODS.browserForward, + localDesktopOwnerEffect(WS_METHODS.browserForward, browser.forward(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserReload]: (input) => + observeRpcEffect( + WS_METHODS.browserReload, + localDesktopOwnerEffect(WS_METHODS.browserReload, browser.reload(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserStop]: (input) => + observeRpcEffect( + WS_METHODS.browserStop, + localDesktopOwnerEffect(WS_METHODS.browserStop, browser.stop(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserInput]: (input) => + observeRpcEffect( + WS_METHODS.browserInput, + localDesktopOwnerEffect(WS_METHODS.browserInput, browser.input(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserInspectStorage]: (input) => + observeRpcEffect( + WS_METHODS.browserInspectStorage, + localDesktopOwnerEffect(WS_METHODS.browserInspectStorage, browser.inspectStorage(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserClearStorage]: (input) => + observeRpcEffect( + WS_METHODS.browserClearStorage, + localDesktopOwnerEffect(WS_METHODS.browserClearStorage, browser.clearStorage(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserDeleteCookie]: (input) => + observeRpcEffect( + WS_METHODS.browserDeleteCookie, + localDesktopOwnerEffect(WS_METHODS.browserDeleteCookie, browser.deleteCookie(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserSnapshotDom]: (input) => + observeRpcEffect( + WS_METHODS.browserSnapshotDom, + localDesktopOwnerEffect(WS_METHODS.browserSnapshotDom, browser.snapshotDom(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserScreenshot]: (input) => + observeRpcEffect( + WS_METHODS.browserScreenshot, + localDesktopOwnerEffect(WS_METHODS.browserScreenshot, browser.screenshot(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserReadConsole]: (input) => + observeRpcEffect( + WS_METHODS.browserReadConsole, + localDesktopOwnerEffect(WS_METHODS.browserReadConsole, browser.readConsole(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserReadNetwork]: (input) => + observeRpcEffect( + WS_METHODS.browserReadNetwork, + localDesktopOwnerEffect(WS_METHODS.browserReadNetwork, browser.readNetwork(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.browserWaitFor]: (input) => + observeRpcEffect( + WS_METHODS.browserWaitFor, + localDesktopOwnerEffect(WS_METHODS.browserWaitFor, browser.waitFor(input)), + { "rpc.aggregate": "browser" }, + ), + [WS_METHODS.subscribeBrowserEvents]: (_input) => + observeRpcStream( + WS_METHODS.subscribeBrowserEvents, + localDesktopOwnerStream(WS_METHODS.subscribeBrowserEvents, browser.events), + { "rpc.aggregate": "browser" }, + ), + }); +}; diff --git a/apps/server/src/ws/context.ts b/apps/server/src/ws/context.ts index b4035e6ca0c..2478c005de5 100644 --- a/apps/server/src/ws/context.ts +++ b/apps/server/src/ws/context.ts @@ -52,6 +52,7 @@ import { AtlassianConnectionService } from "../atlassian/AtlassianConnectionServ import { JiraWorkItemService } from "../atlassian/JiraWorkItemService.ts"; import { AdvertisedEndpointRegistry } from "../remote/Services/AdvertisedEndpointRegistry.ts"; import { LocalDiagnosticsMetrics } from "../observability/Services/LocalDiagnosticsMetrics.ts"; +import { BrowserService } from "../browser/BrowserService.ts"; import { SOURCE_CONTROL_LINKED_REFRESH_DEBOUNCE_MS } from "./context/constants.ts"; import { toGitManagerError } from "./context/gitErrors.ts"; @@ -117,6 +118,7 @@ export const makeWsRpcContext = (session: AuthenticatedSession) => const diagnostics = yield* Diagnostics; const localDiagnosticsMetrics = yield* LocalDiagnosticsMetrics; const advertisedEndpointRegistry = yield* AdvertisedEndpointRegistry; + const browserService = yield* BrowserService; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); const linkedSourceControlRefreshAtByProject = new Map(); @@ -132,6 +134,9 @@ export const makeWsRpcContext = (session: AuthenticatedSession) => const ownerEffect = (method: string, effect: Effect.Effect) => withAccess("owner", method, effect); + const localDesktopOwnerEffect = (method: string, effect: Effect.Effect) => + withAccess("local-desktop-owner", method, effect); + const refreshLinkedWorktreeSourceControlStates = (input: { readonly cwd: string; readonly reason: string; @@ -221,6 +226,9 @@ export const makeWsRpcContext = (session: AuthenticatedSession) => const ownerStream = (method: string, stream: Stream.Stream) => Stream.unwrap(authorize("owner", method).pipe(Effect.as(stream))); + const localDesktopOwnerStream = (method: string, stream: Stream.Stream) => + Stream.unwrap(authorize("local-desktop-owner", method).pipe(Effect.as(stream))); + const loadAuthAccessSnapshot = () => Effect.all({ pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), @@ -637,9 +645,12 @@ export const makeWsRpcContext = (session: AuthenticatedSession) => projectionWorktrees, atlassian, workItems, + browserService, withAccess, ownerEffect, + localDesktopOwnerEffect, ownerStream, + localDesktopOwnerStream, ownerStreamEffect, serverCommandId, refreshGitStatus, diff --git a/apps/server/src/ws/index.ts b/apps/server/src/ws/index.ts index 513866d0d7a..a612be10b6d 100644 --- a/apps/server/src/ws/index.ts +++ b/apps/server/src/ws/index.ts @@ -9,6 +9,7 @@ import { makeTerminalHandlers } from "./terminalRpc.ts"; import { makeProjectHandlers } from "./projectRpc.ts"; import { makeSourceControlHandlers } from "./sourceControlRpc.ts"; import { makeProviderHandlers } from "./providerRpc.ts"; +import { makeBrowserHandlers } from "./browserRpc.ts"; export const makeWsRpcLayer = (session: AuthenticatedSession) => WsRpcGroup.toLayer( @@ -21,6 +22,7 @@ export const makeWsRpcLayer = (session: AuthenticatedSession) => ...makeProjectHandlers(ctx), ...makeGitHandlers(ctx), ...makeTerminalHandlers(ctx), + ...makeBrowserHandlers(ctx), }); }), ); diff --git a/apps/web/src/components/BrowserPanel.logic.test.ts b/apps/web/src/components/BrowserPanel.logic.test.ts new file mode 100644 index 00000000000..8a616da0388 --- /dev/null +++ b/apps/web/src/components/BrowserPanel.logic.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + BrowserArtifactId, + BrowserProfileId, + BrowserSessionId, + BrowserTabId, + ThreadId, + type BrowserEvent, + type BrowserProfile, + type BrowserSessionSnapshot, + type BrowserStorageInspectionResult, +} from "@ryco/contracts"; +import { + applyBrowserEvent, + findThreadBrowserSession, + formatBrowserDownloadUpdate, + formatBrowserPermissionRequest, + readBrowserSurfaceBounds, + resolveBrowserProfileLabel, + resolveSelectedTab, + shouldSyncAddressFromNavigation, + summarizeBrowserStorageInspection, + formatBrowserCookieExpiry, + formatBrowserStorageBytes, +} from "./BrowserPanel.logic"; + +const threadId = ThreadId.make("thread-1"); + +const session = { + sessionId: BrowserSessionId.make("browser-session:test"), + profileId: BrowserProfileId.make("browser-profile:test"), + threadId, + selectedTabId: BrowserTabId.make("browser-tab:test"), + tabs: [ + { + tabId: BrowserTabId.make("browser-tab:test"), + sessionId: BrowserSessionId.make("browser-session:test"), + profileId: BrowserProfileId.make("browser-profile:test"), + selected: true, + crashed: false, + navigation: { + url: "https://example.test/path", + origin: "https://example.test", + loadState: "loaded", + canGoBack: false, + canGoForward: false, + }, + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + ], + status: "ready", + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", +} satisfies BrowserSessionSnapshot; + +const inspection = { + session, + tabId: BrowserTabId.make("browser-tab:test"), + profileId: BrowserProfileId.make("browser-profile:test"), + url: "https://example.test/", + origin: "https://example.test", + cookies: [], + localStorage: [ + { key: "theme", valueBytes: 4 }, + { key: "session", valueBytes: 2048 }, + ], + sessionStorage: [{ key: "draft", valueBytes: 512 }], + cookieCounts: { + currentOrigin: 3, + profile: 8, + }, + inspectedAt: "2026-06-25T10:00:00.000Z", +} satisfies BrowserStorageInspectionResult; + +describe("BrowserPanel storage logic", () => { + it("summarizes cookie counts and visible storage bytes", () => { + expect(summarizeBrowserStorageInspection(inspection)).toEqual({ + currentOriginCookies: 3, + profileCookies: 8, + localStorageKeys: 2, + sessionStorageKeys: 1, + storageBytes: 2564, + }); + }); + + it("formats byte counts for compact browser storage labels", () => { + expect(formatBrowserStorageBytes(0)).toBe("0 B"); + expect(formatBrowserStorageBytes(512)).toBe("512 B"); + expect(formatBrowserStorageBytes(2048)).toBe("2.0 KB"); + expect(formatBrowserStorageBytes(5 * 1024 * 1024)).toBe("5.0 MB"); + }); + + it("does not require persistent cookie expiry metadata", () => { + expect( + formatBrowserCookieExpiry({ + name: "sid", + domain: "example.test", + path: "/", + secure: true, + httpOnly: true, + session: true, + sizeBytes: 24, + }), + ).toBe("Session"); + }); +}); + +describe("BrowserPanel session logic", () => { + it("resolves the selected tab from a session snapshot", () => { + expect(resolveSelectedTab(session)?.tabId).toBe(session.selectedTabId); + }); + + it("finds an active browser session for the current thread", () => { + expect(findThreadBrowserSession([session], threadId)?.sessionId).toBe(session.sessionId); + expect(findThreadBrowserSession([], threadId)).toBeNull(); + }); + + it("adopts agent session updates for the active thread", () => { + const agentSession = { + ...session, + sessionId: BrowserSessionId.make("browser-session:agent"), + tabs: session.tabs.map((tab) => ({ + ...tab, + sessionId: BrowserSessionId.make("browser-session:agent"), + navigation: { + ...tab.navigation, + url: "https://agent.example.test/", + }, + })), + } satisfies BrowserSessionSnapshot; + + const event = { + type: "session.updated", + session: agentSession, + createdAt: "2026-06-25T10:01:00.000Z", + } satisfies BrowserEvent; + + expect(applyBrowserEvent(null, event, threadId)?.sessionId).toBe(agentSession.sessionId); + expect(applyBrowserEvent(session, event, threadId)?.tabs[0]?.navigation.url).toBe( + "https://agent.example.test/", + ); + }); + + it("does not overwrite the address bar while it is focused", () => { + expect( + shouldSyncAddressFromNavigation(true, "https://example.test/new", "https://old.test"), + ).toBe(false); + expect( + shouldSyncAddressFromNavigation(false, "https://example.test/new", "https://old.test"), + ).toBe(true); + }); + + it("reads surface bounds with device scale metadata", () => { + expect(readBrowserSurfaceBounds({ x: 10.4, y: 20.6, width: 800.2, height: 600.8 }, 2)).toEqual({ + x: 10, + y: 21, + width: 800, + height: 600, + deviceScaleFactor: 2, + }); + }); + + it("labels thread profiles for the status bar", () => { + const profiles = [ + { + profileId: session.profileId, + displayName: "Thread", + mode: "thread", + persistent: true, + scope: { mode: "thread", threadId }, + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + ] satisfies ReadonlyArray; + + expect(resolveBrowserProfileLabel(session, profiles)).toBe("Thread profile"); + }); + + it("formats permission and download notifications", () => { + expect( + formatBrowserPermissionRequest({ + origin: "https://example.test", + permission: "camera", + }).title, + ).toContain("Camera"); + + expect( + formatBrowserDownloadUpdate({ + state: "completed", + artifact: { + artifactId: BrowserArtifactId.make("browser-artifact:1"), + kind: "download", + mimeType: "application/pdf", + byteSize: 1024, + url: "https://example.test/report.pdf", + createdAt: "2026-06-25T10:00:00.000Z", + expiresAt: "2026-06-25T11:00:00.000Z", + }, + })?.title, + ).toBe("Download completed"); + }); +}); diff --git a/apps/web/src/components/BrowserPanel.logic.ts b/apps/web/src/components/BrowserPanel.logic.ts new file mode 100644 index 00000000000..2a74c1a6503 --- /dev/null +++ b/apps/web/src/components/BrowserPanel.logic.ts @@ -0,0 +1,288 @@ +import type { + BrowserArtifactRef, + BrowserCookieMetadata, + BrowserEvent, + BrowserPermissionKind, + BrowserProfile, + BrowserProfileMode, + BrowserSessionSnapshot, + BrowserStorageDataType, + BrowserStorageInspectionResult, + BrowserTabSnapshot, + ThreadId, +} from "@ryco/contracts"; + +export const BROWSER_CURRENT_ORIGIN_CLEAR_TYPES = [ + "cookies", + "localStorage", + "sessionStorage", + "indexedDB", + "cacheStorage", + "serviceWorkers", +] as const satisfies ReadonlyArray; + +export const BROWSER_CURRENT_ORIGIN_STORAGE_TYPES = [ + "localStorage", + "sessionStorage", + "indexedDB", + "cacheStorage", + "serviceWorkers", +] as const satisfies ReadonlyArray; + +export const BROWSER_PROFILE_CLEAR_TYPES = [ + "cookies", + "localStorage", + "sessionStorage", + "indexedDB", + "cacheStorage", + "serviceWorkers", + "httpCache", +] as const satisfies ReadonlyArray; + +/** Matches provider browser tools (`browser_open` uses thread-scoped profiles). */ +export const BROWSER_PANEL_PROFILE_MODE: BrowserProfileMode = "thread"; + +export interface BrowserSurfaceBoundsPayload { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly deviceScaleFactor: number; +} + +export function resolveSelectedTab( + session: BrowserSessionSnapshot | null, +): BrowserTabSnapshot | null { + if (!session) return null; + return session.tabs.find((tab) => tab.tabId === session.selectedTabId) ?? session.tabs[0] ?? null; +} + +export function findThreadBrowserSession( + sessions: ReadonlyArray, + threadId: ThreadId, +): BrowserSessionSnapshot | null { + return ( + sessions.find((session) => session.threadId === threadId && session.status !== "closed") ?? null + ); +} + +export function resolveBrowserProfileLabel( + session: BrowserSessionSnapshot | null, + profiles: ReadonlyArray, +): string { + if (!session) return "Opening"; + const profile = profiles.find((candidate) => candidate.profileId === session.profileId); + if (profile?.mode === "thread") return "Thread profile"; + if (profile?.mode === "project") return "Project profile"; + if (profile?.mode === "worktree") return "Worktree profile"; + if (profile?.mode === "temporary") return "Temporary profile"; + if (profile?.displayName) return profile.displayName; + return "Browser profile"; +} + +export function isAgentOwnedBrowserSession( + session: BrowserSessionSnapshot | null, + profiles: ReadonlyArray, +): boolean { + if (!session) return false; + const profile = profiles.find((candidate) => candidate.profileId === session.profileId); + return profile?.mode === "thread"; +} + +export function shouldSyncAddressFromNavigation( + addressFocused: boolean, + nextUrl: string | undefined, + currentAddress: string, +): boolean { + if (addressFocused) return false; + return (nextUrl ?? "") !== currentAddress; +} + +export function readBrowserSurfaceBounds( + rect: Pick, + deviceScaleFactor: number, +): BrowserSurfaceBoundsPayload | null { + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + if ( + !Number.isFinite(rect.x) || + !Number.isFinite(rect.y) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width, + height, + deviceScaleFactor: deviceScaleFactor > 0 ? deviceScaleFactor : 1, + }; +} + +export function applyBrowserEvent( + current: BrowserSessionSnapshot | null, + event: BrowserEvent, + threadId: ThreadId, +): BrowserSessionSnapshot | null { + if (event.type === "session.updated") { + if (current && event.session.sessionId === current.sessionId) { + return event.session; + } + if (event.session.threadId === threadId && event.session.status !== "closed") { + return event.session; + } + return current; + } + if (!current) return current; + if (event.type === "session.closed" && event.sessionId === current.sessionId) { + return { ...current, status: "closed", updatedAt: new Date().toISOString() }; + } + if (event.type === "tab.updated" && event.tab.sessionId === current.sessionId) { + const tabs = current.tabs.some((tab) => tab.tabId === event.tab.tabId) + ? current.tabs.map((tab) => (tab.tabId === event.tab.tabId ? event.tab : tab)) + : [...current.tabs, event.tab]; + return { + ...current, + tabs, + selectedTabId: event.tab.selected ? event.tab.tabId : current.selectedTabId, + updatedAt: new Date().toISOString(), + }; + } + if (event.type === "tab.crashed" && event.sessionId === current.sessionId) { + return { + ...current, + status: "error", + tabs: current.tabs.map((tab) => + tab.tabId === event.tabId ? { ...tab, crashed: true, updatedAt: event.createdAt } : tab, + ), + updatedAt: event.createdAt, + }; + } + return current; +} + +const PERMISSION_LABELS: Record = { + camera: "Camera", + microphone: "Microphone", + location: "Location", + notifications: "Notifications", + midi: "MIDI", + clipboard: "Clipboard", + fullscreen: "Fullscreen", + download: "Download", + popup: "Popup", + "file-system": "File system", + "media-capture": "Media capture", +}; + +export function formatBrowserPermissionRequest(input: { + readonly origin: string; + readonly permission: BrowserPermissionKind; +}): { readonly title: string; readonly description: string } { + const permissionLabel = PERMISSION_LABELS[input.permission] ?? input.permission; + return { + title: `${permissionLabel} permission requested`, + description: `${input.origin} requested ${permissionLabel.toLowerCase()} access in the embedded browser.`, + }; +} + +export function formatBrowserDownloadUpdate(input: { + readonly state: "started" | "progress" | "completed" | "failed" | "cancelled"; + readonly artifact: BrowserArtifactRef; +}): { + readonly title: string; + readonly description: string; + readonly variant: "info" | "error"; +} | null { + const fileName = input.artifact.url + ? decodeURIComponent(input.artifact.url.split(/[/?#]/).pop() ?? "download") + : "Download"; + switch (input.state) { + case "started": + return { + title: "Download started", + description: fileName, + variant: "info", + }; + case "completed": + return { + title: "Download completed", + description: fileName, + variant: "info", + }; + case "failed": + return { + title: "Download failed", + description: fileName, + variant: "error", + }; + case "cancelled": + return { + title: "Download cancelled", + description: fileName, + variant: "info", + }; + default: + return null; + } +} + +export function formatBrowserStorageBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1024) return `${Math.round(bytes)} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(kib < 10 ? 1 : 0)} KB`; + const mib = kib / 1024; + return `${mib.toFixed(mib < 10 ? 1 : 0)} MB`; +} + +export function formatBrowserCookieExpiry(cookie: BrowserCookieMetadata): string { + if (cookie.session || cookie.expirationDate === undefined) return "Session"; + const expiresAt = new Date(cookie.expirationDate * 1000); + if (Number.isNaN(expiresAt.getTime())) return "Persistent"; + return expiresAt.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export function summarizeBrowserStorageInspection( + inspection: BrowserStorageInspectionResult | null, +): { + readonly currentOriginCookies: number; + readonly profileCookies: number; + readonly localStorageKeys: number; + readonly sessionStorageKeys: number; + readonly storageBytes: number; +} { + if (!inspection) { + return { + currentOriginCookies: 0, + profileCookies: 0, + localStorageKeys: 0, + sessionStorageKeys: 0, + storageBytes: 0, + }; + } + const localStorageBytes = inspection.localStorage.reduce( + (total, entry) => total + entry.valueBytes, + 0, + ); + const sessionStorageBytes = inspection.sessionStorage.reduce( + (total, entry) => total + entry.valueBytes, + 0, + ); + return { + currentOriginCookies: inspection.cookieCounts.currentOrigin, + profileCookies: inspection.cookieCounts.profile, + localStorageKeys: inspection.localStorage.length, + sessionStorageKeys: inspection.sessionStorage.length, + storageBytes: localStorageBytes + sessionStorageBytes, + }; +} diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx new file mode 100644 index 00000000000..87cdec20681 --- /dev/null +++ b/apps/web/src/components/BrowserPanel.tsx @@ -0,0 +1,974 @@ +import { scopeThreadRef } from "@ryco/client-runtime"; +import type { + BrowserCookieMetadata, + BrowserEvent, + BrowserProfile, + BrowserSessionSnapshot, + BrowserStorageDataType, + BrowserStorageInspectionResult, + ProjectId, + ScopedThreadRef, +} from "@ryco/contracts"; +import { BrowserSessionId } from "@ryco/contracts"; +import { normalizeBrowserNavigationUrl } from "@ryco/shared/browser"; +import { useParams } from "@tanstack/react-router"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CookieIcon, + DatabaseIcon, + ExternalLinkIcon, + GlobeIcon, + Loader2Icon, + RefreshCwIcon, + ShieldAlertIcon, + SparklesIcon, + SquareIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { readEnvironmentApi } from "../environmentApi"; +import { readLocalApi } from "../localApi"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { useStore } from "../store"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { cn } from "~/lib/utils"; +import { + applyBrowserEvent, + BROWSER_CURRENT_ORIGIN_STORAGE_TYPES, + BROWSER_PANEL_PROFILE_MODE, + BROWSER_PROFILE_CLEAR_TYPES, + findThreadBrowserSession, + formatBrowserCookieExpiry, + formatBrowserDownloadUpdate, + formatBrowserPermissionRequest, + formatBrowserStorageBytes, + readBrowserSurfaceBounds, + resolveBrowserProfileLabel, + resolveSelectedTab, + summarizeBrowserStorageInspection, +} from "./BrowserPanel.logic"; +import { toastManager } from "./ui/toast"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { ScrollArea } from "./ui/scroll-area"; + +const UNSUPPORTED_MESSAGE = "Built-in browser is available for the local Ryco desktop backend."; + +type BrowserPanelStatus = "loading" | "unsupported" | "ready" | "error"; + +function BrowserUnavailableState(props: { title: string; description: string }) { + return ( +
+
+
+ +
+

{props.title}

+

{props.description}

+
+
+ ); +} + +function storageErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function useBrowserRouteTarget(): { + threadRef: ScopedThreadRef | null; + projectId: ProjectId | null; +} { + const params = useParams({ strict: false }) as Record; + const routeThreadRef = resolveThreadRouteRef(params); + const draftId = params.draftId ? DraftId.make(params.draftId) : null; + const draftSession = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : null, + ); + const threadRef = useMemo(() => { + if (routeThreadRef) return routeThreadRef; + if (!draftSession) return null; + return scopeThreadRef(draftSession.environmentId, draftSession.threadId); + }, [draftSession, routeThreadRef]); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + + return { + threadRef, + projectId: serverThread?.projectId ?? draftSession?.projectId ?? null, + }; +} + +function BrowserStorageInspector(props: { + inspection: BrowserStorageInspectionResult | null; + loading: boolean; + message: string | null; + view: "cookies" | "storage" | "actions"; + profileClearConfirmation: "cookies" | "all" | null; + onViewChange: (view: "cookies" | "storage" | "actions") => void; + onRefresh: () => void; + onClose: () => void; + onDeleteCookie: (cookie: BrowserCookieMetadata) => void; + onClearCurrentOriginCookies: () => void; + onClearCurrentOriginStorage: () => void; + onRequestProfileClear: (action: "cookies" | "all") => void; + onCancelProfileClear: () => void; + onConfirmProfileClear: (action: "cookies" | "all") => void; +}) { + const summary = summarizeBrowserStorageInspection(props.inspection); + const originLabel = props.inspection?.origin ?? "No origin"; + const hasInspection = props.inspection !== null; + const currentOriginDisabled = !props.inspection?.origin || props.loading; + const profileDisabled = !hasInspection || props.loading; + const storageSections = [ + { label: "localStorage", entries: props.inspection?.localStorage ?? [] }, + { label: "sessionStorage", entries: props.inspection?.sessionStorage ?? [] }, + ]; + + return ( +
+
+
+ +
+
+
+

Storage

+ + {summary.profileCookies} cookies + + + {formatBrowserStorageBytes(summary.storageBytes)} + +
+

{originLabel}

+
+ + +
+ + {props.message ? ( + + Storage command failed + {props.message} + + ) : null} + +
+ {(["cookies", "storage", "actions"] as const).map((view) => ( + + ))} +
+ +
+ {!hasInspection && props.loading ? ( +
+ + Loading storage +
+ ) : props.view === "cookies" ? ( + +
+ {props.inspection?.cookies.length ? ( + props.inspection.cookies.map((cookie) => ( +
+
+
+

+ {cookie.name} +

+ {cookie.httpOnly ? ( + + HttpOnly + + ) : null} + {cookie.secure ? ( + + Secure + + ) : null} +
+

+ {cookie.domain || props.inspection?.origin} {cookie.path} +

+

+ {formatBrowserCookieExpiry(cookie)} ·{" "} + {formatBrowserStorageBytes(cookie.sizeBytes)} +

+
+ +
+ )) + ) : ( +

+ No cookies for this origin. +

+ )} +
+
+ ) : props.view === "storage" ? ( + +
+ {storageSections.map(({ label, entries }) => ( +
+
+

{label}

+ + {entries.length} + +
+ {entries.length ? ( + entries.map((entry) => ( +
+

{entry.key}

+ + {formatBrowserStorageBytes(entry.valueBytes)} + +
+ )) + ) : ( +

+ Empty +

+ )} +
+ ))} +
+
+ ) : ( + +
+ + + +
+ {props.profileClearConfirmation === "cookies" ? ( +
+

+ Clear all profile cookies? +

+
+ + +
+
+ ) : ( + + )} +
+ +
+ {props.profileClearConfirmation === "all" ? ( +
+

+ Clear all profile data and cache? +

+
+ + +
+
+ ) : ( + + )} +
+
+
+ )} +
+
+ ); +} + +export default function BrowserPanel() { + const { threadRef } = useBrowserRouteTarget(); + const [status, setStatus] = useState("loading"); + const [message, setMessage] = useState(null); + const [session, setSession] = useState(null); + const [profiles, setProfiles] = useState>([]); + const [agentSessionActive, setAgentSessionActive] = useState(false); + const [permissionNotice, setPermissionNotice] = useState(null); + const [addressValue, setAddressValue] = useState(""); + const [isNavigating, setIsNavigating] = useState(false); + const [storageOpen, setStorageOpen] = useState(false); + const [storageView, setStorageView] = useState<"cookies" | "storage" | "actions">("cookies"); + const [storageInspection, setStorageInspection] = useState( + null, + ); + const [storageLoading, setStorageLoading] = useState(false); + const [storageMessage, setStorageMessage] = useState(null); + const [profileClearConfirmation, setProfileClearConfirmation] = useState< + "cookies" | "all" | null + >(null); + const surfaceRef = useRef(null); + const addressFocusedRef = useRef(false); + const sessionOpenedByPanelRef = useRef(false); + const panelOwnedSessionIdRef = useRef(null); + const deviceScaleFactorRef = useRef( + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, + ); + + const api = threadRef ? readEnvironmentApi(threadRef.environmentId) : undefined; + const browserApi = api?.browser; + const selectedTab = resolveSelectedTab(session); + const sessionId = session?.sessionId ?? null; + const selectedTabId = selectedTab?.tabId ?? null; + const navigation = selectedTab?.navigation ?? null; + const nativeBridge = typeof window === "undefined" ? undefined : window.desktopBridge?.browser; + const environmentId = threadRef?.environmentId ?? null; + const threadId = threadRef?.threadId ?? null; + + const handleBrowserEvent = useCallback( + (event: BrowserEvent) => { + if (event.type === "host.disconnected") { + setStatus("unsupported"); + setMessage("The desktop BrowserHost disconnected."); + } + if (event.type === "permission.requested") { + const copy = formatBrowserPermissionRequest(event); + setPermissionNotice(copy.description); + toastManager.add({ + type: "info", + title: copy.title, + description: copy.description, + data: threadRef ? { threadRef } : undefined, + }); + } + if (event.type === "download.updated") { + const copy = formatBrowserDownloadUpdate(event); + if (copy) { + toastManager.add({ + type: copy.variant === "error" ? "error" : "info", + title: copy.title, + description: copy.description, + data: threadRef ? { threadRef } : undefined, + }); + } + } + if ( + event.type === "session.updated" && + event.session.threadId === threadId && + event.session.status !== "closed" && + !sessionOpenedByPanelRef.current + ) { + setAgentSessionActive(true); + } + setSession((current) => (threadId ? applyBrowserEvent(current, event, threadId) : current)); + }, + [threadId, threadRef], + ); + + useEffect(() => { + if (!addressFocusedRef.current) { + setAddressValue(navigation?.url ?? ""); + } + }, [navigation?.url]); + + useEffect(() => { + let cancelled = false; + let unsubscribe: (() => void) | undefined; + + const openSession = async () => { + setSession(null); + setProfiles([]); + setAgentSessionActive(false); + setPermissionNotice(null); + sessionOpenedByPanelRef.current = false; + panelOwnedSessionIdRef.current = null; + setMessage(null); + setStorageInspection(null); + setStorageMessage(null); + setProfileClearConfirmation(null); + + if (!environmentId || !threadId) { + setStatus("unsupported"); + setMessage("No active thread is available for this browser session."); + return; + } + const currentBrowserApi = readEnvironmentApi(environmentId)?.browser; + const currentNativeBridge = + typeof window === "undefined" ? undefined : window.desktopBridge?.browser; + if (!currentBrowserApi || !currentNativeBridge) { + setStatus("unsupported"); + setMessage(UNSUPPORTED_MESSAGE); + return; + } + + setStatus("loading"); + + try { + unsubscribe = currentBrowserApi.onEvent((event) => { + handleBrowserEvent(event); + }); + + const browserStatus = await currentBrowserApi.getStatus(); + if (cancelled) return; + if (!browserStatus.supported || !browserStatus.host?.connected) { + setStatus("unsupported"); + setMessage(UNSUPPORTED_MESSAGE); + return; + } + + const profileList = await currentBrowserApi.listProfiles().catch(() => ({ profiles: [] })); + if (cancelled) return; + setProfiles(profileList.profiles); + + const existingSession = findThreadBrowserSession(browserStatus.sessions, threadId); + if (existingSession) { + setSession(existingSession); + setAgentSessionActive(true); + setStatus("ready"); + setMessage(null); + return; + } + + const snapshot = await currentBrowserApi.openSession({ + threadId, + profileMode: BROWSER_PANEL_PROFILE_MODE, + }); + if (cancelled) return; + sessionOpenedByPanelRef.current = true; + panelOwnedSessionIdRef.current = snapshot.sessionId; + setSession(snapshot); + setAgentSessionActive(false); + setStatus("ready"); + setMessage(null); + } catch (error) { + if (cancelled) return; + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + } + }; + + void openSession(); + + return () => { + cancelled = true; + unsubscribe?.(); + const ownedSessionId = panelOwnedSessionIdRef.current; + const currentBrowserApi = + environmentId !== null ? readEnvironmentApi(environmentId)?.browser : undefined; + if (sessionOpenedByPanelRef.current && ownedSessionId && currentBrowserApi) { + void currentBrowserApi + .closeSession({ sessionId: BrowserSessionId.make(ownedSessionId) }) + .catch(() => undefined); + } + panelOwnedSessionIdRef.current = null; + sessionOpenedByPanelRef.current = false; + }; + }, [environmentId, handleBrowserEvent, threadId]); + + useEffect(() => { + if (!nativeBridge || !selectedTabId || !sessionId || status !== "ready") { + return; + } + + const node = surfaceRef.current; + if (!node) return; + + let cancelled = false; + let attached = false; + const surfaceTarget = { + sessionId, + tabId: selectedTabId, + }; + + const readBounds = () => + readBrowserSurfaceBounds(node.getBoundingClientRect(), deviceScaleFactorRef.current); + + const syncBounds = () => { + if (cancelled) return; + const bounds = readBounds(); + if (!bounds) return; + + if (!attached) { + attached = true; + void nativeBridge.attachSurface({ ...surfaceTarget, bounds }).catch(() => { + attached = false; + }); + return; + } + + void nativeBridge.updateSurfaceBounds({ ...surfaceTarget, bounds }).catch(() => undefined); + }; + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + syncBounds(); + }); + resizeObserver?.observe(node); + const handleViewportChange = () => { + deviceScaleFactorRef.current = window.devicePixelRatio || 1; + syncBounds(); + }; + window.addEventListener("resize", syncBounds); + window.addEventListener("resize", handleViewportChange); + const frame = window.requestAnimationFrame(syncBounds); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", syncBounds); + window.removeEventListener("resize", handleViewportChange); + resizeObserver?.disconnect(); + void nativeBridge.detachSurface(surfaceTarget).catch(() => undefined); + }; + }, [nativeBridge, selectedTabId, sessionId, status]); + + const runControl = useCallback( + async (action: "back" | "forward" | "reload" | "stop") => { + if (!browserApi || !session || !selectedTab) return; + setMessage(null); + try { + const snapshot = await browserApi[action]({ + sessionId: session.sessionId, + tabId: selectedTab.tabId, + }); + setSession(snapshot); + } catch (error) { + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + } + }, + [browserApi, selectedTab, session], + ); + + const navigate = useCallback(async () => { + if (!browserApi || !session || !selectedTab) return; + const normalized = normalizeBrowserNavigationUrl(addressValue); + if (!normalized.ok) { + setStatus("error"); + setMessage(normalized.message); + return; + } + + setIsNavigating(true); + setMessage(null); + try { + const snapshot = await browserApi.navigate({ + sessionId: session.sessionId, + tabId: selectedTab.tabId, + url: normalized.value.url, + }); + setSession(snapshot); + setStatus("ready"); + } catch (error) { + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + setIsNavigating(false); + } + }, [addressValue, browserApi, selectedTab, session]); + + const loadStorageInspection = useCallback(async () => { + if (!browserApi || !sessionId || !selectedTabId) return; + setStorageLoading(true); + setStorageMessage(null); + try { + const inspection = await browserApi.inspectStorage({ + sessionId, + tabId: selectedTabId, + }); + setStorageInspection(inspection); + setSession(inspection.session); + } catch (error) { + setStorageMessage(storageErrorMessage(error)); + } finally { + setStorageLoading(false); + } + }, [browserApi, selectedTabId, sessionId]); + + useEffect(() => { + if (!storageOpen) return; + void loadStorageInspection(); + }, [loadStorageInspection, navigation?.url, storageOpen]); + + const runStorageMutation = useCallback( + async ( + operation: () => Promise<{ readonly session: BrowserSessionSnapshot }>, + ): Promise => { + if (!browserApi || !sessionId || !selectedTabId) return; + setStorageLoading(true); + setStorageMessage(null); + try { + const result = await operation(); + setSession(result.session); + const inspection = await browserApi.inspectStorage({ + sessionId, + tabId: selectedTabId, + }); + setStorageInspection(inspection); + setSession(inspection.session); + } catch (error) { + setStorageMessage(storageErrorMessage(error)); + } finally { + setStorageLoading(false); + } + }, + [browserApi, selectedTabId, sessionId], + ); + + const clearStorage = useCallback( + async ( + scope: "current_origin" | "profile", + dataTypes: ReadonlyArray, + ) => { + if (!browserApi || !sessionId || !selectedTabId) return; + await runStorageMutation(() => + browserApi.clearStorage({ + sessionId, + tabId: selectedTabId, + scope, + dataTypes: [...dataTypes], + }), + ); + }, + [browserApi, runStorageMutation, selectedTabId, sessionId], + ); + + const deleteCookie = useCallback( + async (cookie: BrowserCookieMetadata) => { + if (!browserApi || !sessionId || !selectedTabId) return; + await runStorageMutation(() => + browserApi.deleteCookie({ + sessionId, + tabId: selectedTabId, + url: navigation?.url, + name: cookie.name, + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.path ? { path: cookie.path } : {}), + secure: cookie.secure, + }), + ); + }, + [browserApi, navigation?.url, runStorageMutation, selectedTabId, sessionId], + ); + + const openExternal = useCallback(() => { + if (!navigation?.url) return; + const localApi = readLocalApi(); + void localApi?.shell.openExternal(navigation.url).catch((error: unknown) => { + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + }); + }, [navigation?.url]); + + const focusSurface = useCallback(() => { + if (!nativeBridge || !session || !selectedTab) return; + void nativeBridge + .focusSurface({ sessionId: session.sessionId, tabId: selectedTab.tabId }) + .catch(() => undefined); + }, [nativeBridge, selectedTab, session]); + + if (status === "unsupported") { + return ( + + ); + } + + const loading = status === "loading"; + const crashed = selectedTab?.crashed === true || session?.status === "error"; + const loadState = navigation?.loadState ?? "idle"; + const profileLabel = resolveBrowserProfileLabel(session, profiles); + const showAgentIndicator = agentSessionActive && !loading; + + return ( +
+
{ + event.preventDefault(); + void navigate(); + }} + > + + + {loadState === "loading" || isNavigating ? ( + + ) : ( + + )} + { + addressFocusedRef.current = false; + setAddressValue(navigation?.url ?? ""); + }} + onChange={(event) => setAddressValue(event.currentTarget.value)} + onFocus={() => { + addressFocusedRef.current = true; + }} + placeholder="https://localhost:3000" + size="sm" + type="text" + value={addressValue} + /> + + + +
+ +
+
+ + {crashed ? "Crashed" : profileLabel} + + {showAgentIndicator ? ( + + + Agent session + + ) : null} + {navigation?.origin ?? navigation?.url} + {loadState === "loading" || loading ? ( + + ) : null} +
+ + {permissionNotice ? ( + + Permission request + {permissionNotice} + + ) : null} + + {message && status === "error" ? ( + + Browser command failed + {message} + + ) : null} + + {storageOpen ? ( + setProfileClearConfirmation(null)} + onClearCurrentOriginCookies={() => void clearStorage("current_origin", ["cookies"])} + onClearCurrentOriginStorage={() => + void clearStorage("current_origin", BROWSER_CURRENT_ORIGIN_STORAGE_TYPES) + } + onClose={() => { + setStorageOpen(false); + setProfileClearConfirmation(null); + }} + onConfirmProfileClear={(action) => { + setProfileClearConfirmation(null); + void clearStorage( + "profile", + action === "cookies" ? ["cookies"] : BROWSER_PROFILE_CLEAR_TYPES, + ); + }} + onDeleteCookie={(cookie) => void deleteCookie(cookie)} + onRefresh={() => void loadStorageInspection()} + onRequestProfileClear={(action) => setProfileClearConfirmation(action)} + onViewChange={setStorageView} + /> + ) : null} + +
+ {loading ? ( +
+ + Connecting to BrowserHost +
+ ) : selectedTab ? null : ( +
+ No browser tab is active. +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/ChatRightPanel.tsx b/apps/web/src/components/ChatRightPanel.tsx index 50dfa16da0e..08e0965b600 100644 --- a/apps/web/src/components/ChatRightPanel.tsx +++ b/apps/web/src/components/ChatRightPanel.tsx @@ -138,9 +138,11 @@ export const LazyRightPanel = (props: { ? "Loading file preview..." : props.panelMode === "terminal" ? "Loading terminal..." - : props.panelMode === "agent" - ? "Loading subagent thread..." - : "Loading workspace..." + : props.panelMode === "browser" + ? "Loading browser..." + : props.panelMode === "agent" + ? "Loading subagent thread..." + : "Loading workspace..." } /> } diff --git a/apps/web/src/components/ThreadWorkspacePanel.tsx b/apps/web/src/components/ThreadWorkspacePanel.tsx index 8fd234978d3..2fa6ec0c9ee 100644 --- a/apps/web/src/components/ThreadWorkspacePanel.tsx +++ b/apps/web/src/components/ThreadWorkspacePanel.tsx @@ -24,6 +24,7 @@ import { } from "../rightPanelRouteSearch"; import { buildOpenAgentSearch, + buildOpenBrowserSearch, buildOpenFilesSearch, buildOpenReviewSearch, buildOpenTerminalSearch, @@ -60,6 +61,7 @@ import type { DiffPanelMode } from "./DiffPanelShell"; import DiffPanel from "./DiffPanel"; import PreviewPanel from "./PreviewPanel"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import BrowserPanel from "./BrowserPanel"; function statusBucket(status: ThreadSubagentStatus): "idle" | "in_progress" | "review" | "done" { if (status === "running") return "in_progress"; @@ -101,6 +103,9 @@ function TabIcon(props: { tab: WorkspaceTab; active: boolean }) { if (props.tab.key === "terminal") { return ; } + if (props.tab.key === "browser") { + return ; + } return ; } @@ -523,6 +528,7 @@ function WorkspaceLauncher(props: { const filesTab: WorkspaceTab = { key: "files", label: "Files", mode: "files" }; const reviewTab: WorkspaceTab = { key: "review", label: "Review", mode: "review" }; const terminalTab: WorkspaceTab = { key: "terminal", label: "Terminal", mode: "terminal" }; + const browserTab: WorkspaceTab = { key: "browser", label: "Browser", mode: "browser" }; const filesShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "workspace.files"), [keybindings], @@ -554,7 +560,12 @@ function WorkspaceLauncher(props: { icon={MessageSquarePlusIcon} disabled /> - + props.onSelectTab(browserTab)} + /> findThreadSubagent(subagents, agentKey), [agentKey, subagents]); const activeMode = getRightPanelMode(search) ?? props.panelMode; const openedPanelModes = useMemo(() => { - if (activeMode === "files" || activeMode === "review" || activeMode === "terminal") { + if ( + activeMode === "files" || + activeMode === "review" || + activeMode === "terminal" || + activeMode === "browser" + ) { return props.openedPanelModes.includes(activeMode) ? props.openedPanelModes : [...props.openedPanelModes, activeMode]; @@ -689,6 +705,10 @@ export default function ThreadWorkspacePanel(props: { navigateSearch((previous) => buildOpenTerminalSearch(previous)); return; } + if (tab.mode === "browser") { + navigateSearch((previous) => buildOpenBrowserSearch(previous)); + return; + } if (tab.mode === "agent") { navigateSearch((previous) => buildOpenAgentSearch(previous, tab.agentKey)); } @@ -857,6 +877,8 @@ export default function ThreadWorkspacePanel(props: { ) : activeMode === "terminal" ? ( + ) : activeMode === "browser" ? ( + ) : activeMode === "agent" ? ( ) : ( diff --git a/apps/web/src/components/routeViews/ChatThreadRouteView.tsx b/apps/web/src/components/routeViews/ChatThreadRouteView.tsx index 3f1f9e4a7e6..04ab79d562c 100644 --- a/apps/web/src/components/routeViews/ChatThreadRouteView.tsx +++ b/apps/web/src/components/routeViews/ChatThreadRouteView.tsx @@ -26,6 +26,7 @@ import { import { buildThreadRouteParams } from "../../threadRoutes"; import { buildOpenAgentSearch, + buildOpenBrowserSearch, buildOpenFilesSearch, buildOpenReviewSearch, buildOpenTerminalSearch, @@ -84,6 +85,7 @@ export function ChatThreadRouteView({ hasOpenedDiff: diffOpen, hasOpenedPreview: previewOpen, hasOpenedTerminal: rightPanelMode === "terminal", + hasOpenedBrowser: rightPanelMode === "browser", openedAgentKeys: activeAgentKey ? [activeAgentKey] : [], })); const hasOpenedDiff = @@ -98,6 +100,10 @@ export function ChatThreadRouteView({ diffPanelMountState.threadKey === currentThreadKey ? diffPanelMountState.hasOpenedTerminal : rightPanelMode === "terminal"; + const hasOpenedBrowser = + diffPanelMountState.threadKey === currentThreadKey + ? diffPanelMountState.hasOpenedBrowser + : rightPanelMode === "browser"; const openedAgentKeys = useMemo(() => { const keys = diffPanelMountState.threadKey === currentThreadKey @@ -121,8 +127,11 @@ export function ChatThreadRouteView({ if (hasOpenedTerminal || rightPanelMode === "terminal") { modes.push("terminal"); } + if (hasOpenedBrowser || rightPanelMode === "browser") { + modes.push("browser"); + } return modes; - }, [hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, rightPanelMode]); + }, [hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, rightPanelMode]); const markRightPanelOpened = useCallback( (panelMode: RightPanelMode) => { setLastOpenedRightPanelMode(panelMode); @@ -139,6 +148,10 @@ export function ChatThreadRouteView({ (previous.threadKey === currentThreadKey ? previous.hasOpenedTerminal : rightPanelMode === "terminal") || panelMode === "terminal", + hasOpenedBrowser: + (previous.threadKey === currentThreadKey + ? previous.hasOpenedBrowser + : rightPanelMode === "browser") || panelMode === "browser", openedAgentKeys: previous.threadKey === currentThreadKey ? previous.openedAgentKeys : openedAgentKeys, }; @@ -147,6 +160,7 @@ export function ChatThreadRouteView({ previous.hasOpenedDiff === nextState.hasOpenedDiff && previous.hasOpenedPreview === nextState.hasOpenedPreview && previous.hasOpenedTerminal === nextState.hasOpenedTerminal && + previous.hasOpenedBrowser === nextState.hasOpenedBrowser && previous.openedAgentKeys === nextState.openedAgentKeys ) { return previous; @@ -184,6 +198,9 @@ export function ChatThreadRouteView({ if (lastOpenedRightPanelMode === "terminal" && hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (lastOpenedRightPanelMode === "browser" && hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } if (hasOpenedPreview) { return buildOpenFilesSearch(previous); } @@ -193,6 +210,9 @@ export function ChatThreadRouteView({ if (hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } if (lastAgentKey) { return buildOpenAgentSearch(previous, lastAgentKey); } @@ -205,6 +225,7 @@ export function ChatThreadRouteView({ }); }, [ hasOpenedDiff, + hasOpenedBrowser, hasOpenedPreview, hasOpenedTerminal, lastOpenedRightPanelMode, @@ -236,6 +257,8 @@ export function ChatThreadRouteView({ previous.threadKey === currentThreadKey ? previous.hasOpenedTerminal : hasOpenedTerminal, + hasOpenedBrowser: + previous.threadKey === currentThreadKey ? previous.hasOpenedBrowser : hasOpenedBrowser, openedAgentKeys: nextOpenedAgentKeys, })); @@ -257,6 +280,9 @@ export function ChatThreadRouteView({ if (hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } return buildOpenWorkspaceSearch(previous); }, }); @@ -270,12 +296,15 @@ export function ChatThreadRouteView({ input.mode === "files" ? false : hasOpenedPreview || rightPanelMode === "files"; const nextHasOpenedTerminal = input.mode === "terminal" ? false : hasOpenedTerminal || rightPanelMode === "terminal"; + const nextHasOpenedBrowser = + input.mode === "browser" ? false : hasOpenedBrowser || rightPanelMode === "browser"; setDiffPanelMountState((previous) => { const nextState = { threadKey: currentThreadKey, hasOpenedDiff: nextHasOpenedDiff, hasOpenedPreview: nextHasOpenedPreview, hasOpenedTerminal: nextHasOpenedTerminal, + hasOpenedBrowser: nextHasOpenedBrowser, openedAgentKeys: previous.threadKey === currentThreadKey ? previous.openedAgentKeys : openedAgentKeys, }; @@ -284,6 +313,7 @@ export function ChatThreadRouteView({ previous.hasOpenedDiff === nextState.hasOpenedDiff && previous.hasOpenedPreview === nextState.hasOpenedPreview && previous.hasOpenedTerminal === nextState.hasOpenedTerminal && + previous.hasOpenedBrowser === nextState.hasOpenedBrowser && previous.openedAgentKeys === nextState.openedAgentKeys ) { return previous; @@ -305,6 +335,9 @@ export function ChatThreadRouteView({ if (input.mode !== "terminal" && nextHasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (input.mode !== "browser" && nextHasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } return buildOpenWorkspaceSearch(previous); }; void navigate({ @@ -316,6 +349,7 @@ export function ChatThreadRouteView({ [ currentThreadKey, activeAgentKey, + hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, @@ -347,6 +381,7 @@ export function ChatThreadRouteView({ hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, + hasOpenedBrowser, openedAgentKeys: baseAgentKeys, }; } @@ -359,6 +394,10 @@ export function ChatThreadRouteView({ previous.threadKey === currentThreadKey ? previous.hasOpenedTerminal : rightPanelMode === "terminal", + hasOpenedBrowser: + previous.threadKey === currentThreadKey + ? previous.hasOpenedBrowser + : rightPanelMode === "browser", openedAgentKeys: [...baseAgentKeys, activeAgentKey], }; }); @@ -367,6 +406,7 @@ export function ChatThreadRouteView({ currentThreadKey, diffOpen, hasOpenedDiff, + hasOpenedBrowser, hasOpenedPreview, hasOpenedTerminal, previewOpen, @@ -417,6 +457,8 @@ export function ChatThreadRouteView({ hasOpenedPreview || rightPanelMode === "terminal" || hasOpenedTerminal || + rightPanelMode === "browser" || + hasOpenedBrowser || rightPanelMode === "agent" || openedAgentKeys.length > 0; const mountedRightPanelMode: RightPanelMode | null = rightPanelOpen diff --git a/apps/web/src/components/routeViews/DraftChatThreadRouteView.tsx b/apps/web/src/components/routeViews/DraftChatThreadRouteView.tsx index 383cfa50b18..d90212ef8a1 100644 --- a/apps/web/src/components/routeViews/DraftChatThreadRouteView.tsx +++ b/apps/web/src/components/routeViews/DraftChatThreadRouteView.tsx @@ -16,6 +16,7 @@ import { createThreadSelectorAcrossEnvironments } from "../../storeSelectors"; import { buildThreadRouteParams } from "../../threadRoutes"; import { buildOpenAgentSearch, + buildOpenBrowserSearch, buildOpenFilesSearch, buildOpenReviewSearch, buildOpenTerminalSearch, @@ -71,6 +72,7 @@ export function DraftChatThreadRouteView({ hasOpenedDiff: diffOpen, hasOpenedPreview: previewOpen, hasOpenedTerminal: rightPanelMode === "terminal", + hasOpenedBrowser: rightPanelMode === "browser", openedAgentKeys: activeAgentKey ? [activeAgentKey] : [], })); const hasOpenedDiff = @@ -81,6 +83,10 @@ export function DraftChatThreadRouteView({ rightPanelMountState.draftId === draftId ? rightPanelMountState.hasOpenedTerminal : rightPanelMode === "terminal"; + const hasOpenedBrowser = + rightPanelMountState.draftId === draftId + ? rightPanelMountState.hasOpenedBrowser + : rightPanelMode === "browser"; const openedAgentKeys = useMemo(() => { const keys = rightPanelMountState.draftId === draftId @@ -104,8 +110,11 @@ export function DraftChatThreadRouteView({ if (hasOpenedTerminal || rightPanelMode === "terminal") { modes.push("terminal"); } + if (hasOpenedBrowser || rightPanelMode === "browser") { + modes.push("browser"); + } return modes; - }, [hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, rightPanelMode]); + }, [hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, rightPanelMode]); const markRightPanelOpened = useCallback( (panelMode: RightPanelMode) => { setLastOpenedRightPanelMode(panelMode); @@ -122,6 +131,10 @@ export function DraftChatThreadRouteView({ (previous.draftId === draftId ? previous.hasOpenedTerminal : rightPanelMode === "terminal") || panelMode === "terminal", + hasOpenedBrowser: + (previous.draftId === draftId + ? previous.hasOpenedBrowser + : rightPanelMode === "browser") || panelMode === "browser", openedAgentKeys: previous.draftId === draftId ? previous.openedAgentKeys : openedAgentKeys, }; @@ -130,6 +143,7 @@ export function DraftChatThreadRouteView({ previous.hasOpenedDiff === nextState.hasOpenedDiff && previous.hasOpenedPreview === nextState.hasOpenedPreview && previous.hasOpenedTerminal === nextState.hasOpenedTerminal && + previous.hasOpenedBrowser === nextState.hasOpenedBrowser && previous.openedAgentKeys === nextState.openedAgentKeys ) { return previous; @@ -161,6 +175,9 @@ export function DraftChatThreadRouteView({ if (lastOpenedRightPanelMode === "terminal" && hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (lastOpenedRightPanelMode === "browser" && hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } if (hasOpenedPreview) { return buildOpenFilesSearch(previous); } @@ -170,6 +187,9 @@ export function DraftChatThreadRouteView({ if (hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } if (lastAgentKey) { return buildOpenAgentSearch(previous, lastAgentKey); } @@ -182,6 +202,7 @@ export function DraftChatThreadRouteView({ }); }, [ draftId, + hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, @@ -207,6 +228,8 @@ export function DraftChatThreadRouteView({ previous.draftId === draftId ? previous.hasOpenedPreview : hasOpenedPreview, hasOpenedTerminal: previous.draftId === draftId ? previous.hasOpenedTerminal : hasOpenedTerminal, + hasOpenedBrowser: + previous.draftId === draftId ? previous.hasOpenedBrowser : hasOpenedBrowser, openedAgentKeys: nextOpenedAgentKeys, })); @@ -228,6 +251,9 @@ export function DraftChatThreadRouteView({ if (hasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (hasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } return buildOpenWorkspaceSearch(previous); }, }); @@ -241,12 +267,15 @@ export function DraftChatThreadRouteView({ input.mode === "files" ? false : hasOpenedPreview || rightPanelMode === "files"; const nextHasOpenedTerminal = input.mode === "terminal" ? false : hasOpenedTerminal || rightPanelMode === "terminal"; + const nextHasOpenedBrowser = + input.mode === "browser" ? false : hasOpenedBrowser || rightPanelMode === "browser"; setRightPanelMountState((previous) => { const nextState = { draftId, hasOpenedDiff: nextHasOpenedDiff, hasOpenedPreview: nextHasOpenedPreview, hasOpenedTerminal: nextHasOpenedTerminal, + hasOpenedBrowser: nextHasOpenedBrowser, openedAgentKeys: previous.draftId === draftId ? previous.openedAgentKeys : openedAgentKeys, }; @@ -255,6 +284,7 @@ export function DraftChatThreadRouteView({ previous.hasOpenedDiff === nextState.hasOpenedDiff && previous.hasOpenedPreview === nextState.hasOpenedPreview && previous.hasOpenedTerminal === nextState.hasOpenedTerminal && + previous.hasOpenedBrowser === nextState.hasOpenedBrowser && previous.openedAgentKeys === nextState.openedAgentKeys ) { return previous; @@ -276,6 +306,9 @@ export function DraftChatThreadRouteView({ if (input.mode !== "terminal" && nextHasOpenedTerminal) { return buildOpenTerminalSearch(previous); } + if (input.mode !== "browser" && nextHasOpenedBrowser) { + return buildOpenBrowserSearch(previous); + } return buildOpenWorkspaceSearch(previous); }; void navigate({ @@ -287,6 +320,7 @@ export function DraftChatThreadRouteView({ [ activeAgentKey, draftId, + hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, @@ -317,6 +351,7 @@ export function DraftChatThreadRouteView({ hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, + hasOpenedBrowser, openedAgentKeys: baseAgentKeys, }; } @@ -326,6 +361,8 @@ export function DraftChatThreadRouteView({ hasOpenedPreview: previous.draftId === draftId ? previous.hasOpenedPreview : previewOpen, hasOpenedTerminal: previous.draftId === draftId ? previous.hasOpenedTerminal : rightPanelMode === "terminal", + hasOpenedBrowser: + previous.draftId === draftId ? previous.hasOpenedBrowser : rightPanelMode === "browser", openedAgentKeys: [...baseAgentKeys, activeAgentKey], }; }); @@ -333,6 +370,7 @@ export function DraftChatThreadRouteView({ activeAgentKey, diffOpen, draftId, + hasOpenedBrowser, hasOpenedDiff, hasOpenedPreview, hasOpenedTerminal, @@ -382,6 +420,8 @@ export function DraftChatThreadRouteView({ hasOpenedPreview || rightPanelMode === "terminal" || hasOpenedTerminal || + rightPanelMode === "browser" || + hasOpenedBrowser || rightPanelMode === "agent" || openedAgentKeys.length > 0; const mountedRightPanelMode: RightPanelMode | null = rightPanelOpen diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 25dc47c9da7..f41b6b8db1c 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -16,6 +16,23 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { close: (input) => rpcClient.terminal.close(input as never), onEvent: (callback) => rpcClient.terminal.onEvent(callback), }, + browser: { + getStatus: rpcClient.browser.getStatus, + listProfiles: rpcClient.browser.listProfiles, + openSession: rpcClient.browser.openSession, + closeSession: rpcClient.browser.closeSession, + getSnapshot: rpcClient.browser.getSnapshot, + navigate: rpcClient.browser.navigate, + back: rpcClient.browser.back, + forward: rpcClient.browser.forward, + reload: rpcClient.browser.reload, + stop: rpcClient.browser.stop, + input: rpcClient.browser.input, + inspectStorage: rpcClient.browser.inspectStorage, + clearStorage: rpcClient.browser.clearStorage, + deleteCookie: rpcClient.browser.deleteCookie, + onEvent: (callback) => rpcClient.browser.onEvent(callback), + }, projects: { listEntries: rpcClient.projects.listEntries, readFile: rpcClient.projects.readFile, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index c4facd4e647..c89622dc84e 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -49,6 +49,23 @@ const rpcClientMock = { registerListener(terminalEventListeners, listener), ), }, + browser: { + getStatus: vi.fn(), + listProfiles: vi.fn(), + openSession: vi.fn(), + closeSession: vi.fn(), + getSnapshot: vi.fn(), + navigate: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + reload: vi.fn(), + stop: vi.fn(), + input: vi.fn(), + inspectStorage: vi.fn(), + clearStorage: vi.fn(), + deleteCookie: vi.fn(), + onEvent: vi.fn(), + }, projects: { listEntries: vi.fn(), readFile: vi.fn(), diff --git a/apps/web/src/rightPanelRouteSearch.test.ts b/apps/web/src/rightPanelRouteSearch.test.ts index 0ec0d8d877a..54396001d73 100644 --- a/apps/web/src/rightPanelRouteSearch.test.ts +++ b/apps/web/src/rightPanelRouteSearch.test.ts @@ -56,6 +56,13 @@ describe("parseRightPanelRouteSearch", () => { }); }); + it("parses workspace browser tabs without legacy panel state", () => { + expect(parseRightPanelRouteSearch({ workspaceTab: "browser", diff: "1" })).toEqual({ + workspaceOpen: "1", + workspaceTab: "browser", + }); + }); + it("keeps launcher-only workspace search open without selecting a panel mode", () => { expect(parseRightPanelRouteSearch({ workspaceOpen: "1" })).toEqual({ workspaceOpen: "1", @@ -86,6 +93,7 @@ describe("getRightPanelMode", () => { expect(getRightPanelMode({ diff: "1" })).toBe("review"); expect(getRightPanelMode({ preview: "1" })).toBe("files"); expect(getRightPanelMode({ workspaceTab: "terminal" })).toBe("terminal"); + expect(getRightPanelMode({ workspaceTab: "browser" })).toBe("browser"); expect(getRightPanelMode({ workspaceTab: "agent", workspaceAgentKey: "subagent:1" })).toBe( "agent", ); @@ -100,6 +108,7 @@ describe("isRightPanelOpen", () => { expect(isRightPanelOpen({ diff: "1" })).toBe(true); expect(isRightPanelOpen({ preview: "1" })).toBe(true); expect(isRightPanelOpen({ workspaceTab: "terminal" })).toBe(true); + expect(isRightPanelOpen({ workspaceTab: "browser" })).toBe(true); expect(isRightPanelOpen({})).toBe(false); }); }); diff --git a/apps/web/src/rightPanelRouteSearch.ts b/apps/web/src/rightPanelRouteSearch.ts index 527ccddd728..387838107d7 100644 --- a/apps/web/src/rightPanelRouteSearch.ts +++ b/apps/web/src/rightPanelRouteSearch.ts @@ -2,7 +2,7 @@ import { type DiffRouteSearch, parseDiffRouteSearch } from "./diffRouteSearch"; import { type PreviewRouteSearch, parsePreviewRouteSearch } from "./previewRouteSearch"; import { type WorkspaceRouteSearch, parseWorkspaceRouteSearch } from "./workspaceRouteSearch"; -export type RightPanelMode = "review" | "files" | "terminal" | "agent"; +export type RightPanelMode = "review" | "files" | "terminal" | "browser" | "agent"; export type RightPanelRouteSearch = DiffRouteSearch & PreviewRouteSearch & WorkspaceRouteSearch; @@ -36,6 +36,12 @@ export function parseRightPanelRouteSearch(search: Record): Rig workspaceTab: "terminal", }; } + if (workspaceSearch.workspaceTab === "browser") { + return { + workspaceOpen: "1", + workspaceTab: "browser", + }; + } if (diffSearch.diff === "1") { return { @@ -62,6 +68,7 @@ export function getRightPanelMode(search: RightPanelRouteSearch): RightPanelMode if (search.workspaceTab === "review" || search.diff === "1") return "review"; if (search.workspaceTab === "files" || search.preview === "1") return "files"; if (search.workspaceTab === "terminal") return "terminal"; + if (search.workspaceTab === "browser") return "browser"; return null; } diff --git a/apps/web/src/rpc/protocol.ts b/apps/web/src/rpc/protocol.ts index 9fa3acb7270..e1d4bb0b671 100644 --- a/apps/web/src/rpc/protocol.ts +++ b/apps/web/src/rpc/protocol.ts @@ -156,7 +156,7 @@ function composeLifecycleHandlers( export function createWsRpcProtocolLayer( url: WsRpcProtocolSocketUrlProvider, handlers?: WsProtocolLifecycleHandlers, -) { +): Layer.Layer { const lifecycle = composeLifecycleHandlers(handlers); const resolvedUrl = typeof url === "function" @@ -321,5 +321,5 @@ export function createWsRpcProtocolLayer( ), requestHooksLayer, connectionHooksLayer, - ); + ) as Layer.Layer; } diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 81cb6bc4a22..ea3aa24a7f4 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -67,6 +67,23 @@ export interface WsRpcClient { readonly close: RpcUnaryMethod; readonly onEvent: RpcStreamMethod; }; + readonly browser: { + readonly getStatus: RpcUnaryNoArgMethod; + readonly listProfiles: RpcUnaryNoArgMethod; + readonly openSession: RpcUnaryMethod; + readonly closeSession: RpcUnaryMethod; + readonly getSnapshot: RpcUnaryMethod; + readonly navigate: RpcUnaryMethod; + readonly back: RpcUnaryMethod; + readonly forward: RpcUnaryMethod; + readonly reload: RpcUnaryMethod; + readonly stop: RpcUnaryMethod; + readonly input: RpcUnaryMethod; + readonly inspectStorage: RpcUnaryMethod; + readonly clearStorage: RpcUnaryMethod; + readonly deleteCookie: RpcUnaryMethod; + readonly onEvent: RpcStreamMethod; + }; readonly projects: { readonly listEntries: RpcUnaryMethod; readonly readFile: RpcUnaryMethod; @@ -274,6 +291,33 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { tag: WS_METHODS.subscribeTerminalEvents, }), }, + browser: { + getStatus: () => transport.request((client) => client[WS_METHODS.browserGetStatus]({})), + listProfiles: () => transport.request((client) => client[WS_METHODS.browserListProfiles]({})), + openSession: (input) => + transport.request((client) => client[WS_METHODS.browserOpenSession](input)), + closeSession: (input) => + transport.request((client) => client[WS_METHODS.browserCloseSession](input)), + getSnapshot: (input) => + transport.request((client) => client[WS_METHODS.browserGetSnapshot](input)), + navigate: (input) => transport.request((client) => client[WS_METHODS.browserNavigate](input)), + back: (input) => transport.request((client) => client[WS_METHODS.browserBack](input)), + forward: (input) => transport.request((client) => client[WS_METHODS.browserForward](input)), + reload: (input) => transport.request((client) => client[WS_METHODS.browserReload](input)), + stop: (input) => transport.request((client) => client[WS_METHODS.browserStop](input)), + input: (input) => transport.request((client) => client[WS_METHODS.browserInput](input)), + inspectStorage: (input) => + transport.request((client) => client[WS_METHODS.browserInspectStorage](input)), + clearStorage: (input) => + transport.request((client) => client[WS_METHODS.browserClearStorage](input)), + deleteCookie: (input) => + transport.request((client) => client[WS_METHODS.browserDeleteCookie](input)), + onEvent: (listener, options) => + transport.subscribe((client) => client[WS_METHODS.subscribeBrowserEvents]({}), listener, { + ...options, + tag: WS_METHODS.subscribeBrowserEvents, + }), + }, projects: { listEntries: (input) => transport.request((client) => client[WS_METHODS.projectsListEntries](input)), diff --git a/apps/web/src/threadWorkspaceTabs.ts b/apps/web/src/threadWorkspaceTabs.ts index 26a5a2740f9..34cf22f6aa4 100644 --- a/apps/web/src/threadWorkspaceTabs.ts +++ b/apps/web/src/threadWorkspaceTabs.ts @@ -7,7 +7,7 @@ import { export type WorkspaceTab = | { - key: "files" | "review" | "terminal"; + key: "files" | "review" | "terminal" | "browser"; label: string; mode: Exclude; } @@ -36,6 +36,9 @@ export function buildTabs(input: { if (openedModes.has("terminal")) { tabs.push({ key: "terminal", label: "Terminal", mode: "terminal" }); } + if (openedModes.has("browser")) { + tabs.push({ key: "browser", label: "Browser", mode: "browser" }); + } const visibleAgentKeys = [ ...new Set([...input.openedAgentKeys, ...(input.activeAgentKey ? [input.activeAgentKey] : [])]), diff --git a/apps/web/src/workspaceRouteSearch.test.ts b/apps/web/src/workspaceRouteSearch.test.ts new file mode 100644 index 00000000000..a312b06a8b2 --- /dev/null +++ b/apps/web/src/workspaceRouteSearch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildOpenBrowserSearch, + parseWorkspaceRouteSearch, + stripWorkspacePanelSearchParams, +} from "./workspaceRouteSearch"; + +describe("workspaceRouteSearch browser tab", () => { + it("builds a browser workspace search and clears legacy panel state", () => { + expect( + buildOpenBrowserSearch({ + workspaceOpen: "1", + workspaceTab: "review", + workspaceAgentKey: "subagent:old", + diff: "1", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + preview: "1", + keep: "value", + }), + ).toEqual({ + workspaceOpen: "1", + workspaceTab: "browser", + workspaceAgentKey: undefined, + diff: undefined, + diffTurnId: undefined, + diffFilePath: undefined, + preview: undefined, + keep: "value", + }); + }); + + it("parses browser as a concrete workspace tab", () => { + expect(parseWorkspaceRouteSearch({ workspaceTab: "browser" })).toEqual({ + workspaceOpen: "1", + workspaceTab: "browser", + }); + }); + + it("strips browser workspace keys with other panel keys", () => { + expect( + stripWorkspacePanelSearchParams({ + workspaceOpen: "1", + workspaceTab: "browser", + workspaceAgentKey: "subagent:old", + diff: "1", + preview: "1", + keep: "value", + }), + ).toEqual({ keep: "value" }); + }); +}); diff --git a/apps/web/src/workspaceRouteSearch.ts b/apps/web/src/workspaceRouteSearch.ts index 4b48d1d1de5..0f2c2938fd5 100644 --- a/apps/web/src/workspaceRouteSearch.ts +++ b/apps/web/src/workspaceRouteSearch.ts @@ -3,7 +3,7 @@ import { type TurnId } from "@ryco/contracts"; import { stripDiffSearchParams } from "./diffRouteSearch"; import { stripPreviewSearchParams } from "./previewRouteSearch"; -export type WorkspacePanelTab = "review" | "files" | "terminal" | "agent"; +export type WorkspacePanelTab = "review" | "files" | "terminal" | "browser" | "agent"; export interface WorkspaceRouteSearch { workspaceOpen?: "1" | undefined; @@ -20,7 +20,13 @@ function normalizeSearchString(value: unknown): string | undefined { } function normalizeWorkspaceTab(value: unknown): WorkspacePanelTab | undefined { - if (value === "review" || value === "files" || value === "terminal" || value === "agent") { + if ( + value === "review" || + value === "files" || + value === "terminal" || + value === "browser" || + value === "agent" + ) { return value; } return undefined; @@ -232,6 +238,47 @@ export function buildOpenTerminalSearch>( }; } +export function buildOpenBrowserSearch>( + params: T, +): Omit< + T, + | "diff" + | "diffTurnId" + | "diffFilePath" + | "preview" + | "workspaceOpen" + | "workspaceTab" + | "workspaceAgentKey" +> & + WorkspaceRouteSearch & { + diff?: undefined; + preview?: undefined; + } { + return { + ...stripWorkspacePanelSearchParams(params), + workspaceOpen: "1", + workspaceTab: "browser", + workspaceAgentKey: undefined, + diff: undefined, + diffTurnId: undefined, + diffFilePath: undefined, + preview: undefined, + } as Omit< + T, + | "diff" + | "diffTurnId" + | "diffFilePath" + | "preview" + | "workspaceOpen" + | "workspaceTab" + | "workspaceAgentKey" + > & + WorkspaceRouteSearch & { + diff?: undefined; + preview?: undefined; + }; +} + export function buildOpenAgentSearch>( params: T, agentKey: string, diff --git a/bun.lock b/bun.lock index 89d65fe372f..0ca833da245 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/desktop": { "name": "@ryco/desktop", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -41,7 +41,7 @@ }, "apps/server": { "name": "ryco-cli", - "version": "0.1.5", + "version": "0.1.6", "bin": { "ryco": "dist/bin.mjs", }, @@ -52,6 +52,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@github/copilot-sdk": "0.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", @@ -79,7 +80,7 @@ }, "apps/web": { "name": "@ryco/web", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -156,7 +157,7 @@ }, "packages/contracts": { "name": "@ryco/contracts", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "effect": "catalog:", }, diff --git a/docs/superpowers/plans/2026-06-22-in-app-browser.md b/docs/superpowers/plans/2026-06-22-in-app-browser.md new file mode 100644 index 00000000000..cf7aed2d947 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-in-app-browser.md @@ -0,0 +1,343 @@ +# Built-In In-App Browser Implementation Plan + +> **For agentic workers:** this is a cross-cutting feature. Keep each task +> behind explicit contracts and fake-host tests before wiring real provider +> adapters. Do not hide browser behavior under provider-specific shortcuts. + +**Goal:** Ship a Ryco-owned isolated browser that can be shown in the right +workspace panel and used by supported providers through a shared control plane +and explicit adapter integrations. + +**Spec:** `docs/superpowers/specs/2026-06-22-in-app-browser-design.md` + +## Task 1: Browser contracts and shared helpers + +**Purpose:** Establish the stable schema surface before server, desktop, web, +and provider code diverge. + +**Files likely touched:** + +- `packages/contracts/src/browser.ts` +- `packages/contracts/src/browserHostRpc.ts` +- `packages/contracts/src/rpc.ts` +- `packages/contracts/src/ipc.ts` +- `packages/contracts/src/providerRuntime.ts` +- `packages/shared/src/browser/*` or equivalent explicit subpath export +- `packages/shared/package.json` + +**Steps:** + +- [ ] Add browser IDs, profile/session/tab snapshots, commands, events, policy, + and typed errors to a new contracts module. +- [ ] Add browser RPC method constants and Effect RPC declarations. +- [ ] Add a separate BrowserHost RPC group/schema for host registration, + heartbeat, command stream, command results, and host events. +- [ ] Extend `EnvironmentApi` with a `browser` aggregate. +- [ ] Extend `DesktopBridge` only with browser-surface geometry methods. +- [ ] Add `browser_tool_call` and browser request types to provider runtime + contracts. +- [ ] Add shared URL/origin/profile-key helpers outside contracts. +- [ ] Add an explicit `./browser` subpath export to `packages/shared/package.json`. + +**Acceptance:** + +- Contracts compile without runtime browser logic in `packages/contracts`. +- Origin/profile helper tests cover invalid URLs, local origins, path + traversal, and long names. +- BrowserHost RPC contracts are distinct from user-facing browser RPC contracts. + +## Task 2: Server browser service with fake host + +**Purpose:** Build the provider-neutral control plane before real Electron +embedding. + +**Files likely touched:** + +- `apps/server/src/browser/BrowserHostRegistry.ts` +- `apps/server/src/browser/BrowserService.ts` +- `apps/server/src/browser/BrowserPolicy.ts` +- `apps/server/src/browser/BrowserArtifactStore.ts` +- `apps/server/src/browser/BrowserToolBridge.ts` +- `apps/server/src/auth/Services/BrowserHostAuth.ts` +- `apps/server/src/browserHost/browserHostRpc.ts` +- `apps/server/src/ws/browserRpc.ts` +- `apps/server/src/ws/index.ts` +- `apps/server/src/ws/context.ts` +- `apps/server/src/server.ts` + +**Steps:** + +- [ ] Implement `BrowserHostRegistry` with host capabilities, heartbeat, + disconnect handling, and command correlation. +- [ ] Add a dedicated host-auth path using a desktop-host token, not the one-use + owner desktop bootstrap token. +- [ ] Mount a host-only RPC route such as `/browser-host/ws` with + `BrowserHostRpcGroup` and local-only auth. +- [ ] Implement `BrowserService` open/close/session/tab/navigation operations + against a fake host. +- [ ] Add bounded per-tab command queues and cancellation/timeouts. +- [ ] Add origin policy checks and typed approval decisions, including stricter + handling for unknown localhost/private/link-local targets. +- [ ] Add bounded browser artifact storage for screenshots, large snapshots, and + download metadata. +- [ ] Add browser RPC handlers following the existing terminal RPC pattern. +- [ ] Wire browser handlers into `makeWsRpcLayer`. +- [ ] Provide Browser services through the server layer graph in + `apps/server/src/server.ts`. +- [ ] Add tests for host unavailable, profile locked, open/navigate, + permission ask/deny, command timeout, disconnect, reconnect, and queue + ordering. +- [ ] Add tests proving owner/user WebSocket auth cannot register a BrowserHost + and the dedicated host token can register only on the host route. + +**Acceptance:** + +- WebSocket RPC can operate a fake host end to end. +- Browser policy decisions are server-owned and test-covered. +- The host route and user route have separate auth and schemas. + +## Task 3: Desktop BrowserHost and BrowserKernel + +**Purpose:** Provide a real isolated Chromium host and native right-panel +surface. + +**Files likely touched:** + +- `apps/desktop/src/browser/BrowserHostConnection.ts` +- `apps/desktop/src/browser/BrowserHost.ts` +- `apps/desktop/src/browser/BrowserKernel.ts` +- `apps/desktop/src/browser/BrowserProfiles.ts` +- `apps/desktop/src/browser/BrowserSurfaceManager.ts` +- `apps/desktop/src/main.ts` for backend child bootstrap payload, window + lifecycle, and IPC registration +- `apps/desktop/src/preload.ts` + +**Steps:** + +- [ ] Generate a dedicated desktop BrowserHost token in Electron main and pass + it to the backend child over the private bootstrap fd payload. +- [ ] Add an authenticated browser-host connection from Electron main to the + local backend using that dedicated token. +- [ ] Implement host registration, heartbeat, command receive, result send, and + event send. +- [ ] Implement profile creation with Electron `session.fromPath` or + `session.fromPartition` using isolated profile directories/partitions. +- [ ] Implement one `WebContentsView` per tab or selected tab, with hardened web + preferences. +- [ ] Implement navigation, tab lifecycle, screenshot, DOM/accessibility + snapshot, click/type/key/scroll, console, and network-summary commands. +- [ ] Implement permission handlers, window-open handling, download handling, + crash events, and cleanup. +- [ ] Implement profile leases with `BrowserHostRunId`, expiry, and stale-lock + recovery after desktop crashes. +- [ ] Implement native surface attach/update/detach IPC methods with owner + checks, bounds clipping, detach-on-hide, and detach-on-window-destroy. +- [ ] Implement download path sanitization, size limits, no auto-open behavior, + and symlink race protection. +- [ ] Add desktop tests where Electron can be mocked, plus manual smoke + scripts for persistent profile isolation. + +**Acceptance:** + +- A fake or real server can command the desktop BrowserHost. +- Two persistent profiles do not share cookies/localStorage. +- Temporary profiles are removed after close. +- Permission prompts default deny unless approved. +- Browser content never shares the Ryco app renderer session. +- Renderer IPC cannot attach a browser view outside its owning window bounds. + +## Task 4: Web Browser workspace tab + +**Purpose:** Expose the manual side-by-side workflow in the existing right +workspace panel. + +**Files likely touched:** + +- `apps/web/src/workspaceRouteSearch.ts` +- `apps/web/src/rightPanelRouteSearch.ts` +- `apps/web/src/threadWorkspaceTabs.ts` +- `apps/web/src/components/ThreadWorkspacePanel.tsx` +- `apps/web/src/components/routeViews/ChatThreadRouteView.tsx` +- `apps/web/src/components/routeViews/DraftChatThreadRouteView.tsx` +- `apps/web/src/components/BrowserPanel.tsx` +- `apps/web/src/browser/browserStateStore.ts` +- `apps/web/src/browser/useBrowserSession.ts` +- `apps/web/src/environmentApi.ts` +- `apps/web/src/rpc/wsRpcClient.ts` +- `apps/web/src/components/chat/useChatWorkspacePanels.ts` +- `apps/web/src/components/chat/useChatGlobalShortcuts.ts` + +**Steps:** + +- [ ] Add `"browser"` to workspace/right-panel route parsing and builders. +- [ ] Enable the existing Browser launcher card. +- [ ] Add Browser as a singleton workspace tab. +- [ ] Extend `ChatThreadRouteView` and `DraftChatThreadRouteView` mount state + with `hasOpenedBrowser`, opened-mode tracking, close handling, and + last-opened mode behavior. +- [ ] Add BrowserPanel toolbar and profile selector. +- [ ] Open/focus a browser session through environment RPC. +- [ ] Measure the native surface placeholder with `ResizeObserver`. +- [ ] Send attach/bounds/detach calls through `desktopBridge.browser`. +- [ ] Render unsupported state when the selected environment is not the local + desktop backend, `desktopBridge.browser` is unavailable, or no browser + host is connected. +- [ ] Add route-view, route parser, and component tests for + open/select/close/responsive behavior. + +**Acceptance:** + +- Browser opens in the right panel next to chat. +- The panel survives route refresh and responsive sheet mode. +- Hiding/closing the panel does not corrupt the native browser surface. +- Remote and browser-only clients do not attempt iframe fallback. + +## Task 5: Provider runtime tool registry + +**Purpose:** Keep browser execution provider-neutral and avoid duplicate logic +inside each adapter. + +**Files likely touched:** + +- `apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts` +- `apps/server/src/provider/tools/BrowserRuntimeTool.ts` +- `apps/server/src/provider/Services/ProviderAdapter.ts` +- `apps/server/src/provider/Layers/ProviderService.ts` +- `apps/server/src/provider/Layers/CodexAdapter.ts` +- `apps/server/src/provider/Layers/ClaudeAdapter.ts` +- `apps/server/src/provider/Layers/CopilotAdapter.ts` +- `apps/server/src/provider/Layers/OpenCodeAdapter.ts` +- `apps/server/src/provider/Layers/CursorAdapter.ts` +- `apps/server/src/provider/acp/AcpSessionRuntime.ts` + +**Steps:** + +- [ ] Design the adapter contract/capability extension for runtime tool + definition and execution injection. Do this before wiring concrete + providers. +- [ ] Add a shared registry for runtime tool definitions and normalized + execution. +- [ ] Implement browser tool definitions and result normalization. +- [ ] Emit `browser_tool_call` lifecycle events around executions. +- [ ] Wire Codex first using the best-supported current path, likely MCP or + dynamic tools. +- [ ] Wire Claude through SDK tools and permission hooks. +- [ ] Wire Copilot/OpenCode through their native custom-tool or MCP-equivalent + paths. +- [ ] Keep Cursor/ACP browser tooling unsupported until Ryco can inject + MCP/tool definitions into ACP sessions. +- [ ] Add adapter tests for tool definition exposure, command execution, + approvals, denied origin, and host unavailable. + +**Acceptance:** + +- At least Codex and Claude can use the same BrowserService-backed browser + after the adapter contract extension lands. +- Unsupported providers fail explicitly instead of hallucinating a browser. +- No adapter owns browser policy or profile lifecycle locally. + +## Task 6: Timeline rendering and approvals + +**Purpose:** Make browser actions visible and controllable in the thread UX. + +**Files likely touched:** + +- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` +- `apps/web/src/components/*Tool*` rendering modules +- `apps/web/src/session-logic.ts` +- `packages/contracts/src/orchestration.ts` +- `packages/contracts/src/providerRuntime.ts` +- `apps/server/src/browser/BrowserArtifactStore.ts` + +**Steps:** + +- [ ] Ingest `browser_tool_call` lifecycle events into thread activities. +- [ ] Render browser tool calls with title, URL/origin, action, status, and + result summary. +- [ ] Render browser approval requests with origin, permission, profile, and + requested action. +- [ ] Add affordances to open/focus the Browser tab from a browser tool event. +- [ ] Store screenshot/download/large snapshot references as browser artifacts, + not project files or unbounded event payloads. +- [ ] Add redaction hooks and payload caps for DOM snapshots, console/network + summaries, screenshots, and artifact previews. +- [ ] Add tests for rendering started/progress/completed/failed/denied states. + +**Acceptance:** + +- Users can see what the provider did in the browser. +- Browser-origin and permission approvals use the normal Ryco approval flow. +- Large or sensitive browser observations are bounded and artifact-backed. + +## Task 7: Settings and profile management + +**Purpose:** Give users control over persistence, isolation, and risk. + +**Files likely touched:** + +- `packages/contracts/src/settings.ts` +- `apps/server/src/serverSettings.ts` +- `apps/web/src/components/settings/*` +- `apps/web/src/components/BrowserPanel.tsx` +- `apps/desktop/src/browser/BrowserProfiles.ts` + +**Steps:** + +- [ ] Add browser settings: enabled, default manual profile mode, default agent + profile mode, origin policies, developer mode, download directory, and + profile cleanup controls. +- [ ] Add Browser profile list/clear/delete UI. +- [ ] Add per-origin allow/deny UI. +- [ ] Add developer mode warning and separate CDP capability gate. +- [ ] Add storage stats where practical. +- [ ] Add stale profile-lock recovery UI for crashed desktop hosts. + +**Acceptance:** + +- Users can inspect and delete browser profiles. +- Users can revoke origin allow rules. +- Developer mode is opt-in and visibly separate from normal browsing. + +## Task 8: Remote and non-desktop follow-up + +**Purpose:** Keep the first release honest while leaving a path for browser-only +and remote clients. + +**Files likely touched:** + +- `apps/server/src/browser/*` +- `apps/web/src/components/BrowserPanel.tsx` +- optional Playwright host package/module + +**Steps:** + +- [ ] Keep browser-only web clients on an explicit unsupported state for MVP. +- [ ] Keep remote/SSH environments on an explicit unsupported state for MVP. +- [ ] Design a server/headless `BrowserHost` implementation using Playwright + contexts for remote environments. +- [ ] Add screenshot streaming or DOM-first control if native surface is not + available. +- [ ] Reuse the same BrowserService, policy, profile, and provider contracts. + +**Acceptance:** + +- Desktop implementation does not block a future server/headless host. +- No iframe fallback is introduced as a shortcut. + +## Validation Checklist + +- [ ] `bun fmt` +- [ ] `bun lint` +- [ ] `bun typecheck` +- [ ] `bun run test` for contracts, server browser service, desktop browser + helpers, web route/panel state, provider adapter integrations, and + timeline rendering +- [ ] Manual desktop smoke: open right-panel Browser, navigate to local app, + log in to test site, restart Ryco, verify persistent cookies. +- [ ] Manual isolation smoke: same test site in two profiles, verify cookies do + not cross profiles. +- [ ] Manual temporary smoke: temporary profile deletes cookies/cache on close. +- [ ] Manual provider smoke: Codex and Claude inspect and click through a local + app in the same browser panel. +- [ ] Manual security smoke: denied origin, popup, download, camera/microphone, + and developer-mode gate. diff --git a/docs/superpowers/specs/2026-06-22-in-app-browser-design.md b/docs/superpowers/specs/2026-06-22-in-app-browser-design.md new file mode 100644 index 00000000000..fc9a08b886a --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-in-app-browser-design.md @@ -0,0 +1,790 @@ +# Built-In In-App Browser Design + +## Goal + +Add a fully functioning Ryco-owned browser that can sit in the right workspace +panel next to chat and can be used by supported provider instances through +explicit shared runtime-tool integrations. + +The browser must be isolated from the user's normal system browsers. It should +have its own cache, cookies, permissions, downloads, and profile lifecycle, so +users can sign into services inside Ryco without leaking those sessions into +Chrome, Safari, Firefox, or the Electron shell that renders Ryco itself. + +The end state is: + +- A `Browser` workspace tab in the existing right panel beside `Files`, + `Review`, `Terminal`, and agent tabs. +- Real browser profile persistence with cookies/cache/local storage. +- Temporary and persistent isolated profiles. +- A provider-neutral browser control plane that Codex, Claude, Copilot, + OpenCode, and future supported drivers can use through thin adapters. Cursor + joins once Ryco has ACP MCP/tool injection. +- Durable browser tool events in Ryco's thread timeline and approvals. +- A security model for origins, credentials, downloads, uploads, permissions, + and optional developer/CDP access. + +## MVP Scope + +The first implementation is desktop-local only: + +- The Electron desktop process hosts the browser. +- The hosted browser registers only with the local desktop-managed backend child + process started by `apps/desktop`. +- Browser-only web clients and remote/SSH environments show an explicit + unsupported state. +- Provider browser tools are available only for sessions running on that local + desktop backend and only while a desktop BrowserHost is connected. + +Remote/headless browser hosts are future work. The contracts should allow them, +but the first implementation must not imply that one desktop BrowserHost can +serve arbitrary remote Ryco environments. + +## Research Baseline + +The public OpenAI Codex app browser design is the closest product reference, +but not a complete implementation reference. The public docs describe a shared +in-app browser view optimized for local, file-backed, and public pages, while +signed-in/default-browser state is handled through a separate Chrome extension +path. The docs also expose origin allow/block policies and a developer mode +that enables deeper browser control. + +Ryco should copy the architectural split, not the exact product constraints: + +- Use an in-app isolated browser first. +- Treat access to signed-in pages and persistent credentials as higher risk. +- Keep an explicit origin policy and permission surface. +- Keep deep browser/CDP powers behind a separate developer-mode setting. + +Additional implementation references: + +- Electron `session` supports isolated persistent or in-memory sessions by + partition/path, including independent cookies and cache. +- Electron `WebContentsView` is the modern native embedding primitive for + showing Chromium content inside the app window. +- Electron `` is explicitly discouraged for many app designs; it also + pushes too much lifecycle and permission handling into renderer markup. +- Playwright BrowserContext/persistent context and Playwright MCP validate the + profile model: isolated contexts, persistent user data dirs, single-writer + profile locks, `--isolated`, `--storage-state`, and host allow/block lists. +- The local OpenAI Browser plugin API exposes a useful agent-facing shape: + browser list/get, tabs, navigation, screenshots, DOM snapshots, locators, + keyboard/mouse actions, console/devtools access, and capability flags. + +## Current Ryco State + +Ryco has most of the shell needed for the UX, but no real browser runtime. + +- `apps/web/src/components/ThreadWorkspacePanel.tsx` already has a disabled + `Browser` launcher card with `GlobeIcon`. +- `apps/web/src/workspaceRouteSearch.ts` and + `apps/web/src/rightPanelRouteSearch.ts` currently model only `review`, + `files`, `terminal`, and `agent` workspace modes. +- `apps/web/src/components/ChatRightPanel.tsx` already mounts the right panel as + a resizable sibling next to chat, with sheet behavior on narrow screens. +- `apps/desktop/src/main.ts` creates a hardened Electron main window with + `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, and a + denied `window.open` policy. +- `apps/desktop/src/preload.ts` exposes a narrow `desktopBridge`, currently + limited to shell, settings, SSH, update, context menu, and file-dialog APIs. +- `apps/server/src/ws/index.ts` composes WebSocket RPC handlers for + orchestration, provider, source control, project, git, and terminal. There is + no browser RPC aggregate. +- `packages/contracts/src/rpc.ts` and `packages/contracts/src/ipc.ts` expose no + browser schemas or APIs. +- `packages/contracts/src/providerRuntime.ts` has generic tool lifecycle types + such as `dynamic_tool_call`, `mcp_tool_call`, `web_search`, and `image_view`, + but no browser-specific item type or request type. +- Provider adapters map provider-specific tool and approval events into + canonical runtime events, but MCP support is uneven across providers. +- `apps/server/src/open.ts` opens URLs externally in the user's normal browser; + it is not an isolated browser runtime. + +The UI slot exists, and the provider runtime has the right concept of tool +lifecycle events. The missing piece is an explicit browser service, host bridge, +contract surface, and provider tool bridge. + +## Core Decision + +Use a desktop-hosted native Electron browser surface for the first complete +implementation, with a server-side browser control plane in front of it. + +The browser itself should live in the Electron main process as a +`WebContentsView` backed by dedicated Electron `session` objects. The server +child process should own provider-neutral browser commands, policy, approvals, +tool events, and persistence metadata. The desktop main process registers as a +`BrowserHost` with the local backend over a dedicated authenticated local +bridge. + +This gives Ryco all three required properties: + +- Real embedded browser UX in the right pane. +- Isolation from system browsers and the Ryco app renderer. +- Provider-neutral server access for every adapter. + +Do not build the feature as a React `iframe`. Arbitrary sites block framing, +cookies and permissions cannot be controlled well, and provider automation would +not have a robust target. Do not build the first implementation directly around +Electron ``; Electron recommends alternatives for many apps and the +main process should own permission and lifecycle policy. + +## Architecture + +### Components + +#### Browser Contracts + +Add a schema-only module: + +- `packages/contracts/src/browser.ts` +- `packages/contracts/src/browserHostRpc.ts` if the host RPC group is kept + separate from user-facing browser RPC declarations. + +Suggested schema groups: + +- IDs: `BrowserHostId`, `BrowserProfileId`, `BrowserSessionId`, + `BrowserTabId`, `BrowserCommandId`, `BrowserPermissionRequestId`. +- Profile model: `BrowserProfile`, `BrowserProfileMode`, + `BrowserProfileScope`, `BrowserProfileStorageStats`. +- Session/tab snapshots: `BrowserSessionSnapshot`, `BrowserTabSnapshot`, + `BrowserNavigationState`. +- Commands: open/close session, tab operations, navigation, input, DOM snapshot, + screenshot, console/network reads, download/upload operations, profile data + clearing. +- Events: host connected/disconnected, session opened/closed, tab created, + selected, navigated, title changed, loading state, crashed, permission + requested/resolved, download started/progress/completed, command progress, + command completed/failed. +- Policy: `BrowserOriginPolicy`, `BrowserPermissionPolicy`, + `BrowserToolAccessDecision`. +- Errors: host unavailable, profile locked, tab not found, navigation blocked, + origin denied, permission denied, command timed out, unsupported capability. + +Keep this package schema-only. Put URL normalization, profile-path sanitization, +and origin matching in `packages/shared/browser` if they need to be used by +server and desktop. Because `packages/shared` uses explicit subpath exports, +adding this helper module also requires a `./browser` export in +`packages/shared/package.json`. + +#### Server Browser Control Plane + +Add server services: + +- `apps/server/src/browser/BrowserHostRegistry.ts` +- `apps/server/src/browser/BrowserService.ts` +- `apps/server/src/browser/BrowserPolicy.ts` +- `apps/server/src/browser/BrowserArtifactStore.ts` +- `apps/server/src/browser/BrowserToolBridge.ts` +- `apps/server/src/ws/browserRpc.ts` +- `apps/server/src/browserHost/browserHostRpc.ts` +- `apps/server/src/auth/Services/BrowserHostAuth.ts` + +Responsibilities: + +- Track connected browser hosts and their capabilities. +- Allocate browser sessions and tabs for a thread/profile. +- Enforce origin, permission, download, upload, and developer-mode policy. +- Serialize commands per tab with bounded queues and cancellation. +- Correlate host events with RPC responses and provider tool events. +- Persist policy/profile metadata in server settings or a dedicated persistence + table. +- Provide provider-neutral tool definitions and execute browser tool calls. +- Store browser screenshots, large DOM snapshots, and download metadata as + bounded browser artifacts instead of unbounded provider event payloads or + project files. + +The server should not render browser content. It should broker control and +policy so every provider sees the same browser surface. + +Wire these services through the normal server layer graph in `apps/server/src/server.ts` +and expose them through `apps/server/src/ws/context.ts` where user RPC handlers +need them. + +#### Browser Host Authentication + +Do not reuse the existing desktop bootstrap token for BrowserHost registration. +That token is seeded as a one-use owner bootstrap grant and may already be +consumed by the desktop renderer/user session. + +Add a separate desktop-host credential flow: + +- Desktop main generates a high-entropy `desktopHostToken` and passes it to the + backend child over the existing private bootstrap fd payload. +- The backend stores it only in memory with a role such as + `desktop-browser-host`. +- The token is never exposed through `preload.ts`, `desktopBridge`, URL search + params, local storage, or renderer process state. +- The token is scoped to loopback/local desktop operation, expires on backend + restart, and can be rotated if the BrowserHost reconnects. +- `BrowserHostAuth` validates this token on the host-only WebSocket route and + rejects normal owner/user WebSocket tokens on that route. + +This keeps the browser host trusted enough to execute native browser commands +without giving the renderer a reusable privileged host token. + +#### Browser Host RPC Channel + +Use a separate host route and RPC group rather than overloading `/ws`: + +- User/UI RPC: existing `/ws`, `WsRpcGroup`, owner session auth, methods such as + `browser.openSession` and `browser.navigate`. +- Host RPC: new `/browser-host/ws` or equivalent local-only route, + `BrowserHostRpcGroup`, `desktop-browser-host` auth, host registration, + heartbeat, command stream, command results, and browser events. + +The host channel should be local-only for MVP and reject unexpected origins, +non-loopback hosts, missing host tokens, and owner/user tokens. Commands are +correlated by `BrowserCommandId`, and reconnects use a `BrowserHostRunId` so +the server can distinguish stale events from a previous desktop process. + +#### Desktop Browser Host + +Add desktop main-process modules: + +- `apps/desktop/src/browser/BrowserHost.ts` +- `apps/desktop/src/browser/BrowserKernel.ts` +- `apps/desktop/src/browser/BrowserProfiles.ts` +- `apps/desktop/src/browser/BrowserSurfaceManager.ts` +- `apps/desktop/src/browser/BrowserHostConnection.ts` + +Responsibilities: + +- Connect to the local backend with the dedicated desktop BrowserHost token, not + the one-use owner bootstrap token. +- Register host capabilities and send heartbeats. +- Create Electron `session` objects using isolated persistent paths or + in-memory partitions. +- Create and own `WebContentsView` instances for tabs. +- Attach/detach/position native browser views based on renderer-provided panel + bounds. +- Own `setWindowOpenHandler`, permission handlers, downloads, context menus, + crash handling, and optional devtools/CDP access. +- Emit normalized browser events back to the server. + +The existing Ryco app renderer remains sandboxed. The embedded browser content +must not get Node integration and must not share the Ryco renderer session. + +#### Web Browser Panel + +Add web modules: + +- `apps/web/src/components/BrowserPanel.tsx` +- `apps/web/src/browser/browserStateStore.ts` +- `apps/web/src/browser/useBrowserSession.ts` +- `apps/web/src/browser/browserUrl.ts` + +Extend existing route state: + +- `WorkspacePanelTab`: add `"browser"`. +- `RightPanelMode`: add `"browser"`. +- Add `buildOpenBrowserSearch`. +- Enable the existing Browser launcher card. +- Add a browser tab entry in `threadWorkspaceTabs.ts`. +- Extend route-view mount state in both chat and draft route views so `browser` + is tracked like `review`, `files`, and `terminal`. + +The panel renders: + +- Toolbar: back, forward, reload/stop, address field, profile menu, origin + policy indicator, open-external button, optional devtools button. +- Native surface placeholder: a measured div whose bounds are sent through + `desktopBridge.browser.setSurfaceBounds`. +- Status overlays: no host, profile locked, permission pending, crashed, + unsupported in browser-only web clients. + +The toolbar and state should come from server RPC. Only native view geometry +goes directly through desktop IPC. + +#### Provider Tool Bridge + +Add a shared provider runtime tool layer rather than hard-coding browser logic +inside every adapter: + +- `apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts` +- `apps/server/src/provider/tools/BrowserRuntimeTool.ts` + +Responsibilities: + +- Expose browser tool definitions in provider-native format. +- Execute normalized tool calls through `BrowserService`. +- Emit canonical browser tool lifecycle events. +- Convert provider-specific approval/user-input callbacks into shared browser + approval requests where possible. +- Extend `ProviderAdapterShape` or adjacent adapter construction inputs with + explicit runtime-tool capabilities before wiring concrete providers. The + current adapter contract has no generic custom-tool definition/executor hook. + +Each adapter then needs only a thin integration: + +- Codex: prefer MCP or app-server dynamic tools if supported in the current + app-server path. Map browser-origin approvals to canonical requests. +- Claude: expose SDK tools and route `tool_use` through the shared executor. +- Copilot: replace the current plain "Chromium is available" hint with actual + tool definitions or the closest supported custom-tool bridge. +- OpenCode: expose browser tools through its native tool/MCP layer where + possible and map permission events. +- Cursor/ACP: keep browser tools explicitly unsupported until Ryco can inject + MCP/tool definitions into ACP sessions. Current ACP sessions start with empty + `mcpServers`. + +MCP is a good adapter path where a provider supports it, but it should not be +the Ryco control plane. Current Ryco MCP management is Codex-centric and Cursor +starts sessions with empty `mcpServers`, so a server-owned browser service is +the durable provider-neutral abstraction. + +## Browser Profiles + +### Profile Modes + +Use explicit modes: + +- `temporary`: in-memory, deleted on close. Good for untrusted browsing or quick + local testing. +- `thread`: persistent per thread. Good when an agent needs continuity during a + task but not across unrelated work. +- `worktree`: persistent per worktree/project checkout. Good for app testing. +- `project`: persistent per project. Good default for manual user workflows. +- `named`: user-created persistent profile for a specific account or site. + +Default recommendation: + +- Manual Browser panel: `project` profile. +- Agent-created session: `thread` profile unless the user selects another. +- Sensitive or unknown origin: offer `temporary` profile. + +### Storage Location + +Desktop browser profile data should live under the desktop app data directory, +not inside the user's normal browser profile and not inside project source: + +```text +/browser-profiles// +``` + +The server stores metadata and policy, but the actual Chromium cookie/cache data +is owned by the active browser host. This matches the desktop-local MVP. A +future server/headless host can use the same contracts with different storage. + +### Locks and Cleanup + +Persistent Chromium profiles are single-writer. Add an explicit lock per +profile: + +- Opening a profile that is already attached returns `profile_locked`. +- UI should offer "focus existing session" instead of silently creating a + second browser. +- Locks include `BrowserHostRunId`, lease expiry, and last heartbeat so stale + locks can be recovered after a desktop crash. +- Temporary profiles are deleted when their owning session closes. +- Persistent profiles can be cleared or deleted from settings/profile menu. +- Add storage stats and "clear cookies/cache/site data" controls. + +## Provider-Facing Tool Surface + +Start with a small deterministic tool set. Add raw CDP later behind developer +mode. + +Required tools: + +- `browser_open`: create or focus a browser session for the current thread. +- `browser_navigate`: navigate a tab to a URL. +- `browser_back`, `browser_forward`, `browser_reload`, `browser_stop`. +- `browser_snapshot`: return title, URL, loading state, visible text, accessible + DOM/role snapshot, focused element, and stable node ids. +- `browser_click`: click by node id, selector, or coordinates. +- `browser_type`: type into active element or node id. +- `browser_key`: send a keyboard shortcut or key. +- `browser_scroll`: scroll page or node. +- `browser_screenshot`: return a bounded screenshot reference, not raw unlimited + base64 in thread history. +- `browser_wait_for`: wait for URL, text, selector, load state, or network idle. +- `browser_console`: read recent console entries. +- `browser_network`: read recent request/response summary. + +Optional phase-2 tools: + +- `browser_select`: choose tab/profile. +- `browser_downloads`: inspect and approve downloads. +- `browser_upload`: request user-approved file upload. +- `browser_evaluate_readonly`: inspect page state with constrained JavaScript. +- `browser_cdp`: developer-mode-only CDP method calls. + +Do not expose raw cookies, local storage, or credential stores to providers. +Providers can interact with pages as a user would, but should not get direct +secret dumps. + +Browser observations can still leak secrets. DOM snapshots, accessible text, +console entries, network summaries, and screenshots must have payload caps, +origin metadata, retention limits, and redaction hooks before they are returned +to providers or persisted as artifacts. + +## Runtime Events and Approvals + +Add browser-specific canonical semantics instead of hiding everything under +`dynamic_tool_call`. + +Recommended contract additions: + +- Add `"browser_tool_call"` to `TOOL_LIFECYCLE_ITEM_TYPES`. +- Add request types: + - `"browser_origin_approval"` + - `"browser_permission_approval"` + - `"browser_download_approval"` + - `"browser_upload_approval"` + - `"browser_developer_mode_approval"` +- Add raw sources: + - `"ryco.browser.host"` + - `"ryco.browser.tool"` + +Provider event flow: + +1. Adapter receives provider tool call. +2. Shared tool registry starts a canonical `browser_tool_call` item. +3. `BrowserService` checks profile, host, tab, and origin policy. +4. If approval is required, emit `request.opened` with browser request type. +5. User approves/denies from the normal Ryco approval UI. +6. Server sends command to `BrowserHost`. +7. Host executes and streams progress/events. +8. Tool item completes with a normalized result or fails with a typed error. + +This preserves timeline fidelity: navigation, screenshots, downloads, and +permission decisions are visible and replayable at the event level. + +## Security Model + +### Origin Policy + +Use a per-origin policy: + +- `ask`: default for internet origins. +- `allow_session`: allow for the current browser session. +- `allow_profile`: allow for the selected browser profile. +- `allow_project`: allow for this project. +- `deny`: block. + +Reasonable defaults: + +- Auto-allow read/navigation only for loopback local development origins that + Ryco can associate with the current project or worktree, for example through + known dev-server metadata, terminal-launched URL detection, or explicit user + selection. +- Ask for unknown `localhost`, private-network, link-local, and public internet + origins. +- Ask before providers use browser tools on public internet origins. +- Ask before cross-origin navigation from an approved origin to a new public + origin. +- Always block known unsafe schemes except controlled `file:` access to + approved project files. + +### Permissions + +Electron permission handlers should default deny and ask only when needed: + +- Camera, microphone, location, notifications, MIDI, clipboard, fullscreen, + downloads, popups, file system, and media capture require explicit approval. +- Downloads go to a Ryco-controlled downloads directory first, with path + sanitization, size limits, no automatic open/execute behavior, quarantine or + origin metadata where available, and symlink race protection. +- Uploads require user file selection or explicit approval. +- Popups are opened as controlled tabs in the same browser profile or blocked. + +### Credentials and Page Content + +Treat page content as untrusted instructions. The browser tool should pass page +state to providers as observation data, not as authority to change Ryco policy. + +Rules: + +- Do not import default Chrome/Safari/Firefox profiles in MVP. +- Do not expose cookies/localStorage/sessionStorage directly. +- Do not allow a page to approve browser permissions or tool access. +- Do not persist origin allow rules from agent requests without explicit user + action. +- Put CDP and arbitrary JavaScript behind developer mode. + +### Native Surface Safety + +Browser surface IPC is local-window geometry, but it still needs strict +validation: + +- Only the owning Ryco renderer can attach/update/detach a browser surface for + its window. +- Bounds must be finite, positive, and clipped to the owning BrowserWindow + content bounds. +- Hidden panels, route changes, tab switches, window blur/minimize, and window + destruction detach or hide the native view. +- IPC inputs must identify browser sessions/tabs by server-issued IDs, never by + arbitrary file paths or renderer-supplied profile paths. +- Native browser views must stay below Ryco modal/approval UI or be detached + while blocking approvals are shown. + +### Browser Artifacts + +Use a dedicated browser artifact store for screenshots, large DOM snapshots, +and downloads. Do not put these artifacts into project source directories or +unbounded provider event payloads. + +Artifact records should include: + +- Artifact id, kind, MIME type, byte size, hash, created time, retention expiry. +- Browser profile/session/tab ids. +- URL and origin at capture time. +- Redaction status and capture limits. +- Download final path only after path validation. + +Provider tool results should return artifact references and small summaries. +Large bytes stay in the artifact store with retention and cleanup. + +## UI Behavior + +### Right Panel + +The right panel should behave like current workspace tabs: + +- Browser is opened from the workspace launcher or future chat/tool event + affordances. +- It appears in the tab strip as a singleton `Browser` tab per thread. +- Closing the tab hides the surface but does not necessarily destroy a + persistent profile. +- When the panel is hidden, browser execution may continue if a provider tool is + actively using it, but the native view should be detached or bounds-set to + avoid visual artifacts. + +### Browser Toolbar + +Controls: + +- Back, forward, reload/stop. +- Address field with URL/search normalization. +- Profile picker with mode and profile name. +- Origin policy indicator. +- Tab controls if multi-tab support ships in the first UI pass. +- Open external in system browser. +- Devtools button only when developer mode is enabled. +- Clear profile data menu item. + +The panel should not use cards nested in cards. It should be a compact tool +surface, similar to Terminal and Files. + +### Non-Desktop Fallback + +For browser-only Ryco web clients and non-local/remote environments, show a +clear unsupported state in MVP: + +```text +Built-in browser is available for the local Ryco desktop backend. +``` + +Do not silently fall back to `iframe`. Future work can add a server/headless +browser host with screenshot streaming and DOM-based controls. + +## Data Flow + +### Manual Browser Open + +1. User opens the Browser tab. +2. Web calls `browser.openSession` over environment RPC. +3. Server verifies the selected environment is the local desktop backend and a + BrowserHost is connected. +4. Server selects/creates the profile and active session. +5. Server sends `openSession` to the registered desktop BrowserHost. +6. Desktop creates/focuses the Electron session and `WebContentsView`. +7. Web sends measured placeholder bounds through `desktopBridge.browser`. +8. Desktop validates ownership/bounds, then attaches and positions the native + view. +9. Browser events update server state, then UI state. + +### Provider Browser Use + +1. Provider emits browser tool call. +2. Adapter forwards to `ProviderRuntimeToolRegistry`. +3. `BrowserRuntimeTool` calls `BrowserService`. +4. `BrowserService` resolves or opens a thread browser session. +5. Policy may request user approval. +6. Desktop BrowserHost executes the command. +7. Server returns normalized result to provider. +8. Thread timeline receives a `browser_tool_call` lifecycle item. + +### Reconnect and Crash Recovery + +- BrowserHost heartbeats to server. +- Server marks sessions degraded when host disconnects. +- On reconnect, host sends full profile/session/tab snapshots. +- In-flight commands fail with retryable `host_disconnected` unless the host + confirms completion. +- Crashed tabs emit `tab.crashed` and can be reloaded by user or agent. +- Server-side command queues are bounded and per-tab to preserve order. + +## RPC Sketch + +Add browser methods to `WS_METHODS` and `WsRpcGroup`: + +- `browser.listProfiles` +- `browser.createProfile` +- `browser.updateProfile` +- `browser.deleteProfile` +- `browser.clearProfileData` +- `browser.openSession` +- `browser.closeSession` +- `browser.getSnapshot` +- `browser.listTabs` +- `browser.newTab` +- `browser.selectTab` +- `browser.closeTab` +- `browser.navigate` +- `browser.back` +- `browser.forward` +- `browser.reload` +- `browser.stop` +- `browser.input` +- `browser.snapshotDom` +- `browser.screenshot` +- `browser.readConsole` +- `browser.readNetwork` +- `browser.resolvePermission` +- `browser.subscribeEvents` + +Add a separate authenticated host channel. This can be a private WebSocket route +or an Effect RPC group mounted only for desktop hosts. Prefer a distinct route +such as `/browser-host/ws`: + +- `browserHost.register` +- `browserHost.heartbeat` +- `browserHost.subscribeCommands` +- `browserHost.command.result` +- `browserHost.event` + +Server-to-host commands are correlated by `BrowserCommandId` and guarded by +`BrowserHostRunId` leases so stale reconnects cannot complete newer commands. + +## Desktop IPC Sketch + +Extend `DesktopBridge` only for local window geometry and visible-surface +operations: + +- `browser.attachSurface(input)` +- `browser.updateSurfaceBounds(input)` +- `browser.detachSurface(input)` +- `browser.focusSurface(input)` + +Do not route provider commands through renderer IPC. Providers run in the server +child process and need a direct server-to-host channel that works even if the +React component is not currently mounted. + +For MVP, this bridge is only active when the renderer is connected to the local +desktop-managed backend. Remote environment Browser panels should render the +unsupported state. + +## Alternatives Considered + +### React iframe + +Rejected. It cannot load many real sites, cannot own browser-level permissions +and cookies reliably, and gives providers no stable browser automation target. + +### Electron webview tag + +Not recommended for first implementation. Electron documents significant +behavioral and security caveats, and the main process should own permissions, +downloads, lifecycle, and host registration. + +### Server-only Playwright browser + +Viable later for headless/remote/web clients, but it does not directly provide +the desired native side-by-side browser UX in the desktop app. It also requires +screen streaming or a second browser window for user inspection. + +### Provider-specific browser integrations + +Rejected as the core approach. MCP or native SDK tools are useful adapter +mechanisms, but browser state, policy, profile locking, and events must be +shared across providers. + +## Performance and Reliability + +- Lazy-create browser sessions only when the Browser tab or a provider tool + needs one. +- Reuse persistent profiles, but cap open tabs and background sessions. +- Detach hidden native views to avoid layout artifacts. +- Throttle DOM snapshots, screenshot capture, console reads, and network event + buffers. +- Bound command queues and fail fast when the host is disconnected. +- Store large screenshots/downloads as managed browser artifacts, not unlimited + event payloads or project files. +- Add storage-size visibility and profile cleanup. +- Keep browser runtime failures isolated from provider sessions when possible: + a crashed tab should fail the browser tool, not the whole provider session. + +## Verification Plan + +Automated: + +- Contract tests for browser schemas and RPC method payloads. +- Unit tests for origin policy matching and profile key/path sanitization. +- BrowserService tests with a fake BrowserHost: open, navigate, permission ask, + deny, timeout, disconnect, reconnect, and queue ordering. +- BrowserHost auth tests: one-use desktop bootstrap token cannot register a + host; dedicated host token can register only on the host route. +- Desktop BrowserKernel tests where feasible for profile isolation and event + normalization. +- Web route tests for opening/selecting/closing the Browser workspace tab. +- Chat and draft route-view tests for `hasOpenedBrowser`, opened mode state, + and close/focus behavior. +- Provider adapter tests using fake browser tools for Codex, Claude, Copilot, + OpenCode, and Cursor/ACP capability handling. + +Manual: + +- Open Browser from the workspace launcher and navigate to a localhost app. +- Log into a public test site in a project profile, close/reopen Ryco, and + verify cookies persist only inside Ryco's profile. +- Create a temporary profile and verify cookies/cache are deleted on close. +- Run the same site in two profiles and verify cookie isolation. +- Ask each supported provider to inspect a local web app through the browser. +- Trigger permission prompts, popup attempts, downloads, and denied origins. +- Kill/restart the desktop browser host and verify server/UI recovery. + +Before implementation is considered done: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- `bun run test` with relevant focused tests + +## Open Questions + +- Should the default persistent profile be `project` or `thread` for manual + browsing? This spec recommends `project` for manual use and `thread` for + agent-created sessions. +- What should the first remote/headless BrowserHost implementation look like + after the desktop-local MVP ships? +- How much of the provider tool surface can Codex app-server accept as native + dynamic tools versus MCP in the current integration? +- What retention, indexing, and cleanup policy should the dedicated browser + artifact store use for screenshots, large snapshots, and downloads? +- How should local dev server discovery feed browser URL suggestions? + +## References + +- OpenAI Codex in-app browser docs: + https://developers.openai.com/codex/app/browser +- OpenAI Codex Chrome extension docs: + https://developers.openai.com/codex/app/chrome-extension +- OpenAI Codex app settings browser section: + https://developers.openai.com/codex/app/settings +- OpenAI Codex app-server docs: + https://developers.openai.com/codex/sdk +- Electron `session`: + https://www.electronjs.org/docs/latest/api/session +- Electron `WebContentsView`: + https://www.electronjs.org/docs/latest/api/web-contents-view +- Electron ``: + https://www.electronjs.org/docs/latest/api/webview-tag +- Playwright BrowserContext: + https://playwright.dev/docs/api/class-browsercontext +- Playwright persistent context: + https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context +- Playwright MCP: + https://github.com/microsoft/playwright-mcp +- Playwright MCP user profile docs: + https://playwright.dev/mcp/configuration/user-profile diff --git a/docs/superpowers/specs/2026-06-25-browser-storage-management-design.md b/docs/superpowers/specs/2026-06-25-browser-storage-management-design.md new file mode 100644 index 00000000000..6bb6435ba86 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-browser-storage-management-design.md @@ -0,0 +1,38 @@ +# In-App Browser Storage Management Design + +## Goal + +Make the desktop-local in-app browser's isolated profile data manageable from the UI without weakening the BrowserHost isolation model. Users should be able to inspect safe metadata for cookies and active-origin storage, delete individual cookies, and clear current-origin or whole-profile data. + +## Scope + +- Add BrowserService RPCs for storage inspection, clearing, and cookie deletion. +- Route all storage operations through the server-owned BrowserService and dedicated BrowserHost channel. +- Implement Electron-hosted behavior in the desktop BrowserKernel using isolated `session` APIs and current-page JavaScript for active-origin web storage metadata. +- Add a compact BrowserPanel storage inspector that resizes the native browser surface rather than overlaying it. +- Keep remote/browser-only clients unsupported for this MVP. + +## Data Exposure + +- Cookie inspection returns metadata only: name, domain, path, expiry, session flag, size estimate, and security flags. +- Cookie values are not returned. +- Local/session storage inspection returns key names and approximate value sizes for the active origin only. +- Cache entries are not listed. The UI can clear HTTP cache and origin storage, but Electron does not provide a reliable cache-entry inspection API suitable for this MVP. + +## Actions + +- Refresh storage metadata. +- Delete an individual cookie. +- Clear current-origin cookies. +- Clear current-origin localStorage/sessionStorage/IndexedDB/CacheStorage/service workers where supported. +- Clear all cookies for the isolated browser profile. +- Clear all storage/cache for the isolated browser profile. + +Whole-profile destructive actions require explicit confirmation in the UI. + +## Security And Reliability Notes + +- Browser storage RPCs require the same desktop-local BrowserService support gate as navigation. +- The web app never calls Electron session APIs directly. +- The host validates session and tab ownership before reading or clearing data. +- Unsupported providers continue to report browser tool injection as unsupported; storage controls are a user UI surface only. diff --git a/packages/contracts/src/browser.ts b/packages/contracts/src/browser.ts new file mode 100644 index 00000000000..4820af34fd0 --- /dev/null +++ b/packages/contracts/src/browser.ts @@ -0,0 +1,786 @@ +import { Effect, Schema } from "effect"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; + +const makeBrowserEntityId = (brand: Brand) => + TrimmedNonEmptyStringSchema.pipe(Schema.brand(brand)); + +export const BrowserHostId = makeBrowserEntityId("BrowserHostId"); +export type BrowserHostId = typeof BrowserHostId.Type; + +export const BrowserHostRunId = makeBrowserEntityId("BrowserHostRunId"); +export type BrowserHostRunId = typeof BrowserHostRunId.Type; + +export const BrowserProfileId = makeBrowserEntityId("BrowserProfileId"); +export type BrowserProfileId = typeof BrowserProfileId.Type; + +export const BrowserSessionId = makeBrowserEntityId("BrowserSessionId"); +export type BrowserSessionId = typeof BrowserSessionId.Type; + +export const BrowserTabId = makeBrowserEntityId("BrowserTabId"); +export type BrowserTabId = typeof BrowserTabId.Type; + +export const BrowserCommandId = makeBrowserEntityId("BrowserCommandId"); +export type BrowserCommandId = typeof BrowserCommandId.Type; + +export const BrowserPermissionRequestId = makeBrowserEntityId("BrowserPermissionRequestId"); +export type BrowserPermissionRequestId = typeof BrowserPermissionRequestId.Type; + +export const BrowserArtifactId = makeBrowserEntityId("BrowserArtifactId"); +export type BrowserArtifactId = typeof BrowserArtifactId.Type; + +export const BrowserProfileMode = Schema.Literals([ + "temporary", + "thread", + "worktree", + "project", + "named", +]); +export type BrowserProfileMode = typeof BrowserProfileMode.Type; + +export const BrowserProfileScope = Schema.Struct({ + mode: BrowserProfileMode, + projectId: Schema.optional(ProjectId), + threadId: Schema.optional(ThreadId), + worktreePath: Schema.optional(TrimmedNonEmptyStringSchema), + name: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type BrowserProfileScope = typeof BrowserProfileScope.Type; + +export const BrowserProfileStorageStats = Schema.Struct({ + bytes: NonNegativeInt, + cookies: Schema.optional(NonNegativeInt), + origins: Schema.optional(NonNegativeInt), + lastCalculatedAt: Schema.optional(IsoDateTime), +}); +export type BrowserProfileStorageStats = typeof BrowserProfileStorageStats.Type; + +export const BrowserProfile = Schema.Struct({ + profileId: BrowserProfileId, + displayName: TrimmedNonEmptyStringSchema, + mode: BrowserProfileMode, + scope: BrowserProfileScope, + persistent: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + storageStats: Schema.optional(BrowserProfileStorageStats), + lockedBy: Schema.optional( + Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + leasedUntil: IsoDateTime, + heartbeatAt: IsoDateTime, + }), + ), +}); +export type BrowserProfile = typeof BrowserProfile.Type; + +export const BrowserLoadState = Schema.Literals(["idle", "loading", "loaded", "failed"]); +export type BrowserLoadState = typeof BrowserLoadState.Type; + +export const BrowserNavigationState = Schema.Struct({ + url: Schema.String, + origin: Schema.NullOr(Schema.String), + title: Schema.optional(Schema.String), + loadState: BrowserLoadState, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + lastNavigationError: Schema.optional(Schema.String), +}); +export type BrowserNavigationState = typeof BrowserNavigationState.Type; + +export const BrowserTabSnapshot = Schema.Struct({ + tabId: BrowserTabId, + sessionId: BrowserSessionId, + profileId: BrowserProfileId, + selected: Schema.Boolean, + crashed: Schema.Boolean, + navigation: BrowserNavigationState, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type BrowserTabSnapshot = typeof BrowserTabSnapshot.Type; + +export const BrowserSessionSnapshot = Schema.Struct({ + sessionId: BrowserSessionId, + profileId: BrowserProfileId, + threadId: ThreadId, + projectId: Schema.optional(ProjectId), + hostId: Schema.optional(BrowserHostId), + selectedTabId: Schema.NullOr(BrowserTabId), + tabs: Schema.Array(BrowserTabSnapshot), + status: Schema.Literals(["opening", "ready", "degraded", "closed", "error"]), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type BrowserSessionSnapshot = typeof BrowserSessionSnapshot.Type; + +export const BrowserHostCapabilities = Schema.Struct({ + surface: Schema.Boolean, + persistentProfiles: Schema.Boolean, + temporaryProfiles: Schema.Boolean, + screenshots: Schema.Boolean, + domSnapshot: Schema.Boolean, + input: Schema.Boolean, + downloads: Schema.Boolean, + devtools: Schema.Boolean, +}); +export type BrowserHostCapabilities = typeof BrowserHostCapabilities.Type; + +export const BrowserHostSnapshot = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + connected: Schema.Boolean, + capabilities: BrowserHostCapabilities, + registeredAt: IsoDateTime, + heartbeatAt: IsoDateTime, +}); +export type BrowserHostSnapshot = typeof BrowserHostSnapshot.Type; + +export const BrowserStatusSnapshot = Schema.Struct({ + supported: Schema.Boolean, + reason: Schema.optional( + Schema.Literals(["desktop_host_missing", "remote_unsupported", "browser_disabled"]), + ), + host: Schema.NullOr(BrowserHostSnapshot), + sessions: Schema.Array(BrowserSessionSnapshot), +}); +export type BrowserStatusSnapshot = typeof BrowserStatusSnapshot.Type; + +export const BrowserOriginPolicyDecision = Schema.Literals([ + "ask", + "allow_session", + "allow_profile", + "allow_project", + "deny", +]); +export type BrowserOriginPolicyDecision = typeof BrowserOriginPolicyDecision.Type; + +export const BrowserOriginPolicy = Schema.Struct({ + origin: TrimmedNonEmptyStringSchema, + decision: BrowserOriginPolicyDecision, + scopeId: Schema.optional(TrimmedNonEmptyStringSchema), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type BrowserOriginPolicy = typeof BrowserOriginPolicy.Type; + +export const BrowserPermissionKind = Schema.Literals([ + "camera", + "microphone", + "location", + "notifications", + "midi", + "clipboard", + "fullscreen", + "download", + "popup", + "file-system", + "media-capture", +]); +export type BrowserPermissionKind = typeof BrowserPermissionKind.Type; + +export const BrowserPermissionPolicyDecision = Schema.Literals(["ask", "allow_once", "deny"]); +export type BrowserPermissionPolicyDecision = typeof BrowserPermissionPolicyDecision.Type; + +export const BrowserPermissionPolicy = Schema.Struct({ + permission: BrowserPermissionKind, + origin: Schema.optional(TrimmedNonEmptyStringSchema), + decision: BrowserPermissionPolicyDecision, +}); +export type BrowserPermissionPolicy = typeof BrowserPermissionPolicy.Type; + +export const BrowserToolAccessDecision = Schema.Struct({ + decision: Schema.Literals(["allow", "ask", "deny"]), + reason: Schema.optional(TrimmedNonEmptyStringSchema), + policy: Schema.optional(BrowserOriginPolicy), +}); +export type BrowserToolAccessDecision = typeof BrowserToolAccessDecision.Type; + +export const BrowserNavigationSource = Schema.Literals(["ui", "agent"]); +export type BrowserNavigationSource = typeof BrowserNavigationSource.Type; + +export const BrowserOpenSessionInput = Schema.Struct({ + threadId: ThreadId, + projectId: Schema.optional(ProjectId), + profileMode: Schema.optional(BrowserProfileMode), + profileName: Schema.optional(TrimmedNonEmptyStringSchema), + initialUrl: Schema.optional(Schema.String), + source: Schema.optional(BrowserNavigationSource), +}); +export type BrowserOpenSessionInput = typeof BrowserOpenSessionInput.Type; + +export const BrowserSessionInput = Schema.Struct({ + sessionId: BrowserSessionId, +}); +export type BrowserSessionInput = typeof BrowserSessionInput.Type; + +export const BrowserTabInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: BrowserTabId, +}); +export type BrowserTabInput = typeof BrowserTabInput.Type; + +export const BrowserNavigateInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), + url: Schema.String.check(Schema.isMaxLength(8_192)), + source: Schema.optional(BrowserNavigationSource), +}); +export type BrowserNavigateInput = typeof BrowserNavigateInput.Type; + +export const BrowserControlInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), +}); +export type BrowserControlInput = typeof BrowserControlInput.Type; + +export const BrowserInputAction = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("click"), + x: Schema.Number, + y: Schema.Number, + }), + Schema.Struct({ + type: Schema.Literal("click"), + ref: TrimmedNonEmptyStringSchema, + }), + Schema.Struct({ + type: Schema.Literal("type"), + text: Schema.String.check(Schema.isMaxLength(32_768)), + }), + Schema.Struct({ + type: Schema.Literal("key"), + key: TrimmedNonEmptyStringSchema, + }), + Schema.Struct({ + type: Schema.Literal("scroll"), + deltaX: Schema.Number, + deltaY: Schema.Number, + }), +]); +export type BrowserInputAction = typeof BrowserInputAction.Type; + +export const BrowserInputCommandInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), + action: BrowserInputAction, +}); +export type BrowserInputCommandInput = typeof BrowserInputCommandInput.Type; + +export const BrowserStorageScope = Schema.Literals(["current_origin", "profile"]); +export type BrowserStorageScope = typeof BrowserStorageScope.Type; + +export const BrowserStorageDataType = Schema.Literals([ + "cookies", + "localStorage", + "sessionStorage", + "indexedDB", + "cacheStorage", + "serviceWorkers", + "httpCache", +]); +export type BrowserStorageDataType = typeof BrowserStorageDataType.Type; + +export const BrowserCookieMetadata = Schema.Struct({ + name: Schema.String.check(Schema.isMaxLength(4_096)), + domain: Schema.String.check(Schema.isMaxLength(4_096)), + path: Schema.String.check(Schema.isMaxLength(4_096)), + secure: Schema.Boolean, + httpOnly: Schema.Boolean, + session: Schema.Boolean, + sameSite: Schema.optional(Schema.String.check(Schema.isMaxLength(128))), + expirationDate: Schema.optional(Schema.Number), + sizeBytes: NonNegativeInt, +}); +export type BrowserCookieMetadata = typeof BrowserCookieMetadata.Type; + +export const BrowserStorageEntryMetadata = Schema.Struct({ + key: Schema.String.check(Schema.isMaxLength(4_096)), + valueBytes: NonNegativeInt, +}); +export type BrowserStorageEntryMetadata = typeof BrowserStorageEntryMetadata.Type; + +export const BrowserStorageInspectionResult = Schema.Struct({ + session: BrowserSessionSnapshot, + tabId: BrowserTabId, + profileId: BrowserProfileId, + url: Schema.String, + origin: Schema.NullOr(Schema.String), + cookies: Schema.Array(BrowserCookieMetadata), + localStorage: Schema.Array(BrowserStorageEntryMetadata), + sessionStorage: Schema.Array(BrowserStorageEntryMetadata), + cookieCounts: Schema.Struct({ + currentOrigin: NonNegativeInt, + profile: NonNegativeInt, + }), + inspectedAt: IsoDateTime, +}); +export type BrowserStorageInspectionResult = typeof BrowserStorageInspectionResult.Type; + +export const BrowserStorageInspectInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), +}); +export type BrowserStorageInspectInput = typeof BrowserStorageInspectInput.Type; + +export const BrowserStorageClearInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), + scope: BrowserStorageScope, + dataTypes: Schema.Array(BrowserStorageDataType), +}); +export type BrowserStorageClearInput = typeof BrowserStorageClearInput.Type; + +export const BrowserStorageClearResult = Schema.Struct({ + session: BrowserSessionSnapshot, + scope: BrowserStorageScope, + origin: Schema.NullOr(Schema.String), + clearedDataTypes: Schema.Array(BrowserStorageDataType), + clearedAt: IsoDateTime, +}); +export type BrowserStorageClearResult = typeof BrowserStorageClearResult.Type; + +export const BrowserCookieDeleteInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), + url: Schema.optional(Schema.String.check(Schema.isMaxLength(8_192))), + name: TrimmedNonEmptyStringSchema, + domain: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + path: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + secure: Schema.optional(Schema.Boolean), +}); +export type BrowserCookieDeleteInput = typeof BrowserCookieDeleteInput.Type; + +export const BrowserCookieDeleteResult = Schema.Struct({ + session: BrowserSessionSnapshot, + deleted: Schema.Boolean, + cookie: Schema.Struct({ + name: TrimmedNonEmptyStringSchema, + domain: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + path: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + secure: Schema.optional(Schema.Boolean), + }), + deletedAt: IsoDateTime, +}); +export type BrowserCookieDeleteResult = typeof BrowserCookieDeleteResult.Type; + +export const BrowserListProfilesResult = Schema.Struct({ + profiles: Schema.Array(BrowserProfile), +}); +export type BrowserListProfilesResult = typeof BrowserListProfilesResult.Type; + +export const BrowserArtifactKind = Schema.Literals(["screenshot", "dom_snapshot", "download"]); +export type BrowserArtifactKind = typeof BrowserArtifactKind.Type; + +export const BrowserArtifactRef = Schema.Struct({ + artifactId: BrowserArtifactId, + kind: BrowserArtifactKind, + mimeType: TrimmedNonEmptyStringSchema, + byteSize: NonNegativeInt, + url: Schema.optional(Schema.String), + origin: Schema.optional(Schema.String), + createdAt: IsoDateTime, + expiresAt: IsoDateTime, +}); +export type BrowserArtifactRef = typeof BrowserArtifactRef.Type; + +export const BrowserViewportSize = Schema.Struct({ + width: PositiveInt, + height: PositiveInt, +}); +export type BrowserViewportSize = typeof BrowserViewportSize.Type; + +export const BrowserDomBounds = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); +export type BrowserDomBounds = typeof BrowserDomBounds.Type; + +export interface BrowserDomNode { + readonly ref: string; + readonly tag: string; + readonly role?: string | undefined; + readonly name?: string | undefined; + readonly bounds?: BrowserDomBounds | undefined; + readonly children?: ReadonlyArray | undefined; +} + +export const BrowserDomNode: Schema.Schema = Schema.Struct({ + ref: TrimmedNonEmptyStringSchema, + tag: Schema.String.check(Schema.isMaxLength(128)), + role: Schema.optional(Schema.String.check(Schema.isMaxLength(128))), + name: Schema.optional(Schema.String.check(Schema.isMaxLength(2_048))), + bounds: Schema.optional(BrowserDomBounds), + children: Schema.optional( + Schema.Array(Schema.suspend((): Schema.Schema => BrowserDomNode)), + ), +}); + +export const BrowserDomSnapshotData = Schema.Struct({ + url: Schema.String.check(Schema.isMaxLength(8_192)), + title: Schema.String.check(Schema.isMaxLength(2_048)), + viewport: BrowserViewportSize, + tree: Schema.Array(BrowserDomNode), + truncated: Schema.optional(Schema.Boolean), + nodeCount: Schema.optional(NonNegativeInt), +}); +export type BrowserDomSnapshotData = typeof BrowserDomSnapshotData.Type; + +export const BrowserDomSnapshotResult = Schema.Struct({ + session: BrowserSessionSnapshot, + snapshot: BrowserDomSnapshotData, + text: Schema.optional(Schema.String), + artifact: Schema.optional(BrowserArtifactRef), +}); +export type BrowserDomSnapshotResult = typeof BrowserDomSnapshotResult.Type; + +export const BrowserScreenshotResult = Schema.Struct({ + session: BrowserSessionSnapshot, + artifact: BrowserArtifactRef, +}); +export type BrowserScreenshotResult = typeof BrowserScreenshotResult.Type; + +export const BrowserConsoleLevel = Schema.Literals([ + "debug", + "info", + "warning", + "error", + "verbose", +]); +export type BrowserConsoleLevel = typeof BrowserConsoleLevel.Type; + +export const BrowserConsoleEntry = Schema.Struct({ + level: BrowserConsoleLevel, + message: Schema.String.check(Schema.isMaxLength(16_384)), + timestamp: IsoDateTime, + source: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + line: Schema.optional(NonNegativeInt), +}); +export type BrowserConsoleEntry = typeof BrowserConsoleEntry.Type; + +export const BrowserConsoleResult = Schema.Struct({ + session: BrowserSessionSnapshot, + entries: Schema.Array(BrowserConsoleEntry), +}); +export type BrowserConsoleResult = typeof BrowserConsoleResult.Type; + +export const BrowserNetworkEntry = Schema.Struct({ + requestId: TrimmedNonEmptyStringSchema, + url: Schema.String.check(Schema.isMaxLength(8_192)), + method: Schema.String.check(Schema.isMaxLength(32)), + status: Schema.optional(NonNegativeInt), + resourceType: Schema.optional(Schema.String.check(Schema.isMaxLength(128))), + startedAt: IsoDateTime, + completedAt: Schema.optional(IsoDateTime), +}); +export type BrowserNetworkEntry = typeof BrowserNetworkEntry.Type; + +export const BrowserNetworkResult = Schema.Struct({ + session: BrowserSessionSnapshot, + entries: Schema.Array(BrowserNetworkEntry), +}); +export type BrowserNetworkResult = typeof BrowserNetworkResult.Type; + +export const BrowserWaitForInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: Schema.optional(BrowserTabId), + timeoutMs: Schema.optional(PositiveInt), + url: Schema.optional(Schema.String.check(Schema.isMaxLength(8_192))), + title: Schema.optional(Schema.String.check(Schema.isMaxLength(2_048))), + text: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + textGone: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + loadState: Schema.optional(BrowserLoadState), +}); +export type BrowserWaitForInput = typeof BrowserWaitForInput.Type; + +export const BrowserWaitForResult = Schema.Struct({ + session: BrowserSessionSnapshot, + matched: Schema.Boolean, + waitedMs: NonNegativeInt, +}); +export type BrowserWaitForResult = typeof BrowserWaitForResult.Type; + +export const BrowserHostScreenshotData = Schema.Struct({ + kind: Schema.Literal("screenshot"), + base64: Schema.String, +}); +export type BrowserHostScreenshotData = typeof BrowserHostScreenshotData.Type; + +export const BrowserHostDomSnapshotData = Schema.Struct({ + kind: Schema.Literal("dom_snapshot"), + snapshot: BrowserDomSnapshotData, + text: Schema.optional(Schema.String), +}); +export type BrowserHostDomSnapshotData = typeof BrowserHostDomSnapshotData.Type; + +export const BrowserHostConsoleData = Schema.Struct({ + kind: Schema.Literal("console"), + entries: Schema.Array(BrowserConsoleEntry), +}); +export type BrowserHostConsoleData = typeof BrowserHostConsoleData.Type; + +export const BrowserHostNetworkData = Schema.Struct({ + kind: Schema.Literal("network"), + entries: Schema.Array(BrowserNetworkEntry), +}); +export type BrowserHostNetworkData = typeof BrowserHostNetworkData.Type; + +export const BrowserHostObservationData = Schema.Union([ + BrowserHostScreenshotData, + BrowserHostDomSnapshotData, + BrowserHostConsoleData, + BrowserHostNetworkData, +]); +export type BrowserHostObservationData = typeof BrowserHostObservationData.Type; + +export const BrowserCommandKind = Schema.Literals([ + "open_session", + "close_session", + "navigate", + "back", + "forward", + "reload", + "stop", + "input", + "snapshot_dom", + "screenshot", + "read_console", + "read_network", + "inspect_storage", + "clear_storage", + "delete_cookie", +]); +export type BrowserCommandKind = typeof BrowserCommandKind.Type; + +export const BrowserHostCommand = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("open_session"), + session: BrowserSessionSnapshot, + profile: BrowserProfile, + initialUrl: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("close_session"), + sessionId: BrowserSessionId, + }), + Schema.Struct({ + kind: Schema.Literal("navigate"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + url: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literals(["back", "forward", "reload", "stop"]), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + }), + Schema.Struct({ + kind: Schema.Literal("input"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + action: BrowserInputAction, + }), + Schema.Struct({ + kind: Schema.Literals(["snapshot_dom", "screenshot", "read_console", "read_network"]), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + }), + Schema.Struct({ + kind: Schema.Literal("inspect_storage"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + }), + Schema.Struct({ + kind: Schema.Literal("clear_storage"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + scope: BrowserStorageScope, + dataTypes: Schema.Array(BrowserStorageDataType), + }), + Schema.Struct({ + kind: Schema.Literal("delete_cookie"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + url: Schema.optional(Schema.String.check(Schema.isMaxLength(8_192))), + name: TrimmedNonEmptyStringSchema, + domain: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + path: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), + secure: Schema.optional(Schema.Boolean), + }), +]); +export type BrowserHostCommand = typeof BrowserHostCommand.Type; + +export const BrowserHostCommandEnvelope = Schema.Struct({ + commandId: BrowserCommandId, + hostId: BrowserHostId, + runId: BrowserHostRunId, + command: BrowserHostCommand, + issuedAt: IsoDateTime, + timeoutMs: PositiveInt, +}); +export type BrowserHostCommandEnvelope = typeof BrowserHostCommandEnvelope.Type; + +export const BrowserCommandResultPayload = Schema.Struct({ + session: Schema.optional(BrowserSessionSnapshot), + tab: Schema.optional(BrowserTabSnapshot), + text: Schema.optional(Schema.String), + artifact: Schema.optional(BrowserArtifactRef), + storageInspection: Schema.optional(BrowserStorageInspectionResult), + storageClear: Schema.optional(BrowserStorageClearResult), + cookieDelete: Schema.optional(BrowserCookieDeleteResult), + data: Schema.optional(Schema.Unknown), +}); +export type BrowserCommandResultPayload = typeof BrowserCommandResultPayload.Type; + +export const BrowserCommandResult = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + commandId: BrowserCommandId, + result: BrowserCommandResultPayload, + }), + Schema.Struct({ + ok: Schema.Literal(false), + commandId: BrowserCommandId, + error: Schema.Struct({ + code: TrimmedNonEmptyStringSchema, + message: TrimmedNonEmptyStringSchema, + retryable: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + }), + }), +]); +export type BrowserCommandResult = typeof BrowserCommandResult.Type; + +export const BrowserEvent = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("host.connected"), + host: BrowserHostSnapshot, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("host.disconnected"), + hostId: BrowserHostId, + runId: BrowserHostRunId, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("session.updated"), + session: BrowserSessionSnapshot, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("session.closed"), + sessionId: BrowserSessionId, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("tab.updated"), + tab: BrowserTabSnapshot, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("tab.crashed"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + reason: Schema.optional(Schema.String), + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("permission.requested"), + requestId: BrowserPermissionRequestId, + sessionId: BrowserSessionId, + tabId: BrowserTabId, + origin: Schema.String, + permission: BrowserPermissionKind, + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("download.updated"), + sessionId: BrowserSessionId, + tabId: BrowserTabId, + artifact: BrowserArtifactRef, + state: Schema.Literals(["started", "progress", "completed", "failed", "cancelled"]), + createdAt: IsoDateTime, + }), + Schema.Struct({ + type: Schema.Literal("command.progress"), + commandId: BrowserCommandId, + message: TrimmedNonEmptyStringSchema, + createdAt: IsoDateTime, + }), +]); +export type BrowserEvent = typeof BrowserEvent.Type; + +export class BrowserServiceError extends Schema.TaggedErrorClass()( + "BrowserServiceError", + { + code: Schema.Literals([ + "host_unavailable", + "profile_locked", + "session_not_found", + "tab_not_found", + "navigation_blocked", + "origin_denied", + "permission_denied", + "command_timeout", + "unsupported_capability", + "invalid_url", + "queue_full", + "host_disconnected", + ]), + message: TrimmedNonEmptyStringSchema, + retryable: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + cause: Schema.optional(Schema.Defect), + }, +) {} + +export const BrowserHostRegisterInput = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + capabilities: BrowserHostCapabilities, +}); +export type BrowserHostRegisterInput = typeof BrowserHostRegisterInput.Type; + +export const BrowserHostRegisterResult = Schema.Struct({ + accepted: Schema.Boolean, + host: BrowserHostSnapshot, +}); +export type BrowserHostRegisterResult = typeof BrowserHostRegisterResult.Type; + +export const BrowserHostHeartbeatInput = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + sessions: Schema.optional(Schema.Array(BrowserSessionSnapshot)), +}); +export type BrowserHostHeartbeatInput = typeof BrowserHostHeartbeatInput.Type; + +export const BrowserHostSubscribeCommandsInput = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, +}); +export type BrowserHostSubscribeCommandsInput = typeof BrowserHostSubscribeCommandsInput.Type; + +export const BrowserHostCommandResultInput = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + result: BrowserCommandResult, +}); +export type BrowserHostCommandResultInput = typeof BrowserHostCommandResultInput.Type; + +export const BrowserHostEventInput = Schema.Struct({ + hostId: BrowserHostId, + runId: BrowserHostRunId, + event: BrowserEvent, +}); +export type BrowserHostEventInput = typeof BrowserHostEventInput.Type; diff --git a/packages/contracts/src/browserHostRpc.ts b/packages/contracts/src/browserHostRpc.ts new file mode 100644 index 00000000000..ca47f79c693 --- /dev/null +++ b/packages/contracts/src/browserHostRpc.ts @@ -0,0 +1,70 @@ +import { Schema } from "effect"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import { + BrowserEvent, + BrowserHostCommandEnvelope, + BrowserHostCommandResultInput, + BrowserHostEventInput, + BrowserHostHeartbeatInput, + BrowserHostRegisterInput, + BrowserHostRegisterResult, + BrowserHostSubscribeCommandsInput, + BrowserServiceError, +} from "./browser.ts"; +import { AuthRpcError } from "./auth.ts"; + +export const BROWSER_HOST_METHODS = { + register: "browserHost.register", + heartbeat: "browserHost.heartbeat", + subscribeCommands: "browserHost.subscribeCommands", + commandResult: "browserHost.command.result", + event: "browserHost.event", +} as const; + +export const BrowserHostRegisterRpc = Rpc.make(BROWSER_HOST_METHODS.register, { + payload: BrowserHostRegisterInput, + success: BrowserHostRegisterResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const BrowserHostHeartbeatRpc = Rpc.make(BROWSER_HOST_METHODS.heartbeat, { + payload: BrowserHostHeartbeatInput, + success: Schema.Struct({}), + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const BrowserHostSubscribeCommandsRpc = Rpc.make(BROWSER_HOST_METHODS.subscribeCommands, { + payload: BrowserHostSubscribeCommandsInput, + success: BrowserHostCommandEnvelope, + error: Schema.Union([BrowserServiceError, AuthRpcError]), + stream: true, +}); + +export const BrowserHostCommandResultRpc = Rpc.make(BROWSER_HOST_METHODS.commandResult, { + payload: BrowserHostCommandResultInput, + success: Schema.Struct({}), + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const BrowserHostEventRpc = Rpc.make(BROWSER_HOST_METHODS.event, { + payload: BrowserHostEventInput, + success: Schema.Struct({}), + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const BrowserHostEventsRpc = Rpc.make("browserHost.events", { + payload: Schema.Struct({}), + success: BrowserEvent, + error: BrowserServiceError, + stream: true, +}); + +export const BrowserHostRpcGroup = RpcGroup.make( + BrowserHostRegisterRpc, + BrowserHostHeartbeatRpc, + BrowserHostSubscribeCommandsRpc, + BrowserHostCommandResultRpc, + BrowserHostEventRpc, +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2c8f32c3ef9..d2f03a388a3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,5 +1,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; +export * from "./browser.ts"; +export * from "./browserHostRpc.ts"; export * from "./environment.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5815372191b..1d66b06413e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,3 +1,24 @@ +import { Schema } from "effect"; + +import { + BrowserSessionId, + BrowserTabId, + type BrowserCookieDeleteInput, + type BrowserCookieDeleteResult, + type BrowserControlInput, + type BrowserEvent, + type BrowserInputCommandInput, + type BrowserListProfilesResult, + type BrowserNavigateInput, + type BrowserOpenSessionInput, + type BrowserSessionInput, + type BrowserSessionSnapshot, + type BrowserStorageClearInput, + type BrowserStorageClearResult, + type BrowserStorageInspectInput, + type BrowserStorageInspectionResult, + type BrowserStatusSnapshot, +} from "./browser.ts"; import type { GitArchiveWorktreeInput, GitCreateWorktreeForProjectInput, @@ -245,6 +266,38 @@ export interface PickFolderOptions { initialPath?: string | null; } +export const DesktopBrowserSurfaceBounds = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + deviceScaleFactor: Schema.optional(Schema.Number), +}); +export type DesktopBrowserSurfaceBounds = typeof DesktopBrowserSurfaceBounds.Type; + +export const DesktopBrowserSurfaceAttachInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: BrowserTabId, + bounds: DesktopBrowserSurfaceBounds, +}); +export type DesktopBrowserSurfaceAttachInput = typeof DesktopBrowserSurfaceAttachInput.Type; + +export const DesktopBrowserSurfaceUpdateInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: BrowserTabId, + bounds: DesktopBrowserSurfaceBounds, +}); +export type DesktopBrowserSurfaceUpdateInput = typeof DesktopBrowserSurfaceUpdateInput.Type; + +export const DesktopBrowserSurfaceDetachInput = Schema.Struct({ + sessionId: BrowserSessionId, + tabId: BrowserTabId, +}); +export type DesktopBrowserSurfaceDetachInput = typeof DesktopBrowserSurfaceDetachInput.Type; + +export const DesktopBrowserSurfaceFocusInput = DesktopBrowserSurfaceDetachInput; +export type DesktopBrowserSurfaceFocusInput = typeof DesktopBrowserSurfaceFocusInput.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -291,6 +344,12 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + browser?: { + attachSurface: (input: DesktopBrowserSurfaceAttachInput) => Promise; + updateSurfaceBounds: (input: DesktopBrowserSurfaceUpdateInput) => Promise; + detachSurface: (input: DesktopBrowserSurfaceDetachInput) => Promise; + focusSurface: (input: DesktopBrowserSurfaceFocusInput) => Promise; + }; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; @@ -490,4 +549,21 @@ export interface EnvironmentApi { }, ) => () => void; }; + browser?: { + getStatus: () => Promise; + listProfiles: () => Promise; + openSession: (input: BrowserOpenSessionInput) => Promise; + closeSession: (input: BrowserSessionInput) => Promise; + getSnapshot: (input: BrowserSessionInput) => Promise; + navigate: (input: BrowserNavigateInput) => Promise; + back: (input: BrowserControlInput) => Promise; + forward: (input: BrowserControlInput) => Promise; + reload: (input: BrowserControlInput) => Promise; + stop: (input: BrowserControlInput) => Promise; + input: (input: BrowserInputCommandInput) => Promise; + inspectStorage: (input: BrowserStorageInspectInput) => Promise; + clearStorage: (input: BrowserStorageClearInput) => Promise; + deleteCookie: (input: BrowserCookieDeleteInput) => Promise; + onEvent: (callback: (event: BrowserEvent) => void) => () => void; + }; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index ea63a429c52..4f736f67402 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -28,6 +28,8 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), + Schema.Literal("ryco.browser.host"), + Schema.Literal("ryco.browser.tool"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; @@ -107,6 +109,7 @@ export const TOOL_LIFECYCLE_ITEM_TYPES = [ "file_change", "mcp_tool_call", "dynamic_tool_call", + "browser_tool_call", "collab_agent_tool_call", "web_search", "image_view", @@ -141,6 +144,11 @@ export const CanonicalRequestType = Schema.Literals([ "exec_command_approval", "tool_user_input", "dynamic_tool_call", + "browser_origin_approval", + "browser_permission_approval", + "browser_download_approval", + "browser_upload_approval", + "browser_developer_mode_approval", "auth_tokens_refresh", "unknown", ]); diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index ef17ff1433b..4cb6934e196 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { AtlassianSaveProjectLinkInput } from "./atlassian.ts"; +import { BrowserCookieDeleteInput, BrowserStorageClearInput } from "./browser.ts"; import { WS_METHODS } from "./rpc.ts"; import { WorkItemGetInput } from "./workItems.ts"; @@ -29,6 +30,9 @@ describe("WS_METHODS Atlassian and work item names", () => { expect(WS_METHODS.workItemsUpdate).toBe("workItems.update"); expect(WS_METHODS.workItemsEditComment).toBe("workItems.editComment"); expect(WS_METHODS.workItemsTransition).toBe("workItems.transition"); + expect(WS_METHODS.browserInspectStorage).toBe("browser.inspectStorage"); + expect(WS_METHODS.browserClearStorage).toBe("browser.clearStorage"); + expect(WS_METHODS.browserDeleteCookie).toBe("browser.deleteCookie"); }); it("rejects payloads missing required fields", () => { @@ -39,5 +43,41 @@ describe("WS_METHODS Atlassian and work item names", () => { jiraConnectionId: null, }), ).toThrow(); + expect(() => + Schema.decodeUnknownSync(BrowserCookieDeleteInput)({ + sessionId: "browser-session:1", + name: "", + }), + ).toThrow(); + }); + + it("decodes browser storage management payloads", () => { + expect( + Schema.decodeUnknownSync(BrowserStorageClearInput)({ + sessionId: "browser-session:1", + tabId: "browser-tab:1", + scope: "current_origin", + dataTypes: ["cookies", "localStorage"], + }), + ).toMatchObject({ + sessionId: "browser-session:1", + tabId: "browser-tab:1", + scope: "current_origin", + dataTypes: ["cookies", "localStorage"], + }); + expect( + Schema.decodeUnknownSync(BrowserCookieDeleteInput)({ + sessionId: "browser-session:1", + name: "sid", + domain: ".example.test", + path: "/", + secure: true, + }), + ).toMatchObject({ + name: "sid", + domain: ".example.test", + path: "/", + secure: true, + }); }); }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5bb201275a3..34f9d8d8fa2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -4,6 +4,30 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor.ts"; import { AuthAccessStreamEvent, AuthRpcError } from "./auth.ts"; +import { + BrowserControlInput, + BrowserCookieDeleteInput, + BrowserCookieDeleteResult, + BrowserConsoleResult, + BrowserDomSnapshotResult, + BrowserEvent, + BrowserInputCommandInput, + BrowserListProfilesResult, + BrowserNavigateInput, + BrowserNetworkResult, + BrowserOpenSessionInput, + BrowserScreenshotResult, + BrowserServiceError, + BrowserSessionInput, + BrowserSessionSnapshot, + BrowserStorageClearInput, + BrowserStorageClearResult, + BrowserStorageInspectInput, + BrowserStorageInspectionResult, + BrowserStatusSnapshot, + BrowserWaitForInput, + BrowserWaitForResult, +} from "./browser.ts"; import { DiagnosticsError, DiagnosticsSnapshot } from "./diagnostics.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { @@ -242,6 +266,28 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Browser methods + browserGetStatus: "browser.getStatus", + browserListProfiles: "browser.listProfiles", + browserOpenSession: "browser.openSession", + browserCloseSession: "browser.closeSession", + browserGetSnapshot: "browser.getSnapshot", + browserNavigate: "browser.navigate", + browserBack: "browser.back", + browserForward: "browser.forward", + browserReload: "browser.reload", + browserStop: "browser.stop", + browserInput: "browser.input", + browserInspectStorage: "browser.inspectStorage", + browserClearStorage: "browser.clearStorage", + browserDeleteCookie: "browser.deleteCookie", + browserSnapshotDom: "browser.snapshotDom", + browserScreenshot: "browser.screenshot", + browserReadConsole: "browser.readConsole", + browserReadNetwork: "browser.readNetwork", + browserWaitFor: "browser.waitFor", + subscribeBrowserEvents: "subscribeBrowserEvents", + // Server meta serverGetConfig: "server.getConfig", serverGetAdvertisedEndpoints: "server.getAdvertisedEndpoints", @@ -440,6 +486,127 @@ export type ProjectsInitializeGitInput = typeof ProjectsInitializeGitInput.Type; export const EmptyRpcResult = Schema.Struct({}); export type EmptyRpcResult = typeof EmptyRpcResult.Type; +export const WsBrowserGetStatusRpc = Rpc.make(WS_METHODS.browserGetStatus, { + payload: Schema.Struct({}), + success: BrowserStatusSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserListProfilesRpc = Rpc.make(WS_METHODS.browserListProfiles, { + payload: Schema.Struct({}), + success: BrowserListProfilesResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserOpenSessionRpc = Rpc.make(WS_METHODS.browserOpenSession, { + payload: BrowserOpenSessionInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserCloseSessionRpc = Rpc.make(WS_METHODS.browserCloseSession, { + payload: BrowserSessionInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserGetSnapshotRpc = Rpc.make(WS_METHODS.browserGetSnapshot, { + payload: BrowserSessionInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserNavigateRpc = Rpc.make(WS_METHODS.browserNavigate, { + payload: BrowserNavigateInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserBackRpc = Rpc.make(WS_METHODS.browserBack, { + payload: BrowserControlInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserForwardRpc = Rpc.make(WS_METHODS.browserForward, { + payload: BrowserControlInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserReloadRpc = Rpc.make(WS_METHODS.browserReload, { + payload: BrowserControlInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserStopRpc = Rpc.make(WS_METHODS.browserStop, { + payload: BrowserControlInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserInputRpc = Rpc.make(WS_METHODS.browserInput, { + payload: BrowserInputCommandInput, + success: BrowserSessionSnapshot, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserInspectStorageRpc = Rpc.make(WS_METHODS.browserInspectStorage, { + payload: BrowserStorageInspectInput, + success: BrowserStorageInspectionResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserClearStorageRpc = Rpc.make(WS_METHODS.browserClearStorage, { + payload: BrowserStorageClearInput, + success: BrowserStorageClearResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserDeleteCookieRpc = Rpc.make(WS_METHODS.browserDeleteCookie, { + payload: BrowserCookieDeleteInput, + success: BrowserCookieDeleteResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserSnapshotDomRpc = Rpc.make(WS_METHODS.browserSnapshotDom, { + payload: BrowserControlInput, + success: BrowserDomSnapshotResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserScreenshotRpc = Rpc.make(WS_METHODS.browserScreenshot, { + payload: BrowserControlInput, + success: BrowserScreenshotResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserReadConsoleRpc = Rpc.make(WS_METHODS.browserReadConsole, { + payload: BrowserControlInput, + success: BrowserConsoleResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserReadNetworkRpc = Rpc.make(WS_METHODS.browserReadNetwork, { + payload: BrowserControlInput, + success: BrowserNetworkResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsBrowserWaitForRpc = Rpc.make(WS_METHODS.browserWaitFor, { + payload: BrowserWaitForInput, + success: BrowserWaitForResult, + error: Schema.Union([BrowserServiceError, AuthRpcError]), +}); + +export const WsSubscribeBrowserEventsRpc = Rpc.make(WS_METHODS.subscribeBrowserEvents, { + payload: Schema.Struct({}), + success: BrowserEvent, + error: Schema.Union([BrowserServiceError, AuthRpcError]), + stream: true, +}); + export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { payload: ServerUpsertKeybindingInput, success: ServerUpsertKeybindingResult, @@ -1194,6 +1361,26 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, }); export const WsRpcGroup = RpcGroup.make( + WsBrowserGetStatusRpc, + WsBrowserListProfilesRpc, + WsBrowserOpenSessionRpc, + WsBrowserCloseSessionRpc, + WsBrowserGetSnapshotRpc, + WsBrowserNavigateRpc, + WsBrowserBackRpc, + WsBrowserForwardRpc, + WsBrowserReloadRpc, + WsBrowserStopRpc, + WsBrowserInputRpc, + WsBrowserInspectStorageRpc, + WsBrowserClearStorageRpc, + WsBrowserDeleteCookieRpc, + WsBrowserSnapshotDomRpc, + WsBrowserScreenshotRpc, + WsBrowserReadConsoleRpc, + WsBrowserReadNetworkRpc, + WsBrowserWaitForRpc, + WsSubscribeBrowserEventsRpc, WsServerGetConfigRpc, WsServerGetAdvertisedEndpointsRpc, WsServerGetDiagnosticsMetricsRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index c7c48d3e5d9..050429195de 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -92,6 +92,10 @@ "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" }, + "./browser": { + "types": "./src/browser.ts", + "import": "./src/browser.ts" + }, "./sourceControlContextFormatter": { "types": "./src/sourceControlContextFormatter.ts", "import": "./src/sourceControlContextFormatter.ts" diff --git a/packages/shared/src/browser.test.ts b/packages/shared/src/browser.test.ts new file mode 100644 index 00000000000..bcd496ce7eb --- /dev/null +++ b/packages/shared/src/browser.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeBrowserNavigationUrl, + resolveElectronSurfaceBounds, + sanitizeBrowserProfileKey, +} from "./browser.ts"; + +describe("browser shared helpers", () => { + it("normalizes ordinary URLs and origin metadata", () => { + const result = normalizeBrowserNavigationUrl("example.com/path"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.url).toBe("https://example.com/path"); + expect(result.value.origin).toBe("https://example.com"); + expect(result.value.kind).toBe("http"); + }); + + it("classifies loopback and private origins distinctly", () => { + const localhost = normalizeBrowserNavigationUrl("http://localhost:5173"); + const privateNetwork = normalizeBrowserNavigationUrl("http://192.168.1.25"); + expect(localhost.ok && localhost.value.kind).toBe("loopback"); + expect(privateNetwork.ok && privateNetwork.value.kind).toBe("private-network"); + }); + + it("rejects empty, invalid, and unsafe schemes", () => { + expect(normalizeBrowserNavigationUrl("").ok).toBe(false); + expect(normalizeBrowserNavigationUrl("http://[broken").ok).toBe(false); + const javascriptUrl = normalizeBrowserNavigationUrl("javascript:alert(1)"); + expect(javascriptUrl.ok).toBe(false); + expect(!javascriptUrl.ok && javascriptUrl.reason).toBe("blocked-scheme"); + }); + + it("keeps browser profile keys path-safe and bounded", () => { + const key = sanitizeBrowserProfileKey("../../Admin/Profile Name With Spaces"); + expect(key).toMatch(/^[a-z0-9._-]+$/); + expect(key).not.toContain("/"); + expect(key).not.toContain("\\"); + expect(key.startsWith("admin-profile-name-with-spaces-")).toBe(true); + expect(key.length).toBeLessThanOrEqual(80); + + const longKey = sanitizeBrowserProfileKey("x".repeat(500)); + expect(longKey.length).toBeLessThanOrEqual(80); + }); + + it("reconciles renderer and native scale factors for embedded browser bounds", () => { + expect( + resolveElectronSurfaceBounds( + { x: 100, y: 200, width: 800, height: 600, deviceScaleFactor: 2 }, + 2, + ), + ).toEqual({ x: 100, y: 200, width: 800, height: 600 }); + + expect( + resolveElectronSurfaceBounds( + { x: 100, y: 200, width: 800, height: 600, deviceScaleFactor: 1 }, + 2, + ), + ).toEqual({ x: 200, y: 400, width: 1600, height: 1200 }); + }); +}); diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts new file mode 100644 index 00000000000..5b95176956c --- /dev/null +++ b/packages/shared/src/browser.ts @@ -0,0 +1,224 @@ +export type BrowserOriginKind = + | "http" + | "loopback" + | "private-network" + | "link-local" + | "file" + | "about" + | "blocked-scheme"; + +export interface BrowserUrlInfo { + readonly url: string; + readonly origin: string | null; + readonly scheme: string; + readonly hostname: string | null; + readonly kind: BrowserOriginKind; +} + +export type BrowserUrlParseResult = + | { + readonly ok: true; + readonly value: BrowserUrlInfo; + } + | { + readonly ok: false; + readonly reason: "empty" | "invalid" | "blocked-scheme"; + readonly message: string; + }; + +const ALLOWED_BROWSER_SCHEMES = new Set(["http:", "https:", "file:", "about:"]); +const BLOCKED_BROWSER_SCHEMES = new Set([ + "blob:", + "chrome:", + "data:", + "devtools:", + "javascript:", + "view-source:", +]); +const SAFE_PROFILE_KEY_MAX_LENGTH = 80; + +export function normalizeBrowserNavigationUrl(rawInput: string): BrowserUrlParseResult { + const input = rawInput.trim(); + if (input.length === 0) { + return { ok: false, reason: "empty", message: "URL is empty" }; + } + + const explicitScheme = input.match(/^([a-zA-Z][a-zA-Z\d+.-]*):/)?.[0]?.toLowerCase(); + const candidate = + input.includes("://") || + input.startsWith("about:") || + input.startsWith("file:") || + (explicitScheme !== undefined && BLOCKED_BROWSER_SCHEMES.has(explicitScheme)) + ? input + : `${defaultSchemeForInput(input)}://${input}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { ok: false, reason: "invalid", message: "URL is invalid" }; + } + + if (!ALLOWED_BROWSER_SCHEMES.has(parsed.protocol)) { + return { + ok: false, + reason: "blocked-scheme", + message: `Browser navigation blocked unsupported URL scheme: ${parsed.protocol}`, + }; + } + + if (parsed.protocol === "about:" && parsed.href !== "about:blank") { + return { + ok: false, + reason: "blocked-scheme", + message: "Only about:blank is supported", + }; + } + + const hostname = parsed.hostname || null; + const origin = parsed.origin === "null" ? null : parsed.origin; + return { + ok: true, + value: { + url: parsed.href, + origin, + scheme: parsed.protocol.replace(/:$/, ""), + hostname, + kind: classifyBrowserOrigin(parsed), + }, + }; +} + +export function classifyBrowserOrigin(parsed: URL): BrowserOriginKind { + if (parsed.protocol === "file:") return "file"; + if (parsed.protocol === "about:") return "about"; + if (!ALLOWED_BROWSER_SCHEMES.has(parsed.protocol)) return "blocked-scheme"; + + const hostname = parsed.hostname.toLowerCase(); + if (isLoopbackHostname(hostname)) return "loopback"; + if (isPrivateNetworkHostname(hostname)) return "private-network"; + if (isLinkLocalHostname(hostname)) return "link-local"; + return "http"; +} + +export function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" || + normalized === "0:0:0:0:0:0:0:1" || + normalized.startsWith("127.") + ); +} + +export function isPrivateNetworkHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (normalized === "localhost" || normalized.endsWith(".localhost")) return true; + + const parts = normalized.split(".").map((part) => Number.parseInt(part, 10)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return false; + } + + const [a = -1, b = -1] = parts; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +export function isLinkLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + const parts = normalized.split(".").map((part) => Number.parseInt(part, 10)); + if ( + parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ) { + return parts[0] === 169 && parts[1] === 254; + } + return normalized.startsWith("fe80:"); +} + +export function sanitizeBrowserProfileKey(input: string): string { + const trimmed = input.trim().toLowerCase(); + const hash = stableBrowserKeyHash(trimmed || "profile"); + const slug = trimmed + .replace(/[\\/]+/g, "-") + .replace(/\.\.+/g, ".") + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .slice(0, SAFE_PROFILE_KEY_MAX_LENGTH); + const base = slug || "profile"; + if (base.length <= SAFE_PROFILE_KEY_MAX_LENGTH - 9) { + return `${base}-${hash}`; + } + return `${base.slice(0, SAFE_PROFILE_KEY_MAX_LENGTH - 9)}-${hash}`; +} + +function defaultSchemeForInput(input: string): "http" | "https" { + const hostCandidate = input.split(/[/?#]/, 1)[0] ?? input; + const hostname = hostCandidate.startsWith("[") + ? hostCandidate.slice(0, hostCandidate.indexOf("]") + 1) + : (hostCandidate.split(":")[0] ?? hostCandidate); + + return isLoopbackHostname(hostname) || + isPrivateNetworkHostname(hostname) || + isLinkLocalHostname(hostname) + ? "http" + : "https"; +} + +function stableBrowserKeyHash(input: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(36).padStart(7, "0").slice(0, 7); +} + +export interface BrowserSurfaceBoundsInput { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly deviceScaleFactor?: number | undefined; +} + +export interface BrowserSurfaceBoundsRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** Reconcile renderer-reported bounds with the native window scale factor for Electron setBounds. */ +export function resolveElectronSurfaceBounds( + bounds: BrowserSurfaceBoundsInput, + nativeDeviceScaleFactor: number, +): BrowserSurfaceBoundsRect | null { + const reportedScale = + bounds.deviceScaleFactor !== undefined && bounds.deviceScaleFactor > 0 + ? bounds.deviceScaleFactor + : 1; + const nativeScale = nativeDeviceScaleFactor > 0 ? nativeDeviceScaleFactor : 1; + const ratio = nativeScale / reportedScale; + const x = Math.round(bounds.x * ratio); + const y = Math.round(bounds.y * ratio); + const width = Math.max(1, Math.round(bounds.width * ratio)); + const height = Math.max(1, Math.round(bounds.height * ratio)); + + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + + return { x, y, width, height }; +}