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 0000000..34099cb Binary files /dev/null and b/docs/chrome-web-store/icon-128.png differ 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 0000000..66cc984 Binary files /dev/null and b/docs/chrome-web-store/small-promo-440x280.png differ 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 0000000..34099cb Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000..a27238d Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000..6c9500e Binary files /dev/null and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000..230f36c Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..fec1365 --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + 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++ }