From ce35a7ab1a56eb723030db25b4add6bfe6abc60e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 20 Jul 2026 13:59:27 -0400 Subject: [PATCH 1/9] feat(extension): prepare Chrome Web Store beta --- .changeset/calm-spiders-connect.md | 8 + .gitignore | 1 + AGENTS.md | 7 +- CONTEXT.md | 23 +- PLAN.md | 24 +- README.md | 12 +- docs/CHROME_WEB_STORE.md | 116 ++++++++++ docs/PRIVACY.md | 74 +++++++ docs/chrome-web-store/icon-128.png | Bin 0 -> 4889 bytes docs/chrome-web-store/small-promo-440x280.png | Bin 0 -> 34306 bytes docs/chrome-web-store/small-promo.svg | 22 ++ extension/icons/icon-128.png | Bin 0 -> 4889 bytes extension/icons/icon-16.png | Bin 0 -> 529 bytes extension/icons/icon-32.png | Bin 0 -> 992 bytes extension/icons/icon-48.png | Bin 0 -> 1486 bytes extension/icons/icon.svg | 10 + extension/manifest.json | 21 +- extension/src/background.ts | 128 +++++++---- package.json | 4 +- pnpm-lock.yaml | 8 + scripts/build-extension.ts | 12 +- scripts/package-extension.ts | 118 ++++++++++ scripts/smoke.ts | 14 +- skills/browser-control/SKILL.md | 8 +- src/cli.ts | 33 ++- src/doctor.ts | 74 ++++++- src/execute.ts | 16 +- src/extension-rpc.ts | 48 +++- src/http-api.ts | 8 +- src/protocol.ts | 18 ++ src/relay-helpers.ts | 2 +- src/relay-lifecycle.ts | 29 ++- src/relay-schema.ts | 3 + src/relay.ts | 205 ++++++++++++++---- test/cli-output.test.ts | 43 +++- test/doctor.test.ts | 32 ++- test/execute-ergonomics.test.ts | 1 + test/execute-target-selection.test.ts | 4 +- test/extension-package.test.ts | 51 +++++ test/extension-rpc.test.ts | 16 +- test/http-api.test.ts | 16 +- test/protocol.test.ts | 21 ++ test/relay-child-dedupe.test.ts | 2 +- test/relay-extension-handshake.test.ts | 186 ++++++++++++++++ test/relay-helpers.test.ts | 3 +- test/relay-lifecycle.test.ts | 41 ++++ test/relay-schema.test.ts | 5 + test/relay-session-persistence.test.ts | 115 +++++++++- test/relay-visibility-prune.test.ts | 28 ++- 49 files changed, 1451 insertions(+), 159 deletions(-) create mode 100644 .changeset/calm-spiders-connect.md create mode 100644 docs/CHROME_WEB_STORE.md create mode 100644 docs/PRIVACY.md create mode 100644 docs/chrome-web-store/icon-128.png create mode 100644 docs/chrome-web-store/small-promo-440x280.png create mode 100644 docs/chrome-web-store/small-promo.svg create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/icons/icon.svg create mode 100644 scripts/package-extension.ts create mode 100644 test/extension-package.test.ts create mode 100644 test/relay-extension-handshake.test.ts diff --git a/.changeset/calm-spiders-connect.md b/.changeset/calm-spiders-connect.md new file mode 100644 index 0000000..8dbe893 --- /dev/null +++ b/.changeset/calm-spiders-connect.md @@ -0,0 +1,8 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Prepare an unlisted Chrome Web Store extension with protocol-based relay +compatibility, deterministic packaging, and more reliable cold-start target +creation. Session reset and delete now recover relay-owned targets whose +debugger attachment was permanently lost during an extension update. diff --git a/.gitignore b/.gitignore index 76c1942..7b98b6c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ dist/ extension/dist/ +artifacts/ tmp/ diff --git a/AGENTS.md b/AGENTS.md index 58fe731..33cc108 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,10 @@ local Node relay. navigation timestamps. - Session delete/reset must acquire the session's execute permit before closing the sandbox, so running scripts are never yanked mid-flight. +- Reset/delete of an absent persisted relay-owned target waits for protocol-v1 + inventory reconciliation or a bounded grace, then forgets the dead identity + without guessing a physical tab to close. Never apply this dead-target path + to adopted user tabs. - The version string and build id are injected by `scripts/build-cli.ts` (`src/version.ts`; source runs use `0.0.0-dev` and a deterministic source and dependency-lock fingerprint). The relay @@ -238,7 +242,8 @@ browser-control skill - Load `extension/dist` as the unpacked extension. - The relay listens on `127.0.0.1:19989` by default. -- Current shim version is `0.0.18`. +- Current shim version is `0.0.22` and extension protocol version is `1`. +- Store and npm versions may differ when protocol `1` remains compatible. - On socket open the shim sends `hello` and then re-announces every tab it still has `chrome.debugger` attached to (`debugger.attached` events), so a restarted relay rebuilds its target registry without the user re-clicking the toolbar. diff --git a/CONTEXT.md b/CONTEXT.md index d89a745..9de3683 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,7 +13,7 @@ prompt: | Browser Control is a local browser driver for agents. It lets trusted agents operate the user's visible Chromium-family browser through an installed -extension and a local bridge. +extension and a local driver daemon. ## Language @@ -32,6 +32,19 @@ The user's already-running Chromium-family browser with the Browser Control extension installed. _Avoid_: Chrome-only, managed browser +**Local Driver Daemon**: +The persistent Node process that owns Playwright execution, Browser Control +sessions, target ownership, cross-process serialization, artifacts, and the +transport to the browser extension. It is a deep module, not a pass-through +message relay. +_Avoid_: Temporary bridge, extension reload helper + +**Extension Protocol**: +The compatibility version reported by the extension when it connects to the +Local Driver Daemon. Store and npm release versions may differ while this +protocol remains compatible. +_Avoid_: Extension package version, relay build id + **Attached Tab**: A browser tab whose debugger connection is active and therefore visible and controllable by agents. @@ -88,6 +101,14 @@ The endpoint-scoped, private relay file that preserves session identity, read-only mode, and the exact default-target pointer across relay processes. Lifecycle operations acknowledge only after its atomic replacement is durable. It does not serialize Execute Sandbox JavaScript state or snapshot refs. + +**Dead Persisted Target**: +A relay-owned target identity retained in the Session Catalog that is absent +after the current extension finishes reconciling its attached-tab inventory, or +after a bounded reconnect grace when no inventory arrives. Explicit reset or +delete may forget this identity but must never guess which physical tab to +close. +_Avoid_: Detached tab, closed user tab _Avoid_: Current-session store, session journal, sandbox snapshot **MCP Process Session**: diff --git a/PLAN.md b/PLAN.md index 620db6d..b343625 100644 --- a/PLAN.md +++ b/PLAN.md @@ -165,13 +165,19 @@ require a new extension capture protocol and permission model. - The package is published publicly on npm. Normal setup installs the npm artifact; source development uses `pnpm install`, `pnpm build`, and `bun link`. -- The browser extension remains unpacked for v1 and is loaded from - the npm package's `extension/dist` directory or a source build. Its current - shim version is `0.0.18`. +- Until the first Store review completes, the browser extension is loaded + unpacked from the npm package's `extension/dist` directory or a source build. + Its current shim version is `0.0.22`. +- Extension and npm releases are independently versioned. The extension hello + reports an explicit protocol version, and compatibility rather than exact + package-version equality determines whether the local driver may use it. +- Browser data crosses only the loopback connection unless an authorized local + caller sends returned data elsewhere. - Extension source changes require rebuilding and reloading the unpacked extension. Relay-only changes do not. -- Chrome Web Store distribution is deferred until behavior stabilizes. A - bundled unpacked extension belongs to future managed-browser launch flows. +- `pnpm package:extension` produces the deterministic Chrome Web Store review + ZIP. Distribution starts as an unlisted beta before becoming public. A bundled + unpacked extension belongs to future managed-browser launch flows. ## Session And Tab Model @@ -207,6 +213,11 @@ arbitrary tab from the attached pool. - Reset, delete, or detach releases an adopted tab without closing it. - Reset and delete acquire the execute permit before closing a sandbox, so they cannot interrupt a running script. +- Reset and delete give an absent persisted relay target a bounded opportunity + to re-announce. A completed protocol-v1 inventory, or expiry of the reconnect + grace, declares that identity dead so recovery cannot require catalog edits. + The relay never guesses a physical tab to close when the live target identity + is unavailable. - Corrupt session catalogs fail relay startup without being overwritten. - The relay wins the endpoint port before loading the catalog or enabling catalog writes. Lifecycle responses wait for atomic file replacement, file @@ -458,7 +469,8 @@ These items are accepted directions but are not current priorities: relay endpoint, if multiple profiles become a supported workflow. - Add stricter workspace or session ownership only if the loose shared attached tab pool causes concrete failures. -- Publish the extension through the Chrome Web Store after behavior stabilizes. +- Promote the reviewed Chrome Web Store extension from unlisted beta to public + after one successful extension/relay compatibility cycle. - Bundle an unpacked extension for managed and development browser launch. - Add token-authenticated remote relay mode only if the relay must bind beyond trusted local interfaces. diff --git a/README.md b/README.md index 4bc0650..29c58e9 100644 --- a/README.md +++ b/README.md @@ -349,8 +349,8 @@ Control returns them. Browser Control trusts the local agent code it executes. It is a driver, not an untrusted-code sandbox. -The extension requires broad browser permissions, including `debugger`, `tabs`, -`tabCapture`, and access to page content on all URLs. Attaching a user tab gives +The extension requires broad browser permissions, including `debugger`, +`tabCapture`, and a status content script on all URLs. Attaching a user tab gives Browser Control access to that tab through your existing browser profile. The relay blocks destructive browser-wide CDP commands that clear cookies, @@ -362,7 +362,9 @@ of Node.js filesystem and network APIs. Current limitations: - The extension is installed unpacked; Chrome Web Store distribution is not - available yet. + available yet. The repository can produce the review artifact with + `pnpm package:extension`; the first Store release will begin as an unlisted + beta. - Playwright download artifacts are unavailable because Chromium blocks the required download commands through `chrome.debugger`. Fetch exposed response bytes and write them with the provided `fs` module instead. @@ -377,8 +379,8 @@ Current limitations: - **Extension disconnected**: confirm the unpacked extension is enabled, then reload it from the browser's extensions page. The extension reconnects to a running relay automatically. -- **After an npm upgrade**: reload the unpacked extension so its browser code - matches the newly installed CLI and relay. +- **After an npm upgrade**: reload the unpacked extension. Extension and relay + versions may differ when they use the same reported protocol version. - **Stale relay warning**: run `browser-control doctor`, stop the old relay process it identifies, then rerun a relay-backed command. diff --git a/docs/CHROME_WEB_STORE.md b/docs/CHROME_WEB_STORE.md new file mode 100644 index 0000000..4169c68 --- /dev/null +++ b/docs/CHROME_WEB_STORE.md @@ -0,0 +1,116 @@ +# Chrome Web Store Submission + +This document is the source copy for the Browser Control Chrome Web Store +listing and review questionnaire. The initial distribution should be +**unlisted**. + +## Single Purpose + +Connect user-authorized local browser automation programs to controlled tabs in +the user's existing Chromium browser. + +## Short Description + +Connect controlled browser tabs to the local Browser Control driver for +user-authorized automation. + +## Detailed Description + +When enabled, trusted local programs can read and modify controlled pages, use +their signed-in state, create and close tabs, capture matching page network +activity, and record a controlled tab when requested. Data is sent only to the +Browser Control driver on this computer at `127.0.0.1:19989`; Anomaly does not +operate a Browser Control cloud relay. + +Browser Control lets trusted agents and programs running on your computer +control tabs in your existing browser. It uses a local Node driver for +Playwright execution and a small extension adapter for Chrome debugging APIs. + +The extension connects only to `127.0.0.1:19989`. Browser data is not sent to a +Browser Control cloud service. A visible page indicator identifies controlled +tabs, and the toolbar button attaches or detaches the active tab. Human handoff +controls keep authentication, payment confirmation, CAPTCHAs, and other +user-presence steps with the user rather than bypassing them. + +Browser Control is intended for trusted local use. The extension requires the +local `@opencode-ai/browser-control` package. + +## Permission Justifications + +- `activeTab`: grants the user-initiated tab access required by + `chrome.tabCapture.getMediaStreamId` when recording a toolbar-clicked tab. +- `alarms`: wakes the Manifest V3 worker periodically so it can reconnect to a + local driver that starts after the browser. +- `debugger`: provides the Chrome DevTools Protocol transport required for + Playwright to inspect and control user-authorized tabs. +- `offscreen`: hosts `MediaRecorder` while recording an authorized tab because + a Manifest V3 service worker has no DOM media environment. +- `tabCapture`: records a controlled user tab only after an explicit local + recording request. +- `tabGroups`: groups session-owned tabs under the visible `control` group and + restores their prior ungrouped state when released. +- Content script on ``: installs the small status and human-handoff control in controlled + pages across navigations and origins. It does not collect page content by + itself; page access occurs through explicit local driver commands. + +## Remote Code Declaration + +The extension does not download or execute remotely hosted code in the +extension runtime. All extension JavaScript is bundled in the submitted +Manifest V3 package. As its disclosed purpose, the extension relays local +Chrome DevTools Protocol commands, including page-context evaluation, to tabs +controlled by the user. + +## Data Use Disclosure + +Declare access to website content; controlled-page and captured request URLs; +matching request and response headers and optional bodies; user activity on +controlled pages; authentication information available to controlled pages; +and screen or tab recordings requested by an authorized local caller. The data +is used only to provide the extension's single purpose. It is +not sold, used for advertising, used for credit decisions, or transferred to +the publisher. See `docs/PRIVACY.md`. + +## Reviewer Instructions + +1. Install Node.js 20 or later. +2. Run `npm install --global @opencode-ai/browser-control`. +3. Install the submitted Browser Control extension. +4. Run: + + ```bash + browser-control execute 'await page.goto("https://example.com"); return { title: await page.title(), url: page.url() }' + ``` + +5. Confirm that a controlled tab opens and the command returns `Example + Domain`. +6. Open another ordinary web page and click the extension toolbar button once + to attach it. Click again to detach it. +7. Run `browser-control doctor` to see local driver, extension protocol, + session, and target diagnostics. + +No account credentials are required for review. Recording is optional and +requires a separate explicit CLI request. + +## Submission Artifact + +Store assets live under `docs/chrome-web-store/`: + +- `icon-128.png` +- `small-promo-440x280.png` + +A 1280x800 or 640x400 product screenshot is still required before submission; +capture it from the final Store-ID-pinned build rather than staging a mock. + +Run: + +```bash +pnpm package:extension +``` + +Upload `artifacts/browser-control-extension-.zip`. Record the printed +SHA-256 digest with the release notes. + +Before submission, add the Store-assigned extension ID to the relay's accepted +production extension origins while preserving an explicit source-development +path. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..e6ed1d0 --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,74 @@ +# Browser Control Extension Privacy Policy + +Effective July 20, 2026 + +Browser Control is a local browser driver for user-authorized automation. This +policy describes the Browser Control browser extension distributed by Anomaly +and the local Browser Control driver it connects to. + +## Data The Extension Can Access + +Trusted programs using the local driver can +control attached tabs and tabs they create. Depending on the command you run, +that access can include: + +- Page URLs, titles, visible and programmatically available page content. +- Form fields and interactions performed on controlled pages. +- The signed-in state available to the controlled page. +- Browser debugging events and network activity associated with controlled + pages, including matching cross-origin requests and responses. +- Images or recordings of a controlled tab when an authorized local caller + requests them. + +Browser Control does not collect this data for advertising, analytics, credit +decisions, or sale to third parties. + +Browser Control's use of information received from Google APIs complies with +the Chrome Web Store User Data Policy, including the Limited Use requirements. + +## Local Processing + +The extension connects to the Browser Control driver at +`127.0.0.1:19989` on the same computer. Browser data handled by the extension is +sent only over this loopback connection to the local driver. Anomaly does not +operate a Browser Control cloud relay and does not receive page contents, +browsing history, credentials, debugging events, screenshots, or recordings. + +Programs and agents you authorize to call the local driver may receive data +returned by the commands they run. Their handling of that data is governed by +the software and services you chose to run, not by Browser Control. + +## Local Storage + +The extension does not store browsing data. The local driver stores relay state, +session descriptors, access-restricted secret profiles, and execution journals +under `~/.browser-control`. Journals can include agent code, bounded result +previews, page URLs, navigations, errors, and handoff summaries. Screenshots, +recordings, and network exports are written to the path selected by the caller. +Disabling or removing the extension does not automatically delete these local +files. You can delete sessions through the Browser Control CLI and remove +user-selected artifacts normally. Local sessions, journals, captures, and +secret profiles remain until the user deletes their corresponding files or +Browser Control data. + +## Your Controls + +The extension visibly marks controlled tabs. Its toolbar button attaches or +detaches the active tab, subject to safeguards for active operations. You can +reset or delete Browser Control sessions with the local CLI. You can stop all +extension activity by disabling or removing Browser Control from your +browser's extensions page. + +## Security + +Browser Control is intended for trusted local use. Only enable it on a computer +where you trust the programs and agents that can access the local driver. The +driver rejects cross-origin browser requests, limits its listener to local +interfaces by default, and blocks destructive browser-wide debugging commands, +but a trusted caller controlling a tab can still read and modify that tab. + +## Changes And Contact + +Material changes to Browser Control's data handling will be reflected in this +policy and the Chrome Web Store listing. Questions and reports can be filed at +. diff --git a/docs/chrome-web-store/icon-128.png b/docs/chrome-web-store/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..34099cb051d5d744a058d772fd1ea17f494ac760 GIT binary patch literal 4889 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 literal 0 HcmV?d00001 diff --git a/docs/chrome-web-store/small-promo-440x280.png b/docs/chrome-web-store/small-promo-440x280.png new file mode 100644 index 0000000000000000000000000000000000000000..66cc9844bdba01bc070aace86f54cb0f511b6131 GIT binary patch literal 34306 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 literal 0 HcmV?d00001 diff --git a/docs/chrome-web-store/small-promo.svg b/docs/chrome-web-store/small-promo.svg new file mode 100644 index 0000000..7b67e9b --- /dev/null +++ b/docs/chrome-web-store/small-promo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + Browser Control + Your browser. Your signed-in state. + Controlled by trusted local tools. + + diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..34099cb051d5d744a058d772fd1ea17f494ac760 GIT binary patch literal 4889 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 literal 0 HcmV?d00001 diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..a27238d73c7480711bd2f864e5a14927df95bf96 GIT binary patch literal 529 zcmV+s0`C2ZP)G>DUx3T_V4K^Iqv-37to zrp@60(7|1CaOe=hu@o1F%RO+<^SbZn zx#xlZT~0K3viddUTU`^Q1rg1tC}I>5MWd%^%>=fTY@1^7`Qi_Nu?X<3t{Ee@k0#qu zKt@GZT5&9JX8YstIgZYIN^9v~e zf&-<~4E_d$5yCy#d|ct}%R0$)jKXT3!o577-!$0$)IsG4i1mO#tJ>E=`+LCtj>q=< z79Y0iI1Xe_#lsItbyS3>s=5+&Fg+LN%;F@;<1QDkW-*7#_PbX1H>#F~0MDm59#iYM z+yoL!%d;#k&jL`}^Lcc?%>Is7RngK|(=SHrdUqizYwa}9f0AmB<1~Mt_s{AV)r~~6 TC_{Ci00000NkvXXu0mjfs&M$m literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..6c9500ea4a84c6145914c8e345de656b9cc434cc GIT binary patch literal 992 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 + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json index c3988c7..9cc4306 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,10 +1,11 @@ { "manifest_version": 3, "name": "Browser Control", - "version": "0.0.18", - "description": "Attach visible browser tabs to the local browser-control relay.", - "permissions": ["activeTab", "alarms", "debugger", "offscreen", "tabs", "tabCapture", "tabGroups"], - "host_permissions": [""], + "version": "0.0.22", + "description": "Connect browser tabs to the local Browser Control driver.", + "minimum_chrome_version": "120", + "permissions": ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"], + "homepage_url": "https://github.com/anomalyco/browser-control", "background": { "service_worker": "background.js", "type": "module" @@ -16,7 +17,17 @@ "run_at": "document_start" } ], + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, "action": { - "default_title": "Attach tab to Browser Control" + "default_title": "Attach tab to Browser Control", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + } } } diff --git a/extension/src/background.ts b/extension/src/background.ts index 55d23a6..bf9e7f9 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,4 +1,4 @@ -import { parseExtensionCommand, type ExtensionCommand as ShimCommand, type JsonObject } from "../../src/protocol.ts" +import { extensionProtocolVersion, parseExtensionCommand, type ExtensionCommand as ShimCommand, type JsonObject } from "../../src/protocol.ts" import type { OffscreenCancelRecordingResult, OffscreenOutgoingMessage, @@ -12,10 +12,10 @@ import { debuggerDetachedEvent } from "./debugger-detach.ts" const relayHost = "127.0.0.1" const relayPort = 19989 -const shimVersion = "0.0.18" const offscreenDocumentPath = "offscreen.html" let socket: WebSocket | undefined +let connectionPromise: Promise | undefined let reconnectTimer: ReturnType | undefined let offscreenDocumentCreating: Promise | undefined const activeRecordings = new Map() @@ -36,10 +36,7 @@ chrome.alarms.onAlarm.addListener((alarm) => { }) chrome.action.onClicked.addListener((tab) => { - if (!tab.id) { - return - } - sendMessage({ method: "toolbar.clicked", params: { tabId: tab.id } }) + if (tab.id) sendMessage({ method: "toolbar.clicked", params: { tabId: tab.id } }) }) chrome.debugger.onEvent.addListener((source, method, params) => { @@ -81,21 +78,23 @@ chrome.runtime.onMessage.addListener((message: unknown, sender) => { }) connect() -void reconcileBrowserControlGroups().catch(() => {}) function connect(): void { - if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) { - return - } + void ensureConnection().catch(() => {}) +} + +function startConnection(): WebSocket { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = undefined } const currentSocket = new WebSocket(`ws://${relayHost}:${relayPort}/extension`) socket = currentSocket - currentSocket.onopen = announceHelloAndAttachedTabs + currentSocket.onopen = () => { + void announceHelloAndAttachedTabs(currentSocket).catch((error) => reportAnnouncementFailure(currentSocket, error)) + } currentSocket.onmessage = (event) => { - void handleSocketMessage(event.data) + void handleSocketMessage(currentSocket, event.data) } currentSocket.onclose = () => { if (socket !== currentSocket) { @@ -105,77 +104,119 @@ function connect(): void { socket = undefined reconnectTimer = setTimeout(connect, 1000) } + return currentSocket } async function ensureConnection(): Promise { if (socket?.readyState === WebSocket.OPEN) { return } - connect() - await new Promise((resolve, reject) => { - const current = socket - if (!current) { - reject(new Error("No socket")) - return - } - const timeout = setTimeout(() => { - reject(new Error("Relay connection timed out")) - }, 5000) + if (connectionPromise) { + return connectionPromise + } + const current = socket?.readyState === WebSocket.CONNECTING ? socket : startConnection() + const pending = new Promise((resolve, reject) => { if (current.readyState === WebSocket.OPEN) { - clearTimeout(timeout) resolve() return } - current.onopen = () => { + let timeout: ReturnType + const cleanup = () => { clearTimeout(timeout) - announceHelloAndAttachedTabs() + current.removeEventListener("open", onOpen) + current.removeEventListener("error", onError) + current.removeEventListener("close", onClose) + } + const onOpen = () => { + cleanup() resolve() } - current.onerror = () => { - clearTimeout(timeout) + const onError = () => { + cleanup() reject(new Error("Relay connection failed")) } + const onClose = () => { + cleanup() + reject(new Error("Relay connection closed")) + } + timeout = setTimeout(() => { + cleanup() + current.close() + reject(new Error("Relay connection timed out")) + }, 5000) + current.addEventListener("open", onOpen, { once: true }) + current.addEventListener("error", onError, { once: true }) + current.addEventListener("close", onClose, { once: true }) }) + connectionPromise = pending + try { + await pending + } finally { + if (connectionPromise === pending) connectionPromise = undefined + } } -function announceHelloAndAttachedTabs(): void { - sendMessage({ method: "hello", params: { version: shimVersion } }) - void reannounceAttachedTabsAndReconcileGroups().catch((error: unknown) => { - sendMessage({ method: "log", params: { level: "error", message: `Failed to re-announce attached tabs and reconcile groups: ${error instanceof Error ? error.message : String(error)}` } }) +async function announceHelloAndAttachedTabs(currentSocket: WebSocket): Promise { + sendOnSocket(currentSocket, { + method: "hello", + params: { + version: chrome.runtime.getManifest().version, + protocolVersion: extensionProtocolVersion, + }, }) + await reannounceAttachedTabsAndReconcileGroups(currentSocket) + sendOnSocket(currentSocket, { method: "ready" }) +} + +function reportAnnouncementFailure(currentSocket: WebSocket, error: unknown): void { + if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) return + currentSocket.send(JSON.stringify({ method: "log", params: { level: "error", message: `Failed to re-announce attached tabs and reconcile groups: ${error instanceof Error ? error.message : String(error)}` } })) + currentSocket.close(1011, "Attached-tab inventory failed") } -async function reannounceAttachedTabsAndReconcileGroups(): Promise { +async function reannounceAttachedTabsAndReconcileGroups(currentSocket: WebSocket): Promise { const attachedTabIds = new Set() const targets = await chrome.debugger.getTargets() for (const target of targets) { if (target.attached && typeof target.tabId === "number") { attachedTabIds.add(target.tabId) - sendMessage({ method: "debugger.attached", params: { tabId: target.tabId } }) + sendOnSocket(currentSocket, { method: "debugger.attached", params: { tabId: target.tabId } }) } } await reconcileBrowserControlGroups(attachedTabIds) } -async function handleSocketMessage(data: unknown): Promise { +function sendOnSocket(currentSocket: WebSocket, message: JsonObject): void { + if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) { + throw new Error("Extension connection changed during attached-tab inventory") + } + currentSocket.send(JSON.stringify(message)) +} + +async function handleSocketMessage(currentSocket: WebSocket, data: unknown): Promise { let command: ShimCommand try { command = parseExtensionCommand(String(data)) } catch (error) { - sendMessage({ method: "log", params: { level: "error", message: error instanceof Error ? error.message : String(error) } }) + sendOnCurrentSocket(currentSocket, { method: "log", params: { level: "error", message: error instanceof Error ? error.message : String(error) } }) return } try { const result = await handleCommand(command) - sendMessage({ id: command.id, result }) + sendOnCurrentSocket(currentSocket, { id: command.id, result }) } catch (error) { - sendMessage({ id: command.id, error: error instanceof Error ? error.message : String(error) }) + sendOnCurrentSocket(currentSocket, { id: command.id, error: error instanceof Error ? error.message : String(error) }) + } +} + +function sendOnCurrentSocket(currentSocket: WebSocket, message: JsonObject): void { + if (socket === currentSocket && currentSocket.readyState === WebSocket.OPEN) { + currentSocket.send(JSON.stringify(message)) } } async function handleCommand(command: ShimCommand): Promise { if (command.method === "ping") { - sendMessage({ method: "pong" }) return {} } if (command.method === "debugger.attach") { @@ -527,15 +568,16 @@ async function sendPageStatusMessage(tabId: number, message: JsonObject, require } function sendMessage(message: JsonObject): void { + void sendMessageAfterConnection(message).catch(() => {}) +} + +async function sendMessageAfterConnection(message: JsonObject): Promise { if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)) return } - void ensureConnection().then(() => { - if (socket?.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(message)) - } - }) + await ensureConnection() + if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)) } function sendBinary(data: Uint8Array): void { diff --git a/package.json b/package.json index 1bdac69..c5f09f6 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,9 @@ "build:cli": "tsx scripts/build-cli.ts", "build": "pnpm build:cli && pnpm build:extension", "build:extension": "tsx scripts/build-extension.ts", + "package:extension": "pnpm build:extension && tsx scripts/package-extension.ts", "changeset": "changeset", - "ci": "pnpm typecheck && pnpm test && pnpm build", + "ci": "pnpm typecheck && pnpm test && pnpm build && pnpm package:extension", "prepack": "pnpm build", "release": "changeset publish", "smoke": "tsx scripts/smoke.ts", @@ -60,6 +61,7 @@ "@types/node": "^25.0.3", "@types/ws": "^8.18.1", "esbuild": "^0.27.2", + "fflate": "^0.8.3", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ffed42..cb347f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: esbuild: specifier: ^0.27.2 version: 0.27.7 + fflate: + specifier: ^0.8.3 + version: 0.8.3 tsx: specifier: ^4.21.0 version: 4.22.4 @@ -812,6 +815,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2054,6 +2060,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 diff --git a/scripts/build-extension.ts b/scripts/build-extension.ts index c367e30..39d7045 100644 --- a/scripts/build-extension.ts +++ b/scripts/build-extension.ts @@ -9,7 +9,10 @@ const dist = path.join(root, "extension", "dist") await fs.rm(dist, { recursive: true, force: true }) await fs.mkdir(dist, { recursive: true }) await build({ - entryPoints: [path.join(root, "extension", "src", "background.ts"), path.join(root, "extension", "src", "offscreen.ts")], + entryPoints: [ + path.join(root, "extension", "src", "background.ts"), + path.join(root, "extension", "src", "offscreen.ts"), + ], bundle: true, format: "esm", platform: "browser", @@ -26,3 +29,10 @@ await build({ }) await fs.copyFile(path.join(root, "extension", "manifest.json"), path.join(dist, "manifest.json")) await fs.copyFile(path.join(root, "extension", "src", "offscreen.html"), path.join(dist, "offscreen.html")) +await fs.mkdir(path.join(dist, "icons"), { recursive: true }) +await Promise.all([16, 32, 48, 128].map((size) => { + return fs.copyFile( + path.join(root, "extension", "icons", `icon-${size}.png`), + path.join(dist, "icons", `icon-${size}.png`), + ) +})) diff --git a/scripts/package-extension.ts b/scripts/package-extension.ts new file mode 100644 index 0000000..79124ed --- /dev/null +++ b/scripts/package-extension.ts @@ -0,0 +1,118 @@ +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { zipSync, type Zippable } from "fflate" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const storePackageFiles = [ + "background.js", + "content-script.js", + "icons/icon-128.png", + "icons/icon-16.png", + "icons/icon-32.png", + "icons/icon-48.png", + "manifest.json", + "offscreen.html", + "offscreen.js", +] as const + +export async function makeExtensionArchive(dist: string): Promise { + const entries: Zippable = {} + for (const relativePath of await listFiles(dist)) { + if (relativePath.endsWith(".map")) { + throw new Error(`Chrome Web Store package must not contain source maps: ${relativePath}`) + } + entries[relativePath] = [ + new Uint8Array(await fs.readFile(path.join(dist, relativePath))), + { mtime: new Date(2000, 0, 1), os: 3, attrs: 0o644 << 16 }, + ] + } + return zipSync(entries, { level: 9, mtime: new Date(2000, 0, 1), os: 3 }) +} + +export async function extensionVersion(dist: string): Promise { + const parsed: unknown = JSON.parse(await fs.readFile(path.join(dist, "manifest.json"), "utf8")) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Extension manifest must be an object") + } + const version = Reflect.get(parsed, "version") + if (typeof version !== "string" || !isChromeExtensionVersion(version)) { + throw new Error("Extension manifest has an invalid Chrome version") + } + return version +} + +export function isChromeExtensionVersion(version: string): boolean { + const components = version.split(".") + return components.length >= 1 && components.length <= 4 && components.every((component) => { + if (!/^\d+$/.test(component) || component.length > 1 && component.startsWith("0")) return false + const value = Number(component) + return Number.isInteger(value) && value >= 0 && value <= 65_535 + }) +} + +async function validateStoreExtension(dist: string): Promise { + const files = await listFiles(dist) + if (files.length !== storePackageFiles.length || files.some((file, index) => file !== storePackageFiles[index])) { + throw new Error(`Unexpected Chrome Web Store package files: ${files.join(", ")}`) + } + const parsed: unknown = JSON.parse(await fs.readFile(path.join(dist, "manifest.json"), "utf8")) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Extension manifest must be an object") + } + if (Reflect.get(parsed, "manifest_version") !== 3 || Reflect.get(parsed, "minimum_chrome_version") !== "120") { + throw new Error("Extension manifest must target Manifest V3 and Chrome 120 or later") + } + const permissions = Reflect.get(parsed, "permissions") + const expectedPermissions = ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"] + if (!Array.isArray(permissions) || permissions.join(",") !== expectedPermissions.join(",")) { + throw new Error("Extension manifest permissions differ from the reviewed allowlist") + } + if (Reflect.has(parsed, "host_permissions")) { + throw new Error("Extension manifest must not declare redundant host_permissions") + } + await extensionVersion(dist) + await Promise.all([16, 32, 48, 128].map(async (size) => { + const png = await fs.readFile(path.join(dist, "icons", `icon-${size}.png`)) + if (png.readUInt32BE(0) !== 0x89504e47 || png.toString("ascii", 1, 4) !== "PNG") { + throw new Error(`Extension icon ${size} is not a PNG`) + } + if (png.readUInt32BE(16) !== size || png.readUInt32BE(20) !== size) { + throw new Error(`Extension icon ${size} has incorrect dimensions`) + } + })) +} + +async function listFiles(directory: string, prefix = ""): Promise { + const files: string[] = [] + const entries = await fs.readdir(path.join(directory, prefix), { withFileTypes: true }) + for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + const relativePath = path.posix.join(prefix, entry.name) + if (entry.isDirectory()) { + files.push(...await listFiles(directory, relativePath)) + } else if (entry.isFile()) { + files.push(relativePath) + } else { + throw new Error(`Chrome Web Store package contains an unsupported entry: ${relativePath}`) + } + } + return files +} + +async function main(): Promise { + const dist = path.join(root, "extension", "dist") + await validateStoreExtension(dist) + const version = await extensionVersion(dist) + const archive = await makeExtensionArchive(dist) + const artifactDirectory = path.join(root, "artifacts") + const artifactPath = path.join(artifactDirectory, `browser-control-extension-${version}.zip`) + await fs.mkdir(artifactDirectory, { recursive: true }) + await fs.writeFile(artifactPath, archive) + const digest = createHash("sha256").update(archive).digest("hex") + console.log(`${path.relative(root, artifactPath)} sha256:${digest}`) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await main() +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 53274ec..b4ea827 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -23,6 +23,8 @@ const selectedCaseNames = parseCaseFilter(process.env.SMOKE_CASE) type ExtensionStatus = { readonly connected: boolean readonly version: string | null + readonly protocolVersion: number | null + readonly protocolCompatible: boolean | null readonly activeTargets: number readonly childTargets: number readonly cdpClients: number @@ -881,8 +883,8 @@ return { result: await page.locator('#class-result').textContent() } name: "handoff-navigation", run: Effect.fnUntraced(function* (page) { const extension = yield* fetchStatus() - if (extension.version !== "0.0.18") { - return yield* Effect.fail(new Error(`handoff-navigation requires the built 0.0.18 shim; connected extension is ${extension.version ?? "unknown"}`)) + if (extension.protocolVersion !== 1 || extension.protocolCompatible !== true) { + return yield* Effect.fail(new Error(`handoff-navigation requires extension protocol 1; connected extension reports ${extension.protocolVersion ?? "unknown"}`)) } const marker = `bc-handoff-${Date.now()}` const smokeSession = `${marker}-session` @@ -926,8 +928,8 @@ return { result: await page.locator('#class-result').textContent() } name: "handoff-cross-tab", run: Effect.fnUntraced(function* (page) { const extension = yield* fetchStatus() - if (extension.version !== "0.0.18") { - return yield* Effect.fail(new Error(`handoff-cross-tab requires the built 0.0.18 shim; connected extension is ${extension.version ?? "unknown"}`)) + if (extension.protocolVersion !== 1 || extension.protocolCompatible !== true) { + return yield* Effect.fail(new Error(`handoff-cross-tab requires extension protocol 1; connected extension reports ${extension.protocolVersion ?? "unknown"}`)) } const marker = `bc-handoff-a-${Date.now()}` const peerMarker = `bc-handoff-b-${Date.now()}` @@ -2131,13 +2133,15 @@ function parseExtensionStatus(value: unknown): ExtensionStatus { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected extension status object") } - const status = value as { readonly connected?: unknown; readonly version?: unknown; readonly activeTargets?: unknown; readonly childTargets?: unknown; readonly cdpClients?: unknown; readonly sessions?: unknown } + const status = value as { readonly connected?: unknown; readonly version?: unknown; readonly protocolVersion?: unknown; readonly protocolCompatible?: unknown; readonly activeTargets?: unknown; readonly childTargets?: unknown; readonly cdpClients?: unknown; readonly sessions?: unknown } if (typeof status.connected !== "boolean" || typeof status.activeTargets !== "number") { throw new Error("Invalid extension status shape") } return { connected: status.connected, version: typeof status.version === "string" ? status.version : null, + protocolVersion: typeof status.protocolVersion === "number" ? status.protocolVersion : null, + protocolCompatible: typeof status.protocolCompatible === "boolean" ? status.protocolCompatible : null, activeTargets: status.activeTargets, childTargets: typeof status.childTargets === "number" ? status.childTargets : 0, cdpClients: typeof status.cdpClients === "number" ? status.cdpClients : 0, diff --git a/skills/browser-control/SKILL.md b/skills/browser-control/SKILL.md index e62fc2e..53e5d57 100644 --- a/skills/browser-control/SKILL.md +++ b/skills/browser-control/SKILL.md @@ -332,7 +332,7 @@ existence, and report the viewport, state, and interaction path actually tested. ## Troubleshooting 1. Run `browser-control doctor`; it checks package metadata, CLI/relay build - identity, extension connection/version, sessions, targets, and artifacts. + identity, extension protocol compatibility, sessions, targets, and artifacts. 2. Use `status --json` to inspect exact sessions and target ownership. 3. Reproduce once with the smallest execute before changing code. @@ -340,6 +340,8 @@ Common diagnoses: - `connected:false`: run a relay-backed command, then reload the unpacked extension only if its reconnect loop does not recover. +- Incompatible extension protocol: update either the extension or npm package; + exact extension and relay release versions do not need to match. - Stale relay build: operational commands reject it with restart guidance; rebuild the CLI and restart the relay. - `Target not found`: attach the intended tab, then select or adopt it using a @@ -348,6 +350,10 @@ Common diagnoses: tab. Reattach through the toolbar. - Relay restarted: named sessions reclaim exact targets, but JavaScript `state` and snapshot refs reset. Continue after the warning. +- Reset/delete after an extension update may wait briefly for target + re-announcement; if the old relay-owned target is absent from the completed + inventory, Browser Control forgets the dead identity without closing a + guessed tab. - Repeated execution-context errors: run one short follow-up so Browser Control can health-check the page. It may recreate a relay-owned page, but it never replaces an unhealthy adopted user tab; reset or re-adopt that tab. diff --git a/src/cli.ts b/src/cli.ts index 398c3de..4a256db 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,27 @@ const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) const sessionIdConfig = Config.option(Config.string("BROWSER_CONTROL_SESSION")) const targetUrlConfig = Config.option(Config.string("BROWSER_CONTROL_TARGET_URL")) const targetIndexConfig = Config.option(Config.int("BROWSER_CONTROL_TARGET_INDEX")) +const encodedCliOperandsMarker = "bc-cli-operands:v1" +const encodedCliOperandPrefix = "bc-cli-operand:" + +export function normalizeCliArguments(args: ReadonlyArray): ReadonlyArray { + const delimiter = args.indexOf("--") + const secretsRun = args.findIndex((argument, index) => argument === "secrets" && args[index + 1] === "run") + if (delimiter === -1 || secretsRun === -1 || secretsRun > delimiter) return args + return [ + ...args.slice(0, delimiter), + encodedCliOperandsMarker, + ...args.slice(delimiter + 1).map((operand) => `${encodedCliOperandPrefix}${encodeURIComponent(operand)}`), + ] +} + +function decodeCliOperands(operands: ReadonlyArray): ReadonlyArray { + if (operands[0] !== encodedCliOperandsMarker) return operands + return operands.slice(1).map((operand) => { + if (!operand.startsWith(encodedCliOperandPrefix)) throw new Error("Invalid encoded secrets run operand") + return decodeURIComponent(operand.slice(encodedCliOperandPrefix.length)) + }) +} const readExecuteFile = Effect.fnUntraced(function* (filePath: string) { const fs = yield* FileSystem.FileSystem @@ -485,6 +506,11 @@ const status = Command.make( yield* Console.log(`Warning: ${buildProblem}`) } yield* Console.log(`Extension: ${extensionStatus.connected ? "connected" : "disconnected"}${extensionStatus.version ? ` (${extensionStatus.version})` : ""}`) + if (extensionStatus.protocolVersion !== undefined && extensionStatus.protocolVersion !== null) { + const compatibility = extensionStatus.protocolCompatible === false ? "incompatible" : "compatible" + const legacy = extensionStatus.protocolLegacy === true ? ", inferred from legacy hello" : "" + yield* Console.log(`Extension protocol: ${extensionStatus.protocolVersion} (${compatibility}${legacy})`) + } yield* Console.log(`Active targets: ${extensionStatus.activeTargets}`) if (extensionStatus.childTargets !== undefined) { yield* Console.log(`Child targets: ${extensionStatus.childTargets}`) @@ -788,7 +814,7 @@ const secretsRun = Command.make( Effect.fn("Cli.secretsRun")(function* ({ name, command, cwd, timeoutMs }) { const relay = yield* RelayClient.Service yield* ensureCliRelay() - const [executable, ...args] = command + const [executable, ...args] = decodeCliOperands(command) if (!executable) return yield* Effect.fail(new Error("secrets run requires a command after --")) const cwdValue = optionString(cwd) const timeoutMsValue = optionNumber(timeoutMs) @@ -896,4 +922,7 @@ const mainLayer = Layer.mergeAll(RelayClient.layerFetch, SessionStore.layer).pip Layer.provideMerge(NodeServices.layer), ) -browserControl.pipe(Command.run({ version: browserControlVersion }), Effect.provide(mainLayer), NodeRuntime.runMain) +Command.runWith(browserControl, { version: browserControlVersion })(normalizeCliArguments(process.argv.slice(2))).pipe( + Effect.provide(mainLayer), + NodeRuntime.runMain, +) diff --git a/src/doctor.ts b/src/doctor.ts index 588bbcf..6797728 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -2,6 +2,7 @@ import { Effect, FileSystem, Path, Schema } from "effect" import * as RelayClient from "./relay-client.ts" import { relayBuildProblem } from "./relay-lifecycle.ts" import type { ExtensionStatus, RelayVersion, SessionSummary, TargetSummary } from "./relay-schema.ts" +import { extensionProtocolVersion } from "./protocol.ts" import * as SessionStore from "./session-store.ts" import { browserControlBuildId, browserControlVersion } from "./version.ts" @@ -78,6 +79,10 @@ export type DoctorReport = { readonly version: string | null readonly expectedVersion: string | null readonly versionMatches: boolean | null + readonly protocolVersion: number | null + readonly expectedProtocolVersion: number + readonly protocolCompatible: boolean | null + readonly protocolLegacy: boolean | null readonly error: string | null } readonly targets: { @@ -252,6 +257,10 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op version: extensionVersion, expectedVersion, versionMatches: extensionVersionMatches, + protocolVersion: extensionResult.ok ? extensionResult.value.protocolVersion ?? null : null, + expectedProtocolVersion: extensionProtocolVersion, + protocolCompatible: extensionResult.ok ? extensionResult.value.protocolCompatible ?? null : null, + protocolLegacy: extensionResult.ok ? extensionResult.value.protocolLegacy ?? null : null, error: extensionResult.ok ? null : extensionResult.error, }, targets: { @@ -352,6 +361,7 @@ function buildDoctorChecks(options: { status: extensionVersionCheckStatus({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), message: extensionVersionCheckMessage({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), }, + extensionProtocolCheck(options.extensionResult), { id: "targets-readable", label: "targets readable", @@ -459,7 +469,7 @@ function extensionVersionCheckStatus(options: { if (!options.sourceManifestVersion.ok || !options.extensionResult.value.version) { return "warn" } - return options.versionMatches ? "ok" : "warn" + return "ok" } function extensionVersionCheckMessage(options: { @@ -480,8 +490,51 @@ function extensionVersionCheckMessage(options: { return "extension did not report a version" } return options.versionMatches - ? `matches extension/manifest.json (${options.sourceManifestVersion.value})` - : `runtime ${options.extensionResult.value.version} does not match extension/manifest.json ${options.sourceManifestVersion.value}` + ? `matches bundled extension (${options.sourceManifestVersion.value})` + : `runtime ${options.extensionResult.value.version} differs from bundled ${options.sourceManifestVersion.value}; protocol compatibility determines support` +} + +export function extensionProtocolCheck(extensionResult: ProbeResult): DoctorCheck { + if (!extensionResult.ok) { + return { + id: "extension-protocol", + label: "extension protocol", + status: "warn", + message: extensionResult.error, + } + } + const protocolVersion = extensionResult.value.protocolVersion + const protocolCompatible = extensionResult.value.protocolCompatible + if (protocolCompatible === false) { + return { + id: "extension-protocol", + label: "extension protocol", + status: "fail", + message: `runtime ${protocolVersion ?? "unknown"} is incompatible with relay ${extensionProtocolVersion}`, + } + } + if (extensionResult.value.protocolLegacy === true) { + return { + id: "extension-protocol", + label: "extension protocol", + status: "warn", + message: `legacy extension does not report its protocol; relay infers ${protocolVersion ?? "unknown"}`, + } + } + if (protocolVersion === undefined || protocolVersion === null) { + return { + id: "extension-protocol", + label: "extension protocol", + status: "warn", + message: "extension protocol is unknown", + } + } + return { + id: "extension-protocol", + label: "extension protocol", + status: "ok", + message: `runtime ${protocolVersion} is compatible with relay ${extensionProtocolVersion}`, + } } function buildDoctorRecommendations(options: { @@ -501,9 +554,11 @@ function buildDoctorRecommendations(options: { const relayBuildRecommendations = options.relayResult.ok && options.relayBuildMatches !== true ? [ "Restart the relay with `browser-control serve` so it uses the current CLI build.", ] : [] - const extensionRecommendations = options.relayResult.ok && options.extensionResult.ok && !options.extensionResult.value.connected ? [ - "Load or reload `extension/dist` as an unpacked extension, then click the Browser Control toolbar button on a normal web tab.", - ] : [] + const extensionRecommendations = options.relayResult.ok && options.extensionResult.ok && !options.extensionResult.value.connected + ? options.extensionResult.value.protocolCompatible === false + ? ["Update the Browser Control extension or npm package so their extension protocols are compatible."] + : ["Load or reload `extension/dist` as an unpacked extension, then click the Browser Control toolbar button on a normal web tab."] + : [] const artifactRecommendations = options.artifacts.some((artifact) => { return !artifact.exists }) ? ["Run `pnpm build` to regenerate missing CLI or extension artifacts."] : [] @@ -581,8 +636,11 @@ function formatExtensionSummary(report: DoctorReport): string { return `unknown (${report.extension.error ?? "relay unreachable"})` } const version = report.extension.version ? ` (${report.extension.version})` : "" - const match = report.extension.versionMatches === null ? "" : report.extension.versionMatches ? ", version matches manifest" : ", version differs from manifest" - return `${report.extension.connected ? "connected" : "disconnected"}${version}${match}` + const protocol = report.extension.protocolVersion === null + ? "" + : `, protocol ${report.extension.protocolVersion} ${report.extension.protocolCompatible === false ? "incompatible" : "compatible"}` + const match = report.extension.versionMatches === null ? "" : report.extension.versionMatches ? ", matches bundled version" : ", differs from bundled version" + return `${report.extension.connected ? "connected" : "disconnected"}${version}${protocol}${match}` } function formatNullableNumber(value: number | null): string { diff --git a/src/execute.ts b/src/execute.ts index 6a257d5..52cafed 100644 --- a/src/execute.ts +++ b/src/execute.ts @@ -1648,22 +1648,32 @@ export function createSnapshotHelpers(page: Page, registry: SnapshotRefRegistry) .map(({ entry }) => entry) return { entries: selected, truncated: truncated || selected.length < entries.length } } + // tsx/esbuild can inject calls to its module-scoped __name helper into this + // callback. Playwright serializes only the callback, so provide that helper + // inside the serialized function rather than leaking a build detail into the + // page execution context. + const browserCapture = new Function( + "rootOrSettings", + "locatorSettings", + `const __name = (target) => target +return (${capture.toString()})(rootOrSettings, locatorSettings)`, + ) as typeof capture const timeoutMs = options.timeout ?? defaultSnapshotTimeoutMs let result: Awaited> if (locator) { - result = await locator.evaluate(capture, settings, { timeout: timeoutMs }) + result = await locator.evaluate(browserCapture, settings, { timeout: timeoutMs }) } else { try { result = await Effect.runPromise(runPlaywrightOperation({ label: "Compact snapshot", timeoutMs, - run: () => page.evaluate(capture, settings), + run: () => page.evaluate(browserCapture, settings), })) } catch (error) { if (typeof within !== "string" || !/not a valid selector|querySelectorAll/i.test(error instanceof Error ? error.message : String(error))) { throw error } - result = await page.locator(within).evaluate(capture, { ...settings, rootSelector: undefined }, { timeout: timeoutMs }) + result = await page.locator(within).evaluate(browserCapture, { ...settings, rootSelector: undefined }, { timeout: timeoutMs }) } } diff --git a/src/extension-rpc.ts b/src/extension-rpc.ts index 26bfc32..b17df4a 100644 --- a/src/extension-rpc.ts +++ b/src/extension-rpc.ts @@ -1,6 +1,13 @@ import { Effect } from "effect" import { WebSocket } from "ws" -import type { ExtensionCommand, ExtensionResponse, JsonObject } from "./protocol.ts" +import { + extensionProtocolCompatibility, + type ExtensionCommand, + type ExtensionProtocolCompatibility, + type ExtensionResponse, + type JsonObject, + type JsonValue, +} from "./protocol.ts" import type { PendingExtensionRequest } from "./relay-types.ts" export type ExtensionRpcTimeouts = { @@ -24,11 +31,22 @@ export class ExtensionRpc { private readonly pendingRequests = new Map() private livenessProbe: NodeJS.Timeout | undefined version: string | undefined + protocolVersion: number | null | undefined + protocolCompatible: boolean | undefined + protocolLegacy: boolean | undefined constructor(private readonly timeouts: ExtensionRpcTimeouts = {}) {} get connected(): boolean { - return this.socket?.readyState === WebSocket.OPEN && this.ready + return this.socket?.readyState === WebSocket.OPEN && this.ready && this.protocolCompatible !== false + } + + get acceptsEvents(): boolean { + return this.socket?.readyState === WebSocket.OPEN && this.protocolCompatible === true + } + + isCurrent(socket: WebSocket): boolean { + return this.socket === socket } replaceSocket(socket: WebSocket): void { @@ -38,11 +56,24 @@ export class ExtensionRpc { this.socket = socket this.ready = false this.version = undefined + this.protocolVersion = undefined + this.protocolCompatible = undefined + this.protocolLegacy = undefined } - markReady(version: string | undefined): void { - this.ready = true + markHandshake(version: string | undefined, reportedProtocolVersion: JsonValue | undefined): ExtensionProtocolCompatibility { + const protocol = extensionProtocolCompatibility(reportedProtocolVersion) this.version = version + this.protocolVersion = protocol.version + this.protocolCompatible = protocol.compatible + this.protocolLegacy = protocol.legacy + return protocol + } + + markReady(): void { + if (this.protocolCompatible === true) { + this.ready = true + } } disconnectIfCurrent(socket: WebSocket): boolean { @@ -52,6 +83,9 @@ export class ExtensionRpc { this.socket = undefined this.ready = false this.version = undefined + this.protocolVersion = undefined + this.protocolCompatible = undefined + this.protocolLegacy = undefined this.cancelLivenessProbe() this.rejectPending(new Error("Extension disconnected")) return true @@ -99,7 +133,11 @@ export class ExtensionRpc { send(command: Omit): Effect.Effect { return Effect.callback((resume) => { const socket = this.socket - if (!socket || socket.readyState !== WebSocket.OPEN || !this.ready) { + if (this.protocolCompatible === false) { + resume(Effect.fail(new Error(`Browser Control extension protocol ${this.protocolVersion ?? "unknown"} is incompatible`))) + return Effect.void + } + if (!socket || socket.readyState !== WebSocket.OPEN || !this.acceptsEvents) { resume(Effect.fail(new Error("Browser Control extension is not connected"))) return Effect.void } diff --git a/src/http-api.ts b/src/http-api.ts index 255e2f3..4b5d284 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -31,6 +31,7 @@ import { SessionIdRequest, SessionEnsureRequest, SessionNewRequest, + type ExtensionStatus, type TargetSummary, } from "./relay-schema.ts" import { SessionError, type BrowserControlSessions } from "./session-manager.ts" @@ -43,7 +44,9 @@ export function createHttpRequestHandler(options: { readonly port: number readonly browserId: string readonly relayInstance: { readonly id: string; readonly startedAt: string; readonly pid: number } - readonly extensionStatus: () => { readonly connected: boolean; readonly version: string | null; readonly cdpClients?: number } + readonly extensionStatus: () => Pick readonly recordingRelay: RecordingRelay readonly registry: TargetRegistry readonly sessions: BrowserControlSessions @@ -98,6 +101,9 @@ export function createHttpRequestHandler(options: { sendJson(response, { connected: extensionStatus.connected, version: extensionStatus.version, + ...(extensionStatus.protocolVersion === undefined ? {} : { protocolVersion: extensionStatus.protocolVersion }), + ...(extensionStatus.protocolCompatible === undefined ? {} : { protocolCompatible: extensionStatus.protocolCompatible }), + ...(extensionStatus.protocolLegacy === undefined ? {} : { protocolLegacy: extensionStatus.protocolLegacy }), ...(extensionStatus.cdpClients === undefined ? {} : { cdpClients: extensionStatus.cdpClients }), activeTargets: options.registry.rootTargetCount(), childTargets: options.registry.childTargets.size, diff --git a/src/protocol.ts b/src/protocol.ts index 26f4a3f..67f9eaf 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -4,6 +4,22 @@ export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: export type JsonObject = { readonly [key: string]: JsonValue } +export const extensionProtocolVersion = 1 +export const legacyExtensionProtocolVersion = 1 + +export type ExtensionProtocolCompatibility = { + readonly version: number | null + readonly compatible: boolean + readonly legacy: boolean +} + +export function extensionProtocolCompatibility(value: JsonValue | undefined): ExtensionProtocolCompatibility { + const legacy = value === undefined + const valid = typeof value === "number" && Number.isSafeInteger(value) && value > 0 + const version = valid ? value : legacy ? legacyExtensionProtocolVersion : null + return { version, compatible: version === extensionProtocolVersion, legacy } +} + export type CdpRequest = { readonly id: number readonly method: string @@ -79,6 +95,7 @@ export type ExtensionResponse = { export type ExtensionEvent = { readonly method: | "hello" + | "ready" | "toolbar.clicked" | "handoff.completed" | "debugger.event" @@ -123,6 +140,7 @@ const extensionCommandMethods = new Set([ const extensionEventMethods = new Set([ "hello", + "ready", "toolbar.clicked", "handoff.completed", "debugger.event", diff --git a/src/relay-helpers.ts b/src/relay-helpers.ts index fa8e5ff..f467cc3 100644 --- a/src/relay-helpers.ts +++ b/src/relay-helpers.ts @@ -57,7 +57,7 @@ export function validateWebSocketOrigin(options: { readonly requireChromeExtension?: boolean }): string | undefined { if (!options.origin) { - return undefined + return options.requireChromeExtension ? "Extension WebSocket origin is required" : undefined } if (options.origin.startsWith("chrome-extension://")) { return undefined diff --git a/src/relay-lifecycle.ts b/src/relay-lifecycle.ts index 596f005..599ff28 100644 --- a/src/relay-lifecycle.ts +++ b/src/relay-lifecycle.ts @@ -34,6 +34,14 @@ export class ExtensionDisconnected extends Schema.TaggedErrorClass()( + "RelayLifecycle.ExtensionProtocolIncompatible", + { + message: Schema.String, + protocolVersion: Schema.NullOr(Schema.Number), + }, +) {} + export function relayBuildProblem(version: RelayVersion, buildId = browserControlBuildId): string | undefined { if (!version.buildId) { return `Running relay does not report a build id; restart it with the current CLI (${buildId}).` @@ -82,20 +90,29 @@ export const ensureExtensionConnected = Effect.fn("RelayLifecycle.ensureExtensio readonly retryTimes?: number readonly retryDelayMs?: number }) { - const check = options.relay.extensionStatus.pipe( - Effect.flatMap((status) => status.connected + const check = options.relay.extensionStatus.pipe(Effect.flatMap((status): Effect.Effect< + ExtensionStatus, + ExtensionProtocolIncompatible | ExtensionDisconnected + > => { + if (status.protocolCompatible === false) { + return Effect.fail(new ExtensionProtocolIncompatible({ + message: `Browser Control extension protocol ${status.protocolVersion ?? "unknown"} is incompatible with this relay.`, + protocolVersion: status.protocolVersion ?? null, + })) + } + return status.connected ? Effect.succeed(status) : Effect.fail(new ExtensionDisconnected({ message: "Browser Control extension is not connected. Load extension/dist in Chromium; it reconnects automatically when the relay starts.", - }))), - ) + })) + })) if (!options.waitForReconnect) { return yield* check } return yield* check.pipe( Effect.retry({ - times: options.retryTimes ?? 40, - schedule: Schedule.spaced(options.retryDelayMs ?? 50), + times: options.retryTimes ?? 50, + schedule: Schedule.spaced(options.retryDelayMs ?? 200), while: (error) => error instanceof ExtensionDisconnected || isRelayStartingOrUnreachable(error), }), ) diff --git a/src/relay-schema.ts b/src/relay-schema.ts index 34226be..ce0fa2a 100644 --- a/src/relay-schema.ts +++ b/src/relay-schema.ts @@ -239,6 +239,9 @@ export const TargetSummaries = Schema.Array(TargetSummary) export const ExtensionStatus = Schema.Struct({ connected: Schema.Boolean, version: Schema.NullOr(Schema.String), + protocolVersion: Schema.optionalKey(Schema.NullOr(Schema.Number)), + protocolCompatible: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + protocolLegacy: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), activeTargets: Schema.Number, childTargets: Schema.optionalKey(Schema.Number), cdpClients: Schema.optionalKey(Schema.Number), diff --git a/src/relay.ts b/src/relay.ts index 6a96054..7aea172 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -1,7 +1,7 @@ import http from "node:http" import stream from "node:stream" import crypto from "node:crypto" -import { Config, Effect, Fiber, Semaphore } from "effect" +import { Clock, Config, Effect, Fiber, Semaphore } from "effect" import { WebSocket, WebSocketServer, type RawData } from "ws" import { chromeSessionIdForClientRequest, @@ -20,7 +20,7 @@ import { canClientSeeTarget } from "./cdp-visibility.ts" import { ExtensionRpc } from "./extension-rpc.ts" import { createHttpRequestHandler } from "./http-api.ts" import type { CdpEvent, CdpRequest, JsonObject, PageStatus } from "./protocol.ts" -import { isCdpRequest, isExtensionEvent, isExtensionResponse, parseJsonObject } from "./protocol.ts" +import { extensionProtocolCompatibility, isCdpRequest, isExtensionEvent, isExtensionResponse, parseJsonObject } from "./protocol.ts" import { closeHttpServer, closeWebSocketServer, @@ -64,6 +64,7 @@ export type { RelayServer } from "./relay-types.ts" export const startRelay = Effect.fn("Relay.start")(function* (options: { readonly host?: string readonly port?: number + readonly releaseTargetGraceMs?: number readonly sessionCatalogPath?: string | null } = {}) { yield* installRelayProcessGuard @@ -138,10 +139,12 @@ function debugEnvironmentEnabled(value: string | undefined): boolean { const makeRelay = Effect.fnUntraced(function* (options: { readonly host?: string readonly port?: number + readonly releaseTargetGraceMs?: number readonly sessionCatalogPath?: string | null } = {}) { const host = options.host ?? defaultHost const port = options.port ?? defaultPort + const releaseTargetGraceMs = Math.max(0, options.releaseTargetGraceMs ?? 10_000) const browserId = crypto.randomUUID() const endpointUrl = `http://${formatHostForUrl(host)}:${port}` const sessionCatalog = options.sessionCatalogPath === null @@ -152,12 +155,14 @@ const makeRelay = Effect.fnUntraced(function* (options: { const rootLifecycleSemaphores = new Map() type RootReconciliationWorker = { attachIfMissing: boolean + generation: number pending: boolean - promise: Promise + promise: Promise verificationRetries: number } - const rootReconciliationWorkers = new Map() + const rootReconciliationWorkers = new Map() let relayClosing = false + let extensionGeneration = 0 const extensionRpc = new ExtensionRpc() const sendToExtension = Effect.fnUntraced(function* (command: Parameters[0]) { return yield* extensionRpc.send(command) @@ -191,6 +196,30 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) const handoffs = new HandoffRegistry() const activeHandoffTabs = new Map>() + const clearLiveExtensionState = (reason: string) => { + void recordingRelay.cleanupAll(reason).catch(() => {}) + for (const target of [...registry.listRootTargets()]) { + detachTargetState(target.tabId, { preserveSessionTarget: true, updateExtension: false }) + } + registry.clear() + suppressedChildSessions.clear() + } + const releaseRelayTarget = Effect.fnUntraced(function* (targetId: string) { + const deadline = (yield* Clock.currentTimeMillis) + releaseTargetGraceMs + while (true) { + const target = registry.targetsByTargetId.get(targetId) + if (target && (extensionRpc.connected || extensionRpc.protocolLegacy === true)) { + yield* sendToExtension({ method: "tabs.remove", params: { tabId: target.tabId } }) + return + } + // Protocol v1 reports ready only after its complete attached-tab inventory + // has reconciled. Legacy shims need the full grace because hello came first. + if (extensionRpc.connected && extensionRpc.protocolLegacy === false) return + const remaining = deadline - (yield* Clock.currentTimeMillis) + if (remaining <= 0) return + yield* Effect.sleep(Math.min(50, remaining)) + } + }) const journalBaseDir = defaultJournalBaseDir() const attachedBadge = { text: "ON", color: "#7c3aed", title: "Detach from Browser Control" } const executingBadge = { text: "RUN", color: "#f59e0b", title: "Browser Control is running a script" } @@ -354,12 +383,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { onTargetOwnershipChange: (change) => { reconcileTargetOwnership(change) }, - onReleaseRelayTarget: (targetId) => { - const target = registry.targetsByTargetId.get(targetId) - return target - ? sendToExtension({ method: "tabs.remove", params: { tabId: target.tabId } }).pipe(Effect.asVoid) - : Effect.fail(new Error(`Relay-owned target ${targetId} has not re-announced yet; retry after the extension reconnects`)) - }, + onReleaseRelayTarget: (targetId) => releaseRelayTarget(targetId), onSessionsChanged: async (entries) => { if (catalogWritesEnabled) await sessionCatalog?.save(entries) }, @@ -454,7 +478,14 @@ const makeRelay = Effect.fnUntraced(function* (options: { recordingRelay, sessions, extensionStatus: () => { - return { connected: extensionRpc.connected, version: extensionRpc.version ?? null, cdpClients: cdpClients.size } + return { + connected: extensionRpc.connected, + version: extensionRpc.version ?? null, + protocolVersion: extensionRpc.protocolVersion ?? null, + protocolCompatible: extensionRpc.protocolCompatible ?? null, + protocolLegacy: extensionRpc.protocolLegacy ?? null, + cdpClients: cdpClients.size, + } }, }) let relayReady = false @@ -598,26 +629,36 @@ const makeRelay = Effect.fnUntraced(function* (options: { websocketServer.on("connection", (socket, request) => { const requestUrl = new URL(request.url ?? "/", endpointUrl) if (requestUrl.pathname === "/extension") { - extensionRpc.replaceSocket(socket) + let handshaken = false + let socketGeneration = 0 + const announcedRootTabIds = new Set() socket.on("message", (data, isBinary) => { try { + if (!handshaken) { + const acceptedGeneration = isBinary ? undefined : acceptExtensionHello(socket, data.toString()) + if (acceptedGeneration === undefined) { + socket.close(4002, "Extension hello required") + return + } + socketGeneration = acceptedGeneration + handshaken = true + return + } + if (!extensionRpc.isCurrent(socket) || !extensionRpc.acceptsEvents) { + return + } if (isBinary) { recordingRelay.handleBinaryData(rawDataToBuffer(data)) return } - handleExtensionMessage(data.toString()) + handleExtensionMessage(socket, data.toString(), socketGeneration, announcedRootTabIds) } catch (error) { console.error("Extension message handling failed", error) } }) socket.on("close", () => { if (extensionRpc.disconnectIfCurrent(socket)) { - void recordingRelay.cleanupAll("Extension disconnected").catch(() => {}) - for (const target of registry.listRootTargets()) { - cancelTargetHandoffs(target, "target-detached") - } - registry.clear() - suppressedChildSessions.clear() + clearLiveExtensionState("Extension disconnected") } }) return @@ -666,7 +707,30 @@ const makeRelay = Effect.fnUntraced(function* (options: { detachTargetState(target.tabId) }) - function handleExtensionMessage(raw: string): void { + function acceptExtensionHello(socket: WebSocket, raw: string): number | undefined { + const message = parseJsonObject(raw) + if (!isExtensionEvent(message) || message.method !== "hello") { + return undefined + } + const protocol = extensionProtocolCompatibility(message.params?.protocolVersion) + if (!protocol.compatible && extensionRpc.connected) { + socket.close(4003, "Extension protocol incompatible") + return extensionGeneration + } + extensionGeneration += 1 + clearLiveExtensionState("Extension replaced") + extensionRpc.replaceSocket(socket) + extensionRpc.markHandshake( + typeof message.params?.version === "string" ? message.params.version : undefined, + message.params?.protocolVersion, + ) + if (protocol.legacy) { + extensionRpc.markReady() + } + return extensionGeneration + } + + function handleExtensionMessage(socket: WebSocket, raw: string, generation: number, announcedRootTabIds: Set): void { const message = parseJsonObject(raw) if (isExtensionResponse(message)) { extensionRpc.handleResponse(message) @@ -678,19 +742,37 @@ const makeRelay = Effect.fnUntraced(function* (options: { } const extensionMethod = message.method as string if (extensionMethod === "hello") { - extensionRpc.markReady(typeof message.params?.version === "string" ? message.params.version : undefined) + return + } + if (extensionMethod === "ready") { + const workers = Array.from(rootReconciliationWorkers.values()) + .filter((worker) => worker.generation === generation) + .map((worker) => worker.promise) + void Promise.all(workers).then((reconciled) => { + if (!extensionRpc.isCurrent(socket) || generation !== extensionGeneration) return + if (reconciled.every(Boolean)) { + for (const target of registry.listRootTargets()) { + if (!announcedRootTabIds.has(target.tabId)) detachTargetState(target.tabId) + } + extensionRpc.markReady() + } else { + socket.close(1011, "Target inventory reconciliation failed") + } + }) return } if (extensionMethod === "debugger.attached") { const tabId = typeof message.params?.tabId === "number" ? message.params.tabId : undefined if (tabId) { - queueRootReconciliation(tabId, true, 0, "Debugger re-announce failed") + announcedRootTabIds.add(tabId) + queueRootReconciliation(tabId, true, 0, "Debugger re-announce failed", generation) } return } if (extensionMethod === "toolbar.clicked") { const tabId = typeof message.params?.tabId === "number" ? message.params.tabId : undefined if (tabId) { + announcedRootTabIds.add(tabId) handleToolbarClick(tabId) } return @@ -1057,10 +1139,9 @@ const makeRelay = Effect.fnUntraced(function* (options: { if (message.method === "Target.setAutoAttach" && !message.sessionId) { autoAttachParams = message.params for (const target of registry.targets.values()) { + if (!canSeeTarget(socket, target)) continue yield* Effect.ignore(sendDebuggerCommand({ tabId: target.tabId, method: "Target.setAutoAttach", params: message.params ?? {} })) - if (canSeeTarget(socket, target)) { - sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) - } + sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } return {} } @@ -1363,11 +1444,15 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly owner: "relay" | "user" readonly browserControlSessionId?: string readonly alreadyAttached?: boolean + readonly expectedExtensionGeneration?: number }) { const semaphore = rootLifecycleSemaphores.get(options.tabId) ?? Semaphore.makeUnsafe(1) rootLifecycleSemaphores.set(options.tabId, semaphore) return yield* semaphore.withPermit(Effect.gen(function* () { if (relayClosing) return yield* Effect.fail(new Error("Relay is closing")) + if (options.expectedExtensionGeneration !== undefined && options.expectedExtensionGeneration !== extensionGeneration) { + return yield* Effect.fail(new Error("Extension changed before target reconciliation acquired its permit")) + } return yield* attachTabUnlocked(options) })) }) @@ -1406,18 +1491,28 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) }) - const reconcileAttachedRoot = Effect.fnUntraced(function* (tabId: number) { + const reconcileAttachedRoot = Effect.fnUntraced(function* (tabId: number, expectedExtensionGeneration?: number) { const semaphore = rootLifecycleSemaphores.get(tabId) ?? Semaphore.makeUnsafe(1) rootLifecycleSemaphores.set(tabId, semaphore) yield* semaphore.withPermit(Effect.gen(function* () { if (relayClosing) return + if (expectedExtensionGeneration !== undefined && expectedExtensionGeneration !== extensionGeneration) { + return yield* Effect.fail(new Error("Extension changed before target reconciliation acquired its permit")) + } yield* reconcileAttachedRootUnlocked(tabId) })) }) - function queueRootReconciliation(tabId: number, attachIfMissing: boolean, verificationRetries: number, errorMessage: string): void { + function queueRootReconciliation( + tabId: number, + attachIfMissing: boolean, + verificationRetries: number, + errorMessage: string, + generation = extensionGeneration, + ): void { if (relayClosing) return - const existing = rootReconciliationWorkers.get(tabId) + const workerKey = `${generation}:${tabId}` + const existing = rootReconciliationWorkers.get(workerKey) if (existing) { existing.pending = true existing.attachIfMissing ||= attachIfMissing @@ -1426,21 +1521,33 @@ const makeRelay = Effect.fnUntraced(function* (options: { } const worker: RootReconciliationWorker = { attachIfMissing, + generation, pending: false, - promise: Promise.resolve(), + promise: Promise.resolve(true), verificationRetries, } worker.promise = (async () => { let retries = 0 + let reconciled = true do { + if (generation !== extensionGeneration) return false worker.pending = false const mayAttach = worker.attachIfMissing worker.attachIfMissing = false try { if (registry.tabTargets.has(tabId)) { - await Effect.runPromise(reconcileAttachedRoot(tabId)) + await Effect.runPromise(reconcileAttachedRoot(tabId, generation)) } else if (mayAttach && !relayClosing) { - await Effect.runPromise(attachTab({ tabId, owner: "user", alreadyAttached: true })) + await Effect.runPromise(attachTab({ + tabId, + owner: "user", + alreadyAttached: true, + expectedExtensionGeneration: generation, + })) + } + if (generation !== extensionGeneration) { + detachTargetState(tabId, { preserveSessionTarget: true, updateExtension: false }) + return false } retries = 0 if (worker.verificationRetries > 0 && !relayClosing) { @@ -1451,20 +1558,27 @@ const makeRelay = Effect.fnUntraced(function* (options: { } } catch (error) { console.error(errorMessage, error) - if (registry.stagedRootTarget(tabId) && retries < 2 && !relayClosing) { + if (generation !== extensionGeneration) { + detachTargetState(tabId, { preserveSessionTarget: true, updateExtension: false }) + reconciled = false + } else if (retries < 2 && !relayClosing) { retries += 1 + worker.attachIfMissing ||= mayAttach await new Promise((resolve) => setTimeout(resolve, 100 * retries)) worker.pending = true + } else { + reconciled = false } } } while (worker.pending && !relayClosing) + return reconciled })().finally(() => { - if (rootReconciliationWorkers.get(tabId) === worker) { - rootReconciliationWorkers.delete(tabId) + if (rootReconciliationWorkers.get(workerKey) === worker) { + rootReconciliationWorkers.delete(workerKey) } if (!registry.tabTargets.has(tabId)) rootLifecycleSemaphores.delete(tabId) }) - rootReconciliationWorkers.set(tabId, worker) + rootReconciliationWorkers.set(workerKey, worker) } const injectGhostCursor = Effect.fnUntraced(function* (tabId: number) { @@ -1511,19 +1625,24 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) }) - function detachTargetState(tabId: number): void { - Effect.runPromise(Effect.ignore(sendToExtension({ method: "pageStatus.clear", params: { tabId } }))).catch(() => {}) - Effect.runPromise(Effect.ignore(sendToExtension({ method: "tabs.ungroup", params: { tabId } }))).catch(() => {}) - Effect.runPromise(Effect.ignore(sendToExtension({ method: "action.setAttached", params: { tabId, attached: false } }))).catch(() => {}) - void recordingRelay.abortRecordingForTab({ tabId, reason: "Tab detached" }).catch((error: unknown) => { - console.error("Failed to abort recording for detached tab", error) - }) + function detachTargetState(tabId: number, options: { + readonly preserveSessionTarget?: boolean + readonly updateExtension?: boolean + } = {}): void { + if (options.updateExtension !== false) { + Effect.runPromise(Effect.ignore(sendToExtension({ method: "pageStatus.clear", params: { tabId } }))).catch(() => {}) + Effect.runPromise(Effect.ignore(sendToExtension({ method: "tabs.ungroup", params: { tabId } }))).catch(() => {}) + Effect.runPromise(Effect.ignore(sendToExtension({ method: "action.setAttached", params: { tabId, attached: false } }))).catch(() => {}) + void recordingRelay.abortRecordingForTab({ tabId, reason: "Tab detached" }).catch((error: unknown) => { + console.error("Failed to abort recording for detached tab", error) + }) + } const detached = registry.detachRootTargetState(tabId) if (!detached) { return } cancelTargetHandoffs(detached.target, "target-detached") - sessions.markTargetDetached(detached.target.targetInfo.targetId) + if (!options.preserveSessionTarget) sessions.markTargetDetached(detached.target.targetInfo.targetId) removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.tabId === tabId) mainFrameIdsByTab.delete(tabId) ghostCursorPositionsByTab.delete(tabId) diff --git a/test/cli-output.test.ts b/test/cli-output.test.ts index ce450d2..c5f60fe 100644 --- a/test/cli-output.test.ts +++ b/test/cli-output.test.ts @@ -11,7 +11,7 @@ vi.mock("@effect/platform-node", async (importOriginal) => { } }) -import { executeJsonEnvelope, formatSessionContinuation } from "../src/cli.ts" +import { executeJsonEnvelope, formatSessionContinuation, normalizeCliArguments } from "../src/cli.ts" import type { ExecuteResponse } from "../src/relay-schema.ts" const session: ExecuteResponse["session"] = { @@ -77,3 +77,44 @@ describe("session continuation", () => { ) }) }) + +describe("CLI argument normalization", () => { + it("preserves secrets run operands after the end-of-options delimiter", () => { + const normalized = normalizeCliArguments([ + "secrets", + "run", + "github", + "--", + "/usr/bin/node", + "-e", + "process.stdout.write(process.env.BC_SECRET_1 || '')", + ]) + + expect(normalized).toEqual([ + "secrets", + "run", + "github", + "bc-cli-operands:v1", + "bc-cli-operand:%2Fusr%2Fbin%2Fnode", + "bc-cli-operand:-e", + "bc-cli-operand:process.stdout.write(process.env.BC_SECRET_1%20%7C%7C%20'')", + ]) + }) + + it("leaves other commands unchanged", () => { + const args = ["execute", "--", "return page.url()"] + expect(normalizeCliArguments(args)).toBe(args) + }) + + it("finds secrets run after leading command flags", () => { + expect(normalizeCliArguments(["--help", "secrets", "run", "github", "--", "printf", "-n"])).toEqual([ + "--help", + "secrets", + "run", + "github", + "bc-cli-operands:v1", + "bc-cli-operand:printf", + "bc-cli-operand:-n", + ]) + }) +}) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index ea7afc7..bcc7a41 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" +import { extensionProtocolCheck, formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" describe("formatTargetSummary", () => { it("shows crashed target state", () => { @@ -67,3 +67,33 @@ describe("relayBuildCheck", () => { })).toMatchObject({ status: "warn", message: "runtime build-from-dist does not match CLI dev" }) }) }) + +describe("extensionProtocolCheck", () => { + it("accepts independently-versioned extensions on the supported protocol", () => { + expect(extensionProtocolCheck({ + ok: true, + value: { + connected: true, + version: "9.4.2", + protocolVersion: 1, + protocolCompatible: true, + protocolLegacy: false, + activeTargets: 0, + }, + })).toMatchObject({ status: "ok", message: "runtime 1 is compatible with relay 1" }) + }) + + it("fails an incompatible extension protocol", () => { + expect(extensionProtocolCheck({ + ok: true, + value: { + connected: false, + version: "10.0.0", + protocolVersion: 2, + protocolCompatible: false, + protocolLegacy: false, + activeTargets: 0, + }, + })).toMatchObject({ status: "fail", message: "runtime 2 is incompatible with relay 1" }) + }) +}) diff --git a/test/execute-ergonomics.test.ts b/test/execute-ergonomics.test.ts index 9984530..e673ecb 100644 --- a/test/execute-ergonomics.test.ts +++ b/test/execute-ergonomics.test.ts @@ -238,6 +238,7 @@ describe("snapshot helpers", () => { const helpers = createSnapshotHelpers(page, { selectors: new Map() }) await expect(helpers.snapshot()).resolves.toBe('- heading "Settings" [level=1]\n - button "Save" [ref=e1]') + expect(evaluate.mock.calls[0]?.[0].toString()).toContain("const __name = (target) => target") expect(evaluate.mock.calls[0]?.[1]).toMatchObject({ maxItems: 80, rootSelector: undefined }) expect(helpers.ref("@e1")).toBe(resolvedLocator) expect(page.locator).toHaveBeenLastCalledWith("#save") diff --git a/test/execute-target-selection.test.ts b/test/execute-target-selection.test.ts index b0d8cfe..9ddd746 100644 --- a/test/execute-target-selection.test.ts +++ b/test/execute-target-selection.test.ts @@ -25,7 +25,7 @@ describe("target selection", () => { await expect(waitForExactTarget({ targetId: "replacement", - timeoutMs: 20, + timeoutMs: 1_000, candidates: () => ++attempts < 2 ? [old, decoy] : [old, decoy, replacement], getTargetId: async (candidate) => candidate.id, delay: () => Promise.resolve(), @@ -76,7 +76,7 @@ describe("target selection", () => { let attempts = 0 await expect(waitForExactTarget({ targetId: "replacement", - timeoutMs: 20, + timeoutMs: 1_000, candidates: () => [candidate], getTargetId: async () => ++attempts === 1 ? undefined : candidate.id, delay: () => Promise.resolve(), diff --git a/test/extension-package.test.ts b/test/extension-package.test.ts new file mode 100644 index 0000000..5ef9360 --- /dev/null +++ b/test/extension-package.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, expect, it } from "vitest" +import { unzipSync } from "fflate" +import { extensionVersion, isChromeExtensionVersion, makeExtensionArchive } from "../scripts/package-extension.ts" + +describe("Chrome Web Store extension package", () => { + it("is deterministic and rooted at the extension manifest", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-extension-package-")) + try { + await fs.mkdir(path.join(directory, "icons")) + await fs.writeFile(path.join(directory, "manifest.json"), JSON.stringify({ manifest_version: 3, version: "1.2.3.4" })) + await fs.writeFile(path.join(directory, "background.js"), "export {}\n") + await fs.writeFile(path.join(directory, "icons", "icon-16.png"), new Uint8Array([1, 2, 3])) + + const first = await makeExtensionArchive(directory) + await fs.utimes(path.join(directory, "background.js"), new Date(), new Date()) + const second = await makeExtensionArchive(directory) + + expect(second).toEqual(first) + expect(Object.keys(unzipSync(first)).sort()).toEqual([ + "background.js", + "icons/icon-16.png", + "manifest.json", + ]) + expect(await extensionVersion(directory)).toBe("1.2.3.4") + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + + it("validates Chrome extension version components", () => { + expect(isChromeExtensionVersion("0.0.19")).toBe(true) + expect(isChromeExtensionVersion("65535.1.2.3")).toBe(true) + expect(isChromeExtensionVersion("1.2.3.4.5")).toBe(false) + expect(isChromeExtensionVersion("65536")).toBe(false) + expect(isChromeExtensionVersion("01.2")).toBe(false) + expect(isChromeExtensionVersion("1.-2")).toBe(false) + }) + + it("rejects source maps", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-extension-package-")) + try { + await fs.writeFile(path.join(directory, "background.js.map"), "{}") + await expect(makeExtensionArchive(directory)).rejects.toThrow("must not contain source maps") + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/test/extension-rpc.test.ts b/test/extension-rpc.test.ts index a5c570e..9aba1ad 100644 --- a/test/extension-rpc.test.ts +++ b/test/extension-rpc.test.ts @@ -56,7 +56,8 @@ const makeFakeSocket = (): FakeSocket => { const connect = (rpc: ExtensionRpc): FakeSocket => { const socket = makeFakeSocket() rpc.replaceSocket(socket) - rpc.markReady("0.0.5") + rpc.markHandshake("0.0.5", 1) + rpc.markReady() return socket } @@ -78,6 +79,19 @@ describe("ExtensionRpc", () => { ) }) + it("refuses commands from an incompatible extension protocol", async () => { + const rpc = new ExtensionRpc() + const socket = makeFakeSocket() + rpc.replaceSocket(socket) + rpc.markHandshake("1.0.0", 2) + + expect(rpc.connected).toBe(false) + await expect(Effect.runPromise(rpc.send({ method: "tabs.create", params: {} }))).rejects.toThrow( + "extension protocol 2 is incompatible", + ) + expect(socket.sent).toHaveLength(0) + }) + it("a command timeout fails only that command and leaves the socket open", async () => { const rpc = new ExtensionRpc({ commandTimeoutMs: 20, livenessProbeTimeoutMs: 1_000 }) const socket = connect(rpc) diff --git a/test/http-api.test.ts b/test/http-api.test.ts index 456a29b..30d2fe4 100644 --- a/test/http-api.test.ts +++ b/test/http-api.test.ts @@ -41,7 +41,13 @@ describe("HTTP request schemas", () => { host: "127.0.0.1", port, browserId: "test-browser", - extensionStatus: () => ({ connected: true, version: "test" }), + extensionStatus: () => ({ + connected: true, + version: "9.4.2", + protocolVersion: 1, + protocolCompatible: true, + protocolLegacy: false, + }), recordingRelay: new RecordingRelay({ isExtensionConnected: () => true, sendToExtension: async () => ({}), @@ -58,6 +64,14 @@ describe("HTTP request schemas", () => { startedAt: "2026-07-19T00:00:00.000Z", pid: 123, }) + const extension = await fetch(`http://127.0.0.1:${port}/extension/status`).then((response) => response.json()) + expect(extension).toMatchObject({ + connected: true, + version: "9.4.2", + protocolVersion: 1, + protocolCompatible: true, + protocolLegacy: false, + }) await expect(postJson(port, "/cli/session/new", { id: "alpha", readOnly: "yes" })).resolves.toMatchObject({ status: 400, body: { error: expect.stringContaining("Invalid session new request"), code: "invalid-request" }, diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 9176594..e06dce8 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest" import { + extensionProtocolCompatibility, + extensionProtocolVersion, isCdpRequest, isExtensionEvent, isExtensionResponse, @@ -30,4 +32,23 @@ describe("protocol validation", () => { expect(isExtensionEvent(parseJsonObject('{"method":"toolbar.clicked","params":{"tabId":7}}'))).toBe(true) expect(isExtensionEvent(parseJsonObject('{"method":"unknown"}'))).toBe(false) }) + + it("treats legacy hellos as protocol v1 and rejects unknown protocol versions", () => { + expect(extensionProtocolCompatibility(undefined)).toEqual({ + version: extensionProtocolVersion, + compatible: true, + legacy: true, + }) + expect(extensionProtocolCompatibility(extensionProtocolVersion)).toEqual({ + version: extensionProtocolVersion, + compatible: true, + legacy: false, + }) + expect(extensionProtocolCompatibility(extensionProtocolVersion + 1)).toMatchObject({ compatible: false, legacy: false }) + expect(extensionProtocolCompatibility("invalid")).toEqual({ version: null, compatible: false, legacy: false }) + expect(extensionProtocolCompatibility(null)).toEqual({ version: null, compatible: false, legacy: false }) + expect(extensionProtocolCompatibility(1.5)).toEqual({ version: null, compatible: false, legacy: false }) + expect(extensionProtocolCompatibility(-1)).toEqual({ version: null, compatible: false, legacy: false }) + expect(extensionProtocolCompatibility({})).toEqual({ version: null, compatible: false, legacy: false }) + }) }) diff --git a/test/relay-child-dedupe.test.ts b/test/relay-child-dedupe.test.ts index fd1653d..e6456da 100644 --- a/test/relay-child-dedupe.test.ts +++ b/test/relay-child-dedupe.test.ts @@ -21,7 +21,7 @@ async function freePort(): Promise { } async function openSocket(url: string): Promise { - const socket = new WebSocket(url) + const socket = new WebSocket(url, { origin: "chrome-extension://browser-control-test" }) await new Promise((resolve, reject) => { socket.once("open", resolve) socket.once("error", reject) diff --git a/test/relay-extension-handshake.test.ts b/test/relay-extension-handshake.test.ts new file mode 100644 index 0000000..4d8bac3 --- /dev/null +++ b/test/relay-extension-handshake.test.ts @@ -0,0 +1,186 @@ +import { Effect } from "effect" +import { describe, expect, it } from "vitest" +import { WebSocket } from "ws" +import type { CdpEvent, CdpRequest, CdpResponse } from "../src/protocol.ts" +import { startRelay } from "../src/relay.ts" + +describe("relay extension handshake", () => { + it("rejects pre-hello events and keeps them from mutating target state", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectExtension(relay.url)) + const closed = waitForClose(extension) + extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) + + expect(yield* Effect.promise(() => closed)).toBe(4002) + const status = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) + expect(status).toMatchObject({ connected: false, activeTargets: 0 }) + }))) + }) + + it("reports incompatible protocol without accepting its events", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectExtension(relay.url)) + extension.send(JSON.stringify({ method: "hello", params: { version: "2.0.0", protocolVersion: 2 } })) + extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) + yield* Effect.sleep("20 millis") + + const status = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) + expect(status).toMatchObject({ + connected: false, + version: "2.0.0", + protocolVersion: 2, + protocolCompatible: false, + protocolLegacy: false, + activeTargets: 0, + }) + extension.close() + }))) + }) + + it("becomes connected after a compatible extension finishes re-announcement", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectExtension(relay.url)) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.19", protocolVersion: 1 } })) + + const beforeReady = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) + expect(beforeReady).toMatchObject({ connected: false, protocolVersion: 1, protocolCompatible: true }) + + extension.send(JSON.stringify({ method: "ready" })) + yield* Effect.sleep("10 millis") + const ready = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) + expect(ready).toMatchObject({ connected: true, protocolVersion: 1, protocolCompatible: true, protocolLegacy: false }) + extension.close() + }))) + }) + + it("replaces the previous socket inventory before accepting a new ready event", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const first = yield* Effect.promise(() => connectRespondingExtension(relay.url, "stale-target")) + first.send(JSON.stringify({ method: "hello", params: { version: "0.0.20", protocolVersion: 1 } })) + first.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) + first.send(JSON.stringify({ method: "ready" })) + yield* Effect.promise(() => waitForStatus(relay.url, (status) => status.connected === true && status.activeTargets === 1)) + const client = yield* Effect.promise(() => connectCdpClient(relay.url)) + yield* Effect.promise(() => sendCdp(client, { id: 1, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) + expect(client.events.some((event) => event.method === "Target.attachedToTarget")).toBe(true) + + const second = yield* Effect.promise(() => connectExtension(relay.url)) + second.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + second.send(JSON.stringify({ method: "ready" })) + const status = yield* Effect.promise(() => waitForStatus(relay.url, (candidate) => candidate.connected === true)) + + expect(status.activeTargets).toBe(0) + yield* Effect.promise(() => waitFor(() => client.events.some((event) => event.method === "Target.detachedFromTarget"))) + client.close() + second.close() + }))) + }) + + it("rejects ready when an announced target cannot be reconciled", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectRespondingExtension(relay.url, undefined, "synthetic reconciliation failure")) + const closed = waitForClose(extension) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) + extension.send(JSON.stringify({ method: "ready" })) + + expect(yield* Effect.promise(() => closed)).toBe(1011) + const status = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) + expect(status).toMatchObject({ connected: false, activeTargets: 0 }) + }))) + }) +}) + +type ExtensionStatus = { readonly connected: boolean; readonly activeTargets: number } +type CdpMessage = CdpEvent | CdpResponse + +async function connectRespondingExtension(relayUrl: string, targetId?: string, error?: string): Promise { + const socket = await connectExtension(relayUrl) + socket.on("message", (data) => { + const command = JSON.parse(data.toString()) as { readonly id: number; readonly method: string; readonly params?: { readonly method?: string } } + if (error) { + socket.send(JSON.stringify({ id: command.id, error })) + return + } + const result = command.method === "debugger.sendCommand" && command.params?.method === "Target.getTargetInfo" && targetId + ? { targetInfo: { targetId, type: "page", title: "Test", url: "https://example.com/", attached: true, canAccessOpener: false } } + : {} + socket.send(JSON.stringify({ id: command.id, result })) + }) + return socket +} + +function connectExtension(relayUrl: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${relayUrl.replace(/^http/, "ws")}/extension`, { + origin: "chrome-extension://browser-control-test", + }) + socket.once("open", () => resolve(socket)) + socket.once("error", reject) + }) +} + +function waitForClose(socket: WebSocket): Promise { + return new Promise((resolve) => { + socket.once("close", (code) => resolve(code)) + }) +} + +async function waitForStatus(relayUrl: string, predicate: (status: ExtensionStatus) => boolean): Promise { + const deadline = Date.now() + 2_000 + while (true) { + const status = await fetch(`${relayUrl}/extension/status`).then((response) => response.json()) as ExtensionStatus + if (predicate(status)) return status + if (Date.now() >= deadline) throw new Error("Timed out waiting for extension status") + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function connectCdpClient(relayUrl: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${relayUrl.replace(/^http/, "ws")}/devtools/browser/test`) + const messages: CdpMessage[] = [] + const events: CdpEvent[] = [] + socket.on("open", () => resolve(Object.assign(socket, { events, messages }))) + socket.on("error", reject) + socket.on("message", (data) => { + const message = JSON.parse(data.toString()) as CdpMessage + messages.push(message) + if ("method" in message) events.push(message) + }) + }) +} + +function sendCdp(socket: WebSocket & { readonly messages: CdpMessage[] }, request: CdpRequest): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timed out waiting for CDP response ${request.id}`)), 1_000) + const onMessage = () => { + const response = socket.messages.find((message): message is CdpResponse => "id" in message && message.id === request.id) + if (!response) return + clearTimeout(timeout) + socket.off("message", onMessage) + if (response.error) reject(new Error(response.error.message)) + else resolve() + } + socket.on("message", onMessage) + socket.send(JSON.stringify(request)) + }) +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for relay event") + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} diff --git a/test/relay-helpers.test.ts b/test/relay-helpers.test.ts index c6dd139..1b5c474 100644 --- a/test/relay-helpers.test.ts +++ b/test/relay-helpers.test.ts @@ -48,12 +48,13 @@ describe("validateBrowserFetchSite", () => { }) describe("validateWebSocketOrigin", () => { - it("accepts extension origins and missing origins", () => { + it("accepts extension origins and missing origins for non-extension clients", () => { expect(validateWebSocketOrigin({ origin: "chrome-extension://abc" })).toBeUndefined() expect(validateWebSocketOrigin({ origin: undefined })).toBeUndefined() }) it("rejects web origins for the extension endpoint", () => { + expect(validateWebSocketOrigin({ origin: undefined, requireChromeExtension: true })).toBeDefined() expect(validateWebSocketOrigin({ origin: "https://example.com", requireChromeExtension: true })).toBeDefined() expect(validateWebSocketOrigin({ origin: "https://example.com" })).toBeDefined() }) diff --git a/test/relay-lifecycle.test.ts b/test/relay-lifecycle.test.ts index 3886042..40f86a5 100644 --- a/test/relay-lifecycle.test.ts +++ b/test/relay-lifecycle.test.ts @@ -137,6 +137,47 @@ describe("relay lifecycle", () => { expect(attempts).toBe(2) }) + it("allows a cold extension more than two seconds of reconnect probes", async () => { + let attempts = 0 + const client = relay({ + version: Effect.succeed(version), + extensionStatus: Effect.sync(() => ({ connected: ++attempts >= 42, version: "0.0.11", activeTargets: 0 })), + }) + + const status = await Effect.runPromise(ensureExtensionConnected({ + relay: client, + waitForReconnect: true, + retryDelayMs: 0, + })) + + expect(status.connected).toBe(true) + expect(attempts).toBe(42) + }) + + it("fails an incompatible extension protocol without retrying", async () => { + let attempts = 0 + const client = relay({ + version: Effect.succeed(version), + extensionStatus: Effect.sync(() => { + attempts++ + return { + connected: false, + version: "1.0.0", + protocolVersion: 2, + protocolCompatible: false, + activeTargets: 0, + } + }), + }) + + await expect(Effect.runPromise(ensureExtensionConnected({ + relay: client, + waitForReconnect: true, + retryDelayMs: 0, + }))).rejects.toThrow("protocol 2 is incompatible") + expect(attempts).toBe(1) + }) + it("formats stopped and consolidated status without extra relay requests", () => { expect(stoppedRelayStatus("http://127.0.0.1:19989")).toEqual({ endpoint: "http://127.0.0.1:19989", diff --git a/test/relay-schema.test.ts b/test/relay-schema.test.ts index fd9384a..161b294 100644 --- a/test/relay-schema.test.ts +++ b/test/relay-schema.test.ts @@ -248,6 +248,9 @@ describe("relay-schema", () => { const full = decodeExtensionStatus({ connected: true, version: "0.0.5", + protocolVersion: 1, + protocolCompatible: true, + protocolLegacy: false, activeTargets: 2, childTargets: 1, cdpClients: 3, @@ -255,6 +258,8 @@ describe("relay-schema", () => { targets: [], }) expect(full.childTargets).toBe(1) + expect(full.protocolCompatible).toBe(true) + expect(full.protocolLegacy).toBe(false) expect(full.sessions).toHaveLength(1) }) diff --git a/test/relay-session-persistence.test.ts b/test/relay-session-persistence.test.ts index 3f2b915..fbf6fd6 100644 --- a/test/relay-session-persistence.test.ts +++ b/test/relay-session-persistence.test.ts @@ -131,7 +131,7 @@ describe("relay session persistence", () => { }))) }) - it("retains a restored relay target when reset runs before extension re-announcement", async () => { + it("waits for a delayed relay target re-announcement before resetting", async () => { const port = await freePort() const directory = fs.mkdtempSync(path.join(os.tmpdir(), "browser-control-relay-sessions-")) temporaryDirectories.push(directory) @@ -146,23 +146,126 @@ describe("relay session persistence", () => { }]) await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const relay = yield* startRelay({ port, sessionCatalogPath }) + const relay = yield* startRelay({ port, sessionCatalogPath, releaseTargetGraceMs: 1_000 }) yield* Effect.tryPromise(async () => { - const reset = await fetch(new URL("/cli/session/reset", relay.url), { + let settled = false + const resetPromise = fetch(new URL("/cli/session/reset", relay.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: "restored" }), + }).then((response) => { + settled = true + return response }) - expect(reset.status).toBe(500) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + + const extension = await openProtocolExtension(relay.url, "relay-target") + const reset = await resetPromise + expect(reset.status).toBe(200) + expect(extension.commands.some((command) => command.method === "tabs.remove" && command.params?.tabId === 7)).toBe(true) await expect(catalog.load()).resolves.toMatchObject([{ id: "restored", - target: { id: "relay-target", owner: "relay" }, }]) + expect((await catalog.load())[0]?.target).toBeUndefined() + extension.close() + }) + }))) + }) + + for (const operation of ["reset", "delete"] as const) { + it(`${operation} forgets a relay target missing from a completed extension inventory`, async () => { + const port = await freePort() + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "browser-control-relay-sessions-")) + temporaryDirectories.push(directory) + const sessionCatalogPath = path.join(directory, "sessions.json") + const catalog = new SessionCatalog(sessionCatalogPath) + await catalog.save([{ + id: "restored", + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:01:00.000Z", + readOnly: false, + target: { id: "dead-target", owner: "relay" }, + }]) + + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath, releaseTargetGraceMs: 1_000 }) + yield* Effect.tryPromise(async () => { + const lifecycle = fetch(new URL(`/cli/session/${operation}`, relay.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "restored" }), + }) + const extension = await openProtocolExtension(relay.url) + const response = await lifecycle + + expect(response.status).toBe(200) + expect(extension.commands.some((command) => command.method === "tabs.remove")).toBe(false) + const entries = await catalog.load() + if (operation === "reset") { + expect(entries).toMatchObject([{ id: "restored" }]) + expect(entries[0]?.target).toBeUndefined() + } else { + expect(entries).toEqual([]) + } + extension.close() + }) + }))) + }) + } + + it("bounds stale relay target cleanup when no extension inventory arrives", async () => { + const port = await freePort() + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "browser-control-relay-sessions-")) + temporaryDirectories.push(directory) + const sessionCatalogPath = path.join(directory, "sessions.json") + const catalog = new SessionCatalog(sessionCatalogPath) + await catalog.save([{ + id: "restored", + createdAt: "2026-07-19T00:00:00.000Z", + updatedAt: "2026-07-19T00:01:00.000Z", + readOnly: false, + target: { id: "dead-target", owner: "relay" }, + }]) + + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath, releaseTargetGraceMs: 20 }) + yield* Effect.tryPromise(async () => { + const response = await fetch(new URL("/cli/session/reset", relay.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "restored" }), + }) + expect(response.status).toBe(200) + expect((await catalog.load())[0]?.target).toBeUndefined() }) }))) }) }) +type FakeExtensionCommand = { + readonly id: number + readonly method: string + readonly params?: { readonly method?: string; readonly tabId?: number } +} + +async function openProtocolExtension(relayUrl: string, targetId?: string): Promise { + const commands: FakeExtensionCommand[] = [] + const extension = await openSocket(`${relayUrl.replace("http://", "ws://")}/extension`) + extension.on("message", (data) => { + const command = JSON.parse(data.toString()) as FakeExtensionCommand + commands.push(command) + const result = command.method === "debugger.sendCommand" && command.params?.method === "Target.getTargetInfo" && targetId + ? { targetInfo: { targetId, type: "page", title: "Restored", url: "https://example.com/", attached: true, canAccessOpener: false } } + : {} + extension.send(JSON.stringify({ id: command.id, result })) + }) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + if (targetId) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) + extension.send(JSON.stringify({ method: "ready" })) + return Object.assign(extension, { commands }) +} + async function openFakeExtension(relayUrl: string, targetId: string): Promise { const extension = await openSocket(`${relayUrl.replace("http://", "ws://")}/extension`) extension.on("message", (data) => { @@ -178,7 +281,7 @@ async function openFakeExtension(relayUrl: string, targetId: string): Promise { - const socket = new WebSocket(url) + const socket = new WebSocket(url, { origin: "chrome-extension://browser-control-test" }) await new Promise((resolve, reject) => { socket.once("open", resolve) socket.once("error", reject) diff --git a/test/relay-visibility-prune.test.ts b/test/relay-visibility-prune.test.ts index 5fcd0b8..24da317 100644 --- a/test/relay-visibility-prune.test.ts +++ b/test/relay-visibility-prune.test.ts @@ -25,14 +25,20 @@ describe("relay target visibility pruning", () => { expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-1")).toBe(true) - const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 5, method: "Target.createTarget", params: { url: "about:blank" } })) - expect(ownedCreate.result?.targetId).toBe("target-2") + const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 5, method: "Target.createTarget", params: { url: "about:blank" } })) + expect(ownedCreate.result?.targetId).toBe("target-2") expect(sessionClient.events).toContainEqual({ method: "Target.detachedFromTarget", params: { sessionId: "bc-tab-1", targetId: "target-1" }, }) - expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) + expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) + + extension.commands.length = 0 + yield* Effect.promise(() => sendCdp(sessionClient, { id: 6, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) + expect(extension.commands.filter((command) => { + return command.method === "debugger.sendCommand" && command.params?.method === "Target.setAutoAttach" + }).map((command) => command.params?.tabId)).toEqual([2]) const beforeRawEventCount = sessionClient.events.filter((event) => event.method === "Runtime.consoleAPICalled" && event.sessionId === "bc-tab-1").length extension.send(JSON.stringify({ @@ -55,18 +61,26 @@ describe("relay target visibility pruning", () => { }) }) -function connectFakeExtension(relayUrl: string): Promise { +type ExtensionCommand = { + readonly id: number + readonly method: string + readonly params?: { readonly tabId?: number; readonly method?: string } +} + +function connectFakeExtension(relayUrl: string): Promise { return new Promise((resolve, reject) => { let nextTabId = 1 + const commands: ExtensionCommand[] = [] const socket = new WebSocket(`${relayUrl.replace(/^http/, "ws")}/extension`, { origin: "chrome-extension://browser-control-test" }) socket.on("open", () => { socket.send(JSON.stringify({ method: "hello", params: { version: "test" } })) - resolve(socket) + resolve(Object.assign(socket, { commands })) }) socket.on("error", reject) socket.on("message", (data) => { - const command = JSON.parse(data.toString()) as { readonly id: number; readonly method: string; readonly params?: JsonObject } - const params = command.params as { readonly tabId?: number; readonly method?: string } | undefined + const command = JSON.parse(data.toString()) as ExtensionCommand + commands.push(command) + const params = command.params let result: JsonObject = {} if (command.method === "tabs.create") { result = { tabId: nextTabId++ } From 30d7b94543cd5248ca608acc8aa5de5ab7c40607 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Jul 2026 14:09:50 -0400 Subject: [PATCH 2/9] refactor(relay): isolate CDP client routing --- .changeset/bright-clients-route.md | 9 + PLAN.md | 58 +++--- src/cdp-client-pool.ts | 115 ++++++++++++ src/cdp-router.ts | 115 ++++++++++++ src/cdp-shims.ts | 54 ++---- src/relay.ts | 276 ++++++++++------------------ src/target-registry.ts | 20 -- test/cdp-client-pool.test.ts | 152 +++++++++++++++ test/cdp-router.test.ts | 182 ++++++++++++++++++ test/cdp-shims.test.ts | 64 ++----- test/relay-child-dedupe.test.ts | 31 ++++ test/relay-visibility-prune.test.ts | 119 +++++++++++- test/target-registry.test.ts | 12 +- 13 files changed, 884 insertions(+), 323 deletions(-) create mode 100644 .changeset/bright-clients-route.md create mode 100644 src/cdp-client-pool.ts create mode 100644 src/cdp-router.ts create mode 100644 test/cdp-client-pool.test.ts create mode 100644 test/cdp-router.test.ts diff --git a/.changeset/bright-clients-route.md b/.changeset/bright-clients-route.md new file mode 100644 index 0000000..b9416a2 --- /dev/null +++ b/.changeset/bright-clients-route.md @@ -0,0 +1,9 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Isolate CDP client state so concurrent clients retain their own auto-attach +settings, invalidate target aliases when ownership hides a tab, reject hidden +session routing, avoid arbitrary target fallback, and detach child targets when +their root disappears. Centralize target and alias routing so stale root and +child sessions fail closed. diff --git a/PLAN.md b/PLAN.md index b343625..65b6693 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,8 +17,8 @@ Agent / MCP client / CLI -> user's Chromium-family browser tabs ``` -The end-to-end path is working. Current work should finish CDP routing -correctness, simplify the relay, and make recording robust. New features +The end-to-end path is working. Current work should simplify the relay and make +recording robust. New features should not weaken the code-first interface or move behavior into the extension without a concrete browser-API reason. @@ -27,34 +27,29 @@ without a concrete browser-API reason. Work these in order unless field evidence changes the priority. Every item should land with unit or smoke evidence appropriate to the behavior. -### 1. Finish CDP routing correctness - -- Emit child `Target.detachedFromTarget` events when a root target detaches so - clients cannot retain orphaned child sessions. -- Remove arbitrary-first-target fallbacks from `Target.getTargetInfo` and other - sessionless CDP routing. -- Store `autoAttachParams` per client instead of using global - last-writer-wins state. - -Verification: - -- Extend reconnect, OOPIF, and multi-client smoke cases to cover root detach and - conflicting client auto-attach settings. - -### 2. Split the relay into testable responsibilities +### 1. Split the relay into testable responsibilities Extract cohesive modules from `makeRelay` without changing the protocol: -- `CdpRouter`: command routing, guardrails, and compatibility shims. +- Deepen `CdpRouter` with command classification, guardrails, and compatibility + shims. - `ExtensionEventHandler`: extension event decoding and registry mutation. -- `CdpClientPool`: client sockets, per-client attachment sets, and connection - generations. + +`CdpClientPool` now owns client sockets, per-client attachment sets, aliases, +auto-attach settings, and connection generations. `CdpRouter` now owns +client-relative visibility, target inventory, target and alias resolution, and +exact root-versus-child Chrome session routing. The goal is browser-free testing of routing and lifecycle behavior, not smaller files for their own sake. Keep orchestration in `makeRelay` and avoid exposing internal protocol details to the CLI or MCP server. -### 3. Stream recordings with unambiguous framing +Verification: + +- Extend reconnect, OOPIF, and multi-client smoke cases to cover root detach and + conflicting client auto-attach settings. + +### 2. Stream recordings with unambiguous framing - Include the tab id and sequence number in each binary websocket frame instead of pairing a JSON metadata frame with the next binary frame. @@ -70,7 +65,7 @@ Verification: in-memory bound. - Confirm CLI and MCP recording behavior match. -### 4. Resolve smaller agent-experience gaps +### 3. Resolve smaller agent-experience gaps - Make `fillInput` and `fillInputs` search open shadow roots recursively. Closed shadow roots remain unsupported. When no DOM match exists, the error should @@ -92,6 +87,25 @@ Verification: ## Recently Shipped +### CDP routing fails closed + +Identity-free `Target.getTargetInfo` no longer returns an arbitrary tab, and +otherwise-unhandled sessionless CDP commands require an explicit session. All +explicit target and session routing now rechecks client visibility, including +session-scoped auto-attach. Root teardown emits each announced child detach +before detaching the root so clients cannot retain orphaned sessions. The +browser-free `CdpRouter` module keeps these visibility, alias, and generation +rules out of relay transport orchestration. + +### CDP client state is isolated per connection + +`CdpClientPool` now owns each CDP client's session identity, target +announcements, aliases, auto-attach settings, and idle-reset generation. New +targets use the originating client's auto-attach settings instead of global +last-writer-wins state. Ownership visibility changes also invalidate target +aliases, so a client cannot continue routing commands to a tab after it becomes +hidden. + ### Wedged session pages recover or fail fast A 2026-07-09 field failure left a relay-owned page open but unusable after its diff --git a/src/cdp-client-pool.ts b/src/cdp-client-pool.ts new file mode 100644 index 0000000..8da2ea9 --- /dev/null +++ b/src/cdp-client-pool.ts @@ -0,0 +1,115 @@ +import { createClientTargetAnnouncements, type ClientCdpSessionAlias, type ClientTargetAnnouncements } from "./cdp-shims.ts" +import type { JsonObject } from "./protocol.ts" +import type { ChildTarget, ConnectedTarget } from "./relay-types.ts" + +type CdpClientState = { + readonly aliases: Map + readonly announcements: ClientTargetAnnouncements + readonly browserControlSessionId?: string + autoAttachParams?: JsonObject +} + +export class CdpClientPool implements Iterable { + private readonly states = new Map() + private nextAliasId = 1 + private connectionGeneration = 0 + + register(client: Client, browserControlSessionId?: string): void { + if (this.states.has(client)) throw new Error("CDP client is already registered") + this.states.set(client, { + aliases: new Map(), + announcements: createClientTargetAnnouncements(), + ...(browserControlSessionId ? { browserControlSessionId } : {}), + }) + this.connectionGeneration += 1 + } + + unregister(client: Client): number | undefined { + if (!this.states.delete(client) || this.states.size !== 0) return undefined + return ++this.connectionGeneration + } + + get size(): number { + return this.states.size + } + + [Symbol.iterator](): IterableIterator { + return this.states.keys() + } + + sessionId(client: Client): string | undefined { + return this.states.get(client)?.browserControlSessionId + } + + announcements(client: Client): ClientTargetAnnouncements { + return this.requireState(client).announcements + } + + setAutoAttachParams(client: Client, params: JsonObject | undefined): void { + const state = this.requireState(client) + if (params === undefined) delete state.autoAttachParams + else state.autoAttachParams = params + } + + autoAttachParams(client: Client): JsonObject | undefined { + return this.states.get(client)?.autoAttachParams + } + + isCurrentIdleGeneration(generation: number): boolean { + return this.states.size === 0 && this.connectionGeneration === generation + } + + createBrowserAlias(client: Client): string { + const aliasId = this.nextAlias("browser") + this.requireState(client).aliases.set(aliasId, { kind: "browser" }) + return aliasId + } + + createTargetAlias(client: Client, target: ConnectedTarget | ChildTarget, rootSessionId: string | undefined): string { + const aliasId = this.nextAlias("session") + this.requireState(client).aliases.set(aliasId, { + kind: "target", + tabId: target.tabId, + targetId: target.targetInfo.targetId, + ...(target.sessionId === rootSessionId ? {} : { chromeSessionId: target.sessionId }), + }) + return aliasId + } + + alias(client: Client, aliasId: string): ClientCdpSessionAlias | undefined { + return this.states.get(client)?.aliases.get(aliasId) + } + + deleteAlias(client: Client, aliasId: string): boolean { + return this.states.get(client)?.aliases.delete(aliasId) ?? false + } + + removeTargetAliases(matches: (alias: Extract) => boolean): void { + for (const state of this.states.values()) { + removeTargetAliases(state, matches) + } + } + + removeClientTargetAliases(client: Client, matches: (alias: Extract) => boolean): void { + removeTargetAliases(this.requireState(client), matches) + } + + private nextAlias(kind: "browser" | "session"): string { + return `bc-client-${kind}-${this.nextAliasId++}` + } + + private requireState(client: Client): CdpClientState { + const state = this.states.get(client) + if (!state) throw new Error("CDP client is not registered") + return state + } +} + +function removeTargetAliases( + state: CdpClientState, + matches: (alias: Extract) => boolean, +): void { + for (const [aliasId, alias] of state.aliases) { + if (alias.kind === "target" && matches(alias)) state.aliases.delete(aliasId) + } +} diff --git a/src/cdp-router.ts b/src/cdp-router.ts new file mode 100644 index 0000000..4bd7356 --- /dev/null +++ b/src/cdp-router.ts @@ -0,0 +1,115 @@ +import type { CdpClientPool } from "./cdp-client-pool.ts" +import { canClientSeeTarget } from "./cdp-visibility.ts" +import type { TargetInfo } from "./protocol.ts" +import { isRestrictedTarget } from "./relay-helpers.ts" +import type { ChildTarget, ConnectedTarget } from "./relay-types.ts" +import { shouldExposeChildTarget, type TargetRegistry } from "./target-registry.ts" + +export type CdpRoutedSession = { + readonly tabId: number + readonly rootSessionId?: string + readonly chromeSessionId?: string +} + +export class CdpRouter { + constructor( + private readonly clients: CdpClientPool, + private readonly registry: TargetRegistry, + ) {} + + canSeeTarget(client: Client, target: ConnectedTarget): boolean { + return this.canSessionSeeTarget(this.clients.sessionId(client), target) + } + + canSessionSeeTarget(clientSessionId: string | undefined, target: ConnectedTarget): boolean { + return canClientSeeTarget({ + clientSessionId, + targetOwnerSessionId: target.browserControlSessionId, + targetOwner: target.owner, + clientHasOwnedTarget: clientSessionId !== undefined && this.registry + .listRootTargets() + .some((candidate) => candidate.browserControlSessionId === clientSessionId), + }) + } + + canSeeTab(client: Client, tabId: number): boolean { + const rootTarget = this.registry.tabTargets.get(tabId) + return rootTarget ? this.canSeeTarget(client, rootTarget) : false + } + + singleVisibleRoot(client: Client): ConnectedTarget | undefined { + const visible = this.visibleRoots(client) + return visible.length === 1 ? visible[0] : undefined + } + + visibleRoots(client: Client): ConnectedTarget[] { + return this.registry.listRootTargets().filter((target) => this.canSeeTarget(client, target)) + } + + rootForSession(client: Client, sessionId: string): ConnectedTarget | undefined { + const target = this.registry.targets.get(sessionId) + return target && this.canSeeTarget(client, target) ? target : undefined + } + + targetForAttach(client: Client, targetId: string): ConnectedTarget | ChildTarget | undefined { + const root = this.registry.targetsByTargetId.get(targetId) + if (root) return this.canSeeTarget(client, root) ? root : undefined + const child = this.registry.childTargetsByTargetId.get(targetId) + return child && this.canSeeTab(client, child.tabId) ? child : undefined + } + + targetInfo(client: Client, options: { + readonly targetId?: string + readonly sessionId?: string + }): ConnectedTarget | ChildTarget | undefined { + const alias = options.sessionId ? this.clients.alias(client, options.sessionId) : undefined + const aliasedTargetId = alias?.kind === "target" ? alias.targetId : undefined + const target = (options.targetId + ? this.registry.targetsByTargetId.get(options.targetId) ?? this.registry.childTargetsByTargetId.get(options.targetId) + : undefined) ?? + (aliasedTargetId + ? this.registry.targetsByTargetId.get(aliasedTargetId) ?? this.registry.childTargetsByTargetId.get(aliasedTargetId) + : undefined) ?? + (options.sessionId + ? this.registry.targets.get(options.sessionId) ?? this.registry.childTargets.get(options.sessionId) + : undefined) + if (!target) return undefined + return "owner" in target + ? this.canSeeTarget(client, target) ? target : undefined + : this.canSeeTab(client, target.tabId) ? target : undefined + } + + session(client: Client, requestedSessionId: string): CdpRoutedSession | undefined { + const sessionAlias = this.clients.alias(client, requestedSessionId) + if (sessionAlias?.kind === "browser") return undefined + const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined + const target = alias + ? this.registry.targetsByTargetId.get(alias.targetId) ?? this.registry.childTargetsByTargetId.get(alias.targetId) + : this.registry.targets.get(requestedSessionId) ?? this.registry.childTargets.get(requestedSessionId) + if (!target || !this.canSeeTab(client, target.tabId)) return undefined + const rootSessionId = this.registry.tabTargets.get(target.tabId)?.sessionId + if (!rootSessionId) return undefined + const expectedChromeSessionId = "owner" in target ? undefined : target.sessionId + if (alias && (alias.tabId !== target.tabId || alias.chromeSessionId !== expectedChromeSessionId)) return undefined + return { + tabId: target.tabId, + rootSessionId, + ...(expectedChromeSessionId ? { chromeSessionId: expectedChromeSessionId } : {}), + } + } + + pruneInvisibleAliases(client: Client, tabIds: Iterable): void { + const invisibleTabIds = new Set(Array.from(tabIds).filter((tabId) => !this.canSeeTab(client, tabId))) + if (invisibleTabIds.size === 0) return + this.clients.removeClientTargetAliases(client, (alias) => invisibleTabIds.has(alias.tabId)) + } + + visibleTargetInfos(client: Client): TargetInfo[] { + return this.registry.allTargetInfos({ + isRestrictedTarget, + isVisibleTarget: (target) => { + return this.canSeeTab(client, target.tabId) && ("owner" in target || shouldExposeChildTarget(target)) + }, + }) + } +} diff --git a/src/cdp-shims.ts b/src/cdp-shims.ts index b28c02c..e870792 100644 --- a/src/cdp-shims.ts +++ b/src/cdp-shims.ts @@ -23,32 +23,6 @@ export function createClientTargetAnnouncements(): ClientTargetAnnouncements { return { sessions: new Set(), targets: new Map(), sessionTargets: new Map() } } -export function chromeSessionIdForClientRequest(options: { - readonly alias: { readonly chromeSessionId?: string } | undefined - readonly requestedSessionId: string | undefined - readonly rootSessionId: string | undefined -}): string | undefined { - if (options.alias) { - return options.alias.chromeSessionId - } - return options.requestedSessionId && options.requestedSessionId !== options.rootSessionId - ? options.requestedSessionId - : undefined -} - -export function removeClientTargetAliases( - clients: Iterable>, - matches: (alias: Extract) => boolean, -): void { - for (const aliases of clients) { - for (const [aliasId, alias] of aliases) { - if (alias.kind === "target" && matches(alias)) { - aliases.delete(aliasId) - } - } - } -} - export function hasAnnouncedSession(state: ClientTargetAnnouncements | undefined, sessionId: string): boolean { return state?.sessions.has(sessionId) ?? false } @@ -67,13 +41,13 @@ export function removeAnnouncedSession(state: ClientTargetAnnouncements | undefi export function sendAttachedToTarget(options: { readonly socket: WebSocket - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly target: ConnectedTarget readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { - const announcements = options.clientAnnouncements.get(options.socket) + const announcements = options.announcements const targetId = options.target.targetInfo.targetId - const existing = announcements?.targets.get(targetId) + const existing = announcements.targets.get(targetId) if (existing?.sessionId === options.target.sessionId) { return } @@ -87,9 +61,9 @@ export function sendAttachedToTarget(options: { }) } removeAnnouncedSession(announcements, options.target.sessionId) - announcements?.sessions.add(options.target.sessionId) - announcements?.targets.set(targetId, { sessionId: options.target.sessionId }) - announcements?.sessionTargets.set(options.target.sessionId, targetId) + announcements.sessions.add(options.target.sessionId) + announcements.targets.set(targetId, { sessionId: options.target.sessionId }) + announcements.sessionTargets.set(options.target.sessionId, targetId) sendCdpEvent(options.socket, { method: "Target.attachedToTarget", params: { @@ -102,13 +76,13 @@ export function sendAttachedToTarget(options: { export function sendAttachedToChildTarget(options: { readonly socket: WebSocket - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly target: ChildTarget readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { - const announcements = options.clientAnnouncements.get(options.socket) + const announcements = options.announcements const targetId = options.target.targetInfo.targetId - const existing = announcements?.targets.get(targetId) + const existing = announcements.targets.get(targetId) if (existing?.sessionId === options.target.sessionId) { return } @@ -122,9 +96,9 @@ export function sendAttachedToChildTarget(options: { }) } removeAnnouncedSession(announcements, options.target.sessionId) - announcements?.sessions.add(options.target.sessionId) - announcements?.targets.set(targetId, { sessionId: options.target.sessionId, parentSessionId: options.target.parentSessionId }) - announcements?.sessionTargets.set(options.target.sessionId, targetId) + announcements.sessions.add(options.target.sessionId) + announcements.targets.set(targetId, { sessionId: options.target.sessionId, parentSessionId: options.target.parentSessionId }) + announcements.sessionTargets.set(options.target.sessionId, targetId) sendCdpEvent(options.socket, { sessionId: options.target.parentSessionId, method: "Target.attachedToTarget", @@ -140,7 +114,7 @@ export function replayChildTargetsForParent(options: { readonly socket: WebSocket readonly parentSessionId: string readonly registry: TargetRegistry - readonly clientAnnouncements: ReadonlyMap + readonly announcements: ClientTargetAnnouncements readonly onDuplicateTarget?: (duplicate: { readonly targetId: string; readonly oldSessionId: string; readonly newSessionId: string }) => void }): void { for (const target of options.registry.childTargets.values()) { @@ -148,7 +122,7 @@ export function replayChildTargetsForParent(options: { replayFrameEventsForChild({ socket: options.socket, registry: options.registry, target }) sendAttachedToChildTarget({ socket: options.socket, - clientAnnouncements: options.clientAnnouncements, + announcements: options.announcements, target, ...(options.onDuplicateTarget ? { onDuplicateTarget: options.onDuplicateTarget } : {}), }) diff --git a/src/relay.ts b/src/relay.ts index 7aea172..84986a1 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -4,19 +4,16 @@ import crypto from "node:crypto" import { Clock, Config, Effect, Fiber, Semaphore } from "effect" import { WebSocket, WebSocketServer, type RawData } from "ws" import { - chromeSessionIdForClientRequest, - createClientTargetAnnouncements, hasAnnouncedSession, removeAnnouncedSession, - removeClientTargetAliases, replayChildFrameNavigation, replayChildTargetsForParent, replayTargetCreated, sendAttachedToChildTarget, sendAttachedToTarget, - type ClientCdpSessionAlias, } from "./cdp-shims.ts" -import { canClientSeeTarget } from "./cdp-visibility.ts" +import { CdpClientPool } from "./cdp-client-pool.ts" +import { CdpRouter } from "./cdp-router.ts" import { ExtensionRpc } from "./extension-rpc.ts" import { createHttpRequestHandler } from "./http-api.ts" import type { CdpEvent, CdpRequest, JsonObject, PageStatus } from "./protocol.ts" @@ -57,7 +54,7 @@ import { BrowserControlSessions } from "./session-manager.ts" import { RecordingRelay } from "./recording-relay.ts" import { appendManagedRelayProcessLog } from "./relay-log.ts" import { boundedToken, runtimeFailureKind, summarizeDiagnosticUrl, summarizeRuntimeEvaluate } from "./runtime-diagnostics.ts" -import { resolveTargetInfoTarget, shouldExposeChildTarget, TargetRegistry, type RootTargetChange, type TargetOwnershipChange } from "./target-registry.ts" +import { shouldExposeChildTarget, TargetRegistry, type RootTargetChange, type TargetOwnershipChange } from "./target-registry.ts" export type { RelayServer } from "./relay-types.ts" @@ -399,16 +396,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { } function resolveHandoffTarget(sessionId: string, selectedPage: HandoffPageTarget): ConnectedTarget { - const clientHasOwnedTarget = registry.listRootTargets().some((target) => target.browserControlSessionId === sessionId) return resolveExactHandoffTarget({ targetId: selectedPage.targetId, targets: registry.listRootTargets(), - isVisible: (target) => canClientSeeTarget({ - clientSessionId: sessionId, - targetOwnerSessionId: target.browserControlSessionId, - targetOwner: target.owner, - clientHasOwnedTarget, - }), + isVisible: (target) => cdpRouter.canSessionSeeTarget(sessionId, target), }) } @@ -502,15 +493,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { const debugLog = debugEnabled ? (line: string) => console.error(`[bc ${new Date().toISOString().slice(11, 23)}] ${line}`) : undefined const contextDebugLog = debugLog ? (line: string) => debugLog(`[bc:ctx] ${line}`) : undefined const websocketServer = new WebSocketServer({ noServer: true }) - const cdpClients = new Set() - const cdpClientAnnouncements = new Map>() - const cdpClientBrowserControlSessionIds = new Map() - const cdpClientSessionAliases = new Map>() + const cdpClients = new CdpClientPool() + const cdpRouter = new CdpRouter(cdpClients, registry) const runtimeContextWaiters = new Set<(event: CdpEvent) => void>() let nextTargetSessionId = 1 - let nextClientSessionAliasId = 1 - let autoAttachParams: JsonObject | undefined - let idleRuntimeResetGeneration = 0 const mainFrameIdsByTab = new Map() const ghostCursorPositionsByTab = new Map() const suppressedChildSessions = new Map() @@ -538,6 +524,12 @@ const makeRelay = Effect.fnUntraced(function* (options: { return registry.tabTargets.get(tabId) } + function diagnosticTargetForClient(socket: WebSocket, sessionId: string | undefined): ConnectedTarget | ChildTarget | undefined { + return sessionId + ? cdpRouter.targetInfo(socket, { sessionId }) + : cdpRouter.singleVisibleRoot(socket) + } + function isRuntimeEvaluationMethod(method: string): boolean { return method === "Runtime.evaluate" || method === "Runtime.callFunctionOn" } @@ -664,14 +656,8 @@ const makeRelay = Effect.fnUntraced(function* (options: { return } - cdpClients.add(socket) - idleRuntimeResetGeneration++ - cdpClientAnnouncements.set(socket, createClientTargetAnnouncements()) - cdpClientSessionAliases.set(socket, new Map()) const browserControlSessionId = requestUrl.searchParams.get("browserControlSessionId") ?? headerValue(request.headers["browser-control-session-id"]) - if (browserControlSessionId) { - cdpClientBrowserControlSessionIds.set(socket, browserControlSessionId) - } + cdpClients.register(socket, browserControlSessionId) debugLog?.(`client+ ${browserControlSessionId ?? "raw"} total=${cdpClients.size}`) socket.on("message", (data) => { Effect.runPromise(handleCdpMessage(socket, data.toString())).catch((error: unknown) => { @@ -682,14 +668,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) }) socket.on("close", () => { - debugLog?.(`client- ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} total=${cdpClients.size - 1}`) - cdpClients.delete(socket) - cdpClientAnnouncements.delete(socket) - cdpClientBrowserControlSessionIds.delete(socket) - cdpClientSessionAliases.delete(socket) - if (cdpClients.size === 0) { - const generation = ++idleRuntimeResetGeneration - Effect.runPromise(disableRuntimeForIdleTargets(generation).pipe(Effect.ignore)).catch((error: unknown) => { + debugLog?.(`client- ${cdpClients.sessionId(socket) ?? "raw"} total=${cdpClients.size - 1}`) + const idleGeneration = cdpClients.unregister(socket) + if (idleGeneration !== undefined) { + Effect.runPromise(disableRuntimeForIdleTargets(idleGeneration).pipe(Effect.ignore)).catch((error: unknown) => { console.error("Failed to reset idle runtime domains", error) }) } @@ -1071,16 +1053,15 @@ const makeRelay = Effect.fnUntraced(function* (options: { return yield* Effect.fail(new Error("Invalid CDP request")) } - debugLog?.(`cdp<- ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ${message.sessionId ?? ""}`) + debugLog?.(`cdp<- ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ${message.sessionId ?? ""}`) yield* Effect.matchEffect(routeCdpCommand(socket, message), { onFailure: (error) => { return Effect.sync(() => { const runtimeEvaluation = isRuntimeEvaluationMethod(message.method) const errorDetail = runtimeEvaluation ? runtimeFailureKind(error) : error.message - debugLog?.(`cdp-> ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ERROR ${errorDetail}`) + debugLog?.(`cdp-> ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ERROR ${errorDetail}`) if (runtimeEvaluation) { - const tabId = message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId - contextDebugLog?.(`evaluation-failed method=${message.method} failure=${runtimeFailureKind(error)} client=${boundedToken(cdpClientBrowserControlSessionIds.get(socket) ?? "raw")} ${targetDiagnosticIdentity(tabId ? targetForCdpSession(tabId, message.sessionId) : undefined)} ${summarizeRuntimeEvaluate(message.params)}`) + contextDebugLog?.(`evaluation-failed method=${message.method} failure=${runtimeFailureKind(error)} client=${boundedToken(cdpClients.sessionId(socket) ?? "raw")} ${targetDiagnosticIdentity(diagnosticTargetForClient(socket, message.sessionId))} ${summarizeRuntimeEvaluate(message.params)}`) } sendCdpResponse(socket, { id: message.id, @@ -1091,12 +1072,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { }, onSuccess: (result) => { return Effect.sync(() => { - debugLog?.(`cdp-> ${cdpClientBrowserControlSessionIds.get(socket) ?? "raw"} #${message.id} ${message.method} ok`) + debugLog?.(`cdp-> ${cdpClients.sessionId(socket) ?? "raw"} #${message.id} ${message.method} ok`) const resultObject = getObject(result) const exceptionDetails = isRuntimeEvaluationMethod(message.method) ? getObject(resultObject?.exceptionDetails) : undefined if (exceptionDetails) { - const tabId = message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId - contextDebugLog?.(`evaluation-exception method=${message.method} exceptionId=${boundedToken(typeof exceptionDetails.exceptionId === "number" || typeof exceptionDetails.exceptionId === "string" ? String(exceptionDetails.exceptionId) : undefined)} line=${typeof exceptionDetails.lineNumber === "number" ? exceptionDetails.lineNumber : "none"} column=${typeof exceptionDetails.columnNumber === "number" ? exceptionDetails.columnNumber : "none"} client=${boundedToken(cdpClientBrowserControlSessionIds.get(socket) ?? "raw")} ${targetDiagnosticIdentity(tabId ? targetForCdpSession(tabId, message.sessionId) : undefined)} ${summarizeRuntimeEvaluate(message.params)}`) + contextDebugLog?.(`evaluation-exception method=${message.method} exceptionId=${boundedToken(typeof exceptionDetails.exceptionId === "number" || typeof exceptionDetails.exceptionId === "string" ? String(exceptionDetails.exceptionId) : undefined)} line=${typeof exceptionDetails.lineNumber === "number" ? exceptionDetails.lineNumber : "none"} column=${typeof exceptionDetails.columnNumber === "number" ? exceptionDetails.columnNumber : "none"} client=${boundedToken(cdpClients.sessionId(socket) ?? "raw")} ${targetDiagnosticIdentity(diagnosticTargetForClient(socket, message.sessionId))} ${summarizeRuntimeEvaluate(message.params)}`) } sendCdpResponse(socket, { id: message.id, @@ -1109,7 +1089,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { }) const routeCdpCommand = Effect.fn("Relay.routeCdpCommand")(function* (socket: WebSocket, message: CdpRequest) { - const clientBrowserControlSessionId = cdpClientBrowserControlSessionIds.get(socket) + const clientBrowserControlSessionId = cdpClients.sessionId(socket) const guardMessage = guardCdpMethod({ method: message.method, readOnly: clientBrowserControlSessionId ? sessions.isReadOnly(clientBrowserControlSessionId) : false, @@ -1132,69 +1112,60 @@ const makeRelay = Effect.fnUntraced(function* (options: { } if (message.method === "Target.setDiscoverTargets") { if (message.params?.discover === true) { - replayTargetCreated({ socket, targetInfos: visibleTargetInfos(socket) }) + replayTargetCreated({ socket, targetInfos: cdpRouter.visibleTargetInfos(socket) }) } return {} } if (message.method === "Target.setAutoAttach" && !message.sessionId) { - autoAttachParams = message.params - for (const target of registry.targets.values()) { - if (!canSeeTarget(socket, target)) continue + cdpClients.setAutoAttachParams(socket, message.params) + for (const target of cdpRouter.visibleRoots(socket)) { yield* Effect.ignore(sendDebuggerCommand({ tabId: target.tabId, method: "Target.setAutoAttach", params: message.params ?? {} })) - sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + sendAttachedToTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } return {} } if (message.method === "Target.setAutoAttach" && message.sessionId && registry.targets.has(message.sessionId)) { - const target = registry.targets.get(message.sessionId) + const target = cdpRouter.rootForSession(socket, message.sessionId) if (!target) { return yield* Effect.fail(new Error(`Target not found: ${message.sessionId}`)) } const result = yield* sendDebuggerCommand({ tabId: target.tabId, method: "Target.setAutoAttach", params: message.params ?? {} }) - replayChildTargetsForParent({ socket, parentSessionId: target.sessionId, registry, clientAnnouncements: cdpClientAnnouncements, onDuplicateTarget: logDuplicateTargetAnnouncement }) + replayChildTargetsForParent({ socket, parentSessionId: target.sessionId, registry, announcements: cdpClients.announcements(socket), onDuplicateTarget: logDuplicateTargetAnnouncement }) return result } if (message.method === "Target.getTargets") { return { - targetInfos: visibleTargetInfos(socket), + targetInfos: cdpRouter.visibleTargetInfos(socket), } } if (message.method === "Target.attachToBrowserTarget") { - const aliasId = `bc-client-browser-${nextClientSessionAliasId++}` - cdpClientSessionAliases.get(socket)?.set(aliasId, { kind: "browser" }) - return { sessionId: aliasId } + return { sessionId: cdpClients.createBrowserAlias(socket) } } if (message.method === "Target.attachToTarget") { const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const target = registry.targetsByTargetId.get(targetId) - if (target && canSeeTarget(socket, target)) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(socket), target.sessionId)) { - return { sessionId: createClientSessionAlias(socket, target) } + const target = cdpRouter.targetForAttach(socket, targetId) + if (target && "owner" in target) { + if (hasAnnouncedSession(cdpClients.announcements(socket), target.sessionId)) { + return { sessionId: cdpClients.createTargetAlias(socket, target, target.sessionId) } } - sendAttachedToTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + sendAttachedToTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) return { sessionId: target.sessionId } } - const childTarget = registry.childTargetsByTargetId.get(targetId) - if (childTarget && canSeeTabId(socket, childTarget.tabId)) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(socket), childTarget.sessionId)) { - return { sessionId: createClientSessionAlias(socket, childTarget) } + if (target) { + if (hasAnnouncedSession(cdpClients.announcements(socket), target.sessionId)) { + return { sessionId: cdpClients.createTargetAlias(socket, target, target.parentSessionId) } } - sendAttachedToChildTarget({ socket, clientAnnouncements: cdpClientAnnouncements, target: childTarget, onDuplicateTarget: logDuplicateTargetAnnouncement }) - replayChildFrameNavigation({ socket, registry, target: childTarget }) - return { sessionId: childTarget.sessionId } + sendAttachedToChildTarget({ socket, announcements: cdpClients.announcements(socket), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + replayChildFrameNavigation({ socket, registry, target }) + return { sessionId: target.sessionId } } return yield* Effect.fail(new Error(`Target not found: ${targetId}`)) } if (message.method === "Target.getTargetInfo") { const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const sessionAlias = message.sessionId ? cdpClientSessionAliases.get(socket)?.get(message.sessionId) : undefined - const aliasedTargetId = sessionAlias?.kind === "target" ? sessionAlias.targetId : undefined - const target = resolveTargetInfoTarget({ - registry, + const target = cdpRouter.targetInfo(socket, { ...(targetId ? { targetId } : {}), ...(message.sessionId ? { sessionId: message.sessionId } : {}), - ...(aliasedTargetId ? { aliasedTargetId } : {}), - fallback: () => firstVisibleRootTarget(socket), }) if (!target) { if (!targetId && !message.sessionId) { @@ -1207,13 +1178,19 @@ const makeRelay = Effect.fnUntraced(function* (options: { if (message.method === "Target.createTarget" || message.method === "Target.closeTarget") { if (message.method === "Target.createTarget") { const url = typeof message.params?.url === "string" ? message.params.url : "about:blank" - const browserControlSessionId = cdpClientBrowserControlSessionIds.get(socket) - const target = yield* createAndAttachTab({ url, active: false, ...(browserControlSessionId ? { browserControlSessionId } : {}) }) + const browserControlSessionId = cdpClients.sessionId(socket) + const autoAttachParams = cdpClients.autoAttachParams(socket) + const target = yield* createAndAttachTab({ + url, + active: false, + ...(browserControlSessionId ? { browserControlSessionId } : {}), + ...(autoAttachParams ? { autoAttachParams } : {}), + }) return { targetId: target.targetInfo.targetId } } const targetId = typeof message.params?.targetId === "string" ? message.params.targetId : "" - const target = registry.targetsByTargetId.get(targetId) - if (!target) { + const target = cdpRouter.targetForAttach(socket, targetId) + if (!target || !("owner" in target)) { return { success: false } } yield* closeTargetByTargetId(targetId) @@ -1222,30 +1199,23 @@ const makeRelay = Effect.fnUntraced(function* (options: { if (message.method === "Target.detachFromTarget") { const childSessionId = typeof message.params?.sessionId === "string" ? message.params.sessionId : undefined if (childSessionId) { - if (cdpClientSessionAliases.get(socket)?.delete(childSessionId)) { + if (cdpClients.deleteAlias(socket, childSessionId)) { return {} } - removeAnnouncedSession(cdpClientAnnouncements.get(socket), childSessionId) + removeAnnouncedSession(cdpClients.announcements(socket), childSessionId) } return {} } const normalizedMessage = removeDefaultLightColorSchemeEmulation(message) if (message.method === "Runtime.enable" && message.sessionId) { const sessionId = message.sessionId - const sessionAlias = cdpClientSessionAliases.get(socket)?.get(sessionId) - const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined - const tabId = alias?.tabId ?? registry.tabIdForSession(sessionId) - if (!tabId) { + const route = cdpRouter.session(socket, sessionId) + if (!route) { return yield* Effect.fail(new Error(`Unknown CDP session ${sessionId} for ${message.method}`)) } - const rootSessionId = registry.tabTargets.get(tabId)?.sessionId - const routedSessionId = chromeSessionIdForClientRequest({ - alias, - requestedSessionId: sessionId, - rootSessionId, - }) - const chromeSessionId = routedSessionId ? { sessionId: routedSessionId } : {} - const contextSessionId = routedSessionId ?? rootSessionId ?? sessionId + const { tabId } = route + const chromeSessionId = route.chromeSessionId ? { sessionId: route.chromeSessionId } : {} + const contextSessionId = route.chromeSessionId ?? route.rootSessionId ?? sessionId contextDebugLog?.(`runtime-enable phase=client-request ${targetDiagnosticIdentity(targetForCdpSession(tabId, sessionId))}`) // Register the waiter before sending the enable so context events that // arrive during the command round trip are not missed. @@ -1273,23 +1243,18 @@ const makeRelay = Effect.fnUntraced(function* (options: { } return result } - const sessionAlias = message.sessionId ? cdpClientSessionAliases.get(socket)?.get(message.sessionId) : undefined - const alias = sessionAlias?.kind === "target" ? sessionAlias : undefined - const tabId = alias?.tabId ?? (message.sessionId ? registry.tabIdForSession(message.sessionId) : firstVisibleRootTarget(socket)?.tabId) - if (!tabId) { - return yield* Effect.fail(new Error(message.sessionId ? `Unknown CDP session ${message.sessionId} for ${message.method}` : `No attached tab for ${message.method}`)) + const route = message.sessionId ? cdpRouter.session(socket, message.sessionId) : undefined + if (!route) { + return yield* Effect.fail(new Error(message.sessionId + ? `Unknown CDP session ${message.sessionId} for ${message.method}` + : `CDP sessionId is required for ${message.method}`)) } - const rootSessionId = registry.tabTargets.get(tabId)?.sessionId - const chromeSessionId = chromeSessionIdForClientRequest({ - alias, - requestedSessionId: message.sessionId, - rootSessionId, - }) + const { tabId } = route const result = yield* sendDebuggerCommand({ tabId, method: normalizedMessage.method, params: normalizedMessage.params ?? {}, - ...(chromeSessionId === undefined ? {} : { sessionId: chromeSessionId }), + ...(route.chromeSessionId === undefined ? {} : { sessionId: route.chromeSessionId }), }) yield* applyGhostCursorMouseEvent({ tabId, message }).pipe(Effect.ignore) return result @@ -1319,18 +1284,6 @@ const makeRelay = Effect.fnUntraced(function* (options: { } } - function createClientSessionAlias(socket: WebSocket, target: ConnectedTarget | ChildTarget): string { - const aliasId = `bc-client-session-${nextClientSessionAliasId++}` - const rootSessionId = registry.tabTargets.get(target.tabId)?.sessionId - cdpClientSessionAliases.get(socket)?.set(aliasId, { - kind: "target", - tabId: target.tabId, - targetId: target.targetInfo.targetId, - ...(target.sessionId === rootSessionId ? {} : { chromeSessionId: target.sessionId }), - }) - return aliasId - } - const toggleTab = Effect.fnUntraced(function* (tabId: number) { if (registry.tabTargets.has(tabId)) { yield* sendToExtension({ method: "debugger.detach", params: { tabId } }) @@ -1345,6 +1298,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly url: string readonly active: boolean readonly browserControlSessionId?: string + readonly autoAttachParams?: JsonObject }) { const result = yield* sendToExtension({ method: "tabs.create", params: { url: options.url, active: options.active } }) const tabId = typeof result.tabId === "number" ? result.tabId : undefined @@ -1355,6 +1309,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { tabId, owner: "relay", ...(options.browserControlSessionId ? { browserControlSessionId: options.browserControlSessionId } : {}), + ...(options.autoAttachParams ? { autoAttachParams: options.autoAttachParams } : {}), }) }) @@ -1363,6 +1318,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly owner: "relay" | "user" readonly browserControlSessionId?: string readonly alreadyAttached?: boolean + readonly autoAttachParams?: JsonObject }) { const { tabId } = options if (!options.alreadyAttached) { @@ -1387,10 +1343,10 @@ const makeRelay = Effect.fnUntraced(function* (options: { owner: restoredTarget?.owner ?? options.owner, ...(browserControlSessionId ? { browserControlSessionId } : {}), } - return yield* finishAttachedTarget(registry.stageRootTarget(candidate)) + return yield* finishAttachedTarget(registry.stageRootTarget(candidate), options.autoAttachParams) }) - const finishAttachedTarget = Effect.fnUntraced(function* (target: ConnectedTarget) { + const finishAttachedTarget = Effect.fnUntraced(function* (target: ConnectedTarget, autoAttachParams?: JsonObject) { const tabId = target.tabId yield* sendDebuggerCommand({ tabId, @@ -1445,6 +1401,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { readonly browserControlSessionId?: string readonly alreadyAttached?: boolean readonly expectedExtensionGeneration?: number + readonly autoAttachParams?: JsonObject }) { const semaphore = rootLifecycleSemaphores.get(options.tabId) ?? Semaphore.makeUnsafe(1) rootLifecycleSemaphores.set(options.tabId, semaphore) @@ -1612,13 +1569,13 @@ const makeRelay = Effect.fnUntraced(function* (options: { const disableRuntimeForIdleTargets = Effect.fnUntraced(function* (generation: number) { yield* Effect.forEach(Array.from(registry.targets.values()), (target) => { - if (generation !== idleRuntimeResetGeneration || cdpClients.size !== 0) { + if (!cdpClients.isCurrentIdleGeneration(generation)) { return Effect.void } return runRuntimeResetCommand({ phase: "idle-client-disconnect", tabId: target.tabId, method: "Runtime.disable", params: {} }).pipe(Effect.asVoid) }) yield* Effect.forEach(Array.from(registry.childTargets.values()), (target) => { - if (generation !== idleRuntimeResetGeneration || cdpClients.size !== 0) { + if (!cdpClients.isCurrentIdleGeneration(generation)) { return Effect.void } return runRuntimeResetCommand({ phase: "idle-client-disconnect", tabId: target.tabId, sessionId: target.sessionId, method: "Runtime.disable", params: {} }).pipe(Effect.asVoid) @@ -1643,7 +1600,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { } cancelTargetHandoffs(detached.target, "target-detached") if (!options.preserveSessionTarget) sessions.markTargetDetached(detached.target.targetInfo.targetId) - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.tabId === tabId) + cdpClients.removeTargetAliases((alias) => alias.tabId === tabId) mainFrameIdsByTab.delete(tabId) ghostCursorPositionsByTab.delete(tabId) for (const [sessionId, childTabId] of suppressedChildSessions) { @@ -1652,6 +1609,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { } } contextDebugLog?.(`target-detached kind=root ${targetDiagnosticIdentity(detached.target)}`) + for (const client of cdpClients) { + for (const childSessionId of detached.childSessionIds) { + detachAnnouncedSession(client, childSessionId) + } + } sendEventToTargetViewers(detached.target.sessionId, { method: "Target.targetDestroyed", params: { targetId: detached.target.targetInfo.targetId }, @@ -1660,11 +1622,9 @@ const makeRelay = Effect.fnUntraced(function* (options: { method: "Target.detachedFromTarget", params: { sessionId: detached.target.sessionId, targetId: detached.target.targetInfo.targetId }, }) - for (const announcements of cdpClientAnnouncements.values()) { + for (const client of cdpClients) { + const announcements = cdpClients.announcements(client) removeAnnouncedSession(announcements, detached.target.sessionId) - for (const childSessionId of detached.childSessionIds) { - removeAnnouncedSession(announcements, childSessionId) - } } } @@ -1677,7 +1637,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { targetSessionId: change.target.sessionId, }) sessions.markTargetReplaced(change.previous.targetInfo.targetId, change.target.targetInfo.targetId) - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.tabId === change.target.tabId) + cdpClients.removeTargetAliases((alias) => alias.tabId === change.target.tabId) mainFrameIdsByTab.delete(change.target.tabId) ghostCursorPositionsByTab.delete(change.target.tabId) for (const [sessionId, childTabId] of suppressedChildSessions) { @@ -1698,50 +1658,25 @@ const makeRelay = Effect.fnUntraced(function* (options: { } const detached = registry.detachChildTargetState(sessionId) if (detached) { - removeClientTargetAliases(cdpClientSessionAliases.values(), (alias) => alias.targetId === detached.targetInfo.targetId) + cdpClients.removeTargetAliases((alias) => alias.targetId === detached.targetInfo.targetId) } if (!notifyClients) { - for (const announcements of cdpClientAnnouncements.values()) { - removeAnnouncedSession(announcements, sessionId) + for (const client of cdpClients) { + removeAnnouncedSession(cdpClients.announcements(client), sessionId) } } } - function canSeeTarget(socket: WebSocket, target: ConnectedTarget): boolean { - const clientSessionId = cdpClientBrowserControlSessionIds.get(socket) - return canClientSeeTarget({ - clientSessionId, - targetOwnerSessionId: target.browserControlSessionId, - targetOwner: target.owner, - clientHasOwnedTarget: clientHasOwnedTarget(clientSessionId), - }) - } - - function clientHasOwnedTarget(clientSessionId: string | undefined): boolean { - return clientSessionId ? registry.listRootTargets().some((candidate) => candidate.browserControlSessionId === clientSessionId) : false - } - - function canSeeTabId(socket: WebSocket, tabId: number): boolean { - const rootTarget = registry.tabTargets.get(tabId) - return rootTarget ? canSeeTarget(socket, rootTarget) : true - } - - function firstVisibleRootTarget(socket: WebSocket): ConnectedTarget | undefined { - return Array.from(registry.targets.values()).find((target) => { - return canSeeTarget(socket, target) - }) - } - // Deliver a session-scoped event only to clients that have been told about // the tab's root target. Broadcasting to every client lets concurrently // connected sandboxes attach to each other's pages and interfere. function sendEventToTargetViewers(rootSessionId: string, event: CdpEvent): void { const target = registry.targets.get(rootSessionId) for (const client of cdpClients) { - if (!hasAnnouncedSession(cdpClientAnnouncements.get(client), rootSessionId)) { + if (!hasAnnouncedSession(cdpClients.announcements(client), rootSessionId)) { continue } - if (target && !canSeeTarget(client, target)) { + if (target && !cdpRouter.canSeeTarget(client, target)) { detachAnnouncedSession(client, rootSessionId) continue } @@ -1751,27 +1686,26 @@ const makeRelay = Effect.fnUntraced(function* (options: { function pruneInvisibleAnnouncementsForSession(browserControlSessionId: string): void { for (const client of cdpClients) { - if (cdpClientBrowserControlSessionIds.get(client) === browserControlSessionId) { + if (cdpClients.sessionId(client) === browserControlSessionId) { pruneInvisibleAnnouncementsForClient(client) } } } function pruneInvisibleAnnouncementsForClient(client: WebSocket): void { - const announcements = cdpClientAnnouncements.get(client) - if (!announcements) { - return - } + const announcements = cdpClients.announcements(client) for (const announced of Array.from(announcements.targets.values())) { const rootTarget = registry.targets.get(announced.sessionId) if (rootTarget) { - if (!canSeeTarget(client, rootTarget)) { + if (!cdpRouter.canSeeTarget(client, rootTarget)) { + cdpClients.removeClientTargetAliases(client, (alias) => alias.tabId === rootTarget.tabId) detachAnnouncedSession(client, announced.sessionId) } continue } const childTarget = registry.childTargets.get(announced.sessionId) - if (childTarget && !canSeeTabId(client, childTarget.tabId)) { + if (childTarget && !cdpRouter.canSeeTab(client, childTarget.tabId)) { + cdpClients.removeClientTargetAliases(client, (alias) => alias.tabId === childTarget.tabId) detachAnnouncedSession(client, announced.sessionId) } } @@ -1779,6 +1713,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { function reconcileTargetOwnership(change: TargetOwnershipChange): void { for (const client of cdpClients) { + cdpRouter.pruneInvisibleAliases(client, change.tabIds) pruneInvisibleAnnouncementsForClient(client) } for (const targetId of change.targetIds) { @@ -1793,7 +1728,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { } function detachAnnouncedSession(client: WebSocket, sessionId: string): void { - const announcements = cdpClientAnnouncements.get(client) + const announcements = cdpClients.announcements(client) const targetId = announcements?.sessionTargets.get(sessionId) const announced = targetId ? announcements?.targets.get(targetId) : undefined removeAnnouncedSession(announcements, sessionId) @@ -1812,29 +1747,20 @@ const makeRelay = Effect.fnUntraced(function* (options: { function announceAttachedTarget(target: ConnectedTarget): void { for (const client of cdpClients) { - if (canSeeTarget(client, target)) { - sendAttachedToTarget({ socket: client, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + if (cdpRouter.canSeeTarget(client, target)) { + sendAttachedToTarget({ socket: client, announcements: cdpClients.announcements(client), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } } } function announceAttachedChildTarget(rootSessionId: string, target: ChildTarget): void { for (const client of cdpClients) { - if (hasAnnouncedSession(cdpClientAnnouncements.get(client), rootSessionId)) { - sendAttachedToChildTarget({ socket: client, clientAnnouncements: cdpClientAnnouncements, target, onDuplicateTarget: logDuplicateTargetAnnouncement }) + if (hasAnnouncedSession(cdpClients.announcements(client), rootSessionId)) { + sendAttachedToChildTarget({ socket: client, announcements: cdpClients.announcements(client), target, onDuplicateTarget: logDuplicateTargetAnnouncement }) } } } - function visibleTargetInfos(socket: WebSocket) { - return registry.allTargetInfos({ - isRestrictedTarget, - isVisibleTarget: (target) => { - return canSeeTabId(socket, target.tabId) && ("owner" in target || shouldExposeChildTarget(target)) - }, - }) - } - // Resolves true once a default Runtime.executionContextCreated event arrives // for the session, or false when none arrives within the wait window. function waitForDefaultRuntimeContext(sessionId: string): Effect.Effect { diff --git a/src/target-registry.ts b/src/target-registry.ts index 77a1395..c38a037 100644 --- a/src/target-registry.ts +++ b/src/target-registry.ts @@ -104,26 +104,6 @@ export function shouldExposeChildTarget(target: ChildTarget): boolean { return target.targetInfo.type !== "page" || target.targetInfo.url !== "" } -export function resolveTargetInfoTarget(options: { - readonly registry: TargetRegistry - readonly targetId?: string - readonly sessionId?: string - readonly aliasedTargetId?: string - readonly fallback?: () => ConnectedTarget | undefined -}): ConnectedTarget | ChildTarget | undefined { - const explicit = Boolean(options.targetId || options.sessionId || options.aliasedTargetId) - return (options.targetId - ? options.registry.targetsByTargetId.get(options.targetId) ?? options.registry.childTargetsByTargetId.get(options.targetId) - : undefined) ?? - (options.aliasedTargetId - ? options.registry.targetsByTargetId.get(options.aliasedTargetId) ?? options.registry.childTargetsByTargetId.get(options.aliasedTargetId) - : undefined) ?? - (options.sessionId - ? options.registry.targets.get(options.sessionId) ?? options.registry.childTargets.get(options.sessionId) - : undefined) ?? - (explicit ? undefined : options.fallback?.()) -} - export class TargetRegistry { readonly targets = new Map() readonly tabTargets = new Map() diff --git a/test/cdp-client-pool.test.ts b/test/cdp-client-pool.test.ts new file mode 100644 index 0000000..eae9c32 --- /dev/null +++ b/test/cdp-client-pool.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest" +import { CdpClientPool } from "../src/cdp-client-pool.ts" +import type { ChildTarget, ConnectedTarget } from "../src/relay-types.ts" + +const rootTarget: ConnectedTarget = { + tabId: 7, + sessionId: "bc-tab-7", + targetInfo: { + targetId: "root-7", + type: "page", + title: "Root", + url: "https://example.com/", + attached: true, + canAccessOpener: false, + }, + owner: "user", +} + +const childTarget: ChildTarget = { + tabId: 7, + sessionId: "chrome-child-7", + parentSessionId: "bc-tab-7", + targetInfo: { + targetId: "child-7", + type: "iframe", + title: "Child", + url: "https://child.example.com/", + attached: true, + canAccessOpener: false, + }, + waitingForDebugger: false, +} + +describe("CdpClientPool", () => { + it("owns registration and cleanup for all per-client state", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client, "session-a") + pool.setAutoAttachParams(client, { autoAttach: true, flatten: true }) + const aliasId = pool.createBrowserAlias(client) + + expect(pool.size).toBe(1) + expect([...pool]).toEqual([client]) + expect(pool.sessionId(client)).toBe("session-a") + expect(pool.autoAttachParams(client)).toEqual({ autoAttach: true, flatten: true }) + expect(pool.alias(client, aliasId)).toEqual({ kind: "browser" }) + expect(pool.announcements(client)).toBeDefined() + + expect(pool.unregister(client)).toBeTypeOf("number") + expect(pool.size).toBe(0) + expect(pool.sessionId(client)).toBeUndefined() + expect(pool.alias(client, aliasId)).toBeUndefined() + expect(() => pool.announcements(client)).toThrow("CDP client is not registered") + }) + + it("keeps conflicting auto-attach settings scoped to their clients", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + pool.setAutoAttachParams(first, { autoAttach: true, waitForDebuggerOnStart: false }) + pool.setAutoAttachParams(second, { autoAttach: false, waitForDebuggerOnStart: true }) + + expect(pool.autoAttachParams(first)).toEqual({ autoAttach: true, waitForDebuggerOnStart: false }) + expect(pool.autoAttachParams(second)).toEqual({ autoAttach: false, waitForDebuggerOnStart: true }) + }) + + it("rejects duplicate registration and aliases for unknown clients", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client) + + expect(() => pool.register(client)).toThrow("CDP client is already registered") + expect(() => pool.createBrowserAlias({})).toThrow("CDP client is not registered") + }) + + it("invalidates an idle generation when another client registers", () => { + const pool = new CdpClientPool() + const first = {} + pool.register(first) + const idleGeneration = pool.unregister(first) + expect(idleGeneration).toBeDefined() + if (idleGeneration === undefined) throw new Error("Expected idle generation") + expect(pool.isCurrentIdleGeneration(idleGeneration)).toBe(true) + + pool.register({}) + expect(pool.isCurrentIdleGeneration(idleGeneration)).toBe(false) + }) + + it("produces an idle generation only when the last client leaves", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + + expect(pool.unregister(first)).toBeUndefined() + const idleGeneration = pool.unregister(second) + expect(idleGeneration).toBeDefined() + expect(pool.unregister(second)).toBeUndefined() + }) + + it("routes root aliases without a Chrome session and child aliases with one", () => { + const pool = new CdpClientPool() + const client = {} + pool.register(client) + + const rootAlias = pool.createTargetAlias(client, rootTarget, rootTarget.sessionId) + const childAlias = pool.createTargetAlias(client, childTarget, rootTarget.sessionId) + + expect(pool.alias(client, rootAlias)).toEqual({ kind: "target", tabId: 7, targetId: "root-7" }) + expect(pool.alias(client, childAlias)).toEqual({ + kind: "target", + tabId: 7, + targetId: "child-7", + chromeSessionId: "chrome-child-7", + }) + }) + + it("removes matching target aliases across clients without touching browser aliases", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + const browserAlias = pool.createBrowserAlias(first) + const firstTargetAlias = pool.createTargetAlias(first, rootTarget, rootTarget.sessionId) + const secondTargetAlias = pool.createTargetAlias(second, rootTarget, rootTarget.sessionId) + + pool.removeTargetAliases((alias) => alias.tabId === 7) + + expect(pool.alias(first, browserAlias)).toEqual({ kind: "browser" }) + expect(pool.alias(first, firstTargetAlias)).toBeUndefined() + expect(pool.alias(second, secondTargetAlias)).toBeUndefined() + }) + + it("can prune one client's target aliases without affecting another client", () => { + const pool = new CdpClientPool() + const first = {} + const second = {} + pool.register(first) + pool.register(second) + const firstAlias = pool.createTargetAlias(first, rootTarget, rootTarget.sessionId) + const secondAlias = pool.createTargetAlias(second, rootTarget, rootTarget.sessionId) + + pool.removeClientTargetAliases(first, (alias) => alias.tabId === 7) + + expect(pool.alias(first, firstAlias)).toBeUndefined() + expect(pool.alias(second, secondAlias)).toBeDefined() + }) +}) diff --git a/test/cdp-router.test.ts b/test/cdp-router.test.ts new file mode 100644 index 0000000..3db451d --- /dev/null +++ b/test/cdp-router.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest" +import { CdpClientPool } from "../src/cdp-client-pool.ts" +import { CdpRouter } from "../src/cdp-router.ts" +import type { ConnectedTarget } from "../src/relay-types.ts" +import { TargetRegistry } from "../src/target-registry.ts" + +function root(options: { + readonly tabId: number + readonly sessionId: string + readonly targetId: string + readonly browserControlSessionId?: string + readonly owner?: "relay" | "user" +}): ConnectedTarget { + return { + tabId: options.tabId, + sessionId: options.sessionId, + targetInfo: { + targetId: options.targetId, + type: "page", + title: options.targetId, + url: `https://example.com/${options.targetId}`, + attached: true, + canAccessOpener: false, + }, + owner: options.owner ?? "relay", + ...(options.browserControlSessionId ? { browserControlSessionId: options.browserControlSessionId } : {}), + } +} + +function setup() { + const clients = new CdpClientPool() + const registry = new TargetRegistry() + const router = new CdpRouter(clients, registry) + return { clients, registry, router } +} + +describe("CdpRouter", () => { + it("resolves only targets visible to the client", () => { + const { clients, registry, router } = setup() + const owner = {} + const other = {} + clients.register(owner, "session-a") + clients.register(other, "session-b") + const target = root({ + tabId: 1, + sessionId: "bc-tab-1", + targetId: "target-1", + browserControlSessionId: "session-a", + }) + registry.addRootTarget(target) + + expect(router.targetForAttach(owner, "target-1")).toBe(target) + expect(router.targetInfo(owner, { sessionId: "bc-tab-1" })).toBe(target) + expect(router.targetForAttach(other, "target-1")).toBeUndefined() + expect(router.targetInfo(other, { targetId: "target-1" })).toBeUndefined() + }) + + it("routes root aliases without a Chrome session and child aliases with one", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client) + const target = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1" }) + registry.addRootTarget(target) + const child = { + tabId: 1, + sessionId: "child-session", + parentSessionId: target.sessionId, + targetInfo: { ...target.targetInfo, targetId: "child-target", type: "iframe" as const }, + waitingForDebugger: false, + } + registry.addChildTarget(child) + const rootAlias = clients.createTargetAlias(client, target, target.sessionId) + const childAlias = clients.createTargetAlias(client, child, target.sessionId) + + expect(router.session(client, rootAlias)).toEqual({ tabId: 1, rootSessionId: "bc-tab-1" }) + expect(router.session(client, childAlias)).toEqual({ + tabId: 1, + rootSessionId: "bc-tab-1", + chromeSessionId: "child-session", + }) + }) + + it("does not resolve identity-free or hidden session routes", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-b") + registry.addRootTarget(root({ + tabId: 1, + sessionId: "bc-tab-1", + targetId: "target-1", + browserControlSessionId: "session-a", + })) + + expect(router.targetInfo(client, {})).toBeUndefined() + expect(router.session(client, "bc-tab-1")).toBeUndefined() + expect(router.rootForSession(client, "bc-tab-1")).toBeUndefined() + }) + + it("rejects stale aliases and children without a live root", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client) + const target = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1" }) + registry.addRootTarget(target) + const alias = clients.createTargetAlias(client, target, target.sessionId) + + registry.addRootTarget(root({ tabId: 1, sessionId: "bc-tab-2", targetId: "target-2" })) + expect(router.session(client, alias)).toBeUndefined() + + registry.detachRootTargetState(1) + const orphan = { + tabId: 1, + sessionId: "orphan-session", + parentSessionId: "bc-tab-missing", + targetInfo: { ...target.targetInfo, targetId: "orphan-target", type: "iframe" as const }, + waitingForDebugger: false, + } + registry.addChildTarget(orphan) + expect(router.targetForAttach(client, "orphan-target")).toBeUndefined() + expect(router.session(client, "orphan-session")).toBeUndefined() + }) + + it("does not fall through from an explicit hidden target to a visible session", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const visible = root({ tabId: 1, sessionId: "visible-session", targetId: "visible-target", browserControlSessionId: "session-a" }) + const hidden = root({ tabId: 2, sessionId: "hidden-session", targetId: "hidden-target", browserControlSessionId: "session-b" }) + registry.addRootTarget(visible) + registry.addRootTarget(hidden) + + expect(router.targetInfo(client, { + targetId: "hidden-target", + sessionId: "visible-session", + })).toBeUndefined() + }) + + it("prunes aliases when ownership hides a tab even without an announcement", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const rawTarget = root({ tabId: 1, sessionId: "raw-session", targetId: "raw-target" }) + registry.addRootTarget(rawTarget) + const alias = clients.createTargetAlias(client, rawTarget, rawTarget.sessionId) + registry.addRootTarget(root({ + tabId: 2, + sessionId: "owned-session", + targetId: "owned-target", + browserControlSessionId: "session-a", + })) + + router.pruneInvisibleAliases(client, [rawTarget.tabId]) + + expect(clients.alias(client, alias)).toBeUndefined() + }) + + it("lists only visible roots and exposed children", () => { + const { clients, registry, router } = setup() + const client = {} + clients.register(client, "session-a") + const visible = root({ tabId: 1, sessionId: "bc-tab-1", targetId: "target-1", browserControlSessionId: "session-a" }) + const hidden = root({ tabId: 2, sessionId: "bc-tab-2", targetId: "target-2", browserControlSessionId: "session-b" }) + registry.addRootTarget(visible) + registry.addRootTarget(hidden) + registry.addChildTarget({ + tabId: 1, + sessionId: "child-visible", + parentSessionId: visible.sessionId, + targetInfo: { ...visible.targetInfo, targetId: "child-visible-target", type: "worker" }, + waitingForDebugger: false, + }) + registry.addChildTarget({ + tabId: 1, + sessionId: "child-held", + parentSessionId: visible.sessionId, + targetInfo: { ...visible.targetInfo, targetId: "child-held-target", type: "page", url: "" }, + waitingForDebugger: false, + }) + + expect(router.visibleTargetInfos(client).map((target) => target.targetId)).toEqual(["target-1", "child-visible-target"]) + }) +}) diff --git a/test/cdp-shims.test.ts b/test/cdp-shims.test.ts index 538796f..6383551 100644 --- a/test/cdp-shims.test.ts +++ b/test/cdp-shims.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from "vitest" import type { WebSocket } from "ws" import { - chromeSessionIdForClientRequest, createClientTargetAnnouncements, hasAnnouncedSession, - removeClientTargetAliases, replayChildTargetsForParent, sendAttachedToChildTarget, sendAttachedToTarget, - type ClientCdpSessionAlias, } from "../src/cdp-shims.ts" import type { ChildTarget, ConnectedTarget } from "../src/relay-types.ts" import type { CdpEvent, TargetInfo } from "../src/protocol.ts" @@ -34,39 +31,6 @@ function child(sessionId: string, targetId = "child-target-1", parentSessionId = return { tabId: 1, sessionId, parentSessionId, targetInfo: { ...targetInfo(targetId), type: "iframe" }, waitingForDebugger: false } } -describe("chromeSessionIdForClientRequest", () => { - it("does not forward a client alias for a root target to Chrome", () => { - expect(chromeSessionIdForClientRequest({ - alias: {}, - requestedSessionId: "bc-client-session-1", - rootSessionId: "bc-tab-1", - })).toBeUndefined() - }) - - it("forwards the real Chrome session behind a child-target alias", () => { - expect(chromeSessionIdForClientRequest({ - alias: { chromeSessionId: "chrome-child-1" }, - requestedSessionId: "bc-client-session-2", - rootSessionId: "bc-tab-1", - })).toBe("chrome-child-1") - }) -}) - -describe("removeClientTargetAliases", () => { - it("removes aliases for a detached tab without touching other tabs", () => { - const aliases = new Map([ - ["browser", { kind: "browser" }], - ["detached-root", { kind: "target", tabId: 7, targetId: "root-7" }], - ["detached-child", { kind: "target", tabId: 7, targetId: "child-7", chromeSessionId: "chrome-child-7" }], - ["other-root", { kind: "target", tabId: 8, targetId: "root-8" }], - ]) - - removeClientTargetAliases([aliases], (alias) => alias.tabId === 7) - - expect(Array.from(aliases.keys())).toEqual(["browser", "other-root"]) - }) -}) - describe("TargetRegistry crash state", () => { it("marks a root target crashed and clears the marker after navigation", () => { const registry = new TargetRegistry() @@ -84,23 +48,23 @@ describe("sendAttachedToTarget", () => { it("does not re-announce the same target id and session id", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) expect(events).toHaveLength(1) expect(events[0]?.method).toBe("Target.attachedToTarget") - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-1")).toBe(true) + expect(hasAnnouncedSession(announcements, "bc-tab-1")).toBe(true) }) it("detaches the old session before re-announcing the same target id under a new session id", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-1") }) - sendAttachedToTarget({ socket: client, clientAnnouncements: announcements, target: root("bc-tab-2") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-1") }) + sendAttachedToTarget({ socket: client, announcements, target: root("bc-tab-2") }) expect(events.map((event) => event.method)).toEqual([ "Target.attachedToTarget", @@ -108,8 +72,8 @@ describe("sendAttachedToTarget", () => { "Target.attachedToTarget", ]) expect(events[1]).toEqual({ method: "Target.detachedFromTarget", params: { sessionId: "bc-tab-1", targetId: "target-1" } }) - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-1")).toBe(false) - expect(hasAnnouncedSession(announcements.get(client), "bc-tab-2")).toBe(true) + expect(hasAnnouncedSession(announcements, "bc-tab-1")).toBe(false) + expect(hasAnnouncedSession(announcements, "bc-tab-2")).toBe(true) }) }) @@ -117,10 +81,10 @@ describe("sendAttachedToChildTarget", () => { it("detaches duplicate child target ids on the parent session before re-announcing", () => { const events: CdpEvent[] = [] const client = socket(events) - const announcements = new Map([[client, createClientTargetAnnouncements()]]) + const announcements = createClientTargetAnnouncements() - sendAttachedToChildTarget({ socket: client, clientAnnouncements: announcements, target: child("child-session-1") }) - sendAttachedToChildTarget({ socket: client, clientAnnouncements: announcements, target: child("child-session-2") }) + sendAttachedToChildTarget({ socket: client, announcements, target: child("child-session-1") }) + sendAttachedToChildTarget({ socket: client, announcements, target: child("child-session-2") }) expect(events.map((event) => event.method)).toEqual([ "Target.attachedToTarget", @@ -149,7 +113,7 @@ describe("sendAttachedToChildTarget", () => { socket: client, parentSessionId: "bc-tab-1", registry, - clientAnnouncements: announcements, + announcements: createClientTargetAnnouncements(), }) expect(events).toHaveLength(1) @@ -173,7 +137,7 @@ describe("sendAttachedToChildTarget", () => { socket: client, parentSessionId: "bc-tab-1", registry, - clientAnnouncements: new Map([[client, createClientTargetAnnouncements()]]), + announcements: createClientTargetAnnouncements(), }) expect(events).toEqual([]) diff --git a/test/relay-child-dedupe.test.ts b/test/relay-child-dedupe.test.ts index e6456da..454ce67 100644 --- a/test/relay-child-dedupe.test.ts +++ b/test/relay-child-dedupe.test.ts @@ -361,6 +361,18 @@ describe("relay child target announce dedupe", () => { command.params?.sessionId === "worker-session" })) + client.send(JSON.stringify({ + id: 3, + sessionId: "worker-session", + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })) + await waitFor(() => extensionCommands.some((command) => { + return command.method === "debugger.sendCommand" && + command.params?.method === "Target.setAutoAttach" && + command.params?.sessionId === "worker-session" + })) + client.close() extension.close() }) @@ -428,6 +440,25 @@ describe("relay child target announce dedupe", () => { ["Target.attachedToTarget", "child-session-2"], ]) + const rootAttach = messages.find((message): message is CdpEvent => { + const info = "method" in message && message.method === "Target.attachedToTarget" ? message.params?.targetInfo : undefined + return info !== undefined && info !== null && typeof info === "object" && !Array.isArray(info) && info.targetId === "root-target" + }) + const rootSessionId = typeof rootAttach?.params?.sessionId === "string" ? rootAttach.params.sessionId : undefined + expect(rootSessionId).toBeDefined() + extension.send(JSON.stringify({ method: "tabs.removed", params: { tabId: 1 } })) + await waitFor(() => messages.some((message) => { + return "method" in message && message.method === "Target.detachedFromTarget" && message.params?.sessionId === rootSessionId + })) + + const detachEvents = messages.filter((message): message is CdpEvent => { + return "method" in message && message.method === "Target.detachedFromTarget" + }) + const childDetachIndex = detachEvents.findIndex((event) => event.params?.sessionId === "child-session-2") + const rootDetachIndex = detachEvents.findIndex((event) => event.params?.sessionId === rootSessionId) + expect(childDetachIndex).toBeGreaterThanOrEqual(0) + expect(rootDetachIndex).toBeGreaterThan(childDetachIndex) + client.close() extension.close() }) diff --git a/test/relay-visibility-prune.test.ts b/test/relay-visibility-prune.test.ts index 24da317..c1fb674 100644 --- a/test/relay-visibility-prune.test.ts +++ b/test/relay-visibility-prune.test.ts @@ -24,18 +24,73 @@ describe("relay target visibility pruning", () => { yield* Effect.promise(() => sendCdp(sessionClient, { id: 4, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-1")).toBe(true) + const alias = yield* Effect.promise(() => sendCdp(sessionClient, { + id: 5, + method: "Target.attachToTarget", + params: { targetId: "target-1", flatten: true }, + })) + const staleAliasId = typeof alias.result?.sessionId === "string" ? alias.result.sessionId : undefined + expect(staleAliasId).toBeDefined() + if (!staleAliasId) throw new Error("Expected target alias") - const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 5, method: "Target.createTarget", params: { url: "about:blank" } })) - expect(ownedCreate.result?.targetId).toBe("target-2") + const ownedCreate = yield* Effect.promise(() => sendCdp(sessionClient, { id: 6, method: "Target.createTarget", params: { url: "about:blank" } })) + expect(ownedCreate.result?.targetId).toBe("target-2") expect(sessionClient.events).toContainEqual({ method: "Target.detachedFromTarget", params: { sessionId: "bc-tab-1", targetId: "target-1" }, }) - expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) + expect(sessionClient.events.some((event) => event.method === "Target.attachedToTarget" && event.params?.sessionId === "bc-tab-2")).toBe(true) extension.commands.length = 0 - yield* Effect.promise(() => sendCdp(sessionClient, { id: 6, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) + yield* Effect.promise(async () => { + await expect(sendCdp(sessionClient, { + id: 7, + sessionId: staleAliasId, + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow(`Unknown CDP session ${staleAliasId} for Runtime.evaluate`) + }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(async () => { + await expect(sendCdp(rawClient, { + id: 9, + method: "Target.getTargetInfo", + params: { targetId: "target-2" }, + })).rejects.toThrow("Target not found: target-2") + await expect(sendCdp(rawClient, { + id: 10, + sessionId: "bc-tab-2", + method: "Target.getTargetInfo", + params: {}, + })).rejects.toThrow("Target not found: bc-tab-2") + await expect(sendCdp(rawClient, { + id: 11, + sessionId: "bc-tab-2", + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow("Unknown CDP session bc-tab-2 for Runtime.evaluate") + }) + const hiddenClose = yield* Effect.promise(() => sendCdp(rawClient, { + id: 12, + method: "Target.closeTarget", + params: { targetId: "target-2" }, + })) + expect(hiddenClose.result).toEqual({ success: false }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(async () => { + await expect(sendCdp(sessionClient, { + id: 13, + sessionId: "bc-tab-1", + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })).rejects.toThrow("Target not found: bc-tab-1") + }) + expect(extension.commands).toEqual([]) + + yield* Effect.promise(() => sendCdp(sessionClient, { id: 14, method: "Target.setAutoAttach", params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true } })) expect(extension.commands.filter((command) => { return command.method === "debugger.sendCommand" && command.params?.method === "Target.setAutoAttach" }).map((command) => command.params?.tabId)).toEqual([2]) @@ -59,12 +114,66 @@ describe("relay target visibility pruning", () => { } }))) }) + + it("uses the originating client's auto-attach settings for new targets", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const extension = yield* Effect.promise(() => connectFakeExtension(relay.url)) + const first = yield* Effect.promise(() => connectCdpClient(relay.url)) + const second = yield* Effect.promise(() => connectCdpClient(relay.url)) + + try { + yield* Effect.promise(() => sendCdp(first, { + id: 1, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + })) + yield* Effect.promise(() => sendCdp(second, { + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: false }, + })) + + yield* Effect.promise(() => sendCdp(first, { id: 3, method: "Target.createTarget", params: { url: "about:blank" } })) + yield* Effect.promise(() => sendCdp(second, { id: 4, method: "Target.createTarget", params: { url: "about:blank" } })) + + const setupCommands = extension.commands.filter((command) => { + return command.method === "debugger.sendCommand" && command.params?.method === "Target.setAutoAttach" + }) + expect(setupCommands.find((command) => command.params?.tabId === 1)?.params?.params).toEqual({ + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }) + expect(setupCommands.find((command) => command.params?.tabId === 2)?.params?.params).toEqual({ + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: false, + }) + + yield* Effect.promise(async () => { + await expect(sendCdp(first, { + id: 5, + method: "Runtime.evaluate", + params: { expression: "1" }, + })).rejects.toThrow("CDP sessionId is required for Runtime.evaluate") + }) + const targetInfo = yield* Effect.promise(() => sendCdp(first, { id: 6, method: "Target.getTargetInfo", params: {} })) + expect(targetInfo.result).toEqual({}) + } finally { + first.close() + second.close() + extension.close() + } + }))) + }) }) type ExtensionCommand = { readonly id: number readonly method: string - readonly params?: { readonly tabId?: number; readonly method?: string } + readonly params?: { readonly tabId?: number; readonly method?: string; readonly params?: JsonObject } } function connectFakeExtension(relayUrl: string): Promise { diff --git a/test/target-registry.test.ts b/test/target-registry.test.ts index 221dcd1..7627f44 100644 --- a/test/target-registry.test.ts +++ b/test/target-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { resolveTargetInfoTarget, TargetRegistry } from "../src/target-registry.ts" +import { TargetRegistry } from "../src/target-registry.ts" import type { ConnectedTarget } from "../src/relay-types.ts" function root(options: { @@ -56,16 +56,6 @@ describe("TargetRegistry root generations", () => { expect(registry.childTargets.size).toBe(0) }) - it("never falls back to an unrelated root for an explicit stale identity", () => { - const registry = new TargetRegistry() - const visible = root({ sessionId: "bc-tab-1", targetId: "target-1" }) - registry.addRootTarget(visible) - - expect(resolveTargetInfoTarget({ registry, targetId: "missing", fallback: () => visible })).toBeUndefined() - expect(resolveTargetInfoTarget({ registry, sessionId: "missing", fallback: () => visible })).toBeUndefined() - expect(resolveTargetInfoTarget({ registry, fallback: () => visible })).toBe(visible) - }) - it("does not preserve provisional adoption ownership across replacement", () => { const registry = new TargetRegistry() registry.addRootTarget(root({ sessionId: "bc-tab-1", targetId: "target-1" })) From c04e89507000648446315b538339ec3e02b7b6c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Jul 2026 14:16:13 -0400 Subject: [PATCH 3/9] feat(cli): improve session and form helpers --- .changeset/calm-sessions-select.md | 6 ++++ .changeset/shiny-forms-open.md | 6 ++++ AGENTS.md | 2 +- PLAN.md | 26 +++++++------- scripts/smoke.ts | 57 +++++++++++++++++++++++++++--- skills/browser-control/SKILL.md | 3 +- src/cli-session-selector.ts | 10 ++++++ src/cli.ts | 23 ++++++++---- src/execute.ts | 14 +++++++- test/cli-session-selector.test.ts | 19 ++++++++++ test/execute-ergonomics.test.ts | 23 ++++++++++++ 11 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 .changeset/calm-sessions-select.md create mode 100644 .changeset/shiny-forms-open.md create mode 100644 src/cli-session-selector.ts create mode 100644 test/cli-session-selector.test.ts diff --git a/.changeset/calm-sessions-select.md b/.changeset/calm-sessions-select.md new file mode 100644 index 0000000..f85105e --- /dev/null +++ b/.changeset/calm-sessions-select.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Accept `--session`, `-s`, and `BROWSER_CONTROL_SESSION` for session reset and +delete while retaining positional and current-session selection. diff --git a/.changeset/shiny-forms-open.md b/.changeset/shiny-forms-open.md new file mode 100644 index 0000000..614f0c9 --- /dev/null +++ b/.changeset/shiny-forms-open.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Search recursively through open shadow roots in `fillInput` and `fillInputs`, +and report the closed-root boundary when a selector has no match. diff --git a/AGENTS.md b/AGENTS.md index 33cc108..3237196 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ local Node relay. - Extension shim changes require reloading the unpacked extension once in Brave. - Relay-only changes should not require reloading the extension. - Use `termctrl` for long-running relay sessions during testing. -- Run `SMOKE_CASE=local-forms,local-cart,local-checkout,reconnect-evaluate,redirect-reconnect-evaluate,execute-target-url,execute-page-recovery,execute-page-detach-recovery,execute-fill-helpers,execute-snapshot-refs,handoff-navigation,handoff-cross-tab,handoff-target-detach,oopif-reconnect,dedicated-worker,network-capture,session-download-capability,execute-ghost-cursor,session-isolation,multi-client,stale-client-checkout,raw-first-checkout pnpm smoke` +- Run `SMOKE_CASE=local-forms,local-cart,local-checkout,reconnect-evaluate,redirect-reconnect-evaluate,session-missing-selector,execute-target-url,execute-page-recovery,execute-page-detach-recovery,execute-fill-helpers,execute-snapshot-refs,handoff-navigation,handoff-cross-tab,handoff-target-detach,oopif-reconnect,dedicated-worker,network-capture,session-download-capability,execute-ghost-cursor,session-isolation,multi-client,stale-client-checkout,raw-first-checkout pnpm smoke` before claiming the current smoke set is green. - CDP target visibility is scoped per client (`src/cdp-visibility.ts`): session-owned tabs are announced and their events delivered only to that diff --git a/PLAN.md b/PLAN.md index 65b6693..6da6c4d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -67,16 +67,6 @@ Verification: ### 3. Resolve smaller agent-experience gaps -- Make `fillInput` and `fillInputs` search open shadow roots recursively. Closed - shadow roots remain unsupported. When no DOM match exists, the error should - explain the helper's boundary and suggest `locator.fill()` if Playwright can - resolve the field. The current zero-match error was observed on - api.data.gov's signup component. -- Accept `--session` and `-s` on `session reset` and `session delete`, matching - `execute` and `journal`. -- Add smoke coverage proving explicit missing session ids fail for both - `--session x` and `BROWSER_CONTROL_SESSION=x`. - Verification: - Add a local open-shadow-root form fixture for both fill helpers and assert the @@ -87,6 +77,19 @@ Verification: ## Recently Shipped +### Fill helpers traverse open shadow roots + +String selectors passed to `fillInput` and `fillInputs` now search recursively +through open shadow roots. A zero-match error explains that closed roots remain +unavailable and suggests `locator.fill()` when Playwright can resolve the field. + +### Session lifecycle selectors are consistent + +`session reset` and `session delete` accept positional ids, `--session`/`-s`, +and `BROWSER_CONTROL_SESSION` before falling back to the saved current session. +Smoke coverage verifies explicit missing flag and environment ids fail instead +of falling back to the saved current session. + ### CDP routing fails closed Identity-free `Target.getTargetInfo` no longer returns an arbitrary tab, and @@ -451,8 +454,7 @@ restarts can be distinguished from session eviction. - Native `locator.fill()` can hang on login-style fields when installed browser extensions inject focus handlers or overlays. `fillInput` is the explicit fallback for ordinary `input` and `textarea` elements. -- `fillInput` currently uses `querySelector` semantics and cannot reach fields - in shadow roots. +- `fillInput` cannot reach fields inside closed shadow roots. - OOPIF behavior is guaranteed only by the current reconnect smoke scenarios. - Clipboard automation on insecure origins is not guaranteed. - Playwright download events and `download.saveAs()` are unavailable in diff --git a/scripts/smoke.ts b/scripts/smoke.ts index b4ea827..ca020c7 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -453,6 +453,29 @@ const cases: SmokeCase[] = [ ) }), }, + { + name: "session-missing-selector", + run: Effect.fnUntraced(function* () { + const missing = `missing-${Date.now()}` + const flagOutcome = yield* Effect.result(runBrowserControl(["session", "reset", "--session", missing])) + if (flagOutcome._tag === "Success") { + return yield* Effect.fail(new Error(`explicit missing --session unexpectedly succeeded: ${flagOutcome.success}`)) + } + const flagError = flagOutcome.failure + if (!flagError.message.includes(`Session not found: ${missing}`)) { + return yield* Effect.fail(new Error(`explicit missing --session returned the wrong error: ${flagError.message}`)) + } + const environmentOutcome = yield* Effect.result(runBrowserControl(["session", "delete"], { sessionId: missing })) + if (environmentOutcome._tag === "Success") { + return yield* Effect.fail(new Error(`explicit missing BROWSER_CONTROL_SESSION unexpectedly succeeded: ${environmentOutcome.success}`)) + } + const environmentError = environmentOutcome.failure + if (!environmentError.message.includes(`Session not found: ${missing}`)) { + return yield* Effect.fail(new Error(`explicit missing BROWSER_CONTROL_SESSION returned the wrong error: ${environmentError.message}`)) + } + return "explicit missing session selectors fail closed" + }), + }, { name: "execute-target-url", run: Effect.fnUntraced(function* (page) { @@ -571,22 +594,44 @@ const cases: SmokeCase[] = [ smokeSession, ` await page.setContent('') +await page.evaluate(() => { + const outer = document.createElement('div') + const outerRoot = outer.attachShadow({ mode: 'open' }) + const inner = document.createElement('div') + const innerRoot = inner.attachShadow({ mode: 'open' }) + const shadowInput = document.createElement('input') + shadowInput.id = 'shadow-input' + innerRoot.append(shadowInput) + outerRoot.append(inner) + document.body.append(outer) + const closed = document.createElement('div') + closed.attachShadow({ mode: 'closed' }).append(document.createElement('input')) + document.body.append(closed) +}) await fillInput('#one', 'alpha') +await fillInput('#shadow-input', 'delta') await fillInputs(page, [ { selector: page.getByRole('textbox').nth(1), value: 'beta' }, { selector: '#three', value: 'gamma' }, ]) +let closedBoundary +try { + await fillInput('#closed-input', 'should-not-appear') +} catch (error) { + closedBoundary = error instanceof Error ? error.message : String(error) +} const values = await page.evaluate(() => ({ one: document.querySelector('#one')?.value, two: document.querySelector('#two')?.value, three: document.querySelector('#three')?.value, + shadow: document.querySelector('div')?.shadowRoot?.querySelector('div')?.shadowRoot?.querySelector('input')?.value, })) -return values +return { ...values, closedBoundary } `, ], { retryOnTimeout: true }, ) - if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma")) { + if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma") || !output.includes("delta") || !output.includes("closed shadow roots")) { return yield* Effect.fail(new Error(`execute fill helpers did not fill fields: ${output}`)) } return output.trim() @@ -1958,18 +2003,19 @@ function boundedCleanup(label: string, run: () => PromiseLike, timeoutM type RunBrowserControlOptions = { readonly retryOnTimeout?: boolean + readonly sessionId?: string } function runBrowserControl(args: readonly string[], options: RunBrowserControlOptions = {}): Effect.Effect { - return runBrowserControlOnce(args).pipe( + return runBrowserControlOnce(args, options).pipe( Effect.catchIf( (error) => options.retryOnTimeout === true && isBrowserControlTimeout(error), - () => runBrowserControlOnce(args), + () => runBrowserControlOnce(args, options), ), ) } -function runBrowserControlOnce(args: readonly string[]): Effect.Effect { +function runBrowserControlOnce(args: readonly string[], options: RunBrowserControlOptions): Effect.Effect { return Effect.callback((resume) => { let completed = false const endpointPort = new URL(endpointUrl).port @@ -1980,6 +2026,7 @@ function runBrowserControlOnce(args: readonly string[]): Effect.Effect): return inputFields.map((field) => { let element: Node | undefined if (typeof field.target === "string") { - const matches = document.querySelectorAll(field.target) + const matches: Element[] = [] + const roots: Array = [document] + for (let index = 0; index < roots.length; index += 1) { + const root = roots[index] + if (!root) continue + matches.push(...root.querySelectorAll(field.target)) + for (const candidate of root.querySelectorAll("*")) { + if (candidate.shadowRoot) roots.push(candidate.shadowRoot) + } + } if (matches.length !== 1) { + if (matches.length === 0) { + throw new Error(`fillInputs found no match for ${field.label} in the document or open shadow roots; closed shadow roots are unavailable. Try locator.fill() if Playwright can resolve the field.`) + } throw new Error(`fillInputs expects exactly one match for ${field.label}; got ${matches.length}`) } element = matches[0] diff --git a/test/cli-session-selector.test.ts b/test/cli-session-selector.test.ts new file mode 100644 index 0000000..4362137 --- /dev/null +++ b/test/cli-session-selector.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" +import { resolveExplicitSessionSelector } from "../src/cli-session-selector.ts" + +describe("resolveExplicitSessionSelector", () => { + it("accepts positional, flag, and environment selectors in precedence order", () => { + expect(resolveExplicitSessionSelector({ positional: "positional", flag: undefined, environment: "environment" })).toBe("positional") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: "flag", environment: "environment" })).toBe("flag") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: undefined, environment: "environment" })).toBe("environment") + expect(resolveExplicitSessionSelector({ positional: undefined, flag: undefined, environment: undefined })).toBeUndefined() + }) + + it("rejects combining positional and flag selectors", () => { + expect(() => resolveExplicitSessionSelector({ + positional: "positional", + flag: "flag", + environment: undefined, + })).toThrow("Use either a positional session id or --session, not both") + }) +}) diff --git a/test/execute-ergonomics.test.ts b/test/execute-ergonomics.test.ts index e673ecb..b63ab36 100644 --- a/test/execute-ergonomics.test.ts +++ b/test/execute-ergonomics.test.ts @@ -152,9 +152,32 @@ describe("fillInputs", () => { expect(locator.elementHandles).toHaveBeenCalledOnce() expect(evaluate).toHaveBeenCalledOnce() + expect(evaluate.mock.calls[0]?.[0].toString()).toContain("candidate.shadowRoot") expect(dispose).toHaveBeenCalledOnce() }) + it("explains the open and closed shadow-root boundary without exposing the value", async () => { + const evaluate = vi.fn(async (run: (fields: Array<{ readonly target: string; readonly label: string; readonly value: string }>) => unknown, fields) => { + const previousDocument = globalThis.document + const root = { + querySelectorAll: vi.fn((selector: string) => selector === "*" ? [] : []), + } + Object.assign(globalThis, { document: root }) + try { + return run(fields as Array<{ readonly target: string; readonly label: string; readonly value: string }>) + } finally { + Object.assign(globalThis, { document: previousDocument }) + } + }) + const page = { evaluate } as unknown as Page + + const outcome = fillInputs(page, [{ selector: "secure-field", value: "private-value" }]) + await expect(outcome).rejects.toThrow( + "fillInputs found no match for selector: secure-field in the document or open shadow roots; closed shadow roots are unavailable. Try locator.fill() if Playwright can resolve the field.", + ) + await expect(outcome).rejects.not.toThrow("private-value") + }) + it("rejects an ambiguous locator without serializing it or exposing values", async () => { const handles = [ { dispose: vi.fn().mockResolvedValue(undefined) }, From 683f8dd5b525455edaa16917564cb6757a54a73d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Jul 2026 14:36:26 -0400 Subject: [PATCH 4/9] feat(recording): stream framed tab captures --- .changeset/clean-recordings-stream.md | 5 + AGENTS.md | 4 +- PLAN.md | 18 +- extension/manifest.json | 2 +- extension/src/background.ts | 44 +++-- extension/src/offscreen.ts | 59 +++++-- extension/src/recording-types.ts | 3 +- src/protocol.ts | 4 +- src/recording-protocol.ts | 62 +++++++ src/recording-relay.ts | 207 +++++++++++++++------- src/relay.ts | 12 +- test/doctor.test.ts | 8 +- test/extension-rpc.test.ts | 6 +- test/http-api.test.ts | 4 +- test/protocol.test.ts | 6 +- test/recording-protocol.test.ts | 54 ++++++ test/recording-relay.test.ts | 230 +++++++++++++++++++++++++ test/relay-child-dedupe.test.ts | 8 +- test/relay-extension-handshake.test.ts | 35 +++- test/relay-schema.test.ts | 2 +- test/relay-session-persistence.test.ts | 4 +- test/relay-visibility-prune.test.ts | 2 +- 22 files changed, 637 insertions(+), 142 deletions(-) create mode 100644 .changeset/clean-recordings-stream.md create mode 100644 src/recording-protocol.ts create mode 100644 test/recording-protocol.test.ts diff --git a/.changeset/clean-recordings-stream.md b/.changeset/clean-recordings-stream.md new file mode 100644 index 0000000..7f6d5b4 --- /dev/null +++ b/.changeset/clean-recordings-stream.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/browser-control": minor +--- + +Stream tab-capture recordings to disk with intrinsically framed, sequenced binary messages instead of buffering complete recordings in relay memory. diff --git a/AGENTS.md b/AGENTS.md index 3237196..932f01c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,8 +242,8 @@ browser-control skill - Load `extension/dist` as the unpacked extension. - The relay listens on `127.0.0.1:19989` by default. -- Current shim version is `0.0.22` and extension protocol version is `1`. -- Store and npm versions may differ when protocol `1` remains compatible. +- Current shim version is `0.0.23` and extension protocol version is `2`. +- Store and npm versions may differ while their extension protocol versions remain compatible. - On socket open the shim sends `hello` and then re-announces every tab it still has `chrome.debugger` attached to (`debugger.attached` events), so a restarted relay rebuilds its target registry without the user re-clicking the toolbar. diff --git a/PLAN.md b/PLAN.md index 6da6c4d..5e110b0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -49,20 +49,14 @@ Verification: - Extend reconnect, OOPIF, and multi-client smoke cases to cover root detach and conflicting client auto-attach settings. -### 2. Stream recordings with unambiguous framing +### 2. Extend recording surfaces -- Include the tab id and sequence number in each binary websocket frame instead - of pairing a JSON metadata frame with the next binary frame. -- Stream chunks to disk instead of buffering complete recordings in relay - memory. - Add MCP recording start, stop, status, and cancel tools after the relay path is robust. - Build the flight-recorder ring buffer only after chunk streaming lands. Verification: -- Exercise interleaved recordings and a recording larger than the intended - in-memory bound. - Confirm CLI and MCP recording behavior match. ### 3. Resolve smaller agent-experience gaps @@ -77,6 +71,14 @@ Verification: ## Recently Shipped +### Tab-capture recordings stream with intrinsic framing + +Extension protocol `2` sends each recording chunk as a sequenced `BCRD` binary +frame containing its tab id. The relay validates framing and sequence, bounds +pending writes, streams each tab to an adjacent temporary file, and atomically +renames complete recordings. Interleaving, oversized queues, malformed frames, +and output larger than a single frame have direct coverage. + ### Fill helpers traverse open shadow roots String selectors passed to `fillInput` and `fillInputs` now search recursively @@ -184,7 +186,7 @@ require a new extension capture protocol and permission model. link`. - Until the first Store review completes, the browser extension is loaded unpacked from the npm package's `extension/dist` directory or a source build. - Its current shim version is `0.0.22`. + Its current shim version is `0.0.23`. - Extension and npm releases are independently versioned. The extension hello reports an explicit protocol version, and compatibility rather than exact package-version equality determines whether the local driver may use it. diff --git a/extension/manifest.json b/extension/manifest.json index 9cc4306..de30df5 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Browser Control", - "version": "0.0.22", + "version": "0.0.23", "description": "Connect browser tabs to the local Browser Control driver.", "minimum_chrome_version": "120", "permissions": ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"], diff --git a/extension/src/background.ts b/extension/src/background.ts index bf9e7f9..14ec5ec 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,4 +1,5 @@ import { extensionProtocolVersion, parseExtensionCommand, type ExtensionCommand as ShimCommand, type JsonObject } from "../../src/protocol.ts" +import { encodeRecordingFrame } from "../../src/recording-protocol.ts" import type { OffscreenCancelRecordingResult, OffscreenOutgoingMessage, @@ -13,6 +14,7 @@ import { debuggerDetachedEvent } from "./debugger-detach.ts" const relayHost = "127.0.0.1" const relayPort = 19989 const offscreenDocumentPath = "offscreen.html" +const maxRecordingSocketBufferedBytes = 16 * 1024 * 1024 let socket: WebSocket | undefined let connectionPromise: Promise | undefined @@ -72,9 +74,12 @@ chrome.tabs.onRemoved.addListener((tabId) => { sendMessage({ method: "tabs.removed", params: { tabId } }) }) -chrome.runtime.onMessage.addListener((message: unknown, sender) => { - handleRuntimeMessage(message, sender) - return false +chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => { + void handleRuntimeMessage(message, sender).then( + () => sendResponse({ success: true }), + (error: unknown) => sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) }), + ) + return true }) connect() @@ -373,10 +378,10 @@ async function stopRecording(params: JsonObject | undefined): Promise { } } -function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSender): void { +async function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSender): Promise { if (!message || typeof message !== "object" || Array.isArray(message)) { return } @@ -541,14 +546,12 @@ function handleRuntimeMessage(message: unknown, sender: chrome.runtime.MessageSe } const offscreenMessage = message as OffscreenOutgoingMessage if (offscreenMessage.action === "recording.chunk") { - if (offscreenMessage.data) { - sendMessage({ method: "recording.data", params: { tabId: offscreenMessage.tabId } }) - sendBinary(Uint8Array.from(offscreenMessage.data)) - return - } - if (offscreenMessage.final) { - sendMessage({ method: "recording.data", params: { tabId: offscreenMessage.tabId, final: true } }) - } + await sendBinaryAfterConnection(encodeRecordingFrame({ + tabId: offscreenMessage.tabId, + sequence: offscreenMessage.sequence, + final: offscreenMessage.final, + payload: Uint8Array.from(offscreenMessage.data ?? []), + })) return } if (offscreenMessage.action === "recording.cancelled") { @@ -580,10 +583,19 @@ async function sendMessageAfterConnection(message: JsonObject): Promise { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)) } -function sendBinary(data: Uint8Array): void { - if (socket?.readyState === WebSocket.OPEN) { - socket.send(data.buffer) +async function sendBinaryAfterConnection(data: Uint8Array): Promise { + if (socket?.readyState !== WebSocket.OPEN) await ensureConnection() + const currentSocket = socket + if (currentSocket?.readyState !== WebSocket.OPEN) throw new Error("Browser Control relay is not connected") + const deadline = Date.now() + 30_000 + while (currentSocket.bufferedAmount + data.byteLength > maxRecordingSocketBufferedBytes) { + await new Promise((resolve) => setTimeout(resolve, 10)) + if (socket !== currentSocket || currentSocket.readyState !== WebSocket.OPEN) { + throw new Error("Browser Control relay disconnected while sending recording data") + } + if (Date.now() >= deadline) throw new Error("Timed out sending recording data to the Browser Control relay") } + currentSocket.send(data.buffer) } function isAlreadyAttachedError(error: unknown): boolean { diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index af15f2f..4fbc8b7 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -12,13 +12,16 @@ import type { OffscreenStopRecordingMessage, OffscreenStopRecordingResult, } from "./recording-types.ts" +import { maxRecordingFramePayloadBytes } from "../../src/recording-protocol.ts" type RecordingState = { readonly recorder: MediaRecorder readonly stream: MediaStream - readonly pendingChunks: Set> readonly startedAt: number readonly tabId: number + nextSequence: number + sendTail: Promise + sendError?: Error } const recordings = new Map() @@ -75,22 +78,40 @@ async function handleStartRecording(message: OffscreenStartRecordingMessage): Pr audioBitsPerSecond: message.audioBitsPerSecond, }) const startedAt = Date.now() - const pendingChunks = new Set>() + const recording: RecordingState = { + recorder, + stream, + startedAt, + tabId: message.tabId, + nextSequence: 0, + sendTail: Promise.resolve(), + } recorder.ondataavailable = (event) => { - if (event.data.size === 0) { + if (event.data.size === 0 || recording.sendError) { return } - const pendingChunk = event.data.arrayBuffer().then((arrayBuffer) => { - chrome.runtime.sendMessage({ - action: "recording.chunk", - tabId: message.tabId, - data: Array.from(new Uint8Array(arrayBuffer)), - }) - }).finally(() => { - pendingChunks.delete(pendingChunk) + if (recorder.state === "recording") recorder.pause() + recording.sendTail = recording.sendTail.then(async () => { + try { + for (let offset = 0; offset < event.data.size; offset += maxRecordingFramePayloadBytes) { + const arrayBuffer = await event.data.slice(offset, offset + maxRecordingFramePayloadBytes).arrayBuffer() + const result = await chrome.runtime.sendMessage({ + action: "recording.chunk", + tabId: message.tabId, + sequence: recording.nextSequence++, + final: false, + data: Array.from(new Uint8Array(arrayBuffer)), + }) as { readonly success: boolean; readonly error?: string } + if (!result.success) throw new Error(result.error ?? "Could not send recording chunk") + } + } catch (error) { + recording.sendError = error instanceof Error ? error : new Error(String(error)) + handleCancelRecordingForTab(message.tabId) + } finally { + if (!recording.sendError && recorder.state === "paused") recorder.resume() + } }) - pendingChunks.add(pendingChunk) } recorder.onerror = () => { handleCancelRecordingForTab(message.tabId) @@ -107,7 +128,7 @@ async function handleStartRecording(message: OffscreenStartRecordingMessage): Pr recorder.start(1_000) }) - recordings.set(message.tabId, { recorder, stream, pendingChunks, startedAt, tabId: message.tabId }) + recordings.set(message.tabId, recording) return { success: true, tabId: message.tabId, startedAt, mimeType: recorder.mimeType || mimeType || "video/webm" } } catch (error) { stream?.getTracks().map((track) => { @@ -139,15 +160,23 @@ async function handleStopRecording(message: OffscreenStopRecordingMessage): Prom } recording.recorder.stop() }) - await Promise.allSettled(recording.pendingChunks) + await recording.sendTail + if (recording.sendError) throw recording.sendError recording.stream.getTracks().map((track) => { track.stop() return undefined }) recordings.delete(message.tabId) - chrome.runtime.sendMessage({ action: "recording.chunk", tabId: message.tabId, final: true }) + const finalResult = await chrome.runtime.sendMessage({ + action: "recording.chunk", + tabId: message.tabId, + sequence: recording.nextSequence, + final: true, + }) as { readonly success: boolean; readonly error?: string } + if (!finalResult.success) throw new Error(finalResult.error ?? "Could not finish recording stream") return { success: true, tabId: message.tabId, duration: Date.now() - recording.startedAt } } catch (error) { + handleCancelRecordingForTab(message.tabId) return { success: false, error: error instanceof Error ? error.message : String(error) } } } diff --git a/extension/src/recording-types.ts b/extension/src/recording-types.ts index a0d24c5..cbc509e 100644 --- a/extension/src/recording-types.ts +++ b/extension/src/recording-types.ts @@ -93,8 +93,9 @@ export type OffscreenResult = export type OffscreenRecordingChunkMessage = { readonly action: "recording.chunk" readonly tabId: number + readonly sequence: number readonly data?: readonly number[] - readonly final?: boolean + readonly final: boolean } export type OffscreenRecordingCancelledMessage = { diff --git a/src/protocol.ts b/src/protocol.ts index 67f9eaf..f385ab1 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -4,7 +4,7 @@ export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: export type JsonObject = { readonly [key: string]: JsonValue } -export const extensionProtocolVersion = 1 +export const extensionProtocolVersion = 2 export const legacyExtensionProtocolVersion = 1 export type ExtensionProtocolCompatibility = { @@ -104,7 +104,6 @@ export type ExtensionEvent = { | "tabs.removed" | "pong" | "log" - | "recording.data" | "recording.cancelled" | "pageStatus.requested" readonly params?: JsonObject @@ -149,7 +148,6 @@ const extensionEventMethods = new Set([ "tabs.removed", "pong", "log", - "recording.data", "recording.cancelled", "pageStatus.requested", ]) diff --git a/src/recording-protocol.ts b/src/recording-protocol.ts new file mode 100644 index 0000000..e46c13c --- /dev/null +++ b/src/recording-protocol.ts @@ -0,0 +1,62 @@ +const magic = [0x42, 0x43, 0x52, 0x44] as const +const version = 1 +const finalFlag = 1 +const headerLength = 20 + +export const maxRecordingFramePayloadBytes = 4 * 1024 * 1024 + +export type RecordingFrame = { + readonly tabId: number + readonly sequence: number + readonly final: boolean + readonly payload: Uint8Array +} + +export function encodeRecordingFrame(frame: RecordingFrame): Uint8Array { + validateUint32(frame.tabId, "tabId", true) + validateUint32(frame.sequence, "sequence", false) + if (frame.payload.byteLength > maxRecordingFramePayloadBytes) { + throw new Error(`Recording frame payload exceeds ${maxRecordingFramePayloadBytes} bytes`) + } + if (frame.final && frame.payload.byteLength !== 0) throw new Error("Final recording frame must have an empty payload") + if (!frame.final && frame.payload.byteLength === 0) throw new Error("Recording data frame must have a payload") + const encoded = new Uint8Array(headerLength + frame.payload.byteLength) + encoded.set(magic, 0) + const view = new DataView(encoded.buffer) + view.setUint8(4, version) + view.setUint8(5, frame.final ? finalFlag : 0) + view.setUint16(6, headerLength) + view.setUint32(8, frame.tabId) + view.setUint32(12, frame.sequence) + view.setUint32(16, frame.payload.byteLength) + encoded.set(frame.payload, headerLength) + return encoded +} + +export function decodeRecordingFrame(data: Uint8Array): RecordingFrame { + if (data.byteLength < headerLength) throw new Error("Recording frame is shorter than its header") + if (magic.some((byte, index) => data[index] !== byte)) throw new Error("Invalid recording frame magic") + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + if (view.getUint8(4) !== version) throw new Error(`Unsupported recording frame version: ${view.getUint8(4)}`) + const flags = view.getUint8(5) + if ((flags & ~finalFlag) !== 0) throw new Error(`Invalid recording frame flags: ${flags}`) + if (view.getUint16(6) !== headerLength) throw new Error(`Invalid recording frame header length: ${view.getUint16(6)}`) + const tabId = view.getUint32(8) + const sequence = view.getUint32(12) + const payloadLength = view.getUint32(16) + if (tabId === 0) throw new Error("Recording frame tabId must be positive") + if (payloadLength > maxRecordingFramePayloadBytes) { + throw new Error(`Recording frame payload exceeds ${maxRecordingFramePayloadBytes} bytes`) + } + if (data.byteLength !== headerLength + payloadLength) throw new Error("Recording frame payload length does not match its header") + const final = (flags & finalFlag) !== 0 + if (final && payloadLength !== 0) throw new Error("Final recording frame must have an empty payload") + if (!final && payloadLength === 0) throw new Error("Recording data frame must have a payload") + return { tabId, sequence, final, payload: data.subarray(headerLength) } +} + +function validateUint32(value: number, field: string, positive: boolean): void { + if (!Number.isInteger(value) || value < (positive ? 1 : 0) || value > 0xffff_ffff) { + throw new Error(`Recording frame ${field} must be ${positive ? "a positive" : "an"} uint32`) + } +} diff --git a/src/recording-relay.ts b/src/recording-relay.ts index 2318153..16b3210 100644 --- a/src/recording-relay.ts +++ b/src/recording-relay.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process" +import crypto from "node:crypto" import { once } from "node:events" import fs from "node:fs/promises" import path from "node:path" @@ -7,6 +8,7 @@ import { mjpegMatroskaFrame, mjpegMatroskaHeader } from "./mjpeg-matroska.ts" import type { ExtensionCommand, JsonObject } from "./protocol.ts" import { getObject } from "./relay-helpers.ts" import type { ConnectedTarget } from "./relay-types.ts" +import { decodeRecordingFrame } from "./recording-protocol.ts" const defaultMaxDurationMs = 15 * 60 * 1_000 const defaultCdpFrameRate = 25 @@ -15,6 +17,8 @@ const maxPendingCdpFrames = 30 const maxCdpWidth = 1_280 const maxCdpHeight = 720 const cdpJpegQuality = 80 +const maxPendingTabCaptureBytes = 16 * 1024 * 1024 +const maxTabCaptureOutputBytes = 1024 * 1024 * 1024 export type RecordingMode = "auto" | "tab-capture" | "cdp" export type ActiveRecordingMode = "tab-capture" | "cdp" @@ -106,7 +110,17 @@ type ActiveRecordingBase = { type TabCaptureRecording = ActiveRecordingBase & { mode: "tab-capture" artifactType: "webm" - chunks: Buffer[] + temporaryPath: string + file: Awaited> + expectedSequence: number + receivedBytes: number + pendingWriteBytes: number + writePromise: Promise + writeError?: Error + stopping: boolean + finalized: boolean + stopPromise?: Promise + cleanupPromise?: Promise } type CdpRecording = ActiveRecordingBase & { @@ -193,7 +207,7 @@ type ExtensionStatusResult = { export class RecordingRelay { private readonly activeRecordings = new Map() private readonly startingRecordings = new Map() - private lastRecordingMetadataTabId: number | undefined + private readonly finalizingRecordings = new Set>() constructor(readonly options: { readonly sendToExtension: (command: Omit) => Promise @@ -239,13 +253,23 @@ export class RecordingRelay { if (recording.mode === "cdp") { return this.stopCdpRecording(recording) } + if (recording.stopPromise) return recording.stopPromise + const stopPromise = this.stopTabCaptureRecording(recording) + recording.stopPromise = stopPromise + return stopPromise + } + private async stopTabCaptureRecording(recording: TabCaptureRecording): Promise { let stopTimeout: ReturnType | undefined const finalResult = new Promise((resolve) => { stopTimeout = setTimeout(() => { delete recording.resolveStop if (this.activeRecordings.get(recording.tabId) === recording) { - this.cleanupRecording(recording.tabId) + recording.stopping = true + if (this.options.isExtensionConnected()) { + void this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }).catch(() => {}) + } + void this.cleanupRecording(recording.tabId) } resolve({ success: false, error: "Timeout waiting for recording data" }) }, 30_000) @@ -263,15 +287,16 @@ export class RecordingRelay { if (!result.success) { if (stopTimeout) clearTimeout(stopTimeout) delete recording.resolveStop - this.cleanupRecording(recording.tabId) + await this.failTabCaptureRecording(recording, result.error) return result } return await finalResult } catch (error) { if (stopTimeout) clearTimeout(stopTimeout) delete recording.resolveStop - this.cleanupRecording(recording.tabId) - return { success: false, error: error instanceof Error ? error.message : String(error) } + const message = error instanceof Error ? error.message : String(error) + await this.failTabCaptureRecording(recording, message) + return { success: false, error: message } } } @@ -298,15 +323,12 @@ export class RecordingRelay { method: "recording.status", params: { tabId: recording.tabId }, })) - const size = recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0) return { isRecording: result.isRecording, tabId: recording.tabId, startedAt: result.startedAt ?? recording.startedAt, path: recording.outputPath, - size, + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", } @@ -318,9 +340,7 @@ export class RecordingRelay { tabId: recording.tabId, startedAt: recording.startedAt, path: recording.outputPath, - size: recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0), + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", } @@ -343,15 +363,18 @@ export class RecordingRelay { return { success: true } } if (!this.options.isExtensionConnected()) { - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return { success: false, error: "Browser Control extension is not connected" } } try { const result = await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }) - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return parseCancelResult(result) } catch (error) { - this.cleanupRecording(recording.tabId) + recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) + await this.cleanupRecording(recording.tabId) return { success: false, error: error instanceof Error ? error.message : String(error) } } } @@ -374,8 +397,9 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return } - this.cleanupRecording(recording.tabId) + await this.cleanupRecording(recording.tabId) })) + await Promise.all(this.finalizingRecordings) } async abortRecordingForTab(options: { readonly tabId: number; readonly reason: string }): Promise { @@ -398,24 +422,7 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return } - this.cleanupRecording(recording.tabId) - } - - handleRecordingData(message: JsonObject): void { - const params = getObject(message.params) - const tabId = typeof params?.tabId === "number" ? params.tabId : undefined - if (tabId === undefined) { - return - } - if (params?.final !== true) { - this.lastRecordingMetadataTabId = tabId - return - } - const recording = this.activeRecordings.get(tabId) - if (!recording || recording.mode !== "tab-capture") { - return - } - void this.finishRecording(recording) + await this.cleanupRecording(recording.tabId) } handleRecordingCancelled(message: JsonObject): void { @@ -428,7 +435,7 @@ export class RecordingRelay { if (recording?.resolveStop) { recording.resolveStop({ success: false, error: "Recording was cancelled" }) } - this.cleanupRecording(tabId) + void this.cleanupRecording(tabId) } handleDebuggerEvent(options: { readonly tabId: number; readonly method: string; readonly params: JsonObject | undefined }): boolean { @@ -482,37 +489,72 @@ export class RecordingRelay { } handleBinaryData(data: Buffer): void { - const tabId = this.lastRecordingMetadataTabId - this.lastRecordingMetadataTabId = undefined - if (tabId === undefined) { + const frame = decodeRecordingFrame(data) + const recording = this.activeRecordings.get(frame.tabId) + if (!recording || recording.mode !== "tab-capture") { return } - const recording = this.activeRecordings.get(tabId) - if (!recording || recording.mode !== "tab-capture") { + if (recording.stopping || recording.finalized) return + if (frame.sequence !== recording.expectedSequence) { + void this.failTabCaptureRecording(recording, `Recording frame sequence ${frame.sequence} did not match expected ${recording.expectedSequence}`) + return + } + recording.expectedSequence += 1 + if (frame.final) { + recording.stopping = true + if (recording.maxDurationTimer) clearTimeout(recording.maxDurationTimer) + this.activeRecordings.delete(frame.tabId) + const finalizing = this.finishTabCaptureRecording(recording).finally(() => { + this.finalizingRecordings.delete(finalizing) + }) + this.finalizingRecordings.add(finalizing) + void finalizing return } - recording.chunks.push(Buffer.from(data)) + const payload = Buffer.from(frame.payload) + if (recording.receivedBytes + payload.byteLength > maxTabCaptureOutputBytes) { + void this.failTabCaptureRecording(recording, `Tab capture exceeds ${maxTabCaptureOutputBytes} bytes`) + return + } + if (recording.pendingWriteBytes + payload.byteLength > maxPendingTabCaptureBytes) { + void this.failTabCaptureRecording(recording, `Tab capture pending writes exceed ${maxPendingTabCaptureBytes} bytes`) + return + } + recording.receivedBytes += payload.byteLength + recording.pendingWriteBytes += payload.byteLength + recording.writePromise = recording.writePromise.then(async () => { + const result = await recording.file.write(payload) + if (result.bytesWritten !== payload.byteLength) throw new Error(`Short recording write: ${result.bytesWritten}/${payload.byteLength}`) + }).catch((error: unknown) => { + recording.writeError ??= error instanceof Error ? error : new Error(String(error)) + }).finally(() => { + recording.pendingWriteBytes -= payload.byteLength + }) } - private async finishRecording(recording: TabCaptureRecording): Promise { + private async finishTabCaptureRecording(recording: TabCaptureRecording): Promise { try { - const size = recording.chunks.reduce((total, chunk) => { - return total + chunk.byteLength - }, 0) - await fs.writeFile(recording.outputPath, Buffer.concat(recording.chunks)) + await recording.writePromise + if (recording.writeError) throw recording.writeError + if (recording.receivedBytes === 0) throw new Error("No tab capture data was received") + await recording.file.sync() + await recording.file.close() + await fs.rename(recording.temporaryPath, recording.outputPath) + recording.finalized = true + const stat = await fs.stat(recording.outputPath) recording.resolveStop?.({ success: true, tabId: recording.tabId, duration: Date.now() - recording.startedAt, path: recording.outputPath, - size, + size: stat.size, mode: "tab-capture", artifactType: "webm", }) } catch (error) { recording.resolveStop?.({ success: false, error: error instanceof Error ? error.message : String(error) }) } finally { - this.cleanupRecording(recording.tabId) + await this.cleanupTabCaptureRecording(recording) } } @@ -542,16 +584,40 @@ export class RecordingRelay { return recordings[0] } - private cleanupRecording(tabId: number): void { + private async cleanupRecording(tabId: number): Promise { const recording = this.activeRecordings.get(tabId) + if (!recording) return if (recording?.maxDurationTimer) { clearTimeout(recording.maxDurationTimer) } - if (recording?.mode === "cdp") { - void this.cleanupCdpRecording(recording) + if (recording.mode === "cdp") { + await this.cleanupCdpRecording(recording) return } + if (recording.cleanupPromise) return recording.cleanupPromise this.activeRecordings.delete(tabId) + return this.cleanupTabCaptureRecording(recording) + } + + private cleanupTabCaptureRecording(recording: TabCaptureRecording): Promise { + if (recording.cleanupPromise) return recording.cleanupPromise + recording.cleanupPromise = (async () => { + await recording.writePromise.catch(() => {}) + await recording.file.close().catch(() => {}) + if (!recording.finalized) await fs.rm(recording.temporaryPath, { force: true }).catch(() => {}) + })() + return recording.cleanupPromise + } + + private async failTabCaptureRecording(recording: TabCaptureRecording, message: string): Promise { + if (recording.stopping || recording.finalized) return + recording.stopping = true + recording.resolveStop?.({ success: false, error: message }) + const cleanup = this.cleanupRecording(recording.tabId) + if (this.options.isExtensionConnected()) { + await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: recording.tabId } }).catch(() => {}) + } + await cleanup } private async startReservedRecording(options: RecordingStartOptions, starting: StartingRecording): Promise { @@ -565,23 +631,40 @@ export class RecordingRelay { await fs.mkdir(path.dirname(options.outputPath), { recursive: true }) if (starting.cancelled) return { success: false, error: "Recording was cancelled while starting" } - const result = parseExtensionStartResult(await this.options.sendToExtension({ - method: "recording.start", - params: recordingStartParams(options), - })) - if (!result.success) return result - if (starting.cancelled) { - await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) - return { success: false, error: "Recording was cancelled while starting" } + const temporaryPath = `${options.outputPath}.partial-${process.pid}-${crypto.randomUUID()}` + const file = await fs.open(temporaryPath, "wx", 0o600) + let result: ExtensionStartResult + try { + result = parseExtensionStartResult(await this.options.sendToExtension({ + method: "recording.start", + params: recordingStartParams(options), + })) + } catch (error) { + await file.close().catch(() => {}) + await fs.rm(temporaryPath, { force: true }).catch(() => {}) + throw error + } + if (!result.success || starting.cancelled) { + if (result.success) await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) + await file.close().catch(() => {}) + await fs.rm(temporaryPath, { force: true }).catch(() => {}) + return result.success ? { success: false, error: "Recording was cancelled while starting" } : result } - const recording: ActiveRecording = { + const recording: TabCaptureRecording = { tabId: result.tabId, ...(options.sessionId ? { sessionId: options.sessionId } : {}), outputPath: options.outputPath, + temporaryPath, + file, mode: "tab-capture", artifactType: "webm", - chunks: [], + expectedSequence: 0, + receivedBytes: 0, + pendingWriteBytes: 0, + writePromise: Promise.resolve(), + stopping: false, + finalized: false, startedAt: result.startedAt, } this.armMaxDuration(recording, options.maxDurationMs) diff --git a/src/relay.ts b/src/relay.ts index 84986a1..382eb69 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -640,7 +640,11 @@ const makeRelay = Effect.fnUntraced(function* (options: { return } if (isBinary) { - recordingRelay.handleBinaryData(rawDataToBuffer(data)) + try { + recordingRelay.handleBinaryData(rawDataToBuffer(data)) + } catch { + socket.close(1002, "Invalid recording frame") + } return } handleExtensionMessage(socket, data.toString(), socketGeneration, announcedRootTabIds) @@ -695,7 +699,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { return undefined } const protocol = extensionProtocolCompatibility(message.params?.protocolVersion) - if (!protocol.compatible && extensionRpc.connected) { + if (!protocol.compatible && extensionRpc.protocolCompatible === true) { socket.close(4003, "Extension protocol incompatible") return extensionGeneration } @@ -810,10 +814,6 @@ const makeRelay = Effect.fnUntraced(function* (options: { } return } - if (extensionMethod === "recording.data") { - recordingRelay.handleRecordingData(message) - return - } if (extensionMethod === "recording.cancelled") { recordingRelay.handleRecordingCancelled(message) return diff --git a/test/doctor.test.ts b/test/doctor.test.ts index bcc7a41..5b54424 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -75,12 +75,12 @@ describe("extensionProtocolCheck", () => { value: { connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, activeTargets: 0, }, - })).toMatchObject({ status: "ok", message: "runtime 1 is compatible with relay 1" }) + })).toMatchObject({ status: "ok", message: "runtime 2 is compatible with relay 2" }) }) it("fails an incompatible extension protocol", () => { @@ -89,11 +89,11 @@ describe("extensionProtocolCheck", () => { value: { connected: false, version: "10.0.0", - protocolVersion: 2, + protocolVersion: 3, protocolCompatible: false, protocolLegacy: false, activeTargets: 0, }, - })).toMatchObject({ status: "fail", message: "runtime 2 is incompatible with relay 1" }) + })).toMatchObject({ status: "fail", message: "runtime 3 is incompatible with relay 2" }) }) }) diff --git a/test/extension-rpc.test.ts b/test/extension-rpc.test.ts index 9aba1ad..eb6e847 100644 --- a/test/extension-rpc.test.ts +++ b/test/extension-rpc.test.ts @@ -56,7 +56,7 @@ const makeFakeSocket = (): FakeSocket => { const connect = (rpc: ExtensionRpc): FakeSocket => { const socket = makeFakeSocket() rpc.replaceSocket(socket) - rpc.markHandshake("0.0.5", 1) + rpc.markHandshake("0.0.23", 2) rpc.markReady() return socket } @@ -83,11 +83,11 @@ describe("ExtensionRpc", () => { const rpc = new ExtensionRpc() const socket = makeFakeSocket() rpc.replaceSocket(socket) - rpc.markHandshake("1.0.0", 2) + rpc.markHandshake("1.0.0", 3) expect(rpc.connected).toBe(false) await expect(Effect.runPromise(rpc.send({ method: "tabs.create", params: {} }))).rejects.toThrow( - "extension protocol 2 is incompatible", + "extension protocol 3 is incompatible", ) expect(socket.sent).toHaveLength(0) }) diff --git a/test/http-api.test.ts b/test/http-api.test.ts index 30d2fe4..971e1e0 100644 --- a/test/http-api.test.ts +++ b/test/http-api.test.ts @@ -44,7 +44,7 @@ describe("HTTP request schemas", () => { extensionStatus: () => ({ connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, }), @@ -68,7 +68,7 @@ describe("HTTP request schemas", () => { expect(extension).toMatchObject({ connected: true, version: "9.4.2", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, }) diff --git a/test/protocol.test.ts b/test/protocol.test.ts index e06dce8..018ebd0 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -33,10 +33,10 @@ describe("protocol validation", () => { expect(isExtensionEvent(parseJsonObject('{"method":"unknown"}'))).toBe(false) }) - it("treats legacy hellos as protocol v1 and rejects unknown protocol versions", () => { + it("rejects legacy hellos and unknown protocol versions", () => { expect(extensionProtocolCompatibility(undefined)).toEqual({ - version: extensionProtocolVersion, - compatible: true, + version: 1, + compatible: false, legacy: true, }) expect(extensionProtocolCompatibility(extensionProtocolVersion)).toEqual({ diff --git a/test/recording-protocol.test.ts b/test/recording-protocol.test.ts new file mode 100644 index 0000000..247f961 --- /dev/null +++ b/test/recording-protocol.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { decodeRecordingFrame, encodeRecordingFrame, maxRecordingFramePayloadBytes } from "../src/recording-protocol.ts" + +describe("recording frame protocol", () => { + it("encodes a byte-exact big-endian header and round trips data", () => { + const encoded = encodeRecordingFrame({ tabId: 7, sequence: 3, final: false, payload: Uint8Array.of(0xaa, 0xbb) }) + expect(Array.from(encoded)).toEqual([ + 0x42, 0x43, 0x52, 0x44, 1, 0, 0, 20, + 0, 0, 0, 7, + 0, 0, 0, 3, + 0, 0, 0, 2, + 0xaa, 0xbb, + ]) + expect(decodeRecordingFrame(encoded)).toEqual({ + tabId: 7, + sequence: 3, + final: false, + payload: Uint8Array.of(0xaa, 0xbb), + }) + }) + + it("round trips a final frame", () => { + expect(decodeRecordingFrame(encodeRecordingFrame({ + tabId: 1, + sequence: 0xffff_ffff, + final: true, + payload: new Uint8Array(), + }))).toMatchObject({ tabId: 1, sequence: 0xffff_ffff, final: true, payload: new Uint8Array() }) + }) + + it.each([ + ["short header", new Uint8Array(19), "shorter than its header"], + ["wrong magic", Uint8Array.of(0, 0, 0, 0, 1, 1, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "magic"], + ["unknown version", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 2, 1, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "version"], + ["unknown flags", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 2, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "flags"], + ["wrong header length", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 1, 0, 19, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), "header length"], + ["zero tab", Uint8Array.of(0x42, 0x43, 0x52, 0x44, 1, 1, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), "tabId"], + ])("rejects %s", (_label, frame, message) => { + expect(() => decodeRecordingFrame(frame as Uint8Array)).toThrow(message as string) + }) + + it("rejects mismatched and oversized payload lengths", () => { + const mismatched = encodeRecordingFrame({ tabId: 1, sequence: 0, final: false, payload: Uint8Array.of(1) }) + new DataView(mismatched.buffer).setUint32(16, 2) + expect(() => decodeRecordingFrame(mismatched)).toThrow("payload length") + + const oversized = new Uint8Array(20) + oversized.set([0x42, 0x43, 0x52, 0x44, 1, 0, 0, 20], 0) + const view = new DataView(oversized.buffer) + view.setUint32(8, 1) + view.setUint32(16, maxRecordingFramePayloadBytes + 1) + expect(() => decodeRecordingFrame(oversized)).toThrow("exceeds") + }) +}) diff --git a/test/recording-relay.test.ts b/test/recording-relay.test.ts index dc97f11..10d0345 100644 --- a/test/recording-relay.test.ts +++ b/test/recording-relay.test.ts @@ -3,6 +3,7 @@ import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it, vi } from "vitest" import { RecordingRelay, type VideoEncoder } from "../src/recording-relay.ts" +import { encodeRecordingFrame } from "../src/recording-protocol.ts" const temporaryPaths: string[] = [] @@ -11,6 +12,235 @@ afterEach(async () => { await Promise.all(temporaryPaths.splice(0).map((temporaryPath) => fs.rm(temporaryPath, { force: true, recursive: true }))) }) +describe("RecordingRelay tab capture", () => { + it("streams intrinsically framed chunks to an atomic output", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "demo.webm") + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") { + return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 2, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + + await expect(relay.startRecording({ tabId: 7, owner: "user", outputPath })).resolves.toMatchObject({ success: true }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 0, + final: false, + payload: new TextEncoder().encode("first"), + }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: false, + payload: new TextEncoder().encode("second"), + }))) + + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ + success: true, + size: 11, + mode: "tab-capture", + }) + expect(await fs.readFile(outputPath, "utf8")).toBe("firstsecond") + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + + it("streams output larger than one frame without retaining the complete recording", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "large.webm") + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 2, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + const chunk = new Uint8Array(3 * 1024 * 1024).fill(0x5a) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: chunk }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: false, payload: chunk }))) + + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ success: true, size: 6 * 1024 * 1024 }) + const contents = await fs.readFile(outputPath) + expect(contents.byteLength).toBe(6 * 1024 * 1024) + expect(contents[0]).toBe(0x5a) + expect(contents.at(-1)).toBe(0x5a) + }) + + it("keeps interleaved tab frames isolated", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputs = new Map([[1, path.join(directory, "one.webm")], [2, path.join(directory, "two.webm")]]) + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + const tabId = typeof command.params?.tabId === "number" ? command.params.tabId : 0 + if (command.method === "recording.start") return { success: true, tabId, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId, duration: 100 } + } + return { success: true } + }, + }) + + await relay.startRecording({ tabId: 1, owner: "user", outputPath: outputs.get(1) ?? "" }) + await relay.startRecording({ tabId: 2, owner: "user", outputPath: outputs.get(2) ?? "" }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 2, sequence: 0, final: false, payload: Uint8Array.of(2) }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 1, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + + await Promise.all([relay.stopRecording({ tabId: 1 }), relay.stopRecording({ tabId: 2 })]) + expect(Array.from(await fs.readFile(outputs.get(1) ?? ""))).toEqual([1]) + expect(Array.from(await fs.readFile(outputs.get(2) ?? ""))).toEqual([2]) + }) + + it("shares an in-flight stop across concurrent callers", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + let stopCalls = 0 + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") return { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + if (command.method === "recording.stop") { + stopCalls += 1 + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath: path.join(directory, "shared.webm") }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + + const [first, second] = await Promise.all([ + relay.stopRecording({ tabId: 7 }), + relay.stopRecording({ tabId: 7 }), + ]) + expect(first).toEqual(second) + expect(first).toMatchObject({ success: true, size: 1 }) + expect(stopCalls).toBe(1) + }) + + it("does not let cancellation race a stream that is already finalizing", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "finalizing.webm") + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1, 2, 3) }))) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: true, payload: new Uint8Array() }))) + + await expect(relay.cancelRecording({ tabId: 7 })).resolves.toEqual({ success: true }) + await relay.cleanupAll("test shutdown") + expect(Array.from(await fs.readFile(outputPath))).toEqual([1, 2, 3]) + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + + it("aborts and removes partial output on a sequence violation", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "bad.webm") + const commands: string[] = [] + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + commands.push(command.method) + return command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath }) + + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: false, payload: Uint8Array.of(1) }))) + await vi.waitFor(async () => { + expect((await relay.statusRecording({ tabId: 7 })).isRecording).toBe(false) + }) + + expect(commands).toContain("recording.cancel") + await vi.waitFor(async () => { + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + }) + + it("aborts when pending disk writes exceed the memory bound", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const commands: string[] = [] + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + commands.push(command.method) + return command.method === "recording.start" + ? { success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" } + : { success: true } + }, + }) + await relay.startRecording({ tabId: 7, owner: "user", outputPath: path.join(directory, "bounded.webm") }) + const chunk = new Uint8Array(4 * 1024 * 1024) + for (let sequence = 0; sequence < 5; sequence += 1) { + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence, final: false, payload: chunk }))) + } + + await vi.waitFor(async () => { + expect((await relay.statusRecording({ tabId: 7 })).isRecording).toBe(false) + expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) + }) + expect(commands).toContain("recording.cancel") + }) +}) + describe("RecordingRelay CDP screencast", () => { it("acknowledges compositor frames and timestamps source frames for constant-rate encoding", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-recording-")) diff --git a/test/relay-child-dedupe.test.ts b/test/relay-child-dedupe.test.ts index 454ce67..36a98fe 100644 --- a/test/relay-child-dedupe.test.ts +++ b/test/relay-child-dedupe.test.ts @@ -61,7 +61,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.17" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -201,7 +201,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.17" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -264,7 +264,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.11" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.some((command) => command.method === "action.setAttached")) @@ -394,7 +394,7 @@ describe("relay child target announce dedupe", () => { : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.7" } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "toolbar.clicked", params: { tabId: 1 } })) await waitFor(() => extensionCommands.includes("action.setAttached")) diff --git a/test/relay-extension-handshake.test.ts b/test/relay-extension-handshake.test.ts index 4d8bac3..25eeeb7 100644 --- a/test/relay-extension-handshake.test.ts +++ b/test/relay-extension-handshake.test.ts @@ -24,7 +24,7 @@ describe("relay extension handshake", () => { await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectExtension(relay.url)) - extension.send(JSON.stringify({ method: "hello", params: { version: "2.0.0", protocolVersion: 2 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "2.0.0", protocolVersion: 3 } })) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) yield* Effect.sleep("20 millis") @@ -32,7 +32,7 @@ describe("relay extension handshake", () => { expect(status).toMatchObject({ connected: false, version: "2.0.0", - protocolVersion: 2, + protocolVersion: 3, protocolCompatible: false, protocolLegacy: false, activeTargets: 0, @@ -46,25 +46,44 @@ describe("relay extension handshake", () => { await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectExtension(relay.url)) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.19", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) const beforeReady = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) - expect(beforeReady).toMatchObject({ connected: false, protocolVersion: 1, protocolCompatible: true }) + expect(beforeReady).toMatchObject({ connected: false, protocolVersion: 2, protocolCompatible: true }) extension.send(JSON.stringify({ method: "ready" })) yield* Effect.sleep("10 millis") const ready = yield* Effect.promise(() => fetch(`${relay.url}/extension/status`).then((response) => response.json())) - expect(ready).toMatchObject({ connected: true, protocolVersion: 1, protocolCompatible: true, protocolLegacy: false }) + expect(ready).toMatchObject({ connected: true, protocolVersion: 2, protocolCompatible: true, protocolLegacy: false }) extension.close() }))) }) + it("does not let an incompatible extension replace a compatible socket before ready", async () => { + const port = 24_000 + Math.floor(Math.random() * 10_000) + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const relay = yield* startRelay({ port, sessionCatalogPath: null }) + const compatible = yield* Effect.promise(() => connectExtension(relay.url)) + compatible.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) + + const incompatible = yield* Effect.promise(() => connectExtension(relay.url)) + const closed = waitForClose(incompatible) + incompatible.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + expect(yield* Effect.promise(() => closed)).toBe(4003) + + compatible.send(JSON.stringify({ method: "ready" })) + const status = yield* Effect.promise(() => waitForStatus(relay.url, (candidate) => candidate.connected === true)) + expect(status).toMatchObject({ connected: true, protocolVersion: 2, activeTargets: 0 }) + compatible.close() + }))) + }) + it("replaces the previous socket inventory before accepting a new ready event", async () => { const port = 24_000 + Math.floor(Math.random() * 10_000) await Effect.runPromise(Effect.scoped(Effect.gen(function* () { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const first = yield* Effect.promise(() => connectRespondingExtension(relay.url, "stale-target")) - first.send(JSON.stringify({ method: "hello", params: { version: "0.0.20", protocolVersion: 1 } })) + first.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) first.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) first.send(JSON.stringify({ method: "ready" })) yield* Effect.promise(() => waitForStatus(relay.url, (status) => status.connected === true && status.activeTargets === 1)) @@ -73,7 +92,7 @@ describe("relay extension handshake", () => { expect(client.events.some((event) => event.method === "Target.attachedToTarget")).toBe(true) const second = yield* Effect.promise(() => connectExtension(relay.url)) - second.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + second.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) second.send(JSON.stringify({ method: "ready" })) const status = yield* Effect.promise(() => waitForStatus(relay.url, (candidate) => candidate.connected === true)) @@ -90,7 +109,7 @@ describe("relay extension handshake", () => { const relay = yield* startRelay({ port, sessionCatalogPath: null }) const extension = yield* Effect.promise(() => connectRespondingExtension(relay.url, undefined, "synthetic reconciliation failure")) const closed = waitForClose(extension) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) extension.send(JSON.stringify({ method: "ready" })) diff --git a/test/relay-schema.test.ts b/test/relay-schema.test.ts index 161b294..8278ea3 100644 --- a/test/relay-schema.test.ts +++ b/test/relay-schema.test.ts @@ -248,7 +248,7 @@ describe("relay-schema", () => { const full = decodeExtensionStatus({ connected: true, version: "0.0.5", - protocolVersion: 1, + protocolVersion: 2, protocolCompatible: true, protocolLegacy: false, activeTargets: 2, diff --git a/test/relay-session-persistence.test.ts b/test/relay-session-persistence.test.ts index fbf6fd6..0221cff 100644 --- a/test/relay-session-persistence.test.ts +++ b/test/relay-session-persistence.test.ts @@ -260,7 +260,7 @@ async function openProtocolExtension(relayUrl: string, targetId?: string): Promi : {} extension.send(JSON.stringify({ id: command.id, result })) }) - extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.22", protocolVersion: 1 } })) + extension.send(JSON.stringify({ method: "hello", params: { version: "0.0.23", protocolVersion: 2 } })) if (targetId) extension.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 7 } })) extension.send(JSON.stringify({ method: "ready" })) return Object.assign(extension, { commands }) @@ -275,7 +275,7 @@ async function openFakeExtension(relayUrl: string, targetId: string): Promise { - socket.send(JSON.stringify({ method: "hello", params: { version: "test" } })) + socket.send(JSON.stringify({ method: "hello", params: { version: "test", protocolVersion: 2 } })) resolve(Object.assign(socket, { commands })) }) socket.on("error", reject) From 283fb0e9ac7866d2b6dfc6418280954f5528cf5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Jul 2026 14:48:38 -0400 Subject: [PATCH 5/9] refactor(recording): simplify capture ownership --- extension/src/background.ts | 50 +++++----------- extension/src/offscreen.ts | 54 ++++++++++++++++-- extension/src/recording-types.ts | 31 +++++++--- src/recording-relay.ts | 98 ++++++++++++++++++++------------ test/recording-relay.test.ts | 74 ++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 84 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 14ec5ec..7548629 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -20,7 +20,6 @@ let socket: WebSocket | undefined let connectionPromise: Promise | undefined let reconnectTimer: ReturnType | undefined let offscreenDocumentCreating: Promise | undefined -const activeRecordings = new Map() chrome.runtime.onInstalled.addListener(() => { chrome.alarms.create("browser-control-reconnect", { periodInMinutes: 0.5 }) @@ -351,9 +350,6 @@ async function handleCommand(command: ShimCommand): Promise { async function startRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - if (activeRecordings.has(tabId)) { - return { success: false, error: "Recording already in progress for this tab" } - } await ensureOffscreenDocument() const streamId = await getTabCaptureStreamId(tabId) const result = await chrome.runtime.sendMessage({ @@ -368,17 +364,12 @@ async function startRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - if (!activeRecordings.has(tabId)) { - return { success: false, error: "No active recording for this tab" } - } const result = await chrome.runtime.sendMessage({ action: "recording.stop", tabId }) as OffscreenStopRecordingResult - activeRecordings.delete(tabId) if (!result.success) { return { success: false, error: result.error } } @@ -387,20 +378,11 @@ async function stopRecording(params: JsonObject | undefined): Promise { const tabId = numberParam(params, "tabId") - const recording = activeRecordings.get(tabId) - if (!recording) { - return { isRecording: false, tabId } - } - try { - const result = await chrome.runtime.sendMessage({ action: "recording.status", tabId }) as OffscreenStatusRecordingResult - return { - isRecording: result.isRecording, - tabId, - ...(result.startedAt === undefined ? { startedAt: recording.startedAt } : { startedAt: result.startedAt }), - } - } catch { - activeRecordings.delete(tabId) - return { isRecording: false, tabId } + const result = await chrome.runtime.sendMessage({ action: "recording.status", tabId }) as OffscreenStatusRecordingResult + return { + isRecording: result.isRecording, + tabId, + ...(result.startedAt === undefined ? {} : { startedAt: result.startedAt }), } } @@ -410,11 +392,7 @@ async function cancelRecording(params: JsonObject | undefined): Promise { - if (!activeRecordings.has(tabId)) { - return { success: true } - } const result = await chrome.runtime.sendMessage({ action: "recording.cancel", tabId }) as OffscreenCancelRecordingResult - activeRecordings.delete(tabId) if (!result.success) { return { success: false, error: result.error } } @@ -424,15 +402,11 @@ async function cancelRecordingForTab(tabId: number): Promise { async function cleanupRecordingForTab(tabId: number): Promise { try { await cancelRecordingForTab(tabId) - } catch { - activeRecordings.delete(tabId) - } + } catch {} } async function cancelAllRecordings(): Promise { - await Promise.all(Array.from(activeRecordings.keys()).map(async (tabId) => { - await cleanupRecordingForTab(tabId) - })) + await chrome.runtime.sendMessage({ action: "recording.cancelAll" }).catch(() => {}) } async function ensureOffscreenDocument(): Promise { @@ -550,12 +524,11 @@ async function handleRuntimeMessage(message: unknown, sender: chrome.runtime.Mes tabId: offscreenMessage.tabId, sequence: offscreenMessage.sequence, final: offscreenMessage.final, - payload: Uint8Array.from(offscreenMessage.data ?? []), + payload: offscreenMessage.final ? new Uint8Array() : decodeBase64(offscreenMessage.dataBase64), })) return } if (offscreenMessage.action === "recording.cancelled") { - activeRecordings.delete(offscreenMessage.tabId) sendMessage({ method: "recording.cancelled", params: { tabId: offscreenMessage.tabId } }) } } @@ -598,6 +571,13 @@ async function sendBinaryAfterConnection(data: Uint8Array): Promise { currentSocket.send(data.buffer) } +function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + function isAlreadyAttachedError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) return message.includes("Another debugger is already attached") || message.includes("Debugger is already attached") diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index 4fbc8b7..eb235de 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -2,6 +2,7 @@ import type { ChromeTabCaptureAudioConstraints, ChromeTabCaptureVideoConstraints, OffscreenCancelRecordingMessage, + OffscreenCancelAllRecordingsResult, OffscreenCancelRecordingResult, OffscreenMessage, OffscreenResult, @@ -22,11 +23,13 @@ type RecordingState = { nextSequence: number sendTail: Promise sendError?: Error + cancelled: boolean } const recordings = new Map() -chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => { + if (!isOffscreenMessage(message)) return false void handleMessage(message).then(sendResponse) return true }) @@ -41,7 +44,19 @@ async function handleMessage(message: OffscreenMessage): Promise { @@ -85,23 +100,24 @@ async function handleStartRecording(message: OffscreenStartRecordingMessage): Pr tabId: message.tabId, nextSequence: 0, sendTail: Promise.resolve(), + cancelled: false, } recorder.ondataavailable = (event) => { - if (event.data.size === 0 || recording.sendError) { + if (event.data.size === 0 || recording.sendError || recording.cancelled) { return } if (recorder.state === "recording") recorder.pause() recording.sendTail = recording.sendTail.then(async () => { try { for (let offset = 0; offset < event.data.size; offset += maxRecordingFramePayloadBytes) { - const arrayBuffer = await event.data.slice(offset, offset + maxRecordingFramePayloadBytes).arrayBuffer() + const dataBase64 = await blobToBase64(event.data.slice(offset, offset + maxRecordingFramePayloadBytes)) const result = await chrome.runtime.sendMessage({ action: "recording.chunk", tabId: message.tabId, sequence: recording.nextSequence++, final: false, - data: Array.from(new Uint8Array(arrayBuffer)), + dataBase64, }) as { readonly success: boolean; readonly error?: string } if (!result.success) throw new Error(result.error ?? "Could not send recording chunk") } @@ -187,7 +203,7 @@ function handleStatusRecording(message: OffscreenStatusRecordingMessage): Offscr return { isRecording: false, tabId: message.tabId } } return { - isRecording: recording.recorder.state === "recording", + isRecording: recording.recorder.state !== "inactive", tabId: message.tabId, startedAt: recording.startedAt, } @@ -197,12 +213,22 @@ function handleCancelRecording(message: OffscreenCancelRecordingMessage): Offscr return handleCancelRecordingForTab(message.tabId) } +function handleCancelAllRecordings(): OffscreenCancelAllRecordingsResult { + let failure: OffscreenCancelAllRecordingsResult | undefined + for (const tabId of Array.from(recordings.keys())) { + const result = handleCancelRecordingForTab(tabId) + if (!result.success) failure ??= result + } + return failure ?? { success: true } +} + function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingResult { const recording = recordings.get(tabId) if (!recording) { return { success: true, tabId } } try { + recording.cancelled = true if (recording.recorder.state !== "inactive") { recording.recorder.stop() } @@ -223,3 +249,19 @@ function selectWebmMimeType(): string { return MediaRecorder.isTypeSupported(mimeType) }) ?? "" } + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result + if (typeof result !== "string") { + reject(new Error("Could not encode recording chunk")) + return + } + resolve(result.slice(result.indexOf(",") + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error("Could not read recording chunk")) + reader.readAsDataURL(blob) + }) +} diff --git a/extension/src/recording-types.ts b/extension/src/recording-types.ts index cbc509e..196cc2e 100644 --- a/extension/src/recording-types.ts +++ b/extension/src/recording-types.ts @@ -39,11 +39,16 @@ export type OffscreenCancelRecordingMessage = { readonly tabId: number } +export type OffscreenCancelAllRecordingsMessage = { + readonly action: "recording.cancelAll" +} + export type OffscreenMessage = | OffscreenStartRecordingMessage | OffscreenStopRecordingMessage | OffscreenStatusRecordingMessage | OffscreenCancelRecordingMessage + | OffscreenCancelAllRecordingsMessage export type OffscreenStartRecordingResult = | { @@ -84,19 +89,31 @@ export type OffscreenCancelRecordingResult = readonly error: string } +export type OffscreenCancelAllRecordingsResult = + | { readonly success: true } + | { readonly success: false; readonly error: string } + export type OffscreenResult = | OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenStatusRecordingResult | OffscreenCancelRecordingResult + | OffscreenCancelAllRecordingsResult -export type OffscreenRecordingChunkMessage = { - readonly action: "recording.chunk" - readonly tabId: number - readonly sequence: number - readonly data?: readonly number[] - readonly final: boolean -} +export type OffscreenRecordingChunkMessage = + | { + readonly action: "recording.chunk" + readonly tabId: number + readonly sequence: number + readonly final: false + readonly dataBase64: string + } + | { + readonly action: "recording.chunk" + readonly tabId: number + readonly sequence: number + readonly final: true + } export type OffscreenRecordingCancelledMessage = { readonly action: "recording.cancelled" diff --git a/src/recording-relay.ts b/src/recording-relay.ts index 16b3210..91fccb7 100644 --- a/src/recording-relay.ts +++ b/src/recording-relay.ts @@ -120,6 +120,7 @@ type TabCaptureRecording = ActiveRecordingBase & { stopping: boolean finalized: boolean stopPromise?: Promise + finalizePromise?: Promise cleanupPromise?: Promise } @@ -207,7 +208,6 @@ type ExtensionStatusResult = { export class RecordingRelay { private readonly activeRecordings = new Map() private readonly startingRecordings = new Map() - private readonly finalizingRecordings = new Set>() constructor(readonly options: { readonly sendToExtension: (command: Omit) => Promise @@ -362,6 +362,10 @@ export class RecordingRelay { await this.cancelCdpRecording(recording) return { success: true } } + if (recording.finalizePromise) { + await recording.finalizePromise + return { success: true } + } if (!this.options.isExtensionConnected()) { recording.resolveStop?.({ success: false, error: "Recording was cancelled" }) await this.cleanupRecording(recording.tabId) @@ -381,11 +385,19 @@ export class RecordingRelay { async cleanupAll(reason: string): Promise { const starting = Array.from(this.startingRecordings.values()) + const startingTabIds = new Set(starting.map((recording) => recording.tabId)) + const active = Array.from(this.activeRecordings.values()).filter((recording) => { + return !startingTabIds.has(recording.tabId) + }) for (const recording of starting) recording.cancelled = true await Promise.all(starting.map(async (recording) => { await recording.promise?.catch(() => {}) })) - await Promise.all(Array.from(this.activeRecordings.values()).map(async (recording) => { + await Promise.all(active.map(async (recording) => { + if (recording.mode === "tab-capture" && recording.finalizePromise) { + await recording.finalizePromise + return + } if (recording.resolveStop) { recording.resolveStop({ success: false, error: reason }) } @@ -399,7 +411,6 @@ export class RecordingRelay { } await this.cleanupRecording(recording.tabId) })) - await Promise.all(this.finalizingRecordings) } async abortRecordingForTab(options: { readonly tabId: number; readonly reason: string }): Promise { @@ -413,6 +424,10 @@ export class RecordingRelay { if (!recording) { return } + if (recording.mode === "tab-capture" && recording.finalizePromise) { + await recording.finalizePromise + return + } recording.resolveStop?.({ success: false, error: options.reason }) if (recording.mode === "cdp") { if (recording.stopPromise) { @@ -432,6 +447,7 @@ export class RecordingRelay { return } const recording = this.activeRecordings.get(tabId) + if (recording?.mode === "tab-capture" && recording.finalizePromise) return if (recording?.resolveStop) { recording.resolveStop({ success: false, error: "Recording was cancelled" }) } @@ -495,6 +511,10 @@ export class RecordingRelay { return } if (recording.stopping || recording.finalized) return + if (recording.writeError) { + void this.failTabCaptureRecording(recording, recording.writeError.message) + return + } if (frame.sequence !== recording.expectedSequence) { void this.failTabCaptureRecording(recording, `Recording frame sequence ${frame.sequence} did not match expected ${recording.expectedSequence}`) return @@ -503,23 +523,19 @@ export class RecordingRelay { if (frame.final) { recording.stopping = true if (recording.maxDurationTimer) clearTimeout(recording.maxDurationTimer) - this.activeRecordings.delete(frame.tabId) - const finalizing = this.finishTabCaptureRecording(recording).finally(() => { - this.finalizingRecordings.delete(finalizing) - }) - this.finalizingRecordings.add(finalizing) - void finalizing + recording.finalizePromise = this.finishTabCaptureRecording(recording) + void recording.finalizePromise return } - const payload = Buffer.from(frame.payload) - if (recording.receivedBytes + payload.byteLength > maxTabCaptureOutputBytes) { + if (recording.receivedBytes + frame.payload.byteLength > maxTabCaptureOutputBytes) { void this.failTabCaptureRecording(recording, `Tab capture exceeds ${maxTabCaptureOutputBytes} bytes`) return } - if (recording.pendingWriteBytes + payload.byteLength > maxPendingTabCaptureBytes) { + if (recording.pendingWriteBytes + frame.payload.byteLength > maxPendingTabCaptureBytes) { void this.failTabCaptureRecording(recording, `Tab capture pending writes exceed ${maxPendingTabCaptureBytes} bytes`) return } + const payload = frame.payload recording.receivedBytes += payload.byteLength recording.pendingWriteBytes += payload.byteLength recording.writePromise = recording.writePromise.then(async () => { @@ -527,6 +543,7 @@ export class RecordingRelay { if (result.bytesWritten !== payload.byteLength) throw new Error(`Short recording write: ${result.bytesWritten}/${payload.byteLength}`) }).catch((error: unknown) => { recording.writeError ??= error instanceof Error ? error : new Error(String(error)) + void this.failTabCaptureRecording(recording, recording.writeError.message) }).finally(() => { recording.pendingWriteBytes -= payload.byteLength }) @@ -541,13 +558,12 @@ export class RecordingRelay { await recording.file.close() await fs.rename(recording.temporaryPath, recording.outputPath) recording.finalized = true - const stat = await fs.stat(recording.outputPath) recording.resolveStop?.({ success: true, tabId: recording.tabId, duration: Date.now() - recording.startedAt, path: recording.outputPath, - size: stat.size, + size: recording.receivedBytes, mode: "tab-capture", artifactType: "webm", }) @@ -555,6 +571,9 @@ export class RecordingRelay { recording.resolveStop?.({ success: false, error: error instanceof Error ? error.message : String(error) }) } finally { await this.cleanupTabCaptureRecording(recording) + if (this.activeRecordings.get(recording.tabId) === recording) { + this.activeRecordings.delete(recording.tabId) + } } } @@ -594,6 +613,7 @@ export class RecordingRelay { await this.cleanupCdpRecording(recording) return } + if (recording.finalizePromise) return recording.finalizePromise if (recording.cleanupPromise) return recording.cleanupPromise this.activeRecordings.delete(tabId) return this.cleanupTabCaptureRecording(recording) @@ -633,26 +653,8 @@ export class RecordingRelay { if (starting.cancelled) return { success: false, error: "Recording was cancelled while starting" } const temporaryPath = `${options.outputPath}.partial-${process.pid}-${crypto.randomUUID()}` const file = await fs.open(temporaryPath, "wx", 0o600) - let result: ExtensionStartResult - try { - result = parseExtensionStartResult(await this.options.sendToExtension({ - method: "recording.start", - params: recordingStartParams(options), - })) - } catch (error) { - await file.close().catch(() => {}) - await fs.rm(temporaryPath, { force: true }).catch(() => {}) - throw error - } - if (!result.success || starting.cancelled) { - if (result.success) await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) - await file.close().catch(() => {}) - await fs.rm(temporaryPath, { force: true }).catch(() => {}) - return result.success ? { success: false, error: "Recording was cancelled while starting" } : result - } - const recording: TabCaptureRecording = { - tabId: result.tabId, + tabId: options.tabId, ...(options.sessionId ? { sessionId: options.sessionId } : {}), outputPath: options.outputPath, temporaryPath, @@ -665,10 +667,34 @@ export class RecordingRelay { writePromise: Promise.resolve(), stopping: false, finalized: false, - startedAt: result.startedAt, + startedAt: this.now(), + } + this.activeRecordings.set(options.tabId, recording) + let result: ExtensionStartResult + try { + result = parseExtensionStartResult(await this.options.sendToExtension({ + method: "recording.start", + params: recordingStartParams(options), + })) + } catch (error) { + await this.cleanupRecording(options.tabId) + throw error + } + if (!result.success || starting.cancelled || result.tabId !== options.tabId || this.activeRecordings.get(options.tabId) !== recording) { + if (result.success) await this.options.sendToExtension({ method: "recording.cancel", params: { tabId: result.tabId } }).catch(() => {}) + await this.cleanupRecording(options.tabId) + if (!result.success) return result + return { + success: false, + error: starting.cancelled + ? "Recording was cancelled while starting" + : result.tabId !== options.tabId + ? `Extension started recording tab ${result.tabId} instead of ${options.tabId}` + : recording.writeError?.message ?? "Recording ended while starting", + } } + recording.startedAt = result.startedAt this.armMaxDuration(recording, options.maxDurationMs) - this.activeRecordings.set(result.tabId, recording) return { success: true, tabId: result.tabId, @@ -993,7 +1019,7 @@ async function startFfmpegVideoEncoder(options: { readonly width: number readonly height: number }): Promise { - const temporaryOutputPath = `${options.outputPath}.partial-${process.pid}-${Date.now()}` + const temporaryOutputPath = `${options.outputPath}.partial-${process.pid}-${crypto.randomUUID()}` const outputArgs = options.artifactType === "webm" ? ["-c:v", "libvpx", "-crf", "8", "-deadline", "realtime", "-cpu-used", "8", "-b:v", "2M", "-threads", "1"] : ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] diff --git a/test/recording-relay.test.ts b/test/recording-relay.test.ts index 10d0345..8f75ed1 100644 --- a/test/recording-relay.test.ts +++ b/test/recording-relay.test.ts @@ -179,12 +179,86 @@ describe("RecordingRelay tab capture", () => { relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1, 2, 3) }))) relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 1, final: true, payload: new Uint8Array() }))) + await expect(relay.startRecording({ tabId: 7, owner: "user", outputPath })).resolves.toEqual({ + success: false, + error: "Recording already in progress for this tab", + }) await expect(relay.cancelRecording({ tabId: 7 })).resolves.toEqual({ success: true }) await relay.cleanupAll("test shutdown") expect(Array.from(await fs.readFile(outputPath))).toEqual([1, 2, 3]) expect((await fs.readdir(directory)).some((entry) => entry.includes(".partial-"))).toBe(false) }) + it("accepts frames that arrive before the extension start response", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + const outputPath = path.join(directory, "early.webm") + let resolveStart: ((result: { success: true; tabId: number; startedAt: number; mimeType: string }) => void) | undefined + let relay: RecordingRelay + relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + if (command.method === "recording.start") { + return new Promise((resolve) => { + resolveStart = resolve + }) + } + if (command.method === "recording.stop") { + queueMicrotask(() => relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ + tabId: 7, + sequence: 1, + final: true, + payload: new Uint8Array(), + })))) + return { success: true, tabId: 7, duration: 100 } + } + return { success: true } + }, + }) + + const started = relay.startRecording({ tabId: 7, owner: "user", outputPath }) + await vi.waitFor(() => expect(resolveStart).toBeTypeOf("function")) + relay.handleBinaryData(Buffer.from(encodeRecordingFrame({ tabId: 7, sequence: 0, final: false, payload: Uint8Array.of(1) }))) + resolveStart?.({ success: true, tabId: 7, startedAt: 1_000, mimeType: "video/webm" }) + + await expect(started).resolves.toMatchObject({ success: true }) + await expect(relay.stopRecording({ tabId: 7 })).resolves.toMatchObject({ success: true, size: 1 }) + expect(Array.from(await fs.readFile(outputPath))).toEqual([1]) + }) + + it("does not let stale cleanup capture a recording started by a new generation", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) + temporaryPaths.push(directory) + let resolveOldStart: ((result: { success: true; tabId: number; startedAt: number; mimeType: string }) => void) | undefined + const relay = new RecordingRelay({ + isExtensionConnected: () => true, + sendDebuggerCommand: async () => ({}), + sendToExtension: async (command) => { + const tabId = typeof command.params?.tabId === "number" ? command.params.tabId : 0 + if (command.method === "recording.start" && tabId === 1) { + return new Promise((resolve) => { + resolveOldStart = resolve + }) + } + if (command.method === "recording.start") return { success: true, tabId, startedAt: 2_000, mimeType: "video/webm" } + if (command.method === "recording.status") return { isRecording: true, tabId, startedAt: 2_000 } + return { success: true } + }, + }) + const oldStart = relay.startRecording({ tabId: 1, owner: "user", outputPath: path.join(directory, "old.webm") }) + await vi.waitFor(() => expect(resolveOldStart).toBeTypeOf("function")) + + const staleCleanup = relay.cleanupAll("Extension replaced") + await expect(relay.startRecording({ tabId: 2, owner: "user", outputPath: path.join(directory, "new.webm") })).resolves.toMatchObject({ success: true }) + resolveOldStart?.({ success: true, tabId: 1, startedAt: 1_000, mimeType: "video/webm" }) + await expect(oldStart).resolves.toEqual({ success: false, error: "Recording was cancelled while starting" }) + await staleCleanup + + await expect(relay.statusRecording({ tabId: 2 })).resolves.toMatchObject({ isRecording: true, tabId: 2 }) + await relay.cancelRecording({ tabId: 2 }) + }) + it("aborts and removes partial output on a sequence violation", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-tab-recording-")) temporaryPaths.push(directory) From f48cb9fce05f6ef5d2778609d289f93fae965877 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 21 Jul 2026 14:50:17 -0400 Subject: [PATCH 6/9] refactor(process): share child termination --- src/auth-profile.ts | 27 +++------------------------ src/child-process.ts | 35 +++++++++++++++++++++++++++++++++++ src/recording-relay.ts | 10 +++------- 3 files changed, 41 insertions(+), 31 deletions(-) create mode 100644 src/child-process.ts diff --git a/src/auth-profile.ts b/src/auth-profile.ts index 482dfc8..7447af1 100644 --- a/src/auth-profile.ts +++ b/src/auth-profile.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { terminateChildProcess } from "./child-process.ts" import { redactKnownValues, type CredentialSlot } from "./network-redaction.ts" const StoredCredentialSlot = Schema.Struct({ @@ -348,7 +349,7 @@ function runChild(options: { closePromiseResolve = resolve }) const timeout = setTimeout(() => { - void terminateChild(child, closePromise, () => closed) + void terminateChildProcess({ child, exit: closePromise, graceMs: 1_000, isExited: () => closed }) }, options.timeoutMs) child.once("error", (cause) => { clearTimeout(timeout) @@ -373,7 +374,7 @@ function runChild(options: { stderrTruncated, })) }) - return Effect.promise(() => terminateChild(child, closePromise, () => closed)) + return Effect.promise(() => terminateChildProcess({ child, exit: closePromise, graceMs: 1_000, isExited: () => closed })) }) } @@ -384,28 +385,6 @@ function childEnvironment(profile: Readonly>): NodeJS.Pro return { ...inherited, ...profile } } -async function terminateChild(child: ReturnType, closePromise: Promise, isClosed: () => boolean): Promise { - if (isClosed()) return - signalChild(child, "SIGTERM") - await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 1_000))]) - if (!isClosed()) { - signalChild(child, "SIGKILL") - await closePromise - } -} - -function signalChild(child: ReturnType, signal: NodeJS.Signals): void { - if (process.platform !== "win32" && child.pid !== undefined) { - try { - process.kill(-child.pid, signal) - return - } catch { - // The child may have exited between the state check and the signal. - } - } - child.kill(signal) -} - function isNodeError(value: unknown): value is NodeJS.ErrnoException { return value instanceof Error && "code" in value } diff --git a/src/child-process.ts b/src/child-process.ts new file mode 100644 index 0000000..a7e416b --- /dev/null +++ b/src/child-process.ts @@ -0,0 +1,35 @@ +import type { ChildProcess } from "node:child_process" + +export async function terminateChildProcess(options: { + readonly child: ChildProcess + readonly exit: Promise + readonly graceMs: number + readonly isExited?: () => boolean +}): Promise { + const isExited = options.isExited ?? (() => options.child.exitCode !== null || options.child.signalCode !== null) + if (isExited()) return + signalChildProcess(options.child, "SIGTERM") + let timeout: ReturnType | undefined + await Promise.race([ + options.exit, + new Promise((resolve) => { + timeout = setTimeout(resolve, options.graceMs) + }), + ]) + if (timeout) clearTimeout(timeout) + if (isExited()) return + signalChildProcess(options.child, "SIGKILL") + await options.exit +} + +function signalChildProcess(child: ChildProcess, signal: NodeJS.Signals): void { + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, signal) + return + } catch { + // The child may have exited between the state check and the signal. + } + } + child.kill(signal) +} diff --git a/src/recording-relay.ts b/src/recording-relay.ts index 91fccb7..842abd4 100644 --- a/src/recording-relay.ts +++ b/src/recording-relay.ts @@ -4,6 +4,7 @@ import { once } from "node:events" import fs from "node:fs/promises" import path from "node:path" import { performance } from "node:perf_hooks" +import { terminateChildProcess } from "./child-process.ts" import { mjpegMatroskaFrame, mjpegMatroskaHeader } from "./mjpeg-matroska.ts" import type { ExtensionCommand, JsonObject } from "./protocol.ts" import { getObject } from "./relay-helpers.ts" @@ -1049,7 +1050,7 @@ async function startFfmpegVideoEncoder(options: { "-f", options.artifactType, temporaryOutputPath, - ], { stdio: "pipe" }) + ], { detached: process.platform !== "win32", stdio: "pipe" }) let stderr = "" child.stderr.setEncoding("utf8") child.stderr.on("data", (chunk: string) => { @@ -1075,12 +1076,7 @@ async function startFfmpegVideoEncoder(options: { let cancelPromise: Promise | undefined const terminate = async () => { child.stdin.destroy() - if (child.exitCode === null) child.kill("SIGTERM") - const exited = await waitForProcessExit(exit, 2_000) - if (!exited && child.exitCode === null) { - child.kill("SIGKILL") - await exit - } + await terminateChildProcess({ child, exit, graceMs: 2_000 }) } return { write: async (frame, timestampMs, durationMs) => { From 08cc47f6b35841f1be3bb896313a3db7750a98ce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 22 Jul 2026 14:56:00 -0400 Subject: [PATCH 7/9] fix: preserve focus when filling inputs --- .changeset/tidy-inputs-focus.md | 6 ++++ scripts/smoke.ts | 59 +++++++++++++++++++++++++++++---- src/execute.ts | 4 --- test/execute-ergonomics.test.ts | 51 ++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 .changeset/tidy-inputs-focus.md diff --git a/.changeset/tidy-inputs-focus.md b/.changeset/tidy-inputs-focus.md new file mode 100644 index 0000000..cc20147 --- /dev/null +++ b/.changeset/tidy-inputs-focus.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Preserve page focus while `fillInput` and `fillInputs` update controlled fields, +preventing focus-sensitive extensions from making the target unresponsive. diff --git a/scripts/smoke.ts b/scripts/smoke.ts index ca020c7..9c48661 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -595,6 +595,10 @@ const cases: SmokeCase[] = [ ` await page.setContent('') await page.evaluate(() => { + document.documentElement.dataset.fillFocusEvents = '0' + document.addEventListener('focusin', () => { + document.documentElement.dataset.fillFocusEvents = String(Number(document.documentElement.dataset.fillFocusEvents ?? '0') + 1) + }) const outer = document.createElement('div') const outerRoot = outer.attachShadow({ mode: 'open' }) const inner = document.createElement('div') @@ -607,13 +611,50 @@ await page.evaluate(() => { const closed = document.createElement('div') closed.attachShadow({ mode: 'closed' }).append(document.createElement('input')) document.body.append(closed) + + const controlled = document.createElement('input') + controlled.id = 'controlled' + const nativeValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') + if (!nativeValue?.get || !nativeValue.set) throw new Error('missing native input value descriptor') + let trackedValue = '' + Object.defineProperty(controlled, 'value', { + configurable: true, + get: () => nativeValue.get?.call(controlled), + set: (value) => { + trackedValue = String(value) + nativeValue.set?.call(controlled, value) + }, + }) + controlled.addEventListener('input', () => { + trackedValue = String(nativeValue.get?.call(controlled) ?? '') + controlled.dataset.state = trackedValue + queueMicrotask(() => { + controlled.value = trackedValue + }) + }) + setTimeout(() => document.body.append(controlled), 100) + + const frame = document.createElement('iframe') + frame.id = 'fixture-frame' + frame.srcdoc = '' + document.body.append(frame) }) -await fillInput('#one', 'alpha') +await fillInput(page.locator('#one'), 'alpha') await fillInput('#shadow-input', 'delta') +await fillInput(page.locator('#controlled'), 'epsilon') await fillInputs(page, [ { selector: page.getByRole('textbox').nth(1), value: 'beta' }, { selector: '#three', value: 'gamma' }, ]) +const fixtureFrame = page.frames().find((frame) => frame !== page.mainFrame()) +if (!fixtureFrame) throw new Error('fixture iframe did not attach') +await fixtureFrame.locator('html').evaluate((element) => { + element.dataset.fillFocusEvents = '0' + element.addEventListener('focusin', () => { + element.dataset.fillFocusEvents = String(Number(element.dataset.fillFocusEvents ?? '0') + 1) + }) +}) +await fillInput(fixtureFrame.locator('#frame-input'), 'zeta') let closedBoundary try { await fillInput('#closed-input', 'should-not-appear') @@ -625,13 +666,21 @@ const values = await page.evaluate(() => ({ two: document.querySelector('#two')?.value, three: document.querySelector('#three')?.value, shadow: document.querySelector('div')?.shadowRoot?.querySelector('div')?.shadowRoot?.querySelector('input')?.value, + controlled: document.querySelector('#controlled')?.value, + controlledState: document.querySelector('#controlled')?.dataset.state, + focusEvents: document.documentElement.dataset.fillFocusEvents, })) -return { ...values, closedBoundary } +return { + ...values, + frame: await fixtureFrame.locator('#frame-input').inputValue(), + frameFocusEvents: await fixtureFrame.locator('html').getAttribute('data-fill-focus-events'), + closedBoundary, +} `, ], { retryOnTimeout: true }, ) - if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma") || !output.includes("delta") || !output.includes("closed shadow roots")) { + if (!output.includes("alpha") || !output.includes("beta") || !output.includes("gamma") || !output.includes("delta") || !output.includes("controlled: 'epsilon'") || !output.includes("controlledState: 'epsilon'") || !output.includes("frame: 'zeta'") || !output.includes("focusEvents: '0'") || !output.includes("frameFocusEvents: '0'") || !output.includes("closed shadow roots")) { return yield* Effect.fail(new Error(`execute fill helpers did not fill fields: ${output}`)) } return output.trim() @@ -1897,7 +1946,6 @@ const fillInput = Effect.fnUntraced(function* (locator: ReturnType): const prototype = Object.getPrototypeOf(element) as HTMLInputElement | HTMLTextAreaElement const valueSetter = Object.getOwnPropertyDescriptor(element, "value")?.set const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set - element.focus() if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(element, field.value) } else { @@ -1933,7 +1930,6 @@ export async function fillInputs(page: Page, fields: ReadonlyArray): } element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: field.value })) element.dispatchEvent(new Event("change", { bubbles: true })) - element.blur() return field.label }) }, resolvedFields) diff --git a/test/execute-ergonomics.test.ts b/test/execute-ergonomics.test.ts index b63ab36..6ffc866 100644 --- a/test/execute-ergonomics.test.ts +++ b/test/execute-ergonomics.test.ts @@ -127,6 +127,57 @@ describe("user code execution", () => { }) describe("fillInputs", () => { + it("updates values without changing document focus", async () => { + class MockInput { + private currentValue = "" + readonly focus = vi.fn() + readonly blur = vi.fn() + readonly dispatchEvent = vi.fn() + + get value() { + return this.currentValue + } + + set value(value: string) { + this.currentValue = value + } + } + + const input = new MockInput() + const evaluate = vi.fn(async (run: (fields: Array<{ readonly target: string; readonly label: string; readonly value: string }>) => unknown, fields) => { + const previousDocument = globalThis.document + const previousInput = globalThis.HTMLInputElement + const previousTextArea = globalThis.HTMLTextAreaElement + const previousInputEvent = globalThis.InputEvent + Object.assign(globalThis, { + document: { + querySelectorAll: vi.fn((selector: string) => selector === "#field" ? [input] : []), + }, + HTMLInputElement: MockInput, + HTMLTextAreaElement: class {}, + InputEvent: class {}, + }) + try { + return run(fields as Array<{ readonly target: string; readonly label: string; readonly value: string }>) + } finally { + Object.assign(globalThis, { + document: previousDocument, + HTMLInputElement: previousInput, + HTMLTextAreaElement: previousTextArea, + InputEvent: previousInputEvent, + }) + } + }) + const page = { evaluate } as unknown as Page + + await fillInputs(page, [{ selector: "#field", value: "next" }]) + + expect(input.value).toBe("next") + expect(input.focus).not.toHaveBeenCalled() + expect(input.blur).not.toHaveBeenCalled() + expect(input.dispatchEvent).toHaveBeenCalledTimes(2) + }) + it("resolves locators before the single batched page evaluation", async () => { const dispose = vi.fn().mockResolvedValue(undefined) const handle = { dispose } as unknown as Awaited> From 684e360e446d2709ba6852c50ef214e4e775b686 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 22 Jul 2026 14:58:33 -0400 Subject: [PATCH 8/9] fix: harden extension reconnect lifecycle --- .changeset/warm-spoons-reconnect.md | 5 ++ AGENTS.md | 3 + extension/src/background.ts | 26 ++++--- extension/src/connection-lifecycle.ts | 15 ++++ src/doctor.ts | 79 +++++++++------------ src/relay-lifecycle.ts | 3 + test/doctor.test.ts | 65 ++++++++++++++++- test/extension-connection-lifecycle.test.ts | 45 ++++++++++++ test/relay-lifecycle.test.ts | 1 + 9 files changed, 187 insertions(+), 55 deletions(-) create mode 100644 .changeset/warm-spoons-reconnect.md create mode 100644 extension/src/connection-lifecycle.ts create mode 100644 test/extension-connection-lifecycle.test.ts diff --git a/.changeset/warm-spoons-reconnect.md b/.changeset/warm-spoons-reconnect.md new file mode 100644 index 0000000..c7f6edd --- /dev/null +++ b/.changeset/warm-spoons-reconnect.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Keep the Chrome extension connected across idle service-worker suspension, repair missing reconnect alarms whenever the worker starts, start the managed relay correctly when MCP runs through a package-manager bin symlink, and make Doctor compare the runtime extension with the manifest shipped in the npm package. diff --git a/AGENTS.md b/AGENTS.md index 932f01c..e07ce39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -247,6 +247,9 @@ browser-control skill - On socket open the shim sends `hello` and then re-announces every tab it still has `chrome.debugger` attached to (`debugger.attached` events), so a restarted relay rebuilds its target registry without the user re-clicking the toolbar. +- Repair the reconnect alarm whenever the MV3 worker starts and send heartbeat + traffic every 20 seconds while its relay socket is open. Chrome may clear + persisted alarms and retires idle extension workers even with an open socket. - The relay dedupes target announcements per CDP client by targetId: a re-announce under a new sessionId emits `Target.detachedFromTarget` for the old session first. Never announce the same targetId twice to one client diff --git a/extension/src/background.ts b/extension/src/background.ts index 7548629..52c5244 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -10,6 +10,7 @@ import type { import { isBrowserControlGroupTitle, isCurrentBrowserControlGroupTitle, isLegacyBrowserControlGroupTitle, shouldUngroupBrowserControlTab, tabGroupColor, tabGroupTitle } from "./tab-groups.ts" import { pageStatusFromJson } from "./page-status.ts" import { debuggerDetachedEvent } from "./debugger-detach.ts" +import { ensureReconnectAlarm, reconnectAlarmName, startSocketKeepAlive } from "./connection-lifecycle.ts" const relayHost = "127.0.0.1" const relayPort = 19989 @@ -21,21 +22,17 @@ let connectionPromise: Promise | undefined let reconnectTimer: ReturnType | undefined let offscreenDocumentCreating: Promise | undefined -chrome.runtime.onInstalled.addListener(() => { - chrome.alarms.create("browser-control-reconnect", { periodInMinutes: 0.5 }) -}) - -chrome.runtime.onStartup.addListener(() => { - chrome.alarms.create("browser-control-reconnect", { periodInMinutes: 0.5 }) -}) - chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== "browser-control-reconnect") { + if (alarm.name !== reconnectAlarmName) { return } void ensureConnection().catch(() => {}) }) +// Chrome can clear persisted alarms, so repair the reconnect wake-up whenever +// the MV3 service worker starts rather than only on install or browser startup. +void ensureReconnectAlarm(chrome.alarms).catch(() => {}) + chrome.action.onClicked.addListener((tab) => { if (tab.id) sendMessage({ method: "toolbar.clicked", params: { tabId: tab.id } }) }) @@ -93,14 +90,23 @@ function startConnection(): WebSocket { reconnectTimer = undefined } const currentSocket = new WebSocket(`ws://${relayHost}:${relayPort}/extension`) + let stopKeepAlive: (() => void) | undefined socket = currentSocket currentSocket.onopen = () => { - void announceHelloAndAttachedTabs(currentSocket).catch((error) => reportAnnouncementFailure(currentSocket, error)) + void announceHelloAndAttachedTabs(currentSocket).then( + () => { + if (socket === currentSocket && currentSocket.readyState === WebSocket.OPEN) { + stopKeepAlive = startSocketKeepAlive(() => sendOnCurrentSocket(currentSocket, { method: "pong" })) + } + }, + (error) => reportAnnouncementFailure(currentSocket, error), + ) } currentSocket.onmessage = (event) => { void handleSocketMessage(currentSocket, event.data) } currentSocket.onclose = () => { + stopKeepAlive?.() if (socket !== currentSocket) { return } diff --git a/extension/src/connection-lifecycle.ts b/extension/src/connection-lifecycle.ts new file mode 100644 index 0000000..078b878 --- /dev/null +++ b/extension/src/connection-lifecycle.ts @@ -0,0 +1,15 @@ +export const reconnectAlarmName = "browser-control-reconnect" +const reconnectAlarmPeriodMinutes = 0.5 +const socketKeepAliveIntervalMs = 20_000 + +type AlarmApi = Pick + +export async function ensureReconnectAlarm(alarms: AlarmApi): Promise { + if (await alarms.get(reconnectAlarmName)) return + await alarms.create(reconnectAlarmName, { periodInMinutes: reconnectAlarmPeriodMinutes }) +} + +export function startSocketKeepAlive(heartbeat: () => void): () => void { + const timer = setInterval(heartbeat, socketKeepAliveIntervalMs) + return () => clearInterval(timer) +} diff --git a/src/doctor.ts b/src/doctor.ts index 6797728..cf91c05 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -25,6 +25,7 @@ const PackageMetadata = Schema.Struct({ const ManifestVersion = Schema.Struct({ version: Schema.String, }) +const bundledExtensionManifestPath = "extension/dist/manifest.json" type PackageInfo = { readonly name: string @@ -163,13 +164,12 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op Effect.catch(() => Effect.succeed(false)), ) - const [packageResult, sourceManifestVersion, distManifestVersion, distCliExists, distMcpExists, extensionDistManifestExists, currentResult] = yield* Effect.all([ + const [packageResult, bundledManifestVersion, distCliExists, distMcpExists, extensionDistManifestExists, currentResult] = yield* Effect.all([ probe(readPackageInfo), - probe(readManifestVersion("extension/manifest.json")), - probe(readManifestVersion("extension/dist/manifest.json")), + probe(readManifestVersion(bundledExtensionManifestPath)), fileExists("dist/cli.js"), fileExists("dist/mcp.js"), - fileExists("extension/dist/manifest.json"), + fileExists(bundledExtensionManifestPath), probe(store.read), ]) const relayResult = yield* probe(relay.version) @@ -189,7 +189,7 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op { ok: false, error: relayResult.error } satisfies ProbeResult, { ok: false, error: relayResult.error } satisfies ProbeResult, ] - const expectedVersion = sourceManifestVersion.ok ? sourceManifestVersion.value : null + const expectedVersion = bundledManifestVersion.ok ? bundledManifestVersion.value : null const extensionVersion = extensionResult.ok ? extensionResult.value.version : null const extensionVersionMatches = extensionResult.ok && extensionVersion && expectedVersion ? extensionVersion === expectedVersion : null const current = currentResult.ok ? currentResult.value ?? null : null @@ -211,17 +211,16 @@ export const createDoctorReport = Effect.fn("Doctor.createReport")(function* (op { path: "dist/cli.js", exists: distCliExists }, { path: "dist/mcp.js", exists: distMcpExists }, { - path: "extension/dist/manifest.json", + path: bundledExtensionManifestPath, exists: extensionDistManifestExists, - ...(distManifestVersion.ok ? { version: distManifestVersion.value } : {}), + ...(bundledManifestVersion.ok ? { version: bundledManifestVersion.value } : {}), }, ] const checks = buildDoctorChecks({ packageResult, relayResult, extensionResult, - sourceManifestVersion, - extensionVersionMatches, + bundledManifestVersion, artifacts, currentResult, staleCurrent, @@ -299,8 +298,7 @@ function buildDoctorChecks(options: { readonly packageResult: ProbeResult readonly relayResult: ProbeResult readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly extensionVersionMatches: boolean | null + readonly bundledManifestVersion: ProbeResult readonly artifacts: readonly DoctorArtifact[] readonly currentResult: ProbeResult readonly staleCurrent: boolean @@ -355,12 +353,10 @@ function buildDoctorChecks(options: { status: options.extensionResult.ok && options.extensionResult.value.connected ? "ok" : "fail", message: options.extensionResult.ok ? options.extensionResult.value.connected ? `connected${options.extensionResult.value.version ? ` (${options.extensionResult.value.version})` : ""}` : "disconnected" : options.extensionResult.error, }, - { - id: "extension-version", - label: "extension version", - status: extensionVersionCheckStatus({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), - message: extensionVersionCheckMessage({ extensionResult: options.extensionResult, sourceManifestVersion: options.sourceManifestVersion, versionMatches: options.extensionVersionMatches }), - }, + extensionVersionCheck({ + extensionResult: options.extensionResult, + bundledManifestVersion: options.bundledManifestVersion, + }), extensionProtocolCheck(options.extensionResult), { id: "targets-readable", @@ -458,40 +454,35 @@ export function relayBuildCheck(options: { } } -function extensionVersionCheckStatus(options: { - readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly versionMatches: boolean | null -}): DoctorCheckStatus { - if (!options.extensionResult.ok || !options.extensionResult.value.connected) { - return "warn" - } - if (!options.sourceManifestVersion.ok || !options.extensionResult.value.version) { - return "warn" - } - return "ok" -} - -function extensionVersionCheckMessage(options: { +function extensionVersionCheck(options: { readonly extensionResult: ProbeResult - readonly sourceManifestVersion: ProbeResult - readonly versionMatches: boolean | null -}): string { + readonly bundledManifestVersion: ProbeResult +}): DoctorCheck { + const check = (status: DoctorCheckStatus, message: string): DoctorCheck => ({ + id: "extension-version", + label: "extension version", + status, + message, + }) if (!options.extensionResult.ok) { - return options.extensionResult.error + return check("warn", options.extensionResult.error) } if (!options.extensionResult.value.connected) { - return "extension disconnected; cannot compare runtime version" + return check("warn", "extension disconnected; cannot compare runtime version") } - if (!options.sourceManifestVersion.ok) { - return `could not read extension/manifest.json: ${options.sourceManifestVersion.error}` + if (!options.bundledManifestVersion.ok) { + return check("warn", `could not read ${bundledExtensionManifestPath}: ${options.bundledManifestVersion.error}`) } - if (!options.extensionResult.value.version) { - return "extension did not report a version" + const runtimeVersion = options.extensionResult.value.version + if (!runtimeVersion) { + return check("warn", "extension did not report a version") } - return options.versionMatches - ? `matches bundled extension (${options.sourceManifestVersion.value})` - : `runtime ${options.extensionResult.value.version} differs from bundled ${options.sourceManifestVersion.value}; protocol compatibility determines support` + return check( + "ok", + runtimeVersion === options.bundledManifestVersion.value + ? `matches bundled extension (${options.bundledManifestVersion.value})` + : `runtime ${runtimeVersion} differs from bundled ${options.bundledManifestVersion.value}; protocol compatibility determines support`, + ) } export function extensionProtocolCheck(extensionResult: ProbeResult): DoctorCheck { diff --git a/src/relay-lifecycle.ts b/src/relay-lifecycle.ts index 599ff28..dea7781 100644 --- a/src/relay-lifecycle.ts +++ b/src/relay-lifecycle.ts @@ -170,6 +170,9 @@ export function managedRelayLaunch( export function managedRelayEntrypoint(entrypoint: string): string { const name = path.basename(entrypoint) + if (name === "browser-control-mcp") { + return path.join(path.dirname(entrypoint), "browser-control") + } if (name === "mcp.js" || name === "index.js" || name === "browser-control-client.js") { return path.join(path.dirname(entrypoint), "cli.js") } diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 5b54424..5517745 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest" -import { extensionProtocolCheck, formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { createDoctorReport, extensionProtocolCheck, formatTargetSummary, relayBuildCheck, unhealthyTargetsCheck } from "../src/doctor.ts" +import * as RelayClient from "../src/relay-client.ts" +import * as SessionStore from "../src/session-store.ts" describe("formatTargetSummary", () => { it("shows crashed target state", () => { @@ -97,3 +104,59 @@ describe("extensionProtocolCheck", () => { })).toMatchObject({ status: "fail", message: "runtime 3 is incompatible with relay 2" }) }) }) + +describe("createDoctorReport", () => { + it("compares the runtime extension with the manifest shipped in the package", async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), "browser-control-doctor-")) + try { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }) + await fs.mkdir(path.join(packageRoot, "extension", "dist"), { recursive: true }) + await Promise.all([ + fs.writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ + name: "@opencode-ai/browser-control", + version: "1.0.0", + bin: { "browser-control": "./dist/cli.js", "browser-control-mcp": "./dist/mcp.js" }, + })), + fs.writeFile(path.join(packageRoot, "dist", "cli.js"), ""), + fs.writeFile(path.join(packageRoot, "dist", "mcp.js"), ""), + fs.writeFile(path.join(packageRoot, "extension", "manifest.json"), JSON.stringify({ version: "9.9.9" })), + fs.writeFile(path.join(packageRoot, "extension", "dist", "manifest.json"), JSON.stringify({ version: "0.0.23" })), + ]) + const relay = { + endpoint: "http://127.0.0.1:19989", + version: Effect.succeed({ version: "1.0.0", buildId: "test" }), + extensionStatus: Effect.succeed({ + connected: true, + version: "0.0.23", + protocolVersion: 2, + protocolCompatible: true, + protocolLegacy: false, + activeTargets: 0, + }), + targets: Effect.succeed([]), + sessions: Effect.succeed([]), + } as unknown as RelayClient.Interface + const store = { + endpoint: relay.endpoint, + filePath: path.join(packageRoot, "session.json"), + read: Effect.succeed(undefined), + } as unknown as SessionStore.Interface + const report = await Effect.runPromise(createDoctorReport({ packageRoot }).pipe( + Effect.provide(Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + Layer.succeed(RelayClient.Service, relay), + Layer.succeed(SessionStore.Service, store), + )), + )) + + expect(report.extension).toMatchObject({ expectedVersion: "0.0.23", versionMatches: true }) + expect(report.checks.find((check) => check.id === "extension-version")).toMatchObject({ + status: "ok", + message: "matches bundled extension (0.0.23)", + }) + } finally { + await fs.rm(packageRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/test/extension-connection-lifecycle.test.ts b/test/extension-connection-lifecycle.test.ts new file mode 100644 index 0000000..9cc35b9 --- /dev/null +++ b/test/extension-connection-lifecycle.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + ensureReconnectAlarm, + reconnectAlarmName, + startSocketKeepAlive, +} from "../extension/src/connection-lifecycle.ts" + +describe("extension connection lifecycle", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("repairs a missing reconnect alarm whenever the service worker starts", async () => { + const create = vi.fn(async () => {}) + const get = vi.fn(async () => undefined) + + await ensureReconnectAlarm({ create, get }) + + expect(get).toHaveBeenCalledWith(reconnectAlarmName) + expect(create).toHaveBeenCalledWith(reconnectAlarmName, { + periodInMinutes: 0.5, + }) + }) + + it("preserves an existing reconnect alarm", async () => { + const create = vi.fn(async () => {}) + const get = vi.fn(async () => ({ name: reconnectAlarmName, scheduledTime: Date.now() })) + + await ensureReconnectAlarm({ create, get }) + + expect(create).not.toHaveBeenCalled() + }) + + it("runs a heartbeat every 20 seconds and stops cleanly", () => { + vi.useFakeTimers() + const heartbeat = vi.fn() + + const stop = startSocketKeepAlive(heartbeat) + vi.advanceTimersByTime(40_000) + stop() + vi.advanceTimersByTime(20_000) + + expect(heartbeat).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/relay-lifecycle.test.ts b/test/relay-lifecycle.test.ts index 40f86a5..5f1b70f 100644 --- a/test/relay-lifecycle.test.ts +++ b/test/relay-lifecycle.test.ts @@ -203,6 +203,7 @@ describe("relay lifecycle", () => { expect(managedRelayEntrypoint("/package/dist/index.js")).toBe("/package/dist/cli.js") expect(managedRelayEntrypoint("/package/src/browser-control-client.ts")).toBe("/package/src/cli.ts") expect(managedRelayEntrypoint("/package/dist/cli.js")).toBe("/package/dist/cli.js") + expect(managedRelayEntrypoint("/package/bin/browser-control-mcp")).toBe("/package/bin/browser-control") }) it("can launch the Node relay independently of a Bun consumer runtime", () => { From 4dc4d2e17f4a236084d897dc15c3b0adeeff7398 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 22 Jul 2026 14:58:55 -0400 Subject: [PATCH 9/9] chore: retain OpenCode Jr browser workflows --- scripts/opencode-jr-developer-onboarding.js | 19 ++++++++++++++++ scripts/opencode-jr-signup-handoff.js | 24 +++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 scripts/opencode-jr-developer-onboarding.js create mode 100644 scripts/opencode-jr-signup-handoff.js diff --git a/scripts/opencode-jr-developer-onboarding.js b/scripts/opencode-jr-developer-onboarding.js new file mode 100644 index 0000000..d5c8e54 --- /dev/null +++ b/scripts/opencode-jr-developer-onboarding.js @@ -0,0 +1,19 @@ +await fillInputs(page, [ + { + selector: page.getByRole("textbox", { name: "Account Name" }), + value: "OpenCode Jr", + }, + { + selector: page.getByRole("textbox", { name: /Describe all of your use cases/i }), + value: "OpenCode Jr is an automated responder for the OpenCode project. It monitors public posts that structurally mention the main @opencode account, admits requests only when the author is a stable allowlisted Anomaly team member, reads the relevant public conversation history for context, and posts one concise public reply from @OpenCodeJr. We use the official X API only. We do not access Direct Messages, sell or redistribute X data, follow users, like posts, delete posts, or perform unsolicited autonomous posting. Stored provider evidence is limited to the durable identifiers and public text required for request processing, thread continuity, reconnect backfill, and duplicate-reply prevention.", + }, +]) +await page.bringToFront() +await handoff( + "Review X's Developer Agreement and policies. If you accept them, check the three agreement boxes and click Submit. Complete any CAPTCHA or verification yourself. When the Developer Console dashboard is visible, use the in-page I'm done, continue control.", + { timeoutMs: 1_800_000 }, +) +if (!page.url().startsWith("https://console.x.com/")) { + throw new Error(`Developer onboarding left the X console: ${page.url()}`) +} +return { url: page.url(), title: await page.title() } diff --git a/scripts/opencode-jr-signup-handoff.js b/scripts/opencode-jr-signup-handoff.js new file mode 100644 index 0000000..304f8a3 --- /dev/null +++ b/scripts/opencode-jr-signup-handoff.js @@ -0,0 +1,24 @@ +await page.goto("https://x.com/home", { waitUntil: "domcontentloaded", timeout: 60_000 }) +await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').waitFor({ timeout: 30_000 }) +await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').click() +await page.getByText(/Add an existing account/i).first().click() +await page.waitForURL(/x\.com\/i\/(flow\/login|jf\/onboarding)/, { timeout: 30_000 }) +await page.evaluate(() => { + const markInputs = () => { + for (const input of document.querySelectorAll("input")) { + input.setAttribute("data-1p-ignore", "true") + input.setAttribute("data-lpignore", "true") + } + } + markInputs() + new MutationObserver(markInputs).observe(document.documentElement, { childList: true, subtree: true }) +}) +await page.bringToFront() +await handoff( + "Create a new X account through account switching. Use display name OpenCode Jr and desired handle @OpenCodeJr. Enter private email/phone, birthday, CAPTCHA, verification code, password, and 2FA yourself. Do not submit the final Create account or Sign up action. Stop on the final review screen, then use the in-page I'm done, continue control.", + { timeoutMs: 1_800_000 }, +) +return { + url: page.url(), + finalCreateVisible: await page.getByRole("button", { name: /^(Sign up|Create account)$/i }).count() > 0, +}