mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
9270efc028
commit
8d8c1f2cbc
24 changed files with 4730 additions and 315 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
982
src/cli/index.ts
982
src/cli/index.ts
File diff suppressed because it is too large
Load diff
462
src/main/browser/agent-browser-bridge.test.ts
Normal file
462
src/main/browser/agent-browser-bridge.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
1263
src/main/browser/agent-browser-bridge.ts
Normal file
1263
src/main/browser/agent-browser-bridge.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
249
src/main/browser/cdp-ws-proxy.test.ts
Normal file
249
src/main/browser/cdp-ws-proxy.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
360
src/main/browser/cdp-ws-proxy.ts
Normal file
360
src/main/browser/cdp-ws-proxy.ts
Normal 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: () => {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
src/preload/api-types.d.ts
vendored
9
src/preload/api-types.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -999,6 +999,7 @@ function BrowserPagePane({
|
|||
void window.api.browser.registerGuest({
|
||||
browserPageId: browserTab.id,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
webContentsId
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => () => {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue