feat: agent-browser bridge with full CLI command coverage, tab management, and CDP proxy

- Add AgentBrowserBridge class bridging Orca CLI to agent-browser daemon
- Add CdpWsProxy translating Electron's webContents.debugger to CDP WebSocket
- Expose 40+ browser automation commands via CLI (snapshot, click, fill, goto,
  scroll, cookies, viewport, intercept, capture, clipboard, mouse, dialog, etc.)
- Implement per-worktree tab isolation to prevent cross-worktree interference
- Tab create auto-activates new tab so subsequent commands target it
- Tab switch updates per-worktree map (fixes silent switch failure)
- Tab list auto-activates first live tab when nothing is explicitly active
- Expand wait command to support selector, text, URL, load state, JS condition
- Fix error handling: parse stdout JSON on non-zero exit for better error messages
- Grant clipboard-read/clipboard-sanitized-write permissions in session registry
- Add Page.bringToFront and Runtime.evaluate auto-focus in CDP proxy
- Add Target.detachFromTarget, Browser.getVersion interceptions in CDP proxy
- Destroy all agent-browser sessions on app quit
- Bundle agent-browser native binary via electron-builder extraResources
This commit is contained in:
Jinwoo-H 2026-04-19 18:07:40 -04:00
parent 9270efc028
commit 8d8c1f2cbc
24 changed files with 4730 additions and 315 deletions

View file

@ -29,6 +29,10 @@ module.exports = {
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
to: 'agent-browser-win32-x64.exe'
}
]
},
@ -60,6 +64,10 @@ module.exports = {
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
to: 'agent-browser-darwin-${arch}'
}
],
target: [
@ -84,6 +92,10 @@ module.exports = {
{
from: 'resources/linux/bin/orca',
to: 'bin/orca'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
to: 'agent-browser-linux-${arch}'
}
],
target: ['AppImage', 'deb'],

View file

@ -73,6 +73,7 @@
"@xterm/addon-webgl": "^0.19.0",
"@xterm/headless": "^6.0.0",
"@xterm/xterm": "^6.0.0",
"agent-browser": "~0.24.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -99,6 +100,7 @@
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"ws": "^8.20.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
@ -111,6 +113,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260406.1",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^41.1.0",

View file

@ -106,6 +106,9 @@ importers:
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
agent-browser:
specifier: ~0.24.1
version: 0.24.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -184,6 +187,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
ws:
specifier: ^8.20.0
version: 8.20.0
zod:
specifier: ^4.3.6
version: 4.3.6
@ -199,7 +205,7 @@ importers:
version: 1.59.1
'@stablyai/playwright-test':
specifier: ^2.1.13
version: 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
version: 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
@ -215,6 +221,9 @@ importers:
'@types/ssh2':
specifier: ^1.15.5
version: 1.15.5
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260406.1
version: 7.0.0-dev.20260406.1
@ -2205,8 +2214,8 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@stablyai/playwright-base@2.1.13':
resolution: {integrity: sha512-F8lc2qSfNZQ53WeWWDLLZSpu6f2ZCuiVgGP0P0+PGdO9swCKEwV0f+ti7a4MlmgMlHoCsf5tvddXIVpikhPRlQ==}
'@stablyai/playwright-base@2.1.14':
resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==}
engines: {node: '>=18'}
peerDependencies:
'@playwright/test': ^1.52.0
@ -2215,13 +2224,13 @@ packages:
zod:
optional: true
'@stablyai/playwright-test@2.1.13':
resolution: {integrity: sha512-VXy65GukMkIsHtTuYuLhSP3l3YMl21ePTXKI2xLRBCkgzhTLdzat0vHM5TEh7vh58lsxmHlruMFESjcaIeb25g==}
'@stablyai/playwright-test@2.1.14':
resolution: {integrity: sha512-CAyVVnRdsyJg9pbK3Yq5L9lcvEabilFLb2RWeTQybKv7sDkEEqE2t1boXqBt3X6wQO6lsyhUHB9pc10wSwuc4Q==}
peerDependencies:
'@playwright/test': ^1.52.0
'@stablyai/playwright@2.1.13':
resolution: {integrity: sha512-PGE6hR5WTknfbEBz+KvhG9i2gukSYdie0at6SI0CnJPu13NvGBno1N0Fm/AePhtO5Kjn1mMWW5cRiknVP4bOwA==}
'@stablyai/playwright@2.1.14':
resolution: {integrity: sha512-+SkphioOf+o2VWiM3KPm/fFTTjwNHUV5b2ZRPrLMTsW6bwmEvjo2FbVOUobNBqbopQBnntNLd8ZCG2gvw7rwtg==}
peerDependencies:
'@playwright/test': ^1.52.0
@ -2751,6 +2760,9 @@ packages:
'@types/verror@1.10.11':
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@ -2880,6 +2892,10 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agent-browser@0.24.1:
resolution: {integrity: sha512-csWJtYEQow52b+p93zVZfNrcNBwbxGCZDXDMNWl2ij2i0MFKubIzN+icUeX2/NrkZe5iIau8px+HQlxata2oPw==}
hasBin: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@ -6047,6 +6063,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
@ -7897,7 +7925,7 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@stablyai/playwright-base@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
'@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
dependencies:
'@playwright/test': 1.59.1
jpeg-js: 0.4.4
@ -7906,18 +7934,18 @@ snapshots:
optionalDependencies:
zod: 4.3.6
'@stablyai/playwright-test@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
'@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
dependencies:
'@playwright/test': 1.59.1
'@stablyai/playwright': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
'@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
'@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
'@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
transitivePeerDependencies:
- zod
'@stablyai/playwright@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
'@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
dependencies:
'@playwright/test': 1.59.1
'@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
'@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
transitivePeerDependencies:
- zod
@ -8482,6 +8510,10 @@ snapshots:
'@types/verror@1.10.11':
optional: true
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.5.0
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.5.0
@ -8608,6 +8640,8 @@ snapshots:
agent-base@7.1.4: {}
agent-browser@0.24.1: {}
ajv-formats@3.0.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@ -12374,6 +12408,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.20.0: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1

View file

@ -39,6 +39,24 @@ Refs like `@e1`, `@e5` are short identifiers assigned to interactive page elemen
If a ref is stale, the command returns `browser_stale_ref` — re-snapshot and retry.
## Worktree Scoping
Browser commands default to the **current worktree** — only tabs belonging to the agent's worktree are visible and targetable. Tab indices are relative to the filtered tab list.
```bash
# Default: operates on tabs in the current worktree
orca snapshot --json
# Explicitly target all worktrees (cross-worktree access)
orca snapshot --worktree all --json
# Tab indices are relative to the worktree-filtered list
orca tab list --json # Shows tabs [0], [1], [2] for this worktree
orca tab switch --index 1 --json # Switches to tab [1] within this worktree
```
If no tabs are open in the current worktree, commands return `browser_no_tab`.
## Commands
### Navigation
@ -54,6 +72,8 @@ orca reload [--json] # Reload the current page
```bash
orca snapshot [--json] # Accessibility tree snapshot with element refs
orca screenshot [--format <png|jpeg>] [--json] # Viewport screenshot (base64)
orca full-screenshot [--format <png|jpeg>] [--json] # Full-page screenshot (base64)
orca pdf [--json] # Export page as PDF (base64)
```
### Interaction
@ -63,7 +83,15 @@ orca click --element <ref> [--json] # Click an element by ref
orca fill --element <ref> --value <text> [--json] # Clear and fill an input
orca type --input <text> [--json] # Type at current focus (no element targeting)
orca select --element <ref> --value <value> [--json] # Select dropdown option
orca check --element <ref> [--json] # Check a checkbox
orca uncheck --element <ref> [--json] # Uncheck a checkbox
orca scroll --direction <up|down> [--amount <pixels>] [--json] # Scroll viewport
orca hover --element <ref> [--json] # Hover over an element
orca drag --from <ref> --to <ref> [--json] # Drag from one element to another
orca clear --element <ref> [--json] # Clear an input field
orca select-all --element <ref> [--json] # Select all text in an element
orca keypress --key <key> [--json] # Press a key (Enter, Tab, Escape, etc.)
orca upload --element <ref> --files <paths> [--json] # Upload files to a file input
```
### Tab Management
@ -71,6 +99,8 @@ orca scroll --direction <up|down> [--amount <pixels>] [--json] # Scroll viewpor
```bash
orca tab list [--json] # List open browser tabs
orca tab switch --index <n> [--json] # Switch active tab (invalidates refs)
orca tab create [--url <url>] [--json] # Open a new browser tab
orca tab close [--index <n>] [--json] # Close a browser tab
```
### Page Inspection
@ -79,6 +109,60 @@ orca tab switch --index <n> [--json] # Switch active tab (invalidates refs)
orca eval --expression <js> [--json] # Evaluate JS in page context
```
### Cookie Management
```bash
orca cookie get [--url <url>] [--json] # List cookies
orca cookie set --name <n> --value <v> [--domain <d>] [--json] # Set a cookie
orca cookie delete --name <n> [--domain <d>] [--json] # Delete a cookie
```
### Emulation
```bash
orca viewport --width <w> --height <h> [--scale <n>] [--mobile] [--json]
orca geolocation --latitude <lat> --longitude <lng> [--accuracy <m>] [--json]
orca timezone --id <tzId> [--json] # e.g. --id America/New_York
orca locale --locale <loc> [--json] # e.g. --locale fr-FR
orca permissions --grant <list> [--origin <url>] [--json]
```
### Request Interception
```bash
orca intercept enable [--patterns <list>] [--json] # Start intercepting requests
orca intercept disable [--json] # Stop intercepting
orca intercept list [--json] # List paused requests
orca intercept continue --id <id> [--json] # Allow a paused request
orca intercept block --id <id> [--reason <r>] [--json] # Block a paused request
```
### Console / Network Capture
```bash
orca capture start [--json] # Start capturing console + network
orca capture stop [--json] # Stop capturing
orca console [--limit <n>] [--json] # Read captured console entries
orca network [--limit <n>] [--json] # Read captured network entries
```
### Extended Commands (Passthrough)
```bash
orca exec --command "<agent-browser command>" [--json]
```
The `exec` command provides access to agent-browser's full command surface. Useful for commands without typed Orca handlers:
```bash
orca exec --command "dblclick @e3" --json
orca exec --command "get text @e5" --json
orca exec --command "mouse move 100 200" --json
orca exec --command "help" --json # See all available commands
```
**Important:** Do not use `orca exec --command "tab ..."` for tab management. Use `orca tab list/create/close/switch` instead — those operate at the Orca level and keep the UI synchronized.
## `fill` vs `type`
- **`fill`** targets a specific element by ref, clears its value first, then enters text. Use for form fields.
@ -88,16 +172,10 @@ orca eval --expression <js> [--json] # Evaluate JS in page context
| Error Code | Meaning | Recovery |
|-----------|---------|----------|
| `browser_no_tab` | No browser tab is open | Open a tab in the Orca UI, or use `orca tab list` to check |
| `browser_no_tab` | No browser tab is open in this worktree | Open a tab, or use `--worktree all` to check other worktrees |
| `browser_stale_ref` | Ref is invalid (page changed since snapshot) | Run `orca snapshot` to get fresh refs |
| `browser_ref_not_found` | Ref was never assigned (typo or out of range) | Run `orca snapshot` to see available refs |
| `browser_tab_not_found` | Tab index does not exist | Run `orca tab list` to see available tabs |
| `browser_navigation_failed` | URL could not be loaded | Check URL spelling, network connectivity |
| `browser_element_not_interactable` | Element is hidden or disabled | Re-snapshot; the element may have changed state |
| `browser_eval_error` | JavaScript threw an exception | Fix the expression and retry |
| `browser_cdp_error` | Internal browser control error | DevTools may be open — close them and retry |
| `browser_debugger_detached` | Tab was closed | Run `orca tab list` to find remaining tabs |
| `browser_timeout` | Operation timed out | Page may be slow to load; retry or check network |
| `browser_error` | Error from the browser automation engine | Read the message for details; common causes: element not found, navigation timeout, JS error |
## Worked Example
@ -135,4 +213,6 @@ orca snapshot --json
- If you get `browser_stale_ref`, re-snapshot and retry with the new refs.
- Use `orca tab list` before `orca tab switch` to know which tabs exist.
- Use `orca eval` as an escape hatch for interactions not covered by other commands.
- Use `orca exec --command "help"` to discover extended commands.
- Worktree scoping is automatic — you'll only see tabs from your worktree by default.
- For full IDE/worktree/terminal commands, see the `orca-cli` skill.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,462 @@
/* eslint-disable max-lines */
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { execFileMock, webContentsFromIdMock, existsSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
existsSyncMock: vi.fn(() => false)
}))
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
accessSync: vi.fn(),
chmodSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
vi.mock('electron', () => {
return {
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
webContents: { fromId: webContentsFromIdMock }
}
})
const { CdpWsProxyMock } = vi.hoisted(() => {
const instances: unknown[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MockClass = vi.fn().mockImplementation(function (this: any, _wc: unknown) {
this.start = vi.fn(async () => 'ws://127.0.0.1:9222')
this.stop = vi.fn(async () => {})
this.getPort = vi.fn(() => 9222)
instances.push(this)
})
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
})
vi.mock('./cdp-ws-proxy', () => ({
CdpWsProxy: CdpWsProxyMock
}))
vi.mock('./cdp-bridge', () => ({
BrowserError: class BrowserError extends Error {
code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
}))
import { AgentBrowserBridge } from './agent-browser-bridge'
import type { BrowserManager } from './browser-manager'
// Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId
// inside a try/catch. Override the private method to inject our mock.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(AgentBrowserBridge.prototype as any).getWebContents = function (id: number) {
return webContentsFromIdMock(id) ?? null
}
function mockBrowserManager(
tabs: Map<string, number> = new Map([['tab-1', 100]]),
worktrees: Map<string, string> = new Map()
): BrowserManager {
return {
getWebContentsIdByTabId: () => tabs,
getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId),
getGuestWebContentsId: vi.fn(() => null)
} as unknown as BrowserManager
}
function mockWebContents(id: number, url = 'https://example.com', title = 'Example') {
return {
id,
getURL: () => url,
getTitle: () => title,
isDestroyed: () => false,
debugger: {
attach: vi.fn(),
detach: vi.fn(),
sendCommand: vi.fn(),
on: vi.fn(),
removeListener: vi.fn()
}
}
}
function succeedWith(data: unknown): void {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, JSON.stringify({ success: true, data }), '')
})
}
function failWith(error: string): void {
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, JSON.stringify({ success: false, error }), '')
})
}
describe('AgentBrowserBridge', () => {
let bridge: AgentBrowserBridge
beforeEach(() => {
vi.clearAllMocks()
CdpWsProxyMock.instances.length = 0
const wc = mockWebContents(100)
webContentsFromIdMock.mockReturnValue(wc)
bridge = new AgentBrowserBridge(mockBrowserManager())
bridge.setActiveTab(100)
})
// ── Session naming ──
it('uses browserPageId as session name', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
const args = execFileMock.mock.calls[0][1] as string[]
expect(args).toContain('--session')
expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1')
})
// ── --cdp first-use only ──
it('passes --cdp only on first command for a session', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
// Why: calls[0] is stale-session 'close'; find the snapshot call
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(snapshotCall![1]).toContain('--cdp')
const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp')
expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222')
succeedWith({ clicked: '@e1' })
await bridge.click('@e1')
const clickCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('click')
)
expect(clickCall![1]).not.toContain('--cdp')
})
// ── --json always appended ──
it('always appends --json to commands', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect((snapshotCall![1] as string[]).at(-1)).toBe('--json')
})
// ── Output translation ──
it('translates success response to result', async () => {
succeedWith({ snapshot: 'tree output' })
const result = await bridge.snapshot()
expect(result).toEqual({ snapshot: 'tree output' })
})
it('translates error response to BrowserError', async () => {
failWith('Element not found')
await expect(bridge.click('@e1')).rejects.toThrow('Element not found')
})
it('handles malformed JSON from agent-browser', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, 'not json at all', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow()
})
// ── exec passthrough ──
it('strips --cdp and --session from exec commands', async () => {
succeedWith({ output: 'ok' })
await bridge.exec('dblclick @e3 --cdp ws://evil --session hijack')
// Why: find the actual exec call (contains 'dblclick'), not the stale-session close
const execCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('dblclick')
)
const args = execCall![1] as string[]
// The bridge's own --session and --cdp (for session init) are expected.
// Verify the user-injected ones were stripped: no 'ws://evil' or 'hijack'
expect(args.join(' ')).not.toContain('ws://evil')
expect(args.join(' ')).not.toContain('hijack')
expect(args).toContain('dblclick')
expect(args).toContain('@e3')
})
// ── Worktree filtering ──
describe('worktree filtering', () => {
it('returns all tabs when no worktreeId', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
const result = b.tabList()
expect(result.tabs).toHaveLength(2)
})
it('returns only matching worktree tabs', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-2']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
const result = b.tabList('wt-1')
expect(result.tabs).toHaveLength(1)
expect(result.tabs[0].url).toBe('https://a.com')
})
})
// ── Tab switch ──
it('throws on out-of-range tab index', async () => {
await expect(bridge.tabSwitch(99)).rejects.toThrow('Tab index 99 out of range')
})
// ── No tab error ──
it('throws browser_no_tab when no tabs registered', async () => {
const b = new AgentBrowserBridge(mockBrowserManager(new Map()))
await expect(b.snapshot()).rejects.toThrow('No browser tab open')
})
// ── Command queue serialization ──
it('serializes concurrent commands per session', async () => {
const commandCalls: string[][] = []
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
commandCalls.push(args)
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const [r1, r2] = await Promise.all([bridge.snapshot(), bridge.click('@e1')])
expect(r1).toEqual({ ok: true })
expect(r2).toEqual({ ok: true })
// Why: close runs first (stale session cleanup), then commands execute sequentially
const snapshotIdx = commandCalls.findIndex((a) => a.includes('snapshot'))
const clickIdx = commandCalls.findIndex((a) => a.includes('click'))
expect(snapshotIdx).toBeLessThan(clickIdx)
})
// ── Timeout escalation ──
it('destroys session after 3 consecutive timeouts', async () => {
const killedError = Object.assign(new Error('timeout'), { killed: true })
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(killedError, '', '')
}
)
for (let i = 0; i < 3; i++) {
await expect(bridge.snapshot()).rejects.toThrow('timed out')
}
// Session is destroyed — next command should re-create it (new --cdp flag)
succeedWith({ snapshot: 'fresh' })
await bridge.snapshot()
const lastArgs = execFileMock.mock.calls.at(-1)![1] as string[]
expect(lastArgs).toContain('--cdp')
})
// ── Process swap ──
it('destroys session on process swap and re-inits with --cdp', async () => {
const tabs = new Map([['tab-1', 100]])
const mgr = mockBrowserManager(tabs)
const b = new AgentBrowserBridge(mgr)
b.setActiveTab(100)
succeedWith({ snapshot: 'tree' })
await b.snapshot()
// Why: calls[0] is the stale-session 'close'; find the snapshot call with --cdp
const firstSnapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(firstSnapshotCall![1]).toContain('--cdp')
// Simulate process swap: update tab mapping + notify bridge
tabs.set('tab-1', 200)
const newWc = mockWebContents(200)
webContentsFromIdMock.mockReturnValue(newWc)
succeedWith(null) // for the 'close' command in destroySession
await b.onProcessSwap('tab-1', 200)
// Next command should re-init with --cdp since session was destroyed
succeedWith({ snapshot: 'new tree' })
await b.snapshot()
const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
expect(snapshotCalls.length).toBeGreaterThanOrEqual(2)
const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[]
// After process swap + session destroy, the new session must re-init with --cdp
expect(lastSnapshotArgs).toContain('--cdp')
})
// ── Tab close clears active ──
it('clears activeWebContentsId on tab close', async () => {
succeedWith({ snapshot: 'tree' })
await bridge.snapshot()
await bridge.onTabClosed(100)
expect(bridge.getActiveWebContentsId()).toBeNull()
})
// ── tabSwitch success ──
it('switches active tab and returns switched index', async () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const wc1 = mockWebContents(1)
const wc2 = mockWebContents(2)
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(1)
const result = await b.tabSwitch(1)
expect(result).toEqual({ switched: 1 })
expect(b.getActiveWebContentsId()).toBe(2)
})
// ── goto command ──
it('passes url to goto command', async () => {
succeedWith({ url: 'https://example.com', title: 'Example' })
await bridge.goto('https://example.com')
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('goto')
expect(args).toContain('https://example.com')
})
// ── Cookie command arg building ──
it('builds cookie set args with all options', async () => {
succeedWith({ success: true })
await bridge.cookieSet({
name: 'sid',
value: 'abc',
domain: '.example.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Lax',
expires: 1700000000
})
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('cookies')
expect(args).toContain('set')
expect(args).toContain('sid')
expect(args).toContain('abc')
expect(args).toContain('--domain')
expect(args).toContain('.example.com')
expect(args).toContain('--path')
expect(args).toContain('/')
expect(args).toContain('--secure')
expect(args).toContain('--httpOnly')
expect(args).toContain('--sameSite')
expect(args).toContain('Lax')
expect(args).toContain('--expires')
expect(args).toContain('1700000000')
})
// ── Viewport command arg building ──
it('builds viewport args with scale', async () => {
succeedWith({ width: 375, height: 812, mobile: true })
await bridge.setViewport(375, 812, 2, true)
// Why: calls[0] is the stale-session 'close'; the actual command is the last call
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('set')
expect(args).toContain('viewport')
expect(args).toContain('375')
expect(args).toContain('812')
expect(args).toContain('2')
})
// ── Stderr passthrough on non-timeout errors ──
it('passes stderr through as error message on execFile failure', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(new Error('exit code 1'), '', 'daemon crashed: segfault')
}
)
await expect(bridge.snapshot()).rejects.toThrow('daemon crashed: segfault')
})
it('falls back to error.message when stderr is empty', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(new Error('Command failed'), '', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow('Command failed')
})
// ── Malformed JSON returns BrowserError ──
it('returns browser_error with truncated output for malformed JSON', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, 'Error: not json output', '')
}
)
await expect(bridge.snapshot()).rejects.toThrow('Unexpected output from agent-browser')
})
// ── destroyAllSessions ──
it('destroys all active sessions', async () => {
succeedWith({ snapshot: 'tree' })
await bridge.snapshot()
// Should have one session now
succeedWith(null) // for the 'close' call
await bridge.destroyAllSessions()
// Next command should re-create session with --cdp
succeedWith({ snapshot: 'fresh' })
await bridge.snapshot()
const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) =>
(c[1] as string[]).includes('snapshot')
)
const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[]
expect(lastSnapshotArgs).toContain('--cdp')
})
})

File diff suppressed because it is too large Load diff

View file

@ -38,6 +38,7 @@ export type BrowserGuestRegistration = {
browserPageId?: string
browserTabId?: string
workspaceId?: string
worktreeId?: string
webContentsId: number
rendererWebContentsId: number
}
@ -80,6 +81,7 @@ export class BrowserManager {
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly policyCleanupByGuestId = new Map<number, () => void>()
private readonly pendingLoadFailuresByGuestId = new Map<
@ -192,6 +194,7 @@ export class BrowserManager {
registerGuest({
browserPageId,
browserTabId: legacyBrowserTabId,
worktreeId,
webContentsId,
rendererWebContentsId
}: BrowserGuestRegistration): void {
@ -234,6 +237,9 @@ export class BrowserManager {
this.webContentsIdByTabId.set(browserTabId, webContentsId)
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
if (worktreeId) {
this.worktreeIdByTabId.set(browserTabId, worktreeId)
}
this.setupContextMenu(browserTabId, guest)
this.setupGrabShortcut(browserTabId, guest)
@ -292,6 +298,7 @@ export class BrowserManager {
}
this.webContentsIdByTabId.delete(browserTabId)
this.rendererWebContentsIdByTabId.delete(browserTabId)
this.worktreeIdByTabId.delete(browserTabId)
}
unregisterAll(): void {
@ -313,6 +320,7 @@ export class BrowserManager {
}
this.policyCleanupByGuestId.clear()
this.tabIdByWebContentsId.clear()
this.worktreeIdByTabId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.pendingPermissionEventsByGuestId.clear()
this.pendingPopupEventsByGuestId.clear()
@ -323,6 +331,14 @@ export class BrowserManager {
return this.webContentsIdByTabId.get(browserTabId) ?? null
}
getWebContentsIdByTabId(): Map<string, number> {
return this.webContentsIdByTabId
}
getWorktreeIdForTab(browserTabId: string): string | undefined {
return this.worktreeIdByTabId.get(browserTabId)
}
notifyPermissionDenied(args: {
guestWebContentsId: number
permission: string

View file

@ -373,8 +373,12 @@ class BrowserSessionRegistry {
this.configuredPartitions.add(partition)
const sess = session.fromPartition(partition)
// Why: clipboard-read and clipboard-sanitized-write are required for agent-browser's
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
// throws NotAllowedError even when invoked via CDP with userGesture:true.
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
sess.setPermissionRequestHandler((webContents, permission, callback) => {
const allowed = permission === 'fullscreen'
const allowed = autoGranted.has(permission)
if (!allowed) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
@ -385,7 +389,7 @@ class BrowserSessionRegistry {
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission) => {
return permission === 'fullscreen'
return autoGranted.has(permission)
})
sess.setDisplayMediaRequestHandler((_request, callback) => {
callback({ video: undefined, audio: undefined })

View file

@ -261,7 +261,8 @@ describe('Browser automation pipeline (integration)', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-'))
const runtime = new OrcaRuntimeService()
runtime.setCdpBridge(cdpBridge)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runtime.setAgentBrowserBridge(cdpBridge as any)
server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
@ -508,7 +509,8 @@ describe('Browser automation pipeline (integration)', () => {
const userDataPath2 = mkdtempSync(join(tmpdir(), 'browser-e2e-empty-'))
const runtime2 = new OrcaRuntimeService()
runtime2.setCdpBridge(emptyBridge)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runtime2.setAgentBrowserBridge(emptyBridge as any)
const server2 = new OrcaRuntimeRpcServer({ runtime: runtime2, userDataPath: userDataPath2 })
await server2.start()

View file

@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import WebSocket from 'ws'
import { CdpWsProxy } from './cdp-ws-proxy'
vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
type DebuggerListener = (...args: unknown[]) => void
function createMockWebContents() {
const listeners = new Map<string, DebuggerListener[]>()
const debuggerObj = {
attach: vi.fn(),
detach: vi.fn(),
sendCommand: vi.fn(async () => ({})),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
listeners.set(event, arr)
}),
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
listeners.set(
event,
arr.filter((h) => h !== handler)
)
})
}
return {
webContents: {
debugger: debuggerObj,
isDestroyed: () => false
},
listeners,
emit(event: string, ...args: unknown[]) {
for (const handler of listeners.get(event) ?? []) {
handler(...args)
}
}
}
}
describe('CdpWsProxy', () => {
let mock: ReturnType<typeof createMockWebContents>
let proxy: CdpWsProxy
let endpoint: string
beforeEach(async () => {
mock = createMockWebContents()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
proxy = new CdpWsProxy(mock.webContents as any)
endpoint = await proxy.start()
})
afterEach(async () => {
await proxy.stop()
})
function connect(): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
}
function sendAndReceive(
ws: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
ws.send(JSON.stringify(msg))
})
}
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
expect(proxy.getPort()).toBeGreaterThan(0)
})
it('attaches debugger on start', () => {
expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
})
// ── CDP message ID correlation ──
it('correlates CDP request/response IDs', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
const ws = connect()
const client = await ws
const response = await sendAndReceive(client, {
id: 42,
method: 'Accessibility.getFullAXTree',
params: {}
})
expect(response.id).toBe(42)
expect(response.result).toEqual({ tree: 'nodes' })
client.close()
})
it('returns error response when sendCommand fails', async () => {
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
const client = await connect()
const response = await sendAndReceive(client, {
id: 7,
method: 'DOM.describeNode',
params: { nodeId: 999 }
})
expect(response.id).toBe(7)
expect(response.error).toEqual({ code: -32000, message: 'Node not found' })
client.close()
})
// ── Concurrent requests get correct responses ──
it('handles concurrent requests with correct correlation', async () => {
let resolveFirst: (v: unknown) => void
const firstPromise = new Promise((r) => {
resolveFirst = r
})
mock.webContents.debugger.sendCommand
.mockImplementationOnce(async () => {
await firstPromise
return { result: 'slow' }
})
.mockResolvedValueOnce({ result: 'fast' })
const client = await connect()
const responses: Record<string, unknown>[] = []
client.on('message', (data) => {
responses.push(JSON.parse(data.toString()))
})
client.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
await new Promise((r) => setTimeout(r, 10))
client.send(JSON.stringify({ id: 2, method: 'Page.enable', params: {} }))
await new Promise((r) => setTimeout(r, 20))
resolveFirst!(undefined)
await new Promise((r) => setTimeout(r, 20))
expect(responses).toHaveLength(2)
const resp1 = responses.find((r) => r.id === 1)
const resp2 = responses.find((r) => r.id === 2)
expect(resp1?.result).toEqual({ result: 'slow' })
expect(resp2?.result).toEqual({ result: 'fast' })
client.close()
})
// ── sessionId envelope translation ──
it('forwards sessionId to sendCommand for OOPIF support', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
const client = await connect()
await sendAndReceive(client, {
id: 1,
method: 'DOM.enable',
params: {},
sessionId: 'oopif-session-123'
})
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith(
'DOM.enable',
{},
'oopif-session-123'
)
client.close()
})
// ── Event forwarding ──
it('forwards CDP events from debugger to client', async () => {
const client = await connect()
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
})
mock.emit('message', {}, 'Console.messageAdded', { entry: { text: 'hello' } })
const event = await eventPromise
expect(event.method).toBe('Console.messageAdded')
expect(event.params).toEqual({ entry: { text: 'hello' } })
client.close()
})
it('forwards sessionId in events when present', async () => {
const client = await connect()
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
})
mock.emit('message', {}, 'DOM.nodeInserted', { node: {} }, 'iframe-session-456')
const event = await eventPromise
expect(event.sessionId).toBe('iframe-session-456')
client.close()
})
// ── Page.frameNavigated interception ──
// ── Cleanup ──
it('detaches debugger and closes server on stop', async () => {
const client = await connect()
await proxy.stop()
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
expect(proxy.getPort()).toBeGreaterThan(0) // port stays set but server is closed
await new Promise<void>((resolve) => {
client.on('close', () => resolve())
if (client.readyState === WebSocket.CLOSED) {
resolve()
}
})
})
it('rejects inflight requests on stop', async () => {
let resolveCommand: (v: unknown) => void
mock.webContents.debugger.sendCommand.mockImplementation(
() =>
new Promise((r) => {
resolveCommand = r as (v: unknown) => void
})
)
const client = await connect()
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
await new Promise((r) => setTimeout(r, 10))
await proxy.stop()
resolveCommand!({})
client.close()
})
})

View file

@ -0,0 +1,360 @@
import { WebSocketServer, WebSocket } from 'ws'
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
import type { WebContents } from 'electron'
/**
* Per-tab WebSocket proxy bridging agent-browser's CDP client connection
* to Electron's webContents.debugger API.
*
* Not a transparent forwarder handles CDP message ID correlation,
* sessionId envelope translation for OOPIFs, and event forwarding.
*
* Also serves HTTP /json endpoints so agent-browser's target discovery
* only sees the proxied webview (not the host renderer).
*/
export class CdpWsProxy {
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
private client: WebSocket | null = null
private port = 0
private nextId = 1
private readonly inflight = new Map<
number,
{ clientId: number; resolve: (v: unknown) => void; reject: (e: Error) => void }
>()
private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null
private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null
private attached = false
// Why: when agent-browser attaches via Target.attachToTarget, it expects all events
// to carry the returned sessionId. We track it here so the event forwarder can tag
// events with the correct sessionId that agent-browser filters on.
private clientSessionId: string | undefined = undefined
constructor(private readonly webContents: WebContents) {}
async start(): Promise<string> {
await this.attachDebugger()
return new Promise<string>((resolve, reject) => {
this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res))
this.wss = new WebSocketServer({ server: this.httpServer })
this.wss.on('connection', (ws) => {
// Single-client: replace any previous connection
if (this.client) {
this.client.close()
}
this.client = ws
ws.on('message', (data) => {
this.handleClientMessage(data.toString())
})
ws.on('close', () => {
if (this.client === ws) {
this.client = null
}
})
})
this.httpServer.listen(0, '127.0.0.1', () => {
const addr = this.httpServer!.address()
if (typeof addr === 'object' && addr) {
this.port = addr.port
resolve(`ws://127.0.0.1:${this.port}`)
} else {
reject(new Error('Failed to bind proxy server'))
}
})
this.httpServer.on('error', reject)
})
}
async stop(): Promise<void> {
this.detachDebugger()
if (this.client) {
this.client.close()
this.client = null
}
if (this.wss) {
this.wss.close()
this.wss = null
}
if (this.httpServer) {
this.httpServer.close()
this.httpServer = null
}
for (const { reject } of this.inflight.values()) {
reject(new Error('Proxy stopped'))
}
this.inflight.clear()
}
getPort(): number {
return this.port
}
// Why: agent-browser (and Playwright) discover CDP targets via HTTP before
// connecting the WebSocket. Serving /json with only the proxied webview
// ensures agent-browser attaches to the correct target instead of picking
// the Orca renderer page.
private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
const url = req.url ?? ''
if (url === '/json/version' || url === '/json/version/') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
Browser: 'Orca/CdpWsProxy',
'Protocol-Version': '1.3',
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
})
)
return
}
if (url === '/json' || url === '/json/' || url === '/json/list' || url === '/json/list/') {
const pageUrl = this.webContents.isDestroyed() ? '' : this.webContents.getURL()
const pageTitle = this.webContents.isDestroyed() ? '' : this.webContents.getTitle()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify([
{
id: 'orca-proxy-target',
type: 'page',
title: pageTitle,
url: pageUrl,
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
}
])
)
return
}
res.writeHead(404)
res.end()
}
private async attachDebugger(): Promise<void> {
if (this.attached) {
return
}
try {
this.webContents.debugger.attach('1.3')
} catch {
throw new Error('Could not attach debugger. DevTools may already be open for this tab.')
}
this.attached = true
this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => {
const [method, params, sessionId] = rest as [
string,
Record<string, unknown>,
string | undefined
]
if (!this.client || this.client.readyState !== WebSocket.OPEN) {
return
}
// Why: events from the root debugger session have no sessionId, but agent-browser
// expects them tagged with the sessionId returned from Target.attachToTarget.
// Without this, agent-browser drops events and commands like goto hang forever.
const msg: Record<string, unknown> = { method, params }
if (sessionId) {
msg.sessionId = sessionId
} else if (this.clientSessionId) {
msg.sessionId = this.clientSessionId
}
this.client.send(JSON.stringify(msg))
}
this.debuggerDetachHandler = () => {
this.attached = false
this.stop()
}
this.webContents.debugger.on('message', this.debuggerMessageHandler as never)
this.webContents.debugger.on('detach', this.debuggerDetachHandler as never)
}
private detachDebugger(): void {
if (this.debuggerMessageHandler) {
this.webContents.debugger.removeListener('message', this.debuggerMessageHandler as never)
this.debuggerMessageHandler = null
}
if (this.debuggerDetachHandler) {
this.webContents.debugger.removeListener('detach', this.debuggerDetachHandler as never)
this.debuggerDetachHandler = null
}
if (this.attached) {
try {
this.webContents.debugger.detach()
} catch {
// Already detached
}
this.attached = false
}
}
private handleClientMessage(raw: string): void {
let msg: { id?: number; method?: string; params?: Record<string, unknown>; sessionId?: string }
try {
msg = JSON.parse(raw)
} catch {
return
}
if (msg.id == null || !msg.method) {
return
}
const clientId = msg.id
// Why: Target.getTargets() is browser-level — even when the debugger is attached
// to the webview, it returns ALL targets (including the Orca renderer). Intercept
// and return only the proxied webview so agent-browser doesn't discover/switch to
// the wrong target.
if (msg.method === 'Target.getTargets') {
const targetInfo = {
targetId: 'orca-proxy-target',
type: 'page',
title: this.webContents.isDestroyed() ? '' : this.webContents.getTitle(),
url: this.webContents.isDestroyed() ? '' : this.webContents.getURL(),
attached: true,
canAccessOpener: false
}
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result: { targetInfos: [targetInfo] } }))
}
return
}
// Why: Target.setDiscoverTargets would cause the proxy to emit Target.targetCreated
// events for all browser targets. Acknowledge it without forwarding.
if (msg.method === 'Target.setDiscoverTargets') {
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result: {} }))
}
return
}
// Why: agent-browser calls attachToTarget after discovering our synthetic target ID.
// Since the proxy WebSocket is already directly connected to the webview's debugger,
// no real attachment is needed. Return a synthetic sessionId so agent-browser can
// correlate events. We tag forwarded events with this same sessionId.
if (msg.method === 'Target.attachToTarget') {
this.clientSessionId = 'orca-proxy-session'
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(
JSON.stringify({ id: clientId, result: { sessionId: this.clientSessionId } })
)
}
return
}
// Why: Target.getTargetInfo returns info for a single target — return our synthetic
// target consistent with getTargets to prevent agent-browser from seeing other targets.
if (msg.method === 'Target.getTargetInfo') {
const targetInfo = {
targetId: 'orca-proxy-target',
type: 'page',
title: this.webContents.isDestroyed() ? '' : this.webContents.getTitle(),
url: this.webContents.isDestroyed() ? '' : this.webContents.getURL(),
attached: true,
canAccessOpener: false
}
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result: { targetInfo } }))
}
return
}
// Why: agent-browser may call detachFromTarget during cleanup. Since our attachment
// is synthetic, acknowledge without forwarding.
if (msg.method === 'Target.detachFromTarget') {
this.clientSessionId = undefined
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result: {} }))
}
return
}
// Why: Browser.getVersion is browser-level and would fail through Electron's
// per-tab debugger. Return a synthetic response matching Chrome's format.
if (msg.method === 'Browser.getVersion') {
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(
JSON.stringify({
id: clientId,
result: {
protocolVersion: '1.3',
product: 'Orca/Electron',
userAgent: '',
jsVersion: ''
}
})
)
}
return
}
// Why: Page.bringToFront gives document focus which is required for
// navigator.clipboard API. Electron's debugger doesn't handle this —
// we must call webContents.focus() at the native level.
if (msg.method === 'Page.bringToFront') {
if (!this.webContents.isDestroyed()) {
this.webContents.focus()
}
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result: {} }))
}
return
}
// Why: focus-dependent APIs (navigator.clipboard, etc.) require document.hasFocus()
// to return true. In Electron, the webview doesn't automatically have focus since
// it's a background guest. Ensure focus before any JS evaluation so clipboard and
// similar APIs work without the "Document is not focused" error.
if (msg.method === 'Runtime.evaluate' && !this.webContents.isDestroyed()) {
this.webContents.focus()
}
const internalId = this.nextId++
// Why: our Target.attachToTarget intercept returns a synthetic sessionId so
// agent-browser includes it in all subsequent commands. Electron only knows about
// real sessions — strip the synthetic one so commands route to the root session.
const sessionId =
msg.sessionId && msg.sessionId !== this.clientSessionId ? msg.sessionId : undefined
this.webContents.debugger
.sendCommand(msg.method, msg.params ?? {}, sessionId)
.then((result) => {
this.inflight.delete(internalId)
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify({ id: clientId, result }))
}
})
.catch((err: Error) => {
this.inflight.delete(internalId)
if (this.client?.readyState === WebSocket.OPEN) {
this.client.send(
JSON.stringify({
id: clientId,
error: { code: -32000, message: err.message }
})
)
}
})
this.inflight.set(internalId, {
clientId,
resolve: () => {},
reject: () => {}
})
}
}

View file

@ -35,7 +35,7 @@ import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { openCodeHookService } from './opencode/hook-service'
import { StarNagService } from './star-nag/service'
import { CdpBridge } from './browser/cdp-bridge'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
let mainWindow: BrowserWindow | null = null
@ -160,7 +160,7 @@ app.whenReady().then(async () => {
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()
runtime.setCdpBridge(new CdpBridge(browserManager))
runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager))
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
registerAppMenu({
onCheckForUpdates: () => checkForUpdatesFromMenu(),
@ -268,6 +268,9 @@ app.on('will-quit', () => {
openCodeHookService.stop()
starNag?.stop()
stats?.flush()
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
// holding ports and leaving stale session state on disk.
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
killAllPty()
// Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app
// quit) but the client connection must be closed so sockets are released.

View file

@ -2,7 +2,7 @@
trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import type { CdpBridge } from '../browser/cdp-bridge'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import {
pickCookieFile,
@ -29,14 +29,35 @@ import type {
} from '../../shared/types'
let trustedBrowserRendererWebContentsId: number | null = null
let cdpBridgeRef: CdpBridge | null = null
let agentBrowserBridgeRef: AgentBrowserBridge | null = null
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
// subsequent commands. This map holds one-shot resolvers keyed by browserPageId.
const pendingTabRegistrations = new Map<string, () => void>()
export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise<void> {
if (browserManager.getGuestWebContentsId(browserPageId) !== null) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
pendingTabRegistrations.delete(browserPageId)
reject(new Error('Tab registration timed out'))
}, timeoutMs)
pendingTabRegistrations.set(browserPageId, () => {
clearTimeout(timer)
resolve()
})
})
}
export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void {
trustedBrowserRendererWebContentsId = webContentsId
}
export function setCdpBridgeRef(bridge: CdpBridge | null): void {
cdpBridgeRef = bridge
export function setAgentBrowserBridgeRef(bridge: AgentBrowserBridge | null): void {
agentBrowserBridgeRef = bridge
}
function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean {
@ -74,24 +95,34 @@ export function registerBrowserHandlers(): void {
ipcMain.handle(
'browser:registerGuest',
(event, args: { browserPageId: string; workspaceId: string; webContentsId: number }) => {
(
event,
args: {
browserPageId: string
workspaceId: string
worktreeId: string
webContentsId: number
}
) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: when Chromium swaps a guest's renderer process (navigation,
// crash recovery), the renderer re-registers the same browserPageId
// with a new webContentsId. If the CDP bridge was tracking the old
// webContentsId as active, update it to the new one so agent commands
// don't target a destroyed surface.
// with a new webContentsId. The bridge must destroy the old session's
// proxy (its webContents is gone) and let the next command recreate it.
const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId)
browserManager.registerGuest({
...args,
rendererWebContentsId: event.sender.id
})
if (cdpBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) {
if (cdpBridgeRef.getActiveWebContentsId() === previousWcId) {
cdpBridgeRef.onTabChanged(args.webContentsId)
}
if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) {
agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId)
}
const pendingResolve = pendingTabRegistrations.get(args.browserPageId)
if (pendingResolve) {
pendingTabRegistrations.delete(args.browserPageId)
pendingResolve()
}
return true
}
@ -101,30 +132,29 @@ export function registerBrowserHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: notify CDP bridge before unregistering so it can clean up debugger
// state and ref maps for the closing tab. Must happen before unregisterGuest
// clears the webContentsId mapping.
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
if (wcId !== null && cdpBridgeRef) {
cdpBridgeRef.onTabClosed(wcId)
if (wcId !== null && agentBrowserBridgeRef) {
agentBrowserBridgeRef.onTabClosed(wcId)
}
browserManager.unregisterGuest(args.browserPageId)
return true
})
// Why: keeps the CDP bridge's active tab in sync with the renderer's UI state.
// Why: keeps the bridge's active tab in sync with the renderer's UI state.
// Without this, a user switching tabs in the UI would leave the agent operating
// on the previous tab, which is confusing.
ipcMain.handle('browser:activeTabChanged', (event, args: { browserPageId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
if (!cdpBridgeRef) {
if (!agentBrowserBridgeRef) {
return false
}
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
if (wcId !== null) {
cdpBridgeRef.onTabChanged(wcId)
agentBrowserBridgeRef.onTabChanged(wcId)
}
return true
})

View file

@ -20,7 +20,7 @@ const {
registerUpdaterHandlersMock,
registerRateLimitHandlersMock,
registerBrowserHandlersMock,
setCdpBridgeRefMock,
setAgentBrowserBridgeRefMock,
setTrustedBrowserRendererWebContentsIdMock,
registerFilesystemWatcherHandlersMock,
registerAppHandlersMock
@ -44,7 +44,7 @@ const {
registerUpdaterHandlersMock: vi.fn(),
registerRateLimitHandlersMock: vi.fn(),
registerBrowserHandlersMock: vi.fn(),
setCdpBridgeRefMock: vi.fn(),
setAgentBrowserBridgeRefMock: vi.fn(),
setTrustedBrowserRendererWebContentsIdMock: vi.fn(),
registerFilesystemWatcherHandlersMock: vi.fn(),
registerAppHandlersMock: vi.fn()
@ -126,7 +126,7 @@ vi.mock('../window/attach-main-window-services', () => ({
vi.mock('./browser', () => ({
registerBrowserHandlers: registerBrowserHandlersMock,
setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock,
setCdpBridgeRef: setCdpBridgeRefMock
setAgentBrowserBridgeRef: setAgentBrowserBridgeRefMock
}))
vi.mock('./app', () => ({
@ -156,7 +156,7 @@ describe('registerCoreHandlers', () => {
registerUpdaterHandlersMock.mockReset()
registerRateLimitHandlersMock.mockReset()
registerBrowserHandlersMock.mockReset()
setCdpBridgeRefMock.mockReset()
setAgentBrowserBridgeRefMock.mockReset()
setTrustedBrowserRendererWebContentsIdMock.mockReset()
registerFilesystemWatcherHandlersMock.mockReset()
registerAppHandlersMock.mockReset()
@ -164,7 +164,7 @@ describe('registerCoreHandlers', () => {
it('passes the store through to handler registrars that need it', () => {
const store = { marker: 'store' }
const runtime = { marker: 'runtime', getCdpBridge: () => null }
const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null }
const stats = { marker: 'stats' }
const claudeUsage = { marker: 'claudeUsage' }
const codexUsage = { marker: 'codexUsage' }
@ -208,7 +208,7 @@ describe('registerCoreHandlers', () => {
// The first test already called registerCoreHandlers, so the module-level
// guard is now set. beforeEach reset all mocks, so call counts are 0.
const store2 = { marker: 'store2' }
const runtime2 = { marker: 'runtime2', getCdpBridge: () => null }
const runtime2 = { marker: 'runtime2', getAgentBrowserBridge: () => null }
const stats2 = { marker: 'stats2' }
const claudeUsage2 = { marker: 'claudeUsage2' }
const codexUsage2 = { marker: 'codexUsage2' }

View file

@ -14,7 +14,7 @@ import { registerStatsHandlers } from './stats'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerNotificationHandlers } from './notifications'
import { setTrustedBrowserRendererWebContentsId, setCdpBridgeRef } from './browser'
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerBrowserHandlers } from './browser'
@ -49,7 +49,7 @@ export function registerCoreHandlers(
// if a channel is registered twice, so we guard to register only once and
// just update the per-window web-contents ID on subsequent calls.
setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId)
setCdpBridgeRef(runtime.getCdpBridge())
setAgentBrowserBridgeRef(runtime.getAgentBrowserBridge())
if (registered) {
return
}

View file

@ -57,7 +57,6 @@ import type {
BrowserPermissionResult,
BrowserInterceptEnableResult,
BrowserInterceptDisableResult,
BrowserInterceptedRequest,
BrowserInterceptContinueResult,
BrowserInterceptBlockResult,
BrowserCaptureStartResult,
@ -65,7 +64,9 @@ import type {
BrowserConsoleResult,
BrowserNetworkLogResult
} from '../../shared/runtime-types'
import type { CdpBridge } from '../browser/cdp-bridge'
import { BrowserWindow, ipcMain } from 'electron'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { waitForTabRegistration } from '../ipc/browser'
import { getPRForBranch } from '../github/client'
import {
getGitUsername,
@ -190,7 +191,7 @@ export class OrcaRuntimeService {
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
private ptyController: RuntimePtyController | null = null
private notifier: RuntimeNotifier | null = null
private cdpBridge: CdpBridge | null = null
private agentBrowserBridge: AgentBrowserBridge | null = null
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
private agentDetector: AgentDetector | null = null
@ -231,12 +232,12 @@ export class OrcaRuntimeService {
this.notifier = notifier
}
setCdpBridge(bridge: CdpBridge | null): void {
this.cdpBridge = bridge
setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void {
this.agentBrowserBridge = bridge
}
getCdpBridge(): CdpBridge | null {
return this.cdpBridge
getAgentBrowserBridge(): AgentBrowserBridge | null {
return this.agentBrowserBridge
}
attachWindow(windowId: number): void {
@ -1162,118 +1163,205 @@ export class OrcaRuntimeService {
// ── Browser automation ──
private requireCdpBridge(): CdpBridge {
if (!this.cdpBridge) {
private requireAgentBrowserBridge(): AgentBrowserBridge {
if (!this.agentBrowserBridge) {
throw new Error('runtime_unavailable')
}
return this.cdpBridge
return this.agentBrowserBridge
}
async browserSnapshot(): Promise<BrowserSnapshotResult> {
return this.requireCdpBridge().snapshot()
// Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the
// bridge stores worktreeIds in "repoId::path" format (from the renderer's
// Zustand store). This helper resolves the selector to the store-compatible
// ID so the bridge can filter tabs correctly.
private async resolveBrowserWorktreeId(selector?: string): Promise<string | undefined> {
if (!selector) {
return undefined
}
try {
return (await this.resolveWorktreeSelector(selector)).id
} catch {
return undefined
}
}
async browserClick(params: { element: string }): Promise<BrowserClickResult> {
return this.requireCdpBridge().click(params.element)
async browserSnapshot(params: { worktree?: string }): Promise<BrowserSnapshotResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().snapshot(worktreeId)
}
async browserGoto(params: { url: string }): Promise<BrowserGotoResult> {
return this.requireCdpBridge().goto(params.url)
async browserClick(params: { element: string; worktree?: string }): Promise<BrowserClickResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().click(params.element, worktreeId)
}
async browserFill(params: { element: string; value: string }): Promise<BrowserFillResult> {
return this.requireCdpBridge().fill(params.element, params.value)
async browserGoto(params: { url: string; worktree?: string }): Promise<BrowserGotoResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().goto(params.url, worktreeId)
}
async browserType(params: { input: string }): Promise<BrowserTypeResult> {
return this.requireCdpBridge().type(params.input)
async browserFill(params: {
element: string
value: string
worktree?: string
}): Promise<BrowserFillResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().fill(params.element, params.value, worktreeId)
}
async browserSelect(params: { element: string; value: string }): Promise<BrowserSelectResult> {
return this.requireCdpBridge().select(params.element, params.value)
async browserType(params: { input: string; worktree?: string }): Promise<BrowserTypeResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().type(params.input, worktreeId)
}
async browserSelect(params: {
element: string
value: string
worktree?: string
}): Promise<BrowserSelectResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().select(params.element, params.value, worktreeId)
}
async browserScroll(params: {
direction: 'up' | 'down'
amount?: number
worktree?: string
}): Promise<BrowserScrollResult> {
return this.requireCdpBridge().scroll(params.direction, params.amount)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().scroll(params.direction, params.amount, worktreeId)
}
async browserBack(): Promise<BrowserBackResult> {
return this.requireCdpBridge().back()
async browserBack(params: { worktree?: string }): Promise<BrowserBackResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().back(worktreeId)
}
async browserReload(): Promise<BrowserReloadResult> {
return this.requireCdpBridge().reload()
async browserReload(params: { worktree?: string }): Promise<BrowserReloadResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().reload(worktreeId)
}
async browserScreenshot(params: { format?: 'png' | 'jpeg' }): Promise<BrowserScreenshotResult> {
return this.requireCdpBridge().screenshot(params.format)
async browserScreenshot(params: {
format?: 'png' | 'jpeg'
worktree?: string
}): Promise<BrowserScreenshotResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().screenshot(params.format, worktreeId)
}
async browserEval(params: { expression: string }): Promise<BrowserEvalResult> {
return this.requireCdpBridge().evaluate(params.expression)
async browserEval(params: { expression: string; worktree?: string }): Promise<BrowserEvalResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().evaluate(params.expression, worktreeId)
}
browserTabList(): BrowserTabListResult {
return this.requireCdpBridge().tabList()
async browserTabList(params: { worktree?: string }): Promise<BrowserTabListResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().tabList(worktreeId)
}
async browserTabSwitch(params: { index: number }): Promise<BrowserTabSwitchResult> {
return this.requireCdpBridge().tabSwitch(params.index)
async browserTabSwitch(params: {
index: number
worktree?: string
}): Promise<BrowserTabSwitchResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().tabSwitch(params.index, worktreeId)
}
async browserHover(params: { element: string }): Promise<BrowserHoverResult> {
return this.requireCdpBridge().hover(params.element)
async browserHover(params: { element: string; worktree?: string }): Promise<BrowserHoverResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().hover(params.element, worktreeId)
}
async browserDrag(params: { from: string; to: string }): Promise<BrowserDragResult> {
return this.requireCdpBridge().drag(params.from, params.to)
async browserDrag(params: {
from: string
to: string
worktree?: string
}): Promise<BrowserDragResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().drag(params.from, params.to, worktreeId)
}
async browserUpload(params: { element: string; files: string[] }): Promise<BrowserUploadResult> {
return this.requireCdpBridge().uploadFile(params.element, params.files)
async browserUpload(params: {
element: string
files: string[]
worktree?: string
}): Promise<BrowserUploadResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().upload(params.element, params.files, worktreeId)
}
async browserWait(params: { timeout?: number }): Promise<BrowserWaitResult> {
return this.requireCdpBridge().wait(params.timeout)
async browserWait(params: {
selector?: string
timeout?: number
text?: string
url?: string
load?: string
fn?: string
state?: string
worktree?: string
}): Promise<BrowserWaitResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
const { worktree: _, ...options } = params
return this.requireAgentBrowserBridge().wait(options, worktreeId)
}
async browserCheck(params: { element: string; checked: boolean }): Promise<BrowserCheckResult> {
return this.requireCdpBridge().check(params.element, params.checked)
async browserCheck(params: {
element: string
checked: boolean
worktree?: string
}): Promise<BrowserCheckResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().check(params.element, params.checked, worktreeId)
}
async browserFocus(params: { element: string }): Promise<BrowserFocusResult> {
return this.requireCdpBridge().focus(params.element)
async browserFocus(params: { element: string; worktree?: string }): Promise<BrowserFocusResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().focus(params.element, worktreeId)
}
async browserClear(params: { element: string }): Promise<BrowserClearResult> {
return this.requireCdpBridge().clear(params.element)
async browserClear(params: { element: string; worktree?: string }): Promise<BrowserClearResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().clear(params.element, worktreeId)
}
async browserSelectAll(params: { element: string }): Promise<BrowserSelectAllResult> {
return this.requireCdpBridge().selectAll(params.element)
async browserSelectAll(params: {
element: string
worktree?: string
}): Promise<BrowserSelectAllResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().selectAll(params.element, worktreeId)
}
async browserKeypress(params: { key: string }): Promise<BrowserKeypressResult> {
return this.requireCdpBridge().keypress(params.key)
async browserKeypress(params: {
key: string
worktree?: string
}): Promise<BrowserKeypressResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().keypress(params.key, worktreeId)
}
async browserPdf(): Promise<BrowserPdfResult> {
return this.requireCdpBridge().pdf()
async browserPdf(params: { worktree?: string }): Promise<BrowserPdfResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().pdf(worktreeId)
}
async browserFullScreenshot(params: {
format?: 'png' | 'jpeg'
worktree?: string
}): Promise<BrowserScreenshotResult> {
return this.requireCdpBridge().fullPageScreenshot(params.format)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().fullPageScreenshot(params.format, worktreeId)
}
// ── Cookie management ──
async browserCookieGet(params: { url?: string }): Promise<BrowserCookieGetResult> {
return this.requireCdpBridge().cookieGet(params.url)
async browserCookieGet(params: {
url?: string
worktree?: string
}): Promise<BrowserCookieGetResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().cookieGet(params.url, worktreeId)
}
async browserCookieSet(params: {
@ -1285,16 +1373,25 @@ export class OrcaRuntimeService {
httpOnly?: boolean
sameSite?: string
expires?: number
worktree?: string
}): Promise<BrowserCookieSetResult> {
return this.requireCdpBridge().cookieSet(params)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().cookieSet(params, worktreeId)
}
async browserCookieDelete(params: {
name: string
domain?: string
url?: string
worktree?: string
}): Promise<BrowserCookieDeleteResult> {
return this.requireCdpBridge().cookieDelete(params.name, params.domain, params.url)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().cookieDelete(
params.name,
params.domain,
params.url,
worktreeId
)
}
// ── Viewport ──
@ -1304,12 +1401,15 @@ export class OrcaRuntimeService {
height: number
deviceScaleFactor?: number
mobile?: boolean
worktree?: string
}): Promise<BrowserViewportResult> {
return this.requireCdpBridge().setViewport(
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setViewport(
params.width,
params.height,
params.deviceScaleFactor,
params.mobile
params.mobile,
worktreeId
)
}
@ -1319,20 +1419,31 @@ export class OrcaRuntimeService {
latitude: number
longitude: number
accuracy?: number
worktree?: string
}): Promise<BrowserGeolocationResult> {
return this.requireCdpBridge().setGeolocation(
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setGeolocation(
params.latitude,
params.longitude,
params.accuracy
params.accuracy,
worktreeId
)
}
async browserSetTimezone(params: { timezoneId: string }): Promise<BrowserTimezoneResult> {
return this.requireCdpBridge().setTimezone(params.timezoneId)
async browserSetTimezone(params: {
timezoneId: string
worktree?: string
}): Promise<BrowserTimezoneResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setTimezone(params.timezoneId, worktreeId)
}
async browserSetLocale(params: { locale: string }): Promise<BrowserLocaleResult> {
return this.requireCdpBridge().setLocale(params.locale)
async browserSetLocale(params: {
locale: string
worktree?: string
}): Promise<BrowserLocaleResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setLocale(params.locale, worktreeId)
}
// ── Permissions ──
@ -1340,55 +1451,433 @@ export class OrcaRuntimeService {
async browserGrantPermissions(params: {
permissions: string[]
origin?: string
worktree?: string
}): Promise<BrowserPermissionResult> {
return this.requireCdpBridge().grantPermissions(params.permissions, params.origin)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().grantPermissions(
params.permissions,
params.origin,
worktreeId
)
}
// ── Request interception ──
async browserInterceptEnable(params: {
patterns?: string[]
worktree?: string
}): Promise<BrowserInterceptEnableResult> {
return this.requireCdpBridge().interceptEnable(params.patterns)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().interceptEnable(params.patterns, worktreeId)
}
async browserInterceptDisable(): Promise<BrowserInterceptDisableResult> {
return this.requireCdpBridge().interceptDisable()
async browserInterceptDisable(params: {
worktree?: string
}): Promise<BrowserInterceptDisableResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().interceptDisable(worktreeId)
}
browserInterceptList(): { requests: BrowserInterceptedRequest[] } {
return this.requireCdpBridge().interceptList()
async browserInterceptList(params: { worktree?: string }): Promise<{ requests: unknown[] }> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().interceptList(worktreeId)
}
async browserInterceptContinue(params: {
requestId: string
worktree?: string
}): Promise<BrowserInterceptContinueResult> {
return this.requireCdpBridge().interceptContinue(params.requestId)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().interceptContinue(params.requestId, worktreeId)
}
async browserInterceptBlock(params: {
requestId: string
reason?: string
worktree?: string
}): Promise<BrowserInterceptBlockResult> {
return this.requireCdpBridge().interceptBlock(params.requestId, params.reason)
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().interceptBlock(
params.requestId,
params.reason,
worktreeId
)
}
// ── Console/network capture ──
async browserCaptureStart(): Promise<BrowserCaptureStartResult> {
return this.requireCdpBridge().captureStart()
async browserCaptureStart(params: { worktree?: string }): Promise<BrowserCaptureStartResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().captureStart(worktreeId)
}
async browserCaptureStop(): Promise<BrowserCaptureStopResult> {
return this.requireCdpBridge().captureStop()
async browserCaptureStop(params: { worktree?: string }): Promise<BrowserCaptureStopResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().captureStop(worktreeId)
}
browserConsoleLog(params: { limit?: number }): BrowserConsoleResult {
return this.requireCdpBridge().consoleLog(params.limit)
async browserConsoleLog(params: {
limit?: number
worktree?: string
}): Promise<BrowserConsoleResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().consoleLog(params.limit, worktreeId)
}
browserNetworkLog(params: { limit?: number }): BrowserNetworkLogResult {
return this.requireCdpBridge().networkLog(params.limit)
async browserNetworkLog(params: {
limit?: number
worktree?: string
}): Promise<BrowserNetworkLogResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().networkLog(params.limit, worktreeId)
}
// ── Additional core commands ──
async browserDblclick(params: { element: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().dblclick(params.element, worktreeId)
}
async browserForward(params: { worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().forward(worktreeId)
}
async browserScrollIntoView(params: { element: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().scrollIntoView(params.element, worktreeId)
}
async browserGet(params: {
what: string
selector?: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().get(params.what, params.selector, worktreeId)
}
async browserIs(params: { what: string; selector: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().is(params.what, params.selector, worktreeId)
}
// ── Keyboard insert text ──
async browserKeyboardInsertText(params: { text: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().keyboardInsertText(params.text, worktreeId)
}
// ── Mouse commands ──
async browserMouseMove(params: { x: number; y: number; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().mouseMove(params.x, params.y, worktreeId)
}
async browserMouseDown(params: { button?: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().mouseDown(params.button, worktreeId)
}
async browserMouseUp(params: { button?: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().mouseUp(params.button, worktreeId)
}
async browserMouseWheel(params: {
dy: number
dx?: number
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().mouseWheel(params.dy, params.dx, worktreeId)
}
// ── Find (semantic locators) ──
async browserFind(params: {
locator: string
value: string
action: string
text?: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().find(
params.locator,
params.value,
params.action,
params.text,
worktreeId
)
}
// ── Set commands ──
async browserSetDevice(params: { name: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setDevice(params.name, worktreeId)
}
async browserSetOffline(params: { state?: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setOffline(params.state, worktreeId)
}
async browserSetHeaders(params: { headers: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setHeaders(params.headers, worktreeId)
}
async browserSetCredentials(params: {
user: string
pass: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setCredentials(params.user, params.pass, worktreeId)
}
async browserSetMedia(params: {
colorScheme?: string
reducedMotion?: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().setMedia(
params.colorScheme,
params.reducedMotion,
worktreeId
)
}
// ── Clipboard commands ──
async browserClipboardRead(params: { worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().clipboardRead(worktreeId)
}
async browserClipboardWrite(params: { text: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().clipboardWrite(params.text, worktreeId)
}
// ── Dialog commands ──
async browserDialogAccept(params: { text?: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().dialogAccept(params.text, worktreeId)
}
async browserDialogDismiss(params: { worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().dialogDismiss(worktreeId)
}
// ── Storage commands ──
async browserStorageLocalGet(params: { key: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageLocalGet(params.key, worktreeId)
}
async browserStorageLocalSet(params: {
key: string
value: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageLocalSet(params.key, params.value, worktreeId)
}
async browserStorageLocalClear(params: { worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageLocalClear(worktreeId)
}
async browserStorageSessionGet(params: { key: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageSessionGet(params.key, worktreeId)
}
async browserStorageSessionSet(params: {
key: string
value: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageSessionSet(params.key, params.value, worktreeId)
}
async browserStorageSessionClear(params: { worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().storageSessionClear(worktreeId)
}
// ── Download command ──
async browserDownload(params: {
selector: string
path: string
worktree?: string
}): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().download(params.selector, params.path, worktreeId)
}
// ── Highlight command ──
async browserHighlight(params: { selector: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().highlight(params.selector, worktreeId)
}
// ── New: exec passthrough + tab lifecycle ──
async browserExec(params: { command: string; worktree?: string }): Promise<unknown> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().exec(params.command, worktreeId)
}
async browserTabCreate(params: {
url?: string
worktree?: string
}): Promise<{ browserPageId: string }> {
const win = this.getAuthoritativeWindow()
const requestId = randomUUID()
const url = params.url ?? 'about:blank'
// Why: the renderer's Zustand store keys browser tabs by worktreeId in
// "repoId::path" format. The CLI sends a selector (e.g. "path:/Users/...").
// Resolve it here so the renderer receives the store-compatible ID.
const worktreeId = params.worktree
? (await this.resolveWorktreeSelector(params.worktree)).id
: undefined
// Why: tab creation is a renderer-side Zustand store operation. The main process
// sends a request, the renderer creates the tab and replies with the workspace ID
// (which is the browserPageId used by registerGuest and the bridge).
const browserPageId = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCreateReply', handler)
reject(new Error('Tab creation timed out'))
}, 10_000)
const handler = (
_event: Electron.IpcMainEvent,
reply: { requestId: string; browserPageId?: string; error?: string }
): void => {
if (reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCreateReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve(reply.browserPageId!)
}
}
ipcMain.on('browser:tabCreateReply', handler)
win.webContents.send('browser:requestTabCreate', { requestId, url, worktreeId })
})
// Why: the renderer creates the Zustand tab immediately, but the webview must
// mount and fire dom-ready before registerGuest runs. Waiting here ensures the
// tab is operable by subsequent CLI commands (snapshot, click, etc.).
// If registration doesn't complete within timeout, return the ID anyway — the
// tab exists in the UI but may not be ready for automation commands yet.
try {
await waitForTabRegistration(browserPageId)
} catch {
// Tab was created in the renderer but the webview hasn't finished mounting.
// Return success since the tab exists; subsequent commands will fail with a
// clear "tab not available" error if the webview never loads.
}
// Why: newly created tabs should be auto-activated so subsequent commands
// (snapshot, click, goto) target the new tab without requiring an explicit
// tab switch. Without this, the bridge's active tab still points at the
// previously active tab and the new tab shows active: false in tab list.
const bridge = this.requireAgentBrowserBridge()
const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId)
if (wcId != null) {
bridge.setActiveTab(wcId, worktreeId)
}
return { browserPageId }
}
async browserTabClose(params: {
index?: number
worktree?: string
}): Promise<{ closed: boolean }> {
const win = this.getAuthoritativeWindow()
const bridge = this.requireAgentBrowserBridge()
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
let tabId: string | null = null
if (params.index !== undefined) {
const tabs = bridge.getRegisteredTabs(worktreeId)
const entries = [...tabs.entries()]
if (params.index < 0 || params.index >= entries.length) {
throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`)
}
tabId = entries[params.index][0]
} else {
// Why: try the bridge first (registered tabs with webviews), then fall back
// to asking the renderer to close its active browser tab (handles cases where
// the webview hasn't mounted yet, e.g. tab was just created).
const tabs = bridge.getRegisteredTabs(worktreeId)
const entries = [...tabs.entries()]
const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId())
if (activeEntry) {
tabId = activeEntry[0]
}
}
const requestId = randomUUID()
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCloseReply', handler)
reject(new Error('Tab close timed out'))
}, 10_000)
const handler = (
_event: Electron.IpcMainEvent,
reply: { requestId: string; error?: string }
): void => {
if (reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCloseReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve()
}
}
ipcMain.on('browser:tabCloseReply', handler)
win.webContents.send('browser:requestTabClose', { requestId, tabId })
})
return { closed: true }
}
private getAuthoritativeWindow(): BrowserWindow {
if (this.authoritativeWindowId === null) {
throw new Error('No renderer window available')
}
const win = BrowserWindow.fromId(this.authoritativeWindowId)
if (!win || win.isDestroyed()) {
throw new Error('No renderer window available')
}
return win
}
}

View file

@ -702,10 +702,13 @@ export class OrcaRuntimeRpcServer {
}
// ── Browser automation routes ──
// Why: all browser routes extract optional worktree param for worktree-scoped tab routing
if (request.method === 'browser.snapshot') {
try {
const result = await this.runtime.browserSnapshot()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSnapshot({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -719,7 +722,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserClick({ element })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserClick({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -733,7 +737,8 @@ export class OrcaRuntimeRpcServer {
if (!url) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --url')
}
const result = await this.runtime.browserGoto({ url })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserGoto({ url, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -751,7 +756,8 @@ export class OrcaRuntimeRpcServer {
if (value === null) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --value')
}
const result = await this.runtime.browserFill({ element, value })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserFill({ element, value, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -765,7 +771,8 @@ export class OrcaRuntimeRpcServer {
if (!input) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --input')
}
const result = await this.runtime.browserType({ input })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserType({ input, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -783,7 +790,8 @@ export class OrcaRuntimeRpcServer {
if (value === null) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --value')
}
const result = await this.runtime.browserSelect({ element, value })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSelect({ element, value, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -803,7 +811,8 @@ export class OrcaRuntimeRpcServer {
}
const amount =
typeof params?.amount === 'number' && params.amount > 0 ? params.amount : undefined
const result = await this.runtime.browserScroll({ direction, amount })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserScroll({ direction, amount, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -812,7 +821,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.back') {
try {
const result = await this.runtime.browserBack()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserBack({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -821,7 +832,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.reload') {
try {
const result = await this.runtime.browserReload()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserReload({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -836,7 +849,8 @@ export class OrcaRuntimeRpcServer {
(params.format === 'png' || params.format === 'jpeg')
? params.format
: undefined
const result = await this.runtime.browserScreenshot({ format })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserScreenshot({ format, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -850,7 +864,8 @@ export class OrcaRuntimeRpcServer {
if (!expression) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --expression')
}
const result = await this.runtime.browserEval({ expression })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserEval({ expression, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -859,7 +874,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.tabList') {
try {
const result = this.runtime.browserTabList()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserTabList({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -877,7 +894,8 @@ export class OrcaRuntimeRpcServer {
'Missing required --index (non-negative integer)'
)
}
const result = await this.runtime.browserTabSwitch({ index })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserTabSwitch({ index, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -891,7 +909,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserHover({ element })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserHover({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -910,7 +929,8 @@ export class OrcaRuntimeRpcServer {
'Missing required --from and --to element refs'
)
}
const result = await this.runtime.browserDrag({ from, to })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserDrag({ from, to, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -929,7 +949,8 @@ export class OrcaRuntimeRpcServer {
'Missing required --element and --files'
)
}
const result = await this.runtime.browserUpload({ element, files })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserUpload({ element, files, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -939,9 +960,25 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.wait') {
try {
const params = this.extractParams(request)
const selector = typeof params?.selector === 'string' ? params.selector : undefined
const raw = typeof params?.timeout === 'number' ? params.timeout : undefined
const timeout = raw !== undefined && raw > 0 ? raw : undefined
const result = await this.runtime.browserWait({ timeout })
const text = typeof params?.text === 'string' ? params.text : undefined
const url = typeof params?.url === 'string' ? params.url : undefined
const load = typeof params?.load === 'string' ? params.load : undefined
const fn = typeof params?.fn === 'string' ? params.fn : undefined
const state = typeof params?.state === 'string' ? params.state : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserWait({
selector,
timeout,
text,
url,
load,
fn,
state,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -956,7 +993,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserCheck({ element, checked })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCheck({ element, checked, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -970,7 +1008,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserFocus({ element })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserFocus({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -984,7 +1023,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserClear({ element })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserClear({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -998,7 +1038,8 @@ export class OrcaRuntimeRpcServer {
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const result = await this.runtime.browserSelectAll({ element })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSelectAll({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1012,7 +1053,8 @@ export class OrcaRuntimeRpcServer {
if (!key) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key')
}
const result = await this.runtime.browserKeypress({ key })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserKeypress({ key, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1021,7 +1063,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.pdf') {
try {
const result = await this.runtime.browserPdf()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserPdf({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1032,7 +1076,8 @@ export class OrcaRuntimeRpcServer {
try {
const params = this.extractParams(request)
const format = params?.format === 'jpeg' ? ('jpeg' as const) : ('png' as const)
const result = await this.runtime.browserFullScreenshot({ format })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserFullScreenshot({ format, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1045,7 +1090,8 @@ export class OrcaRuntimeRpcServer {
try {
const params = this.extractParams(request)
const url = typeof params?.url === 'string' ? params.url : undefined
const result = await this.runtime.browserCookieGet({ url })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCookieGet({ url, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1060,6 +1106,7 @@ export class OrcaRuntimeRpcServer {
if (!name || value === null) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing name or value')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCookieSet({
name,
value,
@ -1068,7 +1115,8 @@ export class OrcaRuntimeRpcServer {
secure: typeof params?.secure === 'boolean' ? params.secure : undefined,
httpOnly: typeof params?.httpOnly === 'boolean' ? params.httpOnly : undefined,
sameSite: typeof params?.sameSite === 'string' ? params.sameSite : undefined,
expires: typeof params?.expires === 'number' ? params.expires : undefined
expires: typeof params?.expires === 'number' ? params.expires : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1083,10 +1131,12 @@ export class OrcaRuntimeRpcServer {
if (!name) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing cookie name')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCookieDelete({
name,
domain: typeof params?.domain === 'string' ? params.domain : undefined,
url: typeof params?.url === 'string' ? params.url : undefined
url: typeof params?.url === 'string' ? params.url : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1108,12 +1158,14 @@ export class OrcaRuntimeRpcServer {
'Width and height must be positive numbers'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetViewport({
width,
height,
deviceScaleFactor:
typeof params?.deviceScaleFactor === 'number' ? params.deviceScaleFactor : undefined,
mobile: typeof params?.mobile === 'boolean' ? params.mobile : undefined
mobile: typeof params?.mobile === 'boolean' ? params.mobile : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1131,10 +1183,12 @@ export class OrcaRuntimeRpcServer {
if (latitude === null || longitude === null) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing latitude or longitude')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetGeolocation({
latitude,
longitude,
accuracy: typeof params?.accuracy === 'number' ? params.accuracy : undefined
accuracy: typeof params?.accuracy === 'number' ? params.accuracy : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1149,7 +1203,8 @@ export class OrcaRuntimeRpcServer {
if (!timezoneId) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing timezoneId')
}
const result = await this.runtime.browserSetTimezone({ timezoneId })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetTimezone({ timezoneId, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1163,7 +1218,8 @@ export class OrcaRuntimeRpcServer {
if (!locale) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing locale')
}
const result = await this.runtime.browserSetLocale({ locale })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetLocale({ locale, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1185,9 +1241,11 @@ export class OrcaRuntimeRpcServer {
'Permissions array must not be empty'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserGrantPermissions({
permissions,
origin: typeof params?.origin === 'string' ? params.origin : undefined
origin: typeof params?.origin === 'string' ? params.origin : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1201,7 +1259,8 @@ export class OrcaRuntimeRpcServer {
try {
const params = this.extractParams(request)
const patterns = Array.isArray(params?.patterns) ? (params.patterns as string[]) : undefined
const result = await this.runtime.browserInterceptEnable({ patterns })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserInterceptEnable({ patterns, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1210,7 +1269,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.intercept.disable') {
try {
const result = await this.runtime.browserInterceptDisable()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserInterceptDisable({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1219,7 +1280,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.intercept.list') {
try {
const result = this.runtime.browserInterceptList()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserInterceptList({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1233,7 +1296,8 @@ export class OrcaRuntimeRpcServer {
if (!requestId) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing requestId')
}
const result = await this.runtime.browserInterceptContinue({ requestId })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserInterceptContinue({ requestId, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1247,9 +1311,11 @@ export class OrcaRuntimeRpcServer {
if (!requestId) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing requestId')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserInterceptBlock({
requestId,
reason: typeof params?.reason === 'string' ? params.reason : undefined
reason: typeof params?.reason === 'string' ? params.reason : undefined,
worktree
})
return this.successResponse(request.id, result)
} catch (error) {
@ -1261,7 +1327,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.capture.start') {
try {
const result = await this.runtime.browserCaptureStart()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCaptureStart({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1270,7 +1338,9 @@ export class OrcaRuntimeRpcServer {
if (request.method === 'browser.capture.stop') {
try {
const result = await this.runtime.browserCaptureStop()
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserCaptureStop({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1281,7 +1351,8 @@ export class OrcaRuntimeRpcServer {
try {
const params = this.extractParams(request)
const limit = typeof params?.limit === 'number' ? params.limit : undefined
const result = this.runtime.browserConsoleLog({ limit })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserConsoleLog({ limit, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
@ -1292,7 +1363,499 @@ export class OrcaRuntimeRpcServer {
try {
const params = this.extractParams(request)
const limit = typeof params?.limit === 'number' ? params.limit : undefined
const result = this.runtime.browserNetworkLog({ limit })
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserNetworkLog({ limit, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Additional core commands ──
if (request.method === 'browser.dblclick') {
try {
const params = this.extractParams(request)
const element = typeof params?.element === 'string' ? params.element : null
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserDblclick({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.forward') {
try {
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserForward({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.scrollIntoView') {
try {
const params = this.extractParams(request)
const element = typeof params?.element === 'string' ? params.element : null
if (!element) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserScrollIntoView({ element, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.get') {
try {
const params = this.extractParams(request)
const what = typeof params?.what === 'string' ? params.what : null
if (!what) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --what')
}
const selector = typeof params?.selector === 'string' ? params.selector : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserGet({ what, selector, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.is') {
try {
const params = this.extractParams(request)
const what = typeof params?.what === 'string' ? params.what : null
const selector = typeof params?.selector === 'string' ? params.selector : null
if (!what || !selector) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --what and --element'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserIs({ what, selector, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Keyboard insert text ──
if (request.method === 'browser.keyboardInsertText') {
try {
const params = this.extractParams(request)
const text = typeof params?.text === 'string' ? params.text : null
if (!text) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --text')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserKeyboardInsertText({ text, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Mouse commands ──
if (request.method === 'browser.mouseMove') {
try {
const params = this.extractParams(request)
const x = typeof params?.x === 'number' ? params.x : null
const y = typeof params?.y === 'number' ? params.y : null
if (x === null || y === null) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required x and y coordinates'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserMouseMove({ x, y, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.mouseDown') {
try {
const params = this.extractParams(request)
const button = typeof params?.button === 'string' ? params.button : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserMouseDown({ button, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.mouseUp') {
try {
const params = this.extractParams(request)
const button = typeof params?.button === 'string' ? params.button : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserMouseUp({ button, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.mouseWheel') {
try {
const params = this.extractParams(request)
const dy = typeof params?.dy === 'number' ? params.dy : null
if (dy === null) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --dy')
}
const dx = typeof params?.dx === 'number' ? params.dx : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserMouseWheel({ dy, dx, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Find (semantic locators) ──
if (request.method === 'browser.find') {
try {
const params = this.extractParams(request)
const locator = typeof params?.locator === 'string' ? params.locator : null
const value = typeof params?.value === 'string' ? params.value : null
const action = typeof params?.action === 'string' ? params.action : null
if (!locator || !value || !action) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --locator, --value, and --action'
)
}
const text = typeof params?.text === 'string' ? params.text : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserFind({ locator, value, action, text, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Set commands ──
if (request.method === 'browser.setDevice') {
try {
const params = this.extractParams(request)
const name = typeof params?.name === 'string' ? params.name : null
if (!name) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --name')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetDevice({ name, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.setOffline') {
try {
const params = this.extractParams(request)
const state = typeof params?.state === 'string' ? params.state : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetOffline({ state, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.setHeaders') {
try {
const params = this.extractParams(request)
const headers = typeof params?.headers === 'string' ? params.headers : null
if (!headers) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --headers (JSON string)'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetHeaders({ headers, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.setCredentials') {
try {
const params = this.extractParams(request)
const user = typeof params?.user === 'string' ? params.user : null
const pass = typeof params?.pass === 'string' ? params.pass : null
if (!user || pass === null) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --user and --pass'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetCredentials({ user, pass, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.setMedia') {
try {
const params = this.extractParams(request)
const colorScheme = typeof params?.colorScheme === 'string' ? params.colorScheme : undefined
const reducedMotion =
typeof params?.reducedMotion === 'string' ? params.reducedMotion : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserSetMedia({ colorScheme, reducedMotion, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Clipboard commands ──
if (request.method === 'browser.clipboardRead') {
try {
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserClipboardRead({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.clipboardWrite') {
try {
const params = this.extractParams(request)
const text = typeof params?.text === 'string' ? params.text : null
if (!text) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --text')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserClipboardWrite({ text, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Dialog commands ──
if (request.method === 'browser.dialogAccept') {
try {
const params = this.extractParams(request)
const text = typeof params?.text === 'string' ? params.text : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserDialogAccept({ text, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.dialogDismiss') {
try {
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserDialogDismiss({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Storage commands ──
if (request.method === 'browser.storage.local.get') {
try {
const params = this.extractParams(request)
const key = typeof params?.key === 'string' ? params.key : null
if (!key) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageLocalGet({ key, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.storage.local.set') {
try {
const params = this.extractParams(request)
const key = typeof params?.key === 'string' ? params.key : null
const value = typeof params?.value === 'string' ? params.value : null
if (!key || value === null) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --key and --value'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageLocalSet({ key, value, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.storage.local.clear') {
try {
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageLocalClear({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.storage.session.get') {
try {
const params = this.extractParams(request)
const key = typeof params?.key === 'string' ? params.key : null
if (!key) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageSessionGet({ key, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.storage.session.set') {
try {
const params = this.extractParams(request)
const key = typeof params?.key === 'string' ? params.key : null
const value = typeof params?.value === 'string' ? params.value : null
if (!key || value === null) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --key and --value'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageSessionSet({ key, value, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.storage.session.clear') {
try {
const params = this.extractParams(request)
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserStorageSessionClear({ worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Download command ──
if (request.method === 'browser.download') {
try {
const params = this.extractParams(request)
const selector = typeof params?.selector === 'string' ? params.selector : null
const path = typeof params?.path === 'string' ? params.path : null
if (!selector || !path) {
return this.errorResponse(
request.id,
'invalid_argument',
'Missing required --selector and --path'
)
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserDownload({ selector, path, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── Highlight command ──
if (request.method === 'browser.highlight') {
try {
const params = this.extractParams(request)
const selector = typeof params?.selector === 'string' ? params.selector : null
if (!selector) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --selector')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserHighlight({ selector, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
// ── New: exec passthrough + tab lifecycle ──
if (request.method === 'browser.exec') {
try {
const params = this.extractParams(request)
const command = typeof params?.command === 'string' ? params.command : null
if (!command) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing required --command')
}
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserExec({ command, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.tabCreate') {
try {
const params = this.extractParams(request)
const url = typeof params?.url === 'string' ? params.url : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserTabCreate({ url, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)
}
}
if (request.method === 'browser.tabClose') {
try {
const params = this.extractParams(request)
const index = typeof params?.index === 'number' ? params.index : undefined
const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined
const result = await this.runtime.browserTabClose({ index, worktree })
return this.successResponse(request.id, result)
} catch (error) {
return this.browserErrorResponse(request.id, error)

View file

@ -89,6 +89,7 @@ export type BrowserApi = {
registerGuest: (args: {
browserPageId: string
workspaceId: string
worktreeId: string
webContentsId: number
}) => Promise<void>
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
@ -595,6 +596,14 @@ export type PreloadApi = {
onOpenQuickOpen: (callback: () => void) => () => void
onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void
onNewBrowserTab: (callback: () => void) => () => void
onRequestTabCreate: (
callback: (data: { requestId: string; url: string; worktreeId?: string }) => void
) => () => void
replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void
onRequestTabClose: (
callback: (data: { requestId: string; tabId: string | null }) => void
) => () => void
replyTabClose: (reply: { requestId: string; error?: string }) => void
onNewTerminalTab: (callback: () => void) => () => void
onFocusBrowserAddressBar: (callback: () => void) => () => void
onFindInBrowserPage: (callback: () => void) => () => void

View file

@ -500,6 +500,7 @@ const api = {
registerGuest: (args: {
browserPageId: string
workspaceId: string
worktreeId: string
webContentsId: number
}): Promise<void> => ipcRenderer.invoke('browser:registerGuest', args),
@ -1053,6 +1054,36 @@ const api = {
ipcRenderer.on('ui:newBrowserTab', listener)
return () => ipcRenderer.removeListener('ui:newBrowserTab', listener)
},
onRequestTabCreate: (
callback: (data: { requestId: string; url: string; worktreeId?: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; url: string; worktreeId?: string }
) => callback(data)
ipcRenderer.on('browser:requestTabCreate', listener)
return () => ipcRenderer.removeListener('browser:requestTabCreate', listener)
},
replyTabCreate: (reply: {
requestId: string
browserPageId?: string
error?: string
}): void => {
ipcRenderer.send('browser:tabCreateReply', reply)
},
onRequestTabClose: (
callback: (data: { requestId: string; tabId: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; tabId: string }
) => callback(data)
ipcRenderer.on('browser:requestTabClose', listener)
return () => ipcRenderer.removeListener('browser:requestTabClose', listener)
},
replyTabClose: (reply: { requestId: string; error?: string }): void => {
ipcRenderer.send('browser:tabCloseReply', reply)
},
onNewTerminalTab: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:newTerminalTab', listener)

View file

@ -999,6 +999,7 @@ function BrowserPagePane({
void window.api.browser.registerGuest({
browserPageId: browserTab.id,
workspaceId,
worktreeId,
webContentsId
})
}

View file

@ -152,6 +152,10 @@ describe('useIpcEvents updater integration', () => {
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onNewBrowserTab: () => () => {},
onRequestTabCreate: () => () => {},
replyTabCreate: () => {},
onRequestTabClose: () => () => {},
replyTabClose: () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},
@ -314,6 +318,10 @@ describe('useIpcEvents updater integration', () => {
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onNewBrowserTab: () => () => {},
onRequestTabCreate: () => () => {},
replyTabCreate: () => {},
onRequestTabClose: () => () => {},
replyTabClose: () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},
@ -485,6 +493,10 @@ describe('useIpcEvents shortcut hint clearing', () => {
},
onActivateWorktree: () => () => {},
onNewBrowserTab: () => () => {},
onRequestTabCreate: () => () => {},
replyTabCreate: () => {},
onRequestTabClose: () => () => {},
replyTabClose: () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},

View file

@ -187,6 +187,71 @@ export function useIpcEvents(): void {
})
)
// Why: CLI-driven tab creation sends a request with a specific worktreeId and
// url. The renderer creates the tab and replies with the workspace ID so the
// main process can wait for registerGuest before returning to the CLI.
unsubs.push(
window.api.ui.onRequestTabCreate((data) => {
try {
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
window.api.ui.replyTabCreate({ requestId: data.requestId, error: 'No active worktree' })
return
}
const workspace = store.createBrowserTab(worktreeId, data.url, { title: data.url })
// Why: registerGuest fires with the page ID (not workspace ID) as
// browserPageId. Return the page ID so waitForTabRegistration can
// correlate correctly.
const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? []
const browserPageId = pages[0]?.id ?? workspace.id
window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId })
} catch (err) {
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Tab creation failed'
})
}
})
)
unsubs.push(
window.api.ui.onRequestTabClose((data) => {
try {
const store = useAppStore.getState()
let tabToClose = data.tabId ?? store.activeBrowserTabId
if (!tabToClose) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: 'No active browser tab to close'
})
return
}
// Why: the bridge stores tabs keyed by browserPageId (which is the page
// ID from registerGuest), but closeBrowserTab expects a workspace ID. If
// tabToClose is a page ID, resolve it to its owning workspace ID.
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
.flat()
.some((ws) => ws.id === tabToClose)
if (!isWorkspaceId) {
const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(
([, pages]) => pages.some((p) => p.id === tabToClose)
)
if (owningWorkspace) {
tabToClose = owningWorkspace[0]
}
}
store.closeBrowserTab(tabToClose)
window.api.ui.replyTabClose({ requestId: data.requestId })
} catch (err) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Tab close failed'
})
}
})
)
unsubs.push(
window.api.ui.onNewTerminalTab(() => {
const store = useAppStore.getState()

View file

@ -388,6 +388,18 @@ export type BrowserCaptureStopResult = {
stopped: boolean
}
export type BrowserExecResult = {
output: unknown
}
export type BrowserTabCreateResult = {
browserPageId: string
}
export type BrowserTabCloseResult = {
closed: boolean
}
export type BrowserErrorCode =
| 'browser_no_tab'
| 'browser_tab_not_found'
@ -399,3 +411,4 @@ export type BrowserErrorCode =
| 'browser_cdp_error'
| 'browser_debugger_detached'
| 'browser_timeout'
| 'browser_error'