From fada067277882744102587cd8bad063679e0c63d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 19:31:13 -0700 Subject: [PATCH] feat(browser): add zero-click Chrome extension bootstrap (#121586) * feat(browser): add zero-click extension bootstrap Pre-register deterministic path-derived extension IDs and install a strict native messaging host. Keep the popup and options UI minimal while removing the obsolete copilot and page-share flows. * fix(browser): satisfy native bootstrap CI guards * test(browser): isolate native bootstrap Chrome roots * test(browser): flush native bootstrap profile before status * test(browser): seed Linux native bootstrap identity * fix(browser): preserve native bootstrap upgrade safety Allow immutable root-owned package inputs while keeping mutable state, manifests, and launchers user-owned. Preserve all retired copilot keys whenever active or unrecognized recovery custody remains. * fix(browser): preserve pending copilot custody Retired cleanup now removes copilot state only when the durable registry is exactly empty. Any session, archive, malformed value, future shape, or read failure preserves every retired key. * fix(browser): guard native bootstrap upgrades Fail closed while retired copilot custody remains and make discard durable across partial failures. Require exact launcher-embedded origins and repair full launcher drift without accepting mismatched registrations. * fix(browser): remove stale layout export * chore(release): leave changelog to release flow --- .github/workflows/ci.yml | 4 +- .oxlintrc.json | 1 - config/knip.config.ts | 7 +- docs/cli/browser.md | 31 +- docs/gateway/doctor.md | 8 +- docs/gateway/security/index.md | 9 + docs/tools/chrome-extension.md | 498 ++++---- extensions/browser/browser-doctor.ts | 1 + .../background.access-mode.test.ts | 62 +- .../browser/chrome-extension/background.js | 245 ++-- .../background.test-harness.ts | 204 ++- .../background.test-support.ts | 7 - .../chrome-extension/background.test.ts | 1134 +++++------------ .../bootstrap.chromium.test.ts | 387 ++++++ .../browser/chrome-extension/manifest.json | 17 +- .../modules/copilot-background-shared.d.ts | 34 - .../modules/copilot-background-shared.js | 128 -- .../modules/copilot-background.d.ts | 15 - .../modules/copilot-background.js | 741 ----------- .../modules/copilot-background.test.ts | 739 ----------- .../modules/copilot-gateway-lifecycle.d.ts | 47 - .../modules/copilot-gateway-lifecycle.js | 131 -- .../modules/copilot-gateway.d.ts | 21 - .../modules/copilot-gateway.js | 347 ----- .../modules/copilot-gateway.test.ts | 754 ----------- .../modules/copilot-recovery.d.ts | 23 - .../modules/copilot-recovery.js | 269 ---- .../modules/copilot-relay-custody.d.ts | 5 - .../modules/copilot-relay-custody.js | 89 -- .../modules/copilot-runtime.d.ts | 30 - .../modules/copilot-runtime.js | 1 - .../modules/copilot-session-registry.d.ts | 65 - .../modules/copilot-session-registry.js | 357 ------ .../modules/copilot-session-registry.test.ts | 316 ----- .../modules/copilot-session.d.ts | 18 - .../modules/copilot-session.js | 318 ----- .../modules/copilot-session.test.ts | 97 -- .../modules/native-bootstrap.d.ts | 59 + .../modules/native-bootstrap.js | 292 +++++ .../modules/native-bootstrap.test.ts | 383 ++++++ .../modules/page-share-background.js | 84 -- .../modules/page-share-core.d.ts | 28 - .../modules/page-share-core.js | 244 ---- .../modules/page-share-core.test.ts | 228 ---- .../modules/page-share-relay.d.ts | 15 - .../modules/page-share-relay.js | 56 - .../chrome-extension/modules/panel-core.d.ts | 42 - .../chrome-extension/modules/panel-core.js | 151 --- .../modules/panel-core.test.ts | 107 -- .../modules/popup-background.js | 280 ++-- .../modules/tab-access-events.d.ts | 7 - .../modules/tab-access-events.js | 20 +- .../modules/tab-access-events.test.ts | 14 +- .../browser/chrome-extension/options.html | 147 +++ .../browser/chrome-extension/options.js | 95 ++ .../chrome-extension/package.contract.test.ts | 45 + .../chrome-extension/page-share.e2e.test.ts | 927 -------------- .../chrome-extension/popup-errors.test.ts | 434 ------- .../browser/chrome-extension/popup.html | 490 +------ extensions/browser/chrome-extension/popup.js | 242 +--- .../relay-key.test-support.ts | 7 + .../browser/chrome-extension/sidepanel.css | 356 ------ .../chrome-extension/sidepanel.e2e-support.ts | 596 --------- .../chrome-extension/sidepanel.e2e.test.ts | 979 -------------- .../browser/chrome-extension/sidepanel.html | 59 - .../browser/chrome-extension/sidepanel.js | 257 ---- extensions/browser/cli-output-mode.ts | 6 +- extensions/browser/native-host-entry.ts | 42 + extensions/browser/package.json | 7 - .../scripts/build-copilot-runtime.d.mts | 20 - .../browser/scripts/build-copilot-runtime.mjs | 57 - .../scripts/build-copilot-runtime.test.ts | 49 - .../browser/scripts/copilot-runtime-entry.ts | 14 - .../browser/scripts/copy-chrome-extension.mjs | 15 +- .../src/browser/extension-install-layout.ts | 427 +++++++ .../src/browser/extension-install.test.ts | 834 ++++++++++++ .../browser/src/browser/extension-install.ts | 570 +++++++++ .../src/browser/extension-native-host.test.ts | 311 +++++ .../src/browser/extension-native-host.ts | 189 +++ .../src/browser/extension-native-protocol.ts | 215 ++++ .../browser/src/browser/extension-pairing.ts | 90 ++ .../extension-relay/page-share.test.ts | 125 -- .../src/browser/extension-relay/page-share.ts | 75 -- .../extension-relay/relay-bridge.test.ts | 117 -- .../browser/extension-relay/relay-bridge.ts | 62 - .../extension-relay/relay-lifecycle.test.ts | 6 +- .../extension-relay/relay-lifecycle.ts | 4 +- .../extension-relay/relay-protocol.test.ts | 14 - .../browser/extension-relay/relay-protocol.ts | 38 +- .../browser/extension-relay/relay-server.ts | 8 +- .../src/cli/browser-cli-extension-pairing.ts | 13 - .../src/cli/browser-cli-extension.test.ts | 109 +- .../browser/src/cli/browser-cli-extension.ts | 200 ++- .../browser/src/cli/browser-cli.lazy.test.ts | 1 + extensions/browser/src/cli/browser-cli.ts | 2 +- extensions/browser/src/doctor-browser.ts | 50 + extensions/browser/src/plugin-service.test.ts | 23 - extensions/browser/src/plugin-service.ts | 6 - package.json | 2 +- pnpm-lock.yaml | 9 - scripts/test-projects.test-support.mts | 6 +- scripts/update-gateway.sh | 5 - src/cli/command-catalog.ts | 5 + src/cli/command-path-policy.test.ts | 5 + src/cli/command-startup-policy.test.ts | 7 + .../program/root-command-descriptions.test.ts | 1 + src/commands/doctor-browser.facade.test.ts | 15 + src/commands/doctor-browser.ts | 22 + src/commands/doctor.e2e-harness.ts | 4 + src/commands/doctor.fast-path-mocks.ts | 4 + ...rns-state-directory-is-missing.e2e.test.ts | 4 + .../doctor-core-browser-residue-check.test.ts | 6 + src/flows/doctor-core-checks.ts | 18 + src/flows/doctor-health-contributions.test.ts | 5 + test/package-scripts.test.ts | 6 +- test/scripts/bundled-plugin-assets.test.ts | 6 - ...ged-lanes-generated-extension-lint.test.ts | 8 +- test/scripts/changed-lanes.test.ts | 42 - test/scripts/ci-workflow-guards.test.ts | 12 +- test/scripts/oxlint-config.test.ts | 1 - test/scripts/test-projects.test.ts | 1 - tsdown.config.ts | 1 + 122 files changed, 5638 insertions(+), 12060 deletions(-) create mode 100644 extensions/browser/chrome-extension/bootstrap.chromium.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-background-shared.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-background.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-background.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-background.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-recovery.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-recovery.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-relay-custody.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-runtime.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-runtime.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session.js delete mode 100644 extensions/browser/chrome-extension/modules/copilot-session.test.ts create mode 100644 extensions/browser/chrome-extension/modules/native-bootstrap.d.ts create mode 100644 extensions/browser/chrome-extension/modules/native-bootstrap.js create mode 100644 extensions/browser/chrome-extension/modules/native-bootstrap.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/page-share-background.js delete mode 100644 extensions/browser/chrome-extension/modules/page-share-core.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/page-share-core.js delete mode 100644 extensions/browser/chrome-extension/modules/page-share-core.test.ts delete mode 100644 extensions/browser/chrome-extension/modules/page-share-relay.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/page-share-relay.js delete mode 100644 extensions/browser/chrome-extension/modules/panel-core.d.ts delete mode 100644 extensions/browser/chrome-extension/modules/panel-core.js delete mode 100644 extensions/browser/chrome-extension/modules/panel-core.test.ts create mode 100644 extensions/browser/chrome-extension/options.html create mode 100644 extensions/browser/chrome-extension/options.js create mode 100644 extensions/browser/chrome-extension/package.contract.test.ts delete mode 100644 extensions/browser/chrome-extension/page-share.e2e.test.ts delete mode 100644 extensions/browser/chrome-extension/popup-errors.test.ts create mode 100644 extensions/browser/chrome-extension/relay-key.test-support.ts delete mode 100644 extensions/browser/chrome-extension/sidepanel.css delete mode 100644 extensions/browser/chrome-extension/sidepanel.e2e-support.ts delete mode 100644 extensions/browser/chrome-extension/sidepanel.e2e.test.ts delete mode 100644 extensions/browser/chrome-extension/sidepanel.html delete mode 100644 extensions/browser/chrome-extension/sidepanel.js create mode 100644 extensions/browser/native-host-entry.ts delete mode 100644 extensions/browser/scripts/build-copilot-runtime.d.mts delete mode 100644 extensions/browser/scripts/build-copilot-runtime.mjs delete mode 100644 extensions/browser/scripts/build-copilot-runtime.test.ts delete mode 100644 extensions/browser/scripts/copilot-runtime-entry.ts create mode 100644 extensions/browser/src/browser/extension-install-layout.ts create mode 100644 extensions/browser/src/browser/extension-install.test.ts create mode 100644 extensions/browser/src/browser/extension-install.ts create mode 100644 extensions/browser/src/browser/extension-native-host.test.ts create mode 100644 extensions/browser/src/browser/extension-native-host.ts create mode 100644 extensions/browser/src/browser/extension-native-protocol.ts create mode 100644 extensions/browser/src/browser/extension-pairing.ts delete mode 100644 extensions/browser/src/browser/extension-relay/page-share.test.ts delete mode 100644 extensions/browser/src/browser/extension-relay/page-share.ts delete mode 100644 extensions/browser/src/cli/browser-cli-extension-pairing.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4de171bb3617..b567cbf15f32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1545,9 +1545,9 @@ jobs: --configLoader runner --shard ${{ matrix.shard }}/4 - - name: Test browser copilot end-to-end + - name: Test browser extension bootstrap end-to-end if: matrix.shard == 1 - run: pnpm test:e2e:browser-copilot + run: pnpm test:e2e:browser-extension checks-ui-e2e-real-gateway: permissions: diff --git a/.oxlintrc.json b/.oxlintrc.json index fbef0a8cf208..c12ce2b5494e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -214,7 +214,6 @@ ".agents/skills/autoreview/tests/fixtures/**", "test/fixtures/oxlint-boundary-guards/**", "**/a2ui.bundle.js", - "extensions/browser/chrome-extension/modules/copilot-runtime.js", "extensions/diffs/assets/viewer-runtime.js", "extensions/diffs-language-pack/assets/viewer-runtime.js", "node_modules/", diff --git a/config/knip.config.ts b/config/knip.config.ts index 7c8400310527..7125475b7463 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -685,13 +685,12 @@ const config = { "browser-host-inspection.ts!", "browser-maintenance.ts!", "browser-profiles.ts!", + // Built by tsdown as the native messaging executable; Chrome launches it by path. + "native-host-entry.ts!", // Chrome manifest/package scripts load these without TypeScript imports. "chrome-extension/background.js!", + "chrome-extension/options.js!", "chrome-extension/popup.js!", - "chrome-extension/sidepanel.js!", - "scripts/build-copilot-runtime.mjs!", - // esbuild receives this browser bootstrap by an assembled path. - "scripts/copilot-runtime-entry.ts!", "scripts/copy-chrome-extension.mjs!", ]), [`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([ diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 1ce9a99ef7ac..2a32f76b74f2 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -122,17 +122,33 @@ System-profile import is enabled by default. Set `browser.allowSystemProfileImpo ```bash openclaw browser extension path +openclaw browser extension install +openclaw browser extension install --json --wait-ms 60000 +openclaw browser extension status +openclaw browser extension status --json +openclaw browser extension uninstall-host openclaw browser extension pair openclaw browser extension pair --gateway-url wss://gateway.example.com openclaw browser extension cdp openclaw browser extension cdp --json ``` -- `extension path` prints the unpacked extension directory for Chrome's **Load - unpacked** flow. -- `extension pair` creates the host-local relay key when needed and prints the - pairing string. `--gateway-url` creates a direct remote-Gateway pairing URL; - non-loopback URLs must use `wss://`. +- `extension install` copies the bundled runtime into a stable state-directory + path and pre-registers its deterministic, origin-locked native bootstrap host + in existing Chrome-family user-data roots. Launch Chrome, run this command, + and use **Load unpacked** only after it prints the stable path. The command + waits while Chrome records that exact path, then verifies the recorded ID + against Chromium's path-derived ID. **Load unpacked** is the only manual + action in normal setup. +- `extension status` reports the installed copy, detected IDs/profiles, + owned-registration health, and whether manual setup is required. JSON output + never includes a pairing string or relay key. +- `extension uninstall-host` removes only verified OpenClaw-owned native-host + manifests and launchers. It does not remove the extension from Chrome. +- `extension path` is read-only. It prints the stable installed copy when + present and the bundled source directory otherwise. +- `extension pair` remains the advanced manual flow. `--gateway-url` creates a + direct remote-Gateway pairing URL; non-loopback URLs must use `wss://`. - `extension cdp` prints non-secret Browser Relay Authentication v2 metadata: the loopback browser/CDP endpoints, protocol version, key ID, and fixed challenge/complete binding. It never prints the relay key or an authorization @@ -146,6 +162,11 @@ on stderr so stdout stays valid JSON. Setup, security model, and migration steps: [Chrome extension](/tools/chrome-extension). +If the extension already attempted automatic setup before the native host +existed, Chromium retains that miss for the running browser process. Restart +Chrome once, then repeat the ordered install flow; popup retries alone cannot +recover that existing process. + ## Tabs ```bash diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 2ff0a326a1cd..22fc16c60b61 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -155,7 +155,7 @@ Flags: - Config normalization for legacy value shapes. - Talk config migration from legacy flat `talk.*` fields into `talk.provider` + `talk.providers.`. - - Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness. + - Browser migration checks for legacy Chrome extension configs, owned native-bootstrap registration drift, and Chrome MCP readiness. - OpenCode provider override warnings (`models.providers.opencode` / `opencode-zen` / `opencode-go`). - Legacy OpenAI Codex provider/profile migration (`openai-codex` → `openai`) and shadowing warnings for stale `models.providers.openai-codex`. - OAuth TLS prerequisites check for OpenAI Codex OAuth profiles. @@ -359,6 +359,12 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor warns while `browser.extensionRelay.allowLegacyAuth` is enabled. Upgrade paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set the flag to `false`. V2 clients do not downgrade to legacy authentication. + When a stable Chrome extension copy and owned native-host registration already + exist, doctor reports registration drift. `openclaw doctor --fix` may repair + that owned registration, but it never installs the host for every OpenClaw + user and never overwrites a foreign same-name manifest or launcher. Use + `openclaw browser extension install` for the initial setup. + Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile: - checks whether Google Chrome is installed on the same host for default auto-connect profiles diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index e1a4c4aec9d7..3c27f109cf8d 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -502,6 +502,15 @@ Enabling browser control gives the model a real browser. If that profile already OpenClaw tab group as its ACL. Existing pairings migrate to **Selected tabs**, while new personal-browser pairings recommend **All tabs**. Incognito and internal Chrome pages remain excluded in either mode. +- Automatic Chrome extension setup uses an origin-locked native messaging + manifest discovered from an exact unpacked extension path in Chrome profile + metadata. The one-shot host accepts only a versioned request with a fresh + nonce, caps input at 4 KiB, validates the Chrome-supplied origin, and returns + only a locally owned pairing. It never transfers a remote Gateway key. +- Native-host manifests, launchers, and status output contain no pairing key. + OpenClaw refuses symlinks, unsafe ownership/modes, wildcard origins, and + foreign registrations using the same host name. Windows uses the manual + pairing fallback until an executable native-host path is supported. - Run a **node host** on the browser machine and let the Gateway proxy browser actions when the Gateway is remote from the browser (see [Browser tool](/tools/browser)); treat node pairing like admin access, keep Gateway and node host on the same tailnet, and avoid exposing relay/control ports over LAN, public internet, or Tailscale Funnel. ### Browser SSRF policy (strict by default) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 37074feb3455..54ef1870a190 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -1,87 +1,88 @@ --- -summary: "Chrome extension: let OpenClaw drive your signed-in Chrome with no remote-debugging prompt" +summary: "Chrome extension: securely automate signed-in tabs with automatic local pairing" read_when: - - You want an agent to drive your real signed-in Chrome from your phone - - You keep hitting the Chrome "Allow remote debugging?" prompt with nobody at the desk - - You want to understand the security model of browser takeover via the extension + - You want an agent to drive your signed-in Chrome without remote-debugging prompts + - You are installing, pairing, disabling, or troubleshooting the OpenClaw Chrome extension + - You need the Chrome native bootstrap security and platform support model title: "Chrome Extension" --- # Chrome extension -The OpenClaw Chrome extension lets an agent control your **signed-in Chrome -tabs** without launching a separate managed browser, and **without** Chrome's -blocking "Allow remote debugging?" prompt. +The OpenClaw Chrome extension lets the browser tool automate eligible tabs in +your signed-in Chrome profile. It uses `chrome.debugger`, so it does not require +Chrome's blocking remote-debugging consent prompt. -This matters when you drive OpenClaw from a phone (Telegram, WhatsApp, etc.): -the [`user` profile](/tools/browser#profiles-openclaw-user-chrome) attaches over -Chrome's remote-debugging port, which pops a desktop consent dialog nobody can -click when you are away. The extension uses the `chrome.debugger` API instead, -so the only in-page hint is Chrome's dismissible "OpenClaw started debugging -this browser" banner. +The extension is browser automation infrastructure. It does not include chat, +page sharing, a prompt box, or a tab copilot. Its popup shows connection state, +the current access mode, a Pause/Allow action for the current eligible tab, and +a Settings link. -This is the same shape used by Anthropic's Claude in Chrome and OpenAI's Codex -Chrome extensions. +## Requirements -## How it works +- Google Chrome, Chrome for Testing, or Chromium +- OpenClaw installed on the same machine as Chrome, or an OpenClaw browser node + on that machine +- macOS or Linux for automatic native bootstrap +- Chrome launched at least once so its user-data directory exists -Three parts: +Windows keeps manual pairing. Current Chromium launches native hosts directly +only when the registered host is a Windows executable; OpenClaw does not install +a script launcher or registry key without a proven binary framing path. -- **Browser control service** (Gateway or node host): the API the `browser` - tool calls. -- **Extension relay**: a small server the control service exposes on loopback - for same-host and browser-node setups, or through the Gateway's relay-authenticated - WebSocket route for direct remote setups. It presents a Chrome DevTools - Protocol endpoint to OpenClaw and speaks to the extension. -- **OpenClaw Chrome extension** (MV3): owns the access policy, attaches to - allowed tabs with `chrome.debugger`, forwards CDP traffic, and manages the - **OpenClaw tab group**. +## Install -Pairing chooses one of two extension-owned access modes. **All tabs** makes -every eligible ordinary tab in this Chrome profile available and is the -recommended default for a personal browser. **Selected tabs** exposes only tabs -in the **OpenClaw tab group**. The extension advertises only currently -accessible tabs and rechecks eligibility, mode, and revocation state before and -after authority-bearing commands, so stale relay state cannot restore access. +Launch Chrome, then run this command before loading the extension: -## Install and pair +```bash +openclaw browser extension install +``` -1. Print the unpacked extension path: +Keep the command running. It copies the bundled extension to a stable +OpenClaw-owned directory, predicts the unpacked extension ID from that exact +path, and pre-registers an origin-locked native host in existing Chrome-family +user-data roots. Only after pre-registration succeeds does it print the stable +path to load. - ```bash - openclaw browser extension path - ``` +Chrome does not let a normal CLI silently install an unpacked extension. This +one step is unavoidable: -2. Open `chrome://extensions`, enable **Developer mode**, click **Load - unpacked**, and select the printed directory. +1. Open `chrome://extensions`. +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select the path printed by the command. -3. Print the pairing string: +Leave the install command running while you complete those steps. The extension +pairs on its first native call; you do not need to open its popup, reload the +extension, or restart Chrome during a normal first-time setup. The installer +then reads the profile's `Secure Preferences` and verifies that Chrome loaded +the approved realpath under the predicted ID. - ```bash - openclaw browser extension pair - ``` +The installer accepts an ID only when all of these are true: -4. Click the OpenClaw toolbar icon, paste the pairing string, and choose - **All tabs** or **Selected tabs**. New pairings default to **All tabs**. The - badge turns **ON** when the extension connects to the relay. +- the ID matches Chrome's 32-character extension ID format; +- Chrome records the install location as unpacked; +- the recorded extension path resolves exactly to the installed or bundled + OpenClaw extension directory; +- the recorded ID equals Chromium's deterministic path ID for that exact + canonical realpath. -The pairing key is a **per-host secret** created on first use and stored -under `credentials/` in the state directory (mode `0600`). Each machine that -runs a browser — the Gateway host and every browser node host — owns its own -key, so no credential has to travel between machines. To rotate it, delete the -`browser-extension-relay.secret` file and pair again. +The extension name is not trusted. Existing native-host files with the same +host name are not overwritten unless they are verifiably OpenClaw-owned. -The key stays in the pairing string fragment rather than the WebSocket URL sent -to the server. Browser Relay Authentication v2 uses it only as an HMAC key: the -extension first verifies the relay's signed, connection-bound challenge, then -sends a one-time proof. The key is never sent in a URL, header, WebSocket -subprotocol, or application frame. Still treat the complete pairing string as a -password. +Use a different bounded wait when needed: + +```bash +openclaw browser extension install --wait-ms 60000 +``` + +For automation, use `--json`. The result includes the stable copy, discovered +IDs and profiles, native-host registration health, and whether manual setup is +required. It never includes a relay key or pairing string. ## Use it -Select the built-in `chrome` profile in a `browser` tool call, or make it the -default: +Select the built-in `chrome` profile, or make it the default: ```bash openclaw config set browser.defaultProfile chrome @@ -97,247 +98,180 @@ openclaw config set browser.defaultProfile chrome } ``` +Fresh automatic pairings use **All tabs**. Existing valid pairings are never +overwritten, and older pairings keep their stored access mode. + ### Choose tab access -- **All tabs**: OpenClaw can enumerate and control every eligible ordinary tab - in this Chrome profile, including signed-in sites. The OpenClaw group is an - organizational marker for agent-created tabs; dragging a tab into or out of - it does not change access. Use **Pause OpenClaw on this tab** in the toolbar - popup to exclude one tab, and **Allow OpenClaw on this tab** to restore it. -- **Selected tabs**: group membership is the access-control boundary. Click - **Share this tab with OpenClaw** or drag a tab into the OpenClaw group to - grant access. Click **Stop sharing this tab** or drag it out to revoke access. +- **All tabs** exposes every eligible ordinary tab in that Chrome profile, + except tabs paused for the current browser session. Use **Pause on this tab** + and **Allow on this tab** in the popup. +- **Selected tabs** uses the **OpenClaw** tab group as the access-control + boundary. Moving a tab into the group grants access; moving it out revokes + access. -Change **Access** in the popup settings at any time. Switching from **All tabs** -to **Selected tabs** immediately detaches every ungrouped tab, including an -attach already in flight. Switching back refreshes the available targets. -Paused tab IDs remain paused for the rest of the browser session and become -effective again if you return to **All tabs**. +Open the extension's Settings page to change the access mode. Switching to +Selected tabs immediately detaches ungrouped tabs, including attaches already +in flight. Agent-created tabs stay in the OpenClaw group in either mode. -Existing valid pairings created before access modes migrate to **Selected -tabs**, preserving their original group-only security promise. A malformed -stored mode is also repaired to **Selected tabs** without discarding the -pairing. New pairings explicitly choose a mode and default to **All tabs**. +The extension excludes incognito tabs, internal pages such as `chrome://` and +`chrome-extension://`, and tabs without a usable current URL. `file://` access +also requires Chrome's **Allow access to file URLs** setting. -Agent-created tabs enter the OpenClaw group in both modes. Chrome shows its -dismissible “OpenClaw started debugging this browser” banner while a tab is -attached. Choosing **Cancel** pauses that tab for the browser session in **All -tabs** mode; in **Selected tabs** mode it removes the tab from the group. +## Automatic setup controls -The extension never exposes incognito tabs, tabs without a current ID or URL, -or internal pages such as `chrome://`, `chrome-extension://`, `devtools://`, -and origin-inheriting `about:blank`. It permits ordinary `http://`, `https://`, -and `data:` documents, `blob:` documents backed by an HTTP(S) origin, and -`file://` pages when Chrome's **Allow access to file URLs** setting permits -debugger attachment. +Settings shows redacted relay/native bootstrap status and an **Use automatic +local setup** switch. -### Authenticated external CDP clients +- Turning automatic setup off preserves a valid existing pairing but prevents + new native bootstrap attempts. +- **Disconnect and disable automatic setup** revokes the pairing immediately, + detaches debugger sessions, and persists the opt-out. +- **Use local OpenClaw** clears the opt-out and retries the native host. +- Saving an explicit manual pairing also clears the opt-out. + +### Upgrades from the retired tab copilot + +If Settings says automation is paused to protect a pre-upgrade copilot +session, confirm that old runs are finished. Then click **Disconnect and +disable automatic setup** to discard the retired recovery state, followed by +**Use local OpenClaw** to reconnect. Until that explicit disconnect succeeds, +the extension preserves the retired state and blocks relay connections, native +setup, manual pairing, tab access changes, and debugger attachment. + +Chromium caches the first missing-native-host result for the running browser +process. If an existing extension already attempted automatic setup before the +native host was installed, restart Chrome once (a full browser-process reload). +Retrying from the popup or Settings cannot clear that process-level miss. +Normal setup avoids it by pre-registering the host before **Load unpacked**. + +## Status and removal + +Inspect the installation without printing credentials: + +```bash +openclaw browser extension status +openclaw browser extension status --json +``` + +Remove only OpenClaw-owned native-host manifests and launchers: + +```bash +openclaw browser extension uninstall-host +``` + +This does not remove the unpacked extension from Chrome. Use +`chrome://extensions` for that. It also does not delete the stable extension +copy or an existing relay key. + +`openclaw browser extension path` is read-only. It prints the stable installed +copy when present and the bundled source directory otherwise. + +## Advanced manual pairing + +The Settings page owns manual pairing. Generate a host-local pairing string: + +```bash +openclaw browser extension pair +``` + +Manual pairing remains useful on Windows and for recovery. Treat the complete +pairing string as a password. + +For a laptop that has Chrome but does not run OpenClaw or a browser node, pair +directly to a remote Gateway: + +```bash +openclaw browser extension pair \ + --gateway-url wss://gateway.example.com +``` + +Paste that string in **Settings → Advanced manual pairing**. This flow cannot +use automatic bootstrap: the remote Gateway owns a different relay key, and the +local native host never fetches or copies it. Non-loopback remote URLs require +`wss://`, and the Gateway must expose the exact `/browser/extension` WebSocket +path without a path-rewriting proxy prefix. + +## External CDP clients The relay supports Browser Relay Authentication v2 clients such as mcporter. -They use the same paired Chrome and the same extension access policy, -without Chrome's "Allow remote debugging?" prompt. Print the non-secret v2 -endpoint metadata: +Print non-secret endpoint metadata: ```bash openclaw browser extension cdp +openclaw browser extension cdp --json ``` -`openclaw browser extension cdp --json` emits the loopback endpoint, protocol -version, key ID, and fixed challenge/complete resource metadata. It never emits -the relay key or an authorization header. A v2 client must keep one raw -loopback TCP connection from challenge through `/json/version` and the `/cdp` -WebSocket upgrade; redirects, reconnects, and a second upstream socket are not -valid relay authentication. +The output includes the loopback endpoint, protocol version, key ID, and fixed +challenge/complete resources. It does not include the relay key or an +authorization header. -During the migration window, an old external client can request the legacy -Bearer header explicitly: +`cdp --legacy-bearer` is a temporary, warned compatibility escape hatch. It +works only while `browser.extensionRelay.allowLegacyAuth=true` and prints the +legacy credential on request. + +## Permissions + +The extension requests only: + +- `debugger`: send CDP commands to allowed tabs; +- `tabs` and `tabGroups`: discover tabs and enforce access mode; +- `storage`: persist pairing, access mode, session pauses, and bootstrap opt-out; +- `alarms`: wake the MV3 worker for relay/bootstrap retries; +- `nativeMessaging`: request one local bootstrap pairing. + +It does not request `activeTab`, `contextMenus`, `scripting`, or `sidePanel`. + +## Native bootstrap security + +The native host is `ai.openclaw.browser_bootstrap`. Each +`chrome.runtime.sendNativeMessage` call starts one process, reads one request, +writes one response, and exits. + +The request uses a versioned, length-prefixed JSON frame with a fresh 16-byte +nonce. The host caps input at 4 KiB, requires fatal UTF-8 decoding and exact +fields, verifies the caller origin against the exact installed manifest, and +returns only a locally generated pairing or a bounded non-secret failure code. +The response is below Chrome's 1 MiB native-message limit. Pairing keys never +appear in launcher arguments, manifests, status JSON, or diagnostics. + +The POSIX launcher and manifest use absolute canonical paths under an +OpenClaw-owned mode-`0700` directory. Manifests are mode `0600`; the launcher is +owner-executable. Symlinks, foreign ownership, unsafe modes, path traversal, +wildcard origins, and foreign same-name registrations fail closed. + +The unpacked ID calculation matches Chromium's +`crx_file::id_util::GenerateIdForPath`: hash the canonical absolute path's raw +bytes with SHA-256 (native UTF-16LE path bytes on Windows, with only a lowercase +drive letter uppercased), keep the first 16 digest bytes, then map hexadecimal +digits `0` through `f` to letters `a` through `p`. The extension manifest has no +`key`; registration authorizes only exact IDs derived from approved +OpenClaw-owned realpaths. + +The relay itself uses connection-bound HMAC proofs. The persistent per-host key +is not sent in a URL, header, WebSocket subprotocol, or application frame. + +## Troubleshooting ```bash -openclaw browser extension cdp --legacy-bearer -``` - -This command warns because it reveals the relay key in a credential header. It -works only while `browser.extensionRelay.allowLegacyAuth` is `true`; when legacy -auth is disabled, the command fails without printing a credential. - -[mcporter](https://github.com/openclaw/mcporter) is the supported external CDP -adapter. Use a release that supports Browser Relay Authentication v2; the -OpenClaw-side upgrade does not update mcporter. When a paired relay answers on -this host, a compatible mcporter release transparently rewrites -`chrome-devtools-mcp --autoConnect` server commands to the relay endpoint, so -agents calling Chrome DevTools through mcporter skip the remote-debugging -prompt automatically (set `MCPORTER_DISABLE_CHROME_DEVTOOLS_RELAY=1` there to -opt out). - -## Migrate relay authentication - -New OpenClaw extensions use Browser Relay Authentication v2 and never retry -legacy authentication after a bad proof, timeout, unsupported response, or -connection failure. - -- Existing valid pairing strings migrate locally to `authVersion: 2`; you do - not need to pair again for the protocol upgrade. -- Stored direct-Gateway pairings behind a path-prefix proxy are cleared during - migration. Re-run `openclaw browser extension pair` with a Gateway URL that - has no path prefix; v2 supports the exact `/browser/extension` route only. -- Upgrade OpenClaw before upgrading the extension. A v2 extension reports an - old server as needing an upgrade instead of sending the old token. -- Old extensions and external CDP clients continue to work for one migration - window while `browser.extensionRelay.allowLegacyAuth` keeps its default value - of `true`. -- After every extension and external CDP client uses v2, set - `browser.extensionRelay.allowLegacyAuth` to `false` and restart the Gateway or - browser node host. -- Rotating `credentials/browser-extension-relay.secret` changes the key ID, - closes authenticated relay sessions, clears pending and replay state, and - requires extension re-pairing. - -V2 external CDP access requires a client that implements the same-socket HTTP -challenge, completion, discovery, and WebSocket-upgrade sequence. Generic -Puppeteer or chrome-devtools-mcp clients do not implement that sequence by -themselves; use a v2-capable adapter, or the explicitly warned legacy escape -hatch only during the migration window. - -### Tab copilot side panel - -After pairing the extension, click **Open tab copilot** in its toolbar popup. -OpenClaw configures `sidepanel.html` for that exact accessible Chrome tab; the manifest has -no global side-panel path. Each tab therefore gets a separate panel document, -Gateway session, message subscription, and typed browser-tool binding. - -The panel does not place the page URL, title, DOM, or visible text in your -message. It sends only the text you type. Browser actions carry a separate -Gateway-authenticated binding containing the Chrome tab and CDP target, and the -browser tool rejects attempts to replace that target or use browser-wide -actions. Replies stay in the panel (`deliver: false`); they do not inherit a -Telegram, Discord, or other channel route. - -The copilot is a dedicated paired Gateway device with `operator.read` and -`operator.write` scopes. On first use, inspect and approve its request: - -```bash -openclaw devices list -openclaw devices approve -``` - -The extension retains that device identity and the Gateway-issued device token, -scoped to the canonical Gateway endpoint that issued them. Pairing a different -Gateway creates separate identity, token, and session custody; credentials and -sessions are never reused across endpoints. The extension does not persist the -Gateway shared secret. A panel can subscribe only to its own tab sessions, and -the Gateway filters those events before delivery. - -If the Gateway connection drops during a run, the extension keeps durable -custody of that run ID. On reconnect it aborts the unresolved run before -re-enabling any panel, then reloads transcript history. This fail-closed step -prevents browser actions from continuing unseen across a delivery gap. - -Closing a tab immediately removes its live subscription, aborts any visible -run, and marks that tab's session archived. If the Gateway is temporarily -offline, the extension persists the pending archive and retries only when that -same Gateway endpoint reconnects; it never sends an archive request to a -different Gateway. After a browser crash, the next launch archives sessions -left by the previous browser instance. Archived sessions reject new work, while -their transcripts remain available in session history. Browser-copilot keys are -thread sessions, so normal age and entry-count maintenance preserves them. The -per-agent session disk budget still applies (default `10gb`) and may evict the -oldest sessions under pressure; see [session maintenance](/reference/session-management-compaction#store-maintenance-and-disk-controls). - -The side panel currently requires either a Gateway-hosted extension relay or a -direct remote Gateway relay. A loopback relay on a browser node cannot yet -provide the node route required by the typed tab binding, so the panel denies -that topology instead of falling back to browser-wide routing. - -## Send a page to OpenClaw - -Use **Send page to OpenClaw** in the toolbar popup to share readable page text -with your main OpenClaw session. You can add an optional note, use the page or -selection right-click menu, or press `Alt+Shift+S`. OpenClaw prefers your current -selection when one exists, enqueues the share as a system event, and wakes the -main session immediately. - -The tab does not need to be in the OpenClaw tab group. This is a one-shot, -explicit share: nothing else on the page is exposed, and it grants no ongoing -access. Google Docs are exported as plain text with your signed-in browser -session, without Google API setup. X and Twitter threads are extracted without -the surrounding interface chrome. - -Page text is wrapped in OpenClaw's external-content safety boundary. Your -optional note stays outside that boundary as your own instruction. Page text -and selections are capped at about 120,000 characters and include a truncation -marker when shortened. - -Page sharing works when the extension relay is hosted by the Gateway, using -same-host pairing or direct `wss://` Gateway pairing. Node-hosted relays return -a clear error for now. To remap the keyboard shortcut, open -`chrome://extensions/shortcuts`. - -## Remote / cross-machine - -Chrome does not have to run on the Gateway host. Three topologies work: - -- **Same host** (Gateway + Chrome on one machine): pair on that machine with - `openclaw browser extension pair`. The relay is loopback-only. - If the local Gateway uses TLS, pass its certificate hostname explicitly with - `--gateway-url wss://gateway-host.example`; pairing never substitutes a loopback IP. -- **Direct to a remote Gateway** (Chrome on your laptop, Gateway on a VPS, and - **nothing else on the laptop**): on the Gateway, run - `openclaw browser extension pair --gateway-url wss://your-gateway.example.com`. - It prints a `wss://…/browser/extension#` string; load and pair the - extension on the laptop. The extension connects **straight to the Gateway** - over `wss://` — no OpenClaw install, Node, CLI, or open inbound port on the - laptop. This is the managed-hosting path. The Gateway URL must expose - `/browser/extension` without a path-rewriting proxy prefix because v2 binds - the exact request path into every proof. -- **Via a browser node host** (Chrome on a machine already running an OpenClaw - node): run `pair` on the node and pair locally; the Gateway proxies browser - actions to the node over its existing authenticated node link. - -The pairing secret is per host (the Gateway's, in the direct case), validated by -the Gateway's `/browser/extension` route. For the direct path, serve the Gateway -over TLS (`wss://`) so the proof exchange and CDP traffic are encrypted. The -secret remains in the pairing string's URL fragment and is never presented to -the server. The extension offers only the non-secret -`openclaw-extension-relay.v2` WebSocket subprotocol. Ensure any reverse proxy -preserves the standard `Sec-WebSocket-Protocol` header. - -## Diagnostics - -```bash -openclaw browser status --browser-profile chrome +openclaw browser extension status --json openclaw browser doctor --browser-profile chrome +openclaw doctor ``` -`doctor` reports the **Chrome extension relay** check as failing until the -extension popup shows **Connected**. `openclaw doctor` also warns while legacy -relay authentication remains enabled and tells you when to set -`browser.extensionRelay.allowLegacyAuth=false`. +- **No extension ID detected:** keep Chrome running, rerun `extension install`, + and use **Load unpacked** only after the command says native bootstrap is + ready and prints the stable path. +- **Extension was loaded before native setup:** restart Chrome once to clear its + cached native-host miss, then rerun the ordered install flow. +- **Waiting for local OpenClaw:** run `extension status`; install or repair the + owned native host. +- **Automatic setup disabled:** enable it in Settings or click **Use local + OpenClaw**. +- **Manual setup required:** use Settings for the advanced pairing flow. This + is expected on Windows and direct extension-only remote Gateway setups. +- **Relay unavailable:** confirm the Gateway or browser node is running, then + run browser doctor. -## Security model - -- Same-host and browser-node relays bind loopback; direct remote pairing uses - the Gateway's `wss://` route. Both use connection-bound HMAC proofs derived - from the per-host key, and the extension side is origin-checked to - `chrome-extension://`. -- Before verifying the relay's server proof, the client sends only the - non-secret key ID and a fresh nonce; it never sends an HMAC proof. Client - proofs are short-lived, one-time, and bound to the exact socket, protocol - version, role, transport, method, resource, flow, profile, and relay instance. -- In v2, the per-host key is never transmitted. Failed proof validation does - not fall back to legacy Bearer, Basic, or token-subprotocol auth. -- The relay exposes only tabs allowed by the selected access mode. The - extension independently rechecks eligibility and current policy before and - after each authority-bearing existing-tab command. In **Selected tabs**, the - group is the ACL; in **All tabs**, explicitly paused tabs stay hidden. -- Side-panel runs are scoped twice: Gateway delivery uses a per-session - allowlist, and browser tools enforce the Chrome tab/target binding carried - outside the prompt. -- **All tabs** grants broad authority over eligible signed-in sites in this - profile in exchange for the best unattended experience. **Selected tabs** - reduces that authority to a visible group but requires deliberate tab - management. Compared with the `user` (Chrome MCP) profile, the extension adds - this mode choice and per-tab session pauses without a blocking - remote-debugging prompt. - -See also: [Browser](/tools/browser) for the full profile model and the -managed `openclaw` and Chrome MCP `user` profiles. +See [Browser](/tools/browser) for the full profile model and the managed +`openclaw` and Chrome MCP `user` profiles. diff --git a/extensions/browser/browser-doctor.ts b/extensions/browser/browser-doctor.ts index f97c3d975f86..9bb287bff895 100644 --- a/extensions/browser/browser-doctor.ts +++ b/extensions/browser/browser-doctor.ts @@ -5,6 +5,7 @@ export { detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue, + maybeRepairOwnedChromeExtensionNativeHosts, noteChromeMcpBrowserReadiness, } from "./src/doctor-browser.js"; export type { LegacyClawdBrowserProfileResidue } from "./src/doctor-browser.js"; diff --git a/extensions/browser/chrome-extension/background.access-mode.test.ts b/extensions/browser/chrome-extension/background.access-mode.test.ts index 5d432468b6af..35fbde11811f 100644 --- a/extensions/browser/chrome-extension/background.access-mode.test.ts +++ b/extensions/browser/chrome-extension/background.access-mode.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { loadBackground, RELAY_SECRET, sendRuntimeMessage } from "./background.test-harness.js"; +import { + loadBackground, + TEST_RELAY_KEY, + REPLACEMENT_TEST_RELAY_KEY, + sendRuntimeMessage, +} from "./background.test-harness.js"; const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog"; @@ -47,7 +52,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -84,7 +89,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "selected", }, @@ -99,7 +104,7 @@ describe("relay command authorization", () => { await expect( sendRuntimeMessage(harness, { type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${"b".repeat(64)}`, + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, accessMode: "all", }), ).resolves.toEqual({ ok: true }); @@ -139,7 +144,7 @@ describe("relay command authorization", () => { harness.alarmListener({ name: RELAY_WATCHDOG_ALARM }); const pairing = sendRuntimeMessage(harness, { type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${"b".repeat(64)}`, + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, accessMode: "all", }); releaseConfigRead(); @@ -160,7 +165,7 @@ describe("relay command authorization", () => { deferTabAccessInitialization: true, storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -185,7 +190,7 @@ describe("relay command authorization", () => { deferTabAccessInitialization: true, storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -254,7 +259,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode, }, @@ -280,7 +285,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -325,7 +330,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -374,7 +379,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -408,16 +413,10 @@ describe("relay command authorization", () => { }); it("keeps a Selected barrier ahead of a queued All-mode widening", async () => { - let releaseConsent = () => {}; - const consent = new Promise((resolve) => { - releaseConsent = resolve; - }); - const onConsentChanged = vi.fn(async () => await consent); const harness = await loadBackground({ - onConsentChanged, storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "selected", }, @@ -437,14 +436,17 @@ describe("relay command authorization", () => { type: "setAccessMode", accessMode: "selected", }); + const releaseRestrictingStorage = harness.deferNextStorageSet(); releaseWideningStorage(); - await vi.waitFor(() => expect(onConsentChanged).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith({ accessMode: "selected" }); + }); await expect( sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 206 }), ).resolves.toMatchObject({ accessible: false }); - releaseConsent(); + releaseRestrictingStorage(); await expect(widening).resolves.toEqual({ ok: true, accessMode: "all" }); await expect(restricting).resolves.toEqual({ ok: true, accessMode: "selected" }); }); @@ -453,7 +455,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -505,7 +507,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -548,7 +550,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -660,7 +662,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode, }, @@ -768,7 +770,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode, }, @@ -806,7 +808,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "selected", }, @@ -914,7 +916,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }, @@ -965,7 +967,7 @@ describe("relay command authorization", () => { it("restores a validated Cancel deny after an MV3 worker restart", async () => { const storedConfig = { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", }; @@ -1001,7 +1003,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode, }, @@ -1033,7 +1035,7 @@ describe("relay command authorization", () => { const harness = await loadBackground({ storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode, }, diff --git a/extensions/browser/chrome-extension/background.js b/extensions/browser/chrome-extension/background.js index 970ffc463d78..73950c617cdb 100644 --- a/extensions/browser/chrome-extension/background.js +++ b/extensions/browser/chrome-extension/background.js @@ -1,7 +1,8 @@ -import { createCopilotController } from "./modules/copilot-background.js"; -import { createPageShareController } from "./modules/page-share-background.js"; -import { waitForCondition } from "./modules/page-share-core.js"; -import { createPageShareRelay } from "./modules/page-share-relay.js"; +import { + createNativeBootstrapController, + discardRetiredCopilotState, + prepareRetiredCopilotState, +} from "./modules/native-bootstrap.js"; import { createPopupMessageHandler } from "./modules/popup-background.js"; import { createRelayCommandHandler } from "./modules/relay-command-handler.js"; import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js"; @@ -13,7 +14,6 @@ import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js"; // The OpenClaw tab group is the ACL in selected mode and an ownership marker // in all-tabs mode. import { - ACCESS_MODE_ALL, ACCESS_MODE_SELECTED, OPENCLAW_TAB_GROUP_TITLE, createPairingConfigStore, @@ -30,12 +30,6 @@ const BADGE = { on: { text: "ON", color: "#0F9D58" }, error: { text: "!", color: "#B91C1C" }, }; -const COPILOT_RELAY_LABEL = { - off: "Browser relay disconnected", - connecting: "Connecting to browser relay", - on: "Browser relay connected", - error: "Browser relay reconnecting", -}; const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog"; const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline"; const RELAY_AUTH_TIMEOUT_MS = 10_000; @@ -43,7 +37,6 @@ const RELAY_AUTH_TIMEOUT_MS = 10_000; /** @type {WebSocket|null} */ let relayWs = null; let relayState = "off"; // off | connecting | on | error -let copilot = null; let reconnectAttempt = 0; let reconnectTimer = null; let relayOpeningDeadlineAt = 0; @@ -53,27 +46,46 @@ let relayStatusHint = ""; let reconciledPairingInvalidationRevision = 0; let relayConnectionGeneration = 0; let relayConnectionsSuspended = false; +let nativeBootstrap = null; +// Start blocked: no runtime path may outrun the retired-state storage read. +let retiredCopilotCustodyBlocked = true; /** Tab ids with an active chrome.debugger attachment. */ const attachedTabs = new Set(); /** Access epoch proven for each attachment; debugger events use this synchronously. */ const attachedAccessEpochs = new Map(); -/** Tabs denied to every relay attach while copilot run cleanup is pending. */ -const copilotDeniedTabs = new Set(); /** In-flight attach promises per tab id (coalesces concurrent attaches). */ const attachingTabs = new Map(); -/** Latest revocation task per tab; restoration waits for its exact epoch. */ -const copilotRevocations = new Map(); /** Debounce handle for tab-list refreshes. */ let tabsSyncTimer = null; let accessMutationChain = Promise.resolve(); -const pageShareRelay = createPageShareRelay(); const pairingConfigStore = createPairingConfigStore(chrome.storage.local); const tabAccessPolicy = createTabAccessPolicy({ isSelectedTab: isTabSelected }); const tabAccessReady = (async () => { + const retiredState = await prepareRetiredCopilotState(); + retiredCopilotCustodyBlocked = retiredState.blocked; const config = await pairingConfigStore.read(); - await tabAccessPolicy.initialize(config.accessMode, Boolean(config.relayUrl)); + await tabAccessPolicy.initialize( + config.accessMode, + Boolean(config.relayUrl) && !retiredCopilotCustodyBlocked, + ); + if (retiredCopilotCustodyBlocked) { + tabAccessPolicy.setEnabled(false); + await detachAllDebuggerSessions(); + } })(); +const custodyError = () => + new Error( + "Automation is paused to protect a pre-upgrade copilot session. Open Settings to disconnect before reconnecting.", + ); + +async function requireAutomationAllowed() { + await tabAccessReady; + if (retiredCopilotCustodyBlocked) { + throw custodyError(); + } +} + function closeRelaySocket() { const socket = relayWs; if (!socket) { @@ -83,9 +95,6 @@ function closeRelaySocket() { if (relayAuthenticatedSocket === socket) { relayAuthenticatedSocket = null; } - // Chrome completes close asynchronously; fail pending requests before the - // handshake so pairing and unpairing never leave a popup stuck on Sending. - pageShareRelay.rejectSocket(socket); socket.close(); } @@ -109,7 +118,6 @@ async function reconcilePairingInvalidation() { closeRelaySocket(); setBadge("off"); await detachAllDebuggerSessions(); - await copilot?.refreshConfig(); } function setBadge(kind) { @@ -117,16 +125,12 @@ function setBadge(kind) { const cfg = BADGE[kind] ?? BADGE.off; void chrome.action.setBadgeText({ text: cfg.text }); void chrome.action.setBadgeBackgroundColor({ color: cfg.color }); - void copilot?.onRelayStatus({ - ready: kind === "on", - label: COPILOT_RELAY_LABEL[kind] ?? COPILOT_RELAY_LABEL.off, - }); } async function getConfig() { - const config = await pairingConfigStore.read(); await tabAccessReady; - if (!config.relayUrl) { + const config = await pairingConfigStore.read(); + if (retiredCopilotCustodyBlocked || !config.relayUrl) { tabAccessPolicy.setEnabled(false); } if (config.pairingStatusHint) { @@ -175,11 +179,6 @@ async function removeTabFromOpenClawGroup(tabId) { } } -async function isTabAccessible(tabId) { - await tabAccessReady; - return (await tabAccessPolicy.inspectTab(tabId)).accessible; -} - function scheduleTabsSync() { if (tabsSyncTimer) { return; @@ -191,6 +190,9 @@ function scheduleTabsSync() { } async function syncTabsToRelay() { + if (retiredCopilotCustodyBlocked) { + return; + } if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) { return; } @@ -209,17 +211,10 @@ async function syncTabsToRelay() { // --------------------------------------------------------------------------- async function attachDebugger(tabId) { - await copilotCustodyReady; - await tabAccessReady; + await requireAutomationAllowed(); const accessEpoch = tabAccessPolicy.capture(tabId); const assertAccess = async () => { - if (copilotDeniedTabs.has(tabId)) { - throw new Error(`tab ${tabId} is blocked until its copilot run stops`); - } await tabAccessPolicy.requireTab(tabId, accessEpoch); - if (copilotDeniedTabs.has(tabId)) { - throw new Error(`tab ${tabId} is blocked until its copilot run stops`); - } }; await assertAccess(); // Coalesce concurrent attaches for one tab. Two relay attach commands (or an @@ -267,7 +262,7 @@ async function attachDebugger(tabId) { // The attachment is authorized only by the epoch proven across the whole // attach. Never replace it with a fresh post-await capture: that would let // a revocation during async unwind authorize later debugger events. - if (copilotDeniedTabs.has(tabId) || !tabAccessPolicy.epochIsCurrent(tabId, accessEpoch)) { + if (!tabAccessPolicy.epochIsCurrent(tabId, accessEpoch)) { await detachDebugger(tabId); throw new Error(`tab ${tabId} access was revoked`); } @@ -352,7 +347,6 @@ async function reconcileAccessMode(nextMode, { transitioning = false } = {}) { } } await syncTabsToRelay(); - await copilot?.onConsentChanged(); return mode; } @@ -366,7 +360,6 @@ async function pauseTab(tabId) { await Promise.allSettled([attachingTabs.get(tabId)]); await detachDebugger(tabId); await syncTabsToRelay(); - await copilot?.onConsentChanged(tabId, { revoked: true }); if (storageError) { throw storageError instanceof Error ? storageError @@ -374,46 +367,17 @@ async function pauseTab(tabId) { } } -async function allowTab(tabId) { - await tabAccessPolicy.allow(tabId); - await syncTabsToRelay(); - await copilot?.onConsentChanged(tabId); -} - -async function revokeCopilotDebugger(tabId) { - tabAccessPolicy.invalidateTab(tabId); - copilotDeniedTabs.add(tabId); - const previous = copilotRevocations.get(tabId) ?? Promise.resolve(); - const revocation = previous - .catch(() => undefined) - .then(async () => { - await Promise.allSettled([attachingTabs.get(tabId)]); - await detachDebugger(tabId); - }); - copilotRevocations.set(tabId, revocation); - try { - await revocation; - } finally { - if (copilotRevocations.get(tabId) === revocation) { - copilotRevocations.delete(tabId); - } - } -} - -async function restoreCopilotDebugger(tabId) { - const accessEpoch = tabAccessPolicy.capture(tabId); - await copilotRevocations.get(tabId); - if (tabAccessPolicy.epochIsCurrent(tabId, accessEpoch)) { - copilotDeniedTabs.delete(tabId); - } -} - // --------------------------------------------------------------------------- // Relay connection // --------------------------------------------------------------------------- function send(message) { - if (relayWs && relayWs.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs) { + if ( + !retiredCopilotCustodyBlocked && + relayWs && + relayWs.readyState === WebSocket.OPEN && + relayAuthenticatedSocket === relayWs + ) { relayWs.send(JSON.stringify(message)); } } @@ -473,6 +437,14 @@ async function sendHello() { } async function connectRelay(isConnectionAllowed = () => true) { + await tabAccessReady; + if (retiredCopilotCustodyBlocked) { + tabAccessPolicy.setEnabled(false); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + return; + } const connectionGeneration = relayConnectionGeneration; const connectionIsCurrent = () => !relayConnectionsSuspended && @@ -518,15 +490,10 @@ async function connectRelay(isConnectionAllowed = () => true) { await sendHello(); }, onApplicationMessage: (socket, msg) => { - if (msg?.type === "pageShareResult") { - pageShareRelay.settle(socket, msg); - return; - } void handleRelayCommand(msg); }, onAuthenticationFailure: (socket, error) => failRelayAuthentication(socket, error), onClose: (socket, authenticated) => { - pageShareRelay.rejectSocket(socket); if (relayWs !== socket) { return; } @@ -553,59 +520,6 @@ async function connectRelay(isConnectionAllowed = () => true) { // onclose follows onerror and drives the reconnect, so no error handler needed. } -async function sendPageShareRequest(payload) { - const socket = relayWs; - if (!socket || socket.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== socket) { - throw new Error("Relay not connected."); - } - await pageShareRelay.send(socket, payload); -} - -async function ensureRelayReady() { - const config = await getConfig(); - await reconcilePairingInvalidation(); - if (!config.relayUrl || !config.token) { - throw new Error("Pair the extension first."); - } - if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) { - await connectRelay(); - if ( - !(await waitForCondition( - () => relayWs?.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs, - RELAY_AUTH_TIMEOUT_MS, - )) - ) { - throw new Error("Relay not connected."); - } - } -} - -const pageShare = createPageShareController({ - ensureRelayReady, - sendPageShareRequest, - restoreBadge: () => setBadge(relayState), -}); - -copilot = createCopilotController({ - getConfig, - isTabAccessible, - grantTabAccess: async (tabId) => { - if (tabAccessPolicy.mode === ACCESS_MODE_ALL) { - await allowTab(tabId); - } else { - await addTabToOpenClawGroup(tabId); - scheduleTabsSync(); - } - }, - attachDebugger, - detachDebugger, - revokeDebugger: revokeCopilotDebugger, - restoreDebugger: restoreCopilotDebugger, - scheduleTabsSync, -}); -const copilotCustodyReady = copilot.initializeCustody(); -const copilotReady = copilot.initialize(); - function handleRelayOpeningDeadline() { // Unit-test module isolation can outlive the mocked Chrome global. The real // MV3 worker always has chrome; a detached test timer has no owner to mutate. @@ -650,10 +564,19 @@ function scheduleReconnect() { reconnectAttempt += 1; reconnectTimer = setTimeout(() => { reconnectTimer = null; - void connectRelay(); + void startAutomation(); }, delay); } +async function startAutomation() { + await tabAccessReady; + if (retiredCopilotCustodyBlocked) { + return; + } + await nativeBootstrap.attempt(); + await connectRelay(); +} + // --------------------------------------------------------------------------- // Popup messaging + lifecycle // --------------------------------------------------------------------------- @@ -665,6 +588,28 @@ const handlePopupMessage = createPopupMessageHandler({ getConfig, getRelayState: () => relayState, getRelayStatusHint: () => relayStatusHint, + getNativeBootstrapStatus: async () => { + await tabAccessReady; + if (!retiredCopilotCustodyBlocked) { + await nativeBootstrap.attempt(); + } + return await nativeBootstrap.status(); + }, + enableNativeBootstrap: async (enabled) => { + await requireAutomationAllowed(); + return enabled ? await nativeBootstrap.enable() : await nativeBootstrap.disableSynchronously(); + }, + onManualPairing: () => nativeBootstrap.enable({ attemptNow: false }), + onUnpairStart: () => nativeBootstrap.disableSynchronously(), + isRetiredCopilotCustodyBlocked: () => retiredCopilotCustodyBlocked, + requireAutomationAllowed, + discardRetiredCopilotCustody: async () => { + retiredCopilotCustodyBlocked = true; + tabAccessPolicy.setEnabled(false); + tabAccessPolicy.invalidateAll(); + await discardRetiredCopilotState(); + retiredCopilotCustodyBlocked = false; + }, resetRelayState: () => { relayStatusHint = ""; reconnectAttempt = 0; @@ -680,14 +625,16 @@ const handlePopupMessage = createPopupMessageHandler({ closeRelaySocket, connectRelay, setBadge, - getCopilot: () => copilot, attachingTabs, detachDebugger, removeTabFromOpenClawGroup, addTabToOpenClawGroup, scheduleTabsSync, pauseTab, - pageShare, +}); +nativeBootstrap = createNativeBootstrapController({ + getPairing: getConfig, + applyPairing: async (request) => await handlePopupMessage.applyPairing(request), }); chrome.runtime.onMessage.addListener((msg, _sender, reply) => handlePopupMessage(msg, reply)); @@ -696,9 +643,7 @@ registerTabAccessEvents({ policy: tabAccessPolicy, attachedTabs, attachedAccessEpochs, - copilotDeniedTabs, attachingTabs, - getCopilot: () => copilot, send, scheduleTabsSync, detachDebugger, @@ -711,17 +656,15 @@ registerTabAccessEvents({ chrome.alarms.create(RELAY_WATCHDOG_ALARM, { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === RELAY_WATCHDOG_ALARM) { - void connectRelay(); - void copilot.drainAborts(); - void copilot.drainArchives(); - void copilot.drainStaleScopes(); + void startAutomation(); } else if (alarm.name === RELAY_OPENING_DEADLINE_ALARM) { handleRelayOpeningDeadline(); } }); -chrome.runtime.onStartup.addListener(() => void connectRelay()); -chrome.runtime.onInstalled.addListener(() => { - void pageShare.installContextMenu(); - void connectRelay(); +chrome.runtime.onStartup.addListener(() => { + void startAutomation(); }); -void [connectRelay(), copilotReady]; +chrome.runtime.onInstalled.addListener(() => { + void startAutomation(); +}); +void startAutomation(); diff --git a/extensions/browser/chrome-extension/background.test-harness.ts b/extensions/browser/chrome-extension/background.test-harness.ts index 2a290ec918e1..d02e12bc84b0 100644 --- a/extensions/browser/chrome-extension/background.test-harness.ts +++ b/extensions/browser/chrome-extension/background.test-harness.ts @@ -6,27 +6,41 @@ import { configureFakeWebSockets, FakeWebSocket, } from "./background.test-support.js"; -import type { PageCaptureResult, RuntimeMessageListener } from "./background.test-support.js"; +import type { RuntimeMessageListener } from "./background.test-support.js"; import { computeRelayAuthProof } from "./modules/relay-auth-v2-crypto.js"; +import { relayTestKey } from "./relay-key.test-support.js"; -export const RELAY_SECRET = "a".repeat(64); -export const REPLACEMENT_RELAY_SECRET = "b".repeat(64); +export const TEST_RELAY_KEY = relayTestKey(1); +export const REPLACEMENT_TEST_RELAY_KEY = relayTestKey(2); const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; +const RETIRED_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; + +export type RetiredStorageFailureStage = + | "marker_set" + | "session_remove" + | "retired_local_remove" + | "marker_remove"; export async function loadBackground({ deferTabAccessInitialization = false, + deferRetiredStatePreparation = false, deferSocketClose = false, - onConsentChanged, + inheritedDebuggerTabIds = [], + nativeMessage, rejectStorageRemove = false, + retiredStorageFailureStage, relayNegotiatedProtocol, sessionConfig, storedConfig, initialTabs = [], }: { deferTabAccessInitialization?: boolean; + deferRetiredStatePreparation?: boolean; deferSocketClose?: boolean; - onConsentChanged?: () => Promise; + inheritedDebuggerTabIds?: number[]; + nativeMessage?: (request: unknown) => Promise; rejectStorageRemove?: boolean; + retiredStorageFailureStage?: RetiredStorageFailureStage; relayNegotiatedProtocol?: string; sessionConfig?: Record; storedConfig?: Record; @@ -34,7 +48,9 @@ export async function loadBackground({ } = {}) { const sockets: FakeWebSocket[] = []; let alarmListener: ((alarm: { name: string }) => void) | undefined; + let installedListener: (() => void) | undefined; let messageListener: RuntimeMessageListener | undefined; + let startupListener: (() => void) | undefined; let debuggerDetachListener: | ((source: { tabId?: number }, reason: "target_closed" | "canceled_by_user") => void) | undefined; @@ -50,17 +66,24 @@ export async function loadBackground({ let nextStorageRemove: Promise | null = null; let nextStorageSet: Promise | null = null; let nextSessionStorageSet: Promise | null = null; + let currentRetiredStorageFailureStage = retiredStorageFailureStage; let releaseTabAccessInitialization = () => {}; + let releaseRetiredStatePreparation = () => {}; const tabAccessInitialization = deferTabAccessInitialization ? new Promise((resolve) => { releaseTabAccessInitialization = resolve; }) : Promise.resolve(); + const retiredStatePreparation = deferRetiredStatePreparation + ? new Promise((resolve) => { + releaseRetiredStatePreparation = resolve; + }) + : Promise.resolve(); const sharedTabIds = new Set([1]); const storageValues: Record = { ...(storedConfig ?? { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "selected", groupColor: "orange", @@ -80,7 +103,11 @@ export async function loadBackground({ const clearAlarm = vi.fn(async () => true); const setBadgeText = vi.fn(async () => undefined); const setBadgeBackgroundColor = vi.fn(async () => undefined); - const storageGet = vi.fn(async (keys: string[]) => { + const storageGet = vi.fn(async (requestedKeys: string[] | string) => { + const keys = Array.isArray(requestedKeys) ? requestedKeys : [requestedKeys]; + if (keys.includes("copilotSessionRegistryV1")) { + await retiredStatePreparation; + } const pending = nextStorageGet; nextStorageGet = null; await pending; @@ -94,15 +121,32 @@ export async function loadBackground({ const pending = nextStorageSet; nextStorageSet = null; await pending; + if ( + currentRetiredStorageFailureStage === "marker_set" && + values[RETIRED_CUSTODY_BLOCKED_KEY] === true + ) { + throw new Error("Could not persist retired recovery block."); + } Object.assign(storageValues, values); }); const storageRemove = vi.fn(async (keys: string[]) => { const pending = nextStorageRemove; nextStorageRemove = null; await pending; - if (rejectStorageRemove) { + if ( + rejectStorageRemove && + !keys.some((key) => key.startsWith("copilot") || key === RETIRED_CUSTODY_BLOCKED_KEY) + ) { throw new Error("Could not clear invalid browser pairing."); } + const retiredStage = keys.includes(RETIRED_CUSTODY_BLOCKED_KEY) + ? "marker_remove" + : keys.some((key) => key.startsWith("copilot")) + ? "retired_local_remove" + : null; + if (retiredStage && currentRetiredStorageFailureStage === retiredStage) { + throw new Error("Could not discard retired recovery state."); + } for (const key of keys) { delete storageValues[key]; } @@ -113,14 +157,15 @@ export async function loadBackground({ await pending; Object.assign(sessionStorageValues, values); }); + const sendNativeMessage = vi.fn(async (_host: string, request: unknown) => { + if (nativeMessage) { + return await nativeMessage(request); + } + throw new Error("Specified native messaging host not found."); + }); + let runtimeLastError: { message?: string } | undefined; const chromeMock = { action: { setBadgeText, setBadgeBackgroundColor }, - commands: { onCommand: { addListener } }, - contextMenus: { - create: vi.fn(), - removeAll: vi.fn(async () => undefined), - onClicked: { addListener }, - }, alarms: { create: createAlarm, clear: clearAlarm, @@ -159,20 +204,76 @@ export async function loadBackground({ attach: vi.fn(async () => undefined), detach: vi.fn(async (_source: { tabId: number }) => undefined), getTargets: vi.fn( - async (): Promise> => [], + async (): Promise> => + inheritedDebuggerTabIds.map((tabId) => ({ id: `tab-${tabId}`, tabId, attached: true })), ), sendCommand: vi.fn(async () => ({})), }, runtime: { + get lastError() { + return runtimeLastError; + }, + connectNative: vi.fn((host: string) => { + let disconnected = false; + let nativeMessageListener: ((response: unknown) => void) | undefined; + let disconnectListener: (() => void) | undefined; + const disconnect = () => { + if (disconnected) { + return; + } + disconnected = true; + disconnectListener?.(); + }; + return { + disconnect, + onDisconnect: { + addListener: (listener: () => void) => { + disconnectListener = listener; + }, + }, + onMessage: { + addListener: (listener: (response: unknown) => void) => { + nativeMessageListener = listener; + }, + }, + postMessage: (request: unknown) => { + void sendNativeMessage(host, request).then( + (response) => { + if (!disconnected) { + nativeMessageListener?.(response); + } + }, + (error: unknown) => { + if (!disconnected) { + runtimeLastError = { + message: error instanceof Error ? error.message : String(error), + }; + disconnect(); + runtimeLastError = undefined; + } + }, + ); + }, + }; + }), getManifest: vi.fn(() => ({ version: "1.0.0" })), + openOptionsPage: vi.fn(async () => undefined), onConnect: { addListener }, onMessage: { addListener: vi.fn((listener: RuntimeMessageListener) => { messageListener = listener; }), }, - onStartup: { addListener }, - onInstalled: { addListener }, + onStartup: { + addListener: vi.fn((listener: () => void) => { + startupListener = listener; + }), + }, + onInstalled: { + addListener: vi.fn((listener: () => void) => { + installedListener = listener; + }), + }, }, storage: { local: { get: storageGet, set: storageSet, remove: storageRemove }, @@ -187,15 +288,18 @@ export async function loadBackground({ }), set: sessionStorageSet, remove: vi.fn(async (keys: string[]) => { + if ( + currentRetiredStorageFailureStage === "session_remove" && + keys.some((key) => key.startsWith("copilot")) + ) { + throw new Error("Could not discard retired recovery state."); + } for (const key of keys) { delete sessionStorageValues[key]; } }), }, }, - scripting: { - executeScript: vi.fn(async (): Promise> => []), - }, tabGroups: { query: vi.fn(async (): Promise> => []), get: vi.fn(async (groupId: number) => ({ @@ -276,45 +380,46 @@ export async function loadBackground({ vi.stubGlobal("navigator", { userAgent: "Chromium/125.0.0.0" }); vi.stubGlobal("WebSocket", FakeWebSocket); - if (onConsentChanged) { - const copilotModule = await import("./modules/copilot-background.js"); - const createCopilotController = copilotModule.createCopilotController; - vi.spyOn(copilotModule, "createCopilotController").mockImplementation((options) => ({ - ...createCopilotController(options), - onConsentChanged, - })); - } - const backgroundModulePath = "./background.js"; await import(backgroundModulePath); - await vi.waitFor(() => { - const pairingReads = storageGet.mock.calls.filter(([keys]) => - PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), - ); - expect(pairingReads.length).toBeGreaterThanOrEqual(2); - }); - if (!deferTabAccessInitialization) { - for (let attempt = 0; attempt < 20 && sockets.length === 0; attempt += 1) { + if (!deferRetiredStatePreparation) { + await vi.waitFor(() => { + const pairingReads = storageGet.mock.calls.filter(([keys]) => + PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), + ); + expect(pairingReads.length).toBeGreaterThanOrEqual(1); + }); + } + if (!deferTabAccessInitialization && !deferRetiredStatePreparation) { + await vi.waitFor(() => { const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl"), ); - if (pairingWasCleared) { - break; - } - await Promise.resolve(); - } - const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl")); - expect(sockets.length > 0 || pairingWasCleared).toBe(true); + expect( + sockets.length > 0 || + pairingWasCleared || + sendNativeMessage.mock.calls.length > 0 || + Object.hasOwn(storageValues, "copilotSessionRegistryV1") || + Object.hasOwn(storageValues, RETIRED_CUSTODY_BLOCKED_KEY) || + storageValues.nativeBootstrapDisabled === true, + ).toBe(true); + }); } - if (!alarmListener || !messageListener || !tabsUpdatedListener || !tabsReplacedListener) { + if ( + !alarmListener || + !installedListener || + !messageListener || + !startupListener || + !tabsUpdatedListener || + !tabsReplacedListener + ) { throw new Error("expected background worker lifecycle listeners"); } return { alarmListener, clearAlarm, createAlarm, - executeScript: chromeMock.scripting.executeScript, debuggerAttach: chromeMock.debugger.attach, debuggerDetach: chromeMock.debugger.detach, debuggerDetachListener, @@ -352,8 +457,11 @@ export async function loadBackground({ get gatewaySockets() { return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay.v2")); }, + installedListener, messageListener, + sendNativeMessage, releaseTabAccessInitialization, + releaseRetiredStatePreparation, get relaySockets() { return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay.v2")); }, @@ -422,6 +530,10 @@ export async function loadBackground({ storageRemove, storageSet, storageValues, + setRetiredStorageFailureStage: (stage?: RetiredStorageFailureStage) => { + currentRetiredStorageFailureStage = stage; + }, + startupListener, sessionStorageValues, sessionStorageSet, shareTab: (tabId: number) => sharedTabIds.add(tabId), diff --git a/extensions/browser/chrome-extension/background.test-support.ts b/extensions/browser/chrome-extension/background.test-support.ts index 05fabacd078c..a6543f9dbb5f 100644 --- a/extensions/browser/chrome-extension/background.test-support.ts +++ b/extensions/browser/chrome-extension/background.test-support.ts @@ -20,13 +20,6 @@ export type RuntimeMessageListener = ( sendResponse: (response: unknown) => void, ) => boolean; -export type PageCaptureResult = { - content: string; - selection: string; - title: string; - url: string; -}; - let configuredSockets: FakeWebSocket[] = []; let configuredDeferredClose = false; let configuredProtocol: string | undefined; diff --git a/extensions/browser/chrome-extension/background.test.ts b/extensions/browser/chrome-extension/background.test.ts index 2ddf0d25be49..da5f2b429600 100644 --- a/extensions/browser/chrome-extension/background.test.ts +++ b/extensions/browser/chrome-extension/background.test.ts @@ -1,866 +1,400 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadBackground, - RELAY_SECRET, - REPLACEMENT_RELAY_SECRET, + TEST_RELAY_KEY, + REPLACEMENT_TEST_RELAY_KEY, sendRuntimeMessage, } from "./background.test-harness.js"; -import { AUTH_INSTANCE_ID, AUTH_SERVER_NONCE, AUTH_SESSION_ID } from "./background.test-support.js"; +import type { RetiredStorageFailureStage } from "./background.test-harness.js"; -const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog"; -const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline"; -const START_TIME_MS = Date.parse("2026-07-16T08:00:00.000Z"); +function nativeSuccess(request: unknown, secret = TEST_RELAY_KEY) { + const nonce = (request as { nonce?: unknown }).nonce; + return { + v: 1, + ok: true, + nonce, + pairingString: `ws://127.0.0.1:18797/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#${secret}`, + }; +} -describe("persisted relay pairing validation", () => { +describe("native extension bootstrap", () => { beforeEach(() => { vi.resetModules(); - vi.useFakeTimers(); }); afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("opens the canonical persisted pairing on startup", async () => { - const harness = await loadBackground({ - storedConfig: { - relayUrl: "wss://gateway.example.com/browser/extension", - token: RELAY_SECRET, - authVersion: 2, - gatewayUrl: "wss://gateway.example.com", - groupColor: "blue", - }, - }); + it("keeps an existing manual pairing without contacting the native host", async () => { + const harness = await loadBackground(); - await vi.waitFor(() => { - expect(harness.relaySockets).toHaveLength(1); - expect(harness.gatewaySockets).toHaveLength(1); - }); - expect(harness.relaySockets[0]).toMatchObject({ - url: "wss://gateway.example.com/browser/extension", - protocols: ["openclaw-extension-relay.v2"], - }); - expect(harness.storageRemove).not.toHaveBeenCalled(); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + expect(harness.relaySockets).toHaveLength(1); }); - it("migrates a canonical existing pairing to authVersion 2 before connecting", async () => { + it("records host-not-found as retryable without claiming same-process recovery", async () => { const harness = await loadBackground({ - storedConfig: { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - gatewayUrl: "", - groupColor: "orange", + storedConfig: {}, + nativeMessage: async () => { + throw new Error("Specified native messaging host not found."); }, }); - await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1)); - expect(harness.storageSet).toHaveBeenCalledWith({ authVersion: 2, accessMode: "selected" }); - expect(harness.storageValues.authVersion).toBe(2); - }); - - it.each([ - ["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }], - [ - "an unsafe remote relay URL", - { relayUrl: "ws://gateway.example.com/extension", token: RELAY_SECRET }, - ], - [ - "URL credentials", - { relayUrl: "wss://user:pass@gateway.example.com/extension", token: RELAY_SECRET }, - ], - [ - "an unsafe remote Gateway URL", - { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - gatewayUrl: "ws://gateway.example.com", - }, - ], - [ - "Gateway URL credentials", - { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - gatewayUrl: "wss://user:pass@gateway.example.com", - }, - ], - [ - "a Gateway URL query", - { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - gatewayUrl: "wss://gateway.example.com?token=nope", - }, - ], - [ - "a Gateway URL fragment", - { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - gatewayUrl: "wss://gateway.example.com#fragment", - }, - ], - ["a malformed URL", { relayUrl: "not a URL", token: RELAY_SECRET }], - [ - "an unknown query", - { relayUrl: "ws://127.0.0.1:18797/extension?unknown=1", token: RELAY_SECRET }, - ], - ["partial state", { relayUrl: "ws://127.0.0.1:18797/extension", groupColor: "orange" }], - [ - "a proxy-prefixed direct pairing", - { relayUrl: "wss://gateway.example.com/proxy/browser/extension", token: RELAY_SECRET }, - ], - [ - "mismatched direct state", - { - relayUrl: "wss://gateway.example.com/browser/extension", - token: RELAY_SECRET, - gatewayUrl: "wss://other.example.com", - }, - ], - ])("clears %s before startup can open a socket", async (_label, storedConfig) => { - const harness = await loadBackground({ storedConfig }); - - expect(harness.relaySockets).toHaveLength(0); - expect(harness.gatewaySockets).toHaveLength(0); - expect(harness.storageRemove).toHaveBeenCalledWith([ - "relayUrl", - "gatewayUrl", - "token", - "authVersion", - ]); - const response = vi.fn(); - harness.messageListener({ type: "getStatus" }, {}, response); await vi.waitFor(() => { - expect(response).toHaveBeenCalledWith( - expect.objectContaining({ - paired: false, - state: "off", - accessMode: "selected", - accessibleTabCount: 0, - relayUrl: "", - }), - ); - }); - }); - - it("stays unpaired when clearing invalid persisted state fails", async () => { - const harness = await loadBackground({ - rejectStorageRemove: true, - storedConfig: { relayUrl: "ws://gateway.example.com/extension", token: RELAY_SECRET }, - }); - - const response = vi.fn(); - harness.messageListener({ type: "getStatus" }, {}, response); - - await vi.waitFor(() => { - expect(response).toHaveBeenCalledWith({ - paired: false, - state: "off", - accessMode: "selected", - accessibleTabCount: 0, - relayUrl: "", + expect(harness.storageValues).toMatchObject({ + nativeBootstrapState: "retrying", + nativeBootstrapFailureCode: "host_not_found", }); }); - expect(harness.relaySockets).toHaveLength(0); - expect(harness.gatewaySockets).toHaveLength(0); - expect(harness.storageRemove).toHaveBeenCalled(); - expect(harness.storageValues).toMatchObject({ token: RELAY_SECRET }); + + harness.alarmListener({ name: "openclaw-relay-watchdog" }); + + await vi.waitFor(() => expect(harness.sendNativeMessage).toHaveBeenCalledTimes(2)); + expect(harness.storageValues).not.toHaveProperty("relayUrl"); }); - it("revalidates persisted state before a reconnect", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - if (!socket) { - throw new Error("expected initial relay socket"); - } - harness.storageValues.token = "invalid-after-startup"; - - socket.close(); - await vi.advanceTimersByTimeAsync(1_000); - - expect(harness.sockets).toHaveLength(1); - expect(harness.storageRemove).toHaveBeenCalledWith([ - "relayUrl", - "gatewayUrl", - "token", - "authVersion", - ]); - expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" }); - }); - - it("disconnects both live consumers when the watchdog observes invalid state", async () => { + it("coalesces startup, watchdog, and popup attempts", async () => { + let resolveNative = (_value: unknown) => {}; + const pending = new Promise((resolve) => { + resolveNative = resolve; + }); const harness = await loadBackground({ - storedConfig: { - relayUrl: "wss://gateway.example.com/browser/extension", - token: RELAY_SECRET, - gatewayUrl: "wss://gateway.example.com", + storedConfig: {}, + nativeMessage: async (request) => { + const response = await pending; + return response ?? nativeSuccess(request); }, }); - await vi.waitFor(() => { - expect(harness.relaySockets).toHaveLength(1); - expect(harness.gatewaySockets).toHaveLength(1); - }); - harness.storageValues.token = "invalid-after-startup"; + harness.alarmListener({ name: "openclaw-relay-watchdog" }); + const status = sendRuntimeMessage(harness, { type: "getStatus" }); - harness.alarmListener({ name: RELAY_WATCHDOG_ALARM }); - - await vi.waitFor(() => { - expect(harness.relaySockets[0]?.close).toHaveBeenCalled(); - expect(harness.gatewaySockets[0]?.close).toHaveBeenCalled(); - expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" }); - }); - expect(harness.sockets).toHaveLength(2); + expect(harness.sendNativeMessage).toHaveBeenCalledOnce(); + const request = harness.sendNativeMessage.mock.calls[0]?.[1]; + resolveNative(nativeSuccess(request)); + await status; + expect(harness.sendNativeMessage).toHaveBeenCalledOnce(); }); - it("does not let stale invalid cleanup erase a concurrently saved pairing", async () => { - const harness = await loadBackground(); - harness.storageValues.token = "invalid-after-startup"; - const releaseRemove = harness.deferNextStorageRemove(); - const statusResponse = vi.fn(); - harness.messageListener({ type: "getStatus" }, {}, statusResponse); - await vi.waitFor(() => expect(harness.storageRemove).toHaveBeenCalled()); - const pairResponse = vi.fn(); - harness.messageListener( - { - type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, + it("does not overwrite a manual pairing that wins a native response race", async () => { + let resolveNative = (_value: unknown) => {}; + let request: unknown; + const harness = await loadBackground({ + storedConfig: {}, + nativeMessage: async (value) => { + request = value; + return await new Promise((resolve) => { + resolveNative = resolve; + }); }, - {}, - pairResponse, - ); + }); - releaseRemove(); + await expect( + sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, + accessMode: "selected", + }), + ).resolves.toEqual({ ok: true }); + resolveNative(nativeSuccess(request)); - await vi.waitFor(() => expect(pairResponse).toHaveBeenCalledWith({ ok: true })); + await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1)); expect(harness.storageValues).toMatchObject({ relayUrl: "ws://127.0.0.1:18798/extension", - token: REPLACEMENT_RELAY_SECRET, - gatewayUrl: "", + token: REPLACEMENT_TEST_RELAY_KEY, + accessMode: "selected", }); - const replacement = harness.relaySockets.find( - (socket) => socket.url === "ws://127.0.0.1:18798/extension", - ); - expect(replacement).toBeDefined(); - expect(replacement?.close).not.toHaveBeenCalled(); }); - it("unpair detaches every debugger session and clears session denies", async () => { + it("unpair disables bootstrap before a late native response can re-pair", async () => { + let resolveNative = (_value: unknown) => {}; + let request: unknown; const harness = await loadBackground({ + storedConfig: {}, + nativeMessage: async (value) => { + request = value; + return await new Promise((resolve) => { + resolveNative = resolve; + }); + }, + }); + + await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toEqual({ ok: true }); + expect(harness.storageValues.nativeBootstrapDisabled).toBe(true); + resolveNative(nativeSuccess(request)); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.storageValues).not.toHaveProperty("relayUrl"); + expect(harness.relaySockets).toHaveLength(0); + }); + + it("preserves opt-out across restart and manual pairing clears it", async () => { + const harness = await loadBackground({ + storedConfig: { nativeBootstrapDisabled: true, nativeBootstrapState: "disabled" }, + }); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + + await expect( + sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, + }), + ).resolves.toEqual({ ok: true }); + expect(harness.storageValues).not.toHaveProperty("nativeBootstrapDisabled"); + }); + + it("fails closed on a malformed or nonce-mismatched response", async () => { + const harness = await loadBackground({ + storedConfig: {}, + nativeMessage: async () => ({ + v: 1, + ok: true, + nonce: "wrong", + pairingString: `ws://127.0.0.1:18797/extension#${TEST_RELAY_KEY}`, + }), + }); + await vi.waitFor(() => { + expect(harness.storageValues).toMatchObject({ + nativeBootstrapState: "manual_required", + nativeBootstrapFailureCode: "malformed_response", + }); + }); + expect(harness.storageValues).not.toHaveProperty("relayUrl"); + }); + + it("blocks every startup path while retired copilot custody is unresolved", async () => { + const harness = await loadBackground({ + deferRetiredStatePreparation: true, + inheritedDebuggerTabIds: [17], storedConfig: { relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, + token: TEST_RELAY_KEY, authVersion: 2, accessMode: "all", + copilotSessionRegistryV1: { + sessions: { 17: { creationPending: true } }, + pendingArchives: [], + }, }, - sessionConfig: { deniedTabIdsV1: [122] }, - initialTabs: [ - { id: 121, url: "https://example.com/attached", groupId: -1 }, - { id: 122, url: "https://example.com/paused", groupId: -1 }, - ], + }); + + harness.alarmListener({ name: "openclaw-relay-watchdog" }); + harness.startupListener(); + harness.installedListener(); + await Promise.resolve(); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + expect(harness.relaySockets).toHaveLength(0); + expect(harness.debuggerAttach).not.toHaveBeenCalled(); + + harness.releaseRetiredStatePreparation(); + await vi.waitFor(() => expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 17 })); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + expect(harness.relaySockets).toHaveLength(0); + + const status = await sendRuntimeMessage(harness, { type: "getStatus" }); + expect(status).toMatchObject({ + paired: true, + retiredCopilotCustodyBlocked: true, + accessibleTabCount: 0, + }); + expect(JSON.stringify(status)).not.toMatch(/creationPending|pendingArchives|sessionKey/u); + await expect( + sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, + }), + ).resolves.toMatchObject({ ok: false }); + await expect( + sendRuntimeMessage(harness, { type: "setNativeBootstrapEnabled", enabled: true }), + ).resolves.toMatchObject({ ok: false }); + await expect( + sendRuntimeMessage(harness, { type: "setAccessMode", accessMode: "selected" }), + ).resolves.toMatchObject({ ok: false }); + await expect( + sendRuntimeMessage(harness, { + type: "toggleTabAccess", + tabId: 17, + accessMode: "all", + grant: true, + }), + ).resolves.toMatchObject({ ok: false }); + expect(harness.storageValues).toMatchObject({ + relayUrl: "ws://127.0.0.1:18797/extension", + accessMode: "all", + copilotSessionRegistryV1: expect.any(Object), + }); + }); + + it("uses explicit Disconnect to discard custody before local setup can reconnect", async () => { + const harness = await loadBackground({ + nativeMessage: async (request) => nativeSuccess(request), + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: TEST_RELAY_KEY, + authVersion: 2, + accessMode: "all", + copilotSessionRegistryV1: { + sessions: { 17: { creationPending: true } }, + pendingArchives: [], + }, + copilotDeviceIdentitiesV1: { redacted: true }, + copilotDeviceTokensV1: { redacted: true }, + }, + sessionConfig: { + copilotBrowserInstanceV1: "redacted", + copilotPanelBindingsV1: { 17: "redacted" }, + }, + }); + + await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toEqual({ ok: true }); + expect(harness.storageValues).not.toHaveProperty("relayUrl"); + expect(harness.storageValues).not.toHaveProperty("copilotSessionRegistryV1"); + expect(harness.sessionStorageValues).not.toHaveProperty("copilotBrowserInstanceV1"); + expect(harness.storageValues.nativeBootstrapDisabled).toBe(true); + + await expect( + sendRuntimeMessage(harness, { type: "setNativeBootstrapEnabled", enabled: true }), + ).resolves.toMatchObject({ ok: true }); + await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1)); + expect(harness.sendNativeMessage).toHaveBeenCalledOnce(); + }); + + it.each([ + "marker_set", + "session_remove", + "retired_local_remove", + "marker_remove", + ])( + "keeps custody blocked when Disconnect fails at %s and permits an explicit retry", + async (stage) => { + const harness = await loadBackground({ + inheritedDebuggerTabIds: [17], + retiredStorageFailureStage: stage, + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: TEST_RELAY_KEY, + authVersion: 2, + accessMode: "all", + copilotSessionRegistryV1: { + sessions: { 17: { creationPending: true } }, + pendingArchives: [], + }, + }, + sessionConfig: { + copilotBrowserInstanceV1: "redacted", + copilotPanelBindingsV1: { 17: "redacted" }, + }, + }); + + await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toMatchObject({ + ok: false, + }); + await expect(sendRuntimeMessage(harness, { type: "getStatus" })).resolves.toMatchObject({ + retiredCopilotCustodyBlocked: true, + }); + expect(harness.relaySockets).toHaveLength(0); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + expect(harness.debuggerAttach).not.toHaveBeenCalled(); + if (stage === "marker_set") { + expect(harness.storageValues).toHaveProperty("copilotSessionRegistryV1"); + expect(harness.storageValues).not.toHaveProperty("retiredCopilotCustodyBlockedV1"); + } else { + expect(harness.storageValues.retiredCopilotCustodyBlockedV1).toBe(true); + } + if (stage === "session_remove" || stage === "retired_local_remove") { + expect(harness.storageValues).toHaveProperty("copilotSessionRegistryV1"); + } + if (stage === "marker_remove") { + expect(harness.storageValues).not.toHaveProperty("copilotSessionRegistryV1"); + } + + harness.setRetiredStorageFailureStage(undefined); + await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toEqual({ ok: true }); + expect(harness.storageValues).not.toHaveProperty("retiredCopilotCustodyBlockedV1"); + expect(harness.storageValues).not.toHaveProperty("copilotSessionRegistryV1"); + expect(harness.sessionStorageValues).not.toHaveProperty("copilotBrowserInstanceV1"); + expect(harness.storageValues.nativeBootstrapDisabled).toBe(true); + expect(harness.relaySockets).toHaveLength(0); + }, + ); + + it("keeps a persisted custody marker inert across worker startup without a registry", async () => { + const harness = await loadBackground({ + inheritedDebuggerTabIds: [18], + nativeMessage: async (request) => nativeSuccess(request), + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: TEST_RELAY_KEY, + authVersion: 2, + accessMode: "all", + retiredCopilotCustodyBlockedV1: true, + }, + }); + + await vi.waitFor(() => expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 18 })); + expect(harness.relaySockets).toHaveLength(0); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + expect(harness.debuggerAttach).not.toHaveBeenCalled(); + await expect(sendRuntimeMessage(harness, { type: "getStatus" })).resolves.toMatchObject({ + retiredCopilotCustodyBlocked: true, + accessibleTabCount: 0, + }); + }); +}); + +describe("relay pairing and authentication", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("clears malformed persisted pairing before opening a relay", async () => { + const harness = await loadBackground({ + storedConfig: { relayUrl: "ws://gateway.example/extension", token: TEST_RELAY_KEY }, + }); + + expect(harness.relaySockets).toHaveLength(0); + expect(harness.storageValues).not.toHaveProperty("relayUrl"); + }); + + it("offers only the non-secret v2 relay subprotocol", async () => { + const harness = await loadBackground(); + expect(harness.relaySockets[0]?.protocols).toEqual(["openclaw-extension-relay.v2"]); + expect(JSON.stringify(harness.relaySockets[0]?.protocols)).not.toContain(TEST_RELAY_KEY); + }); + + it("revokes synchronously while an older manual pair is stalled", async () => { + const harness = await loadBackground({ + initialTabs: [{ id: 131, url: "https://example.com/paired", groupId: 7 }], }); const socket = harness.relaySockets[0]; if (!socket) { throw new Error("expected relay socket"); } await harness.authenticate(socket); - socket.receive({ type: "attach", seq: 35, tabId: 121 }); - await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); - - await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toEqual({ ok: true }); - - expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 121 }); - expect(harness.sessionStorageValues).not.toHaveProperty("deniedTabIdsV1"); - expect(harness.storageValues).not.toHaveProperty("accessMode"); - }); - - it("revokes immediately and supersedes an older pair stalled in storage", async () => { - const harness = await loadBackground({ - storedConfig: { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - authVersion: 2, - accessMode: "all", - }, - initialTabs: [{ id: 131, url: "https://example.com/paired", groupId: -1 }], - }); - const socket = harness.relaySockets[0]; - if (!socket || !harness.debuggerEventListener) { - throw new Error("expected relay and debugger event listener"); - } - await harness.authenticate(socket); - socket.receive({ type: "attach", seq: 36, tabId: 131 }); - await vi.waitFor(() => { - const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); - expect(frames).toContainEqual({ - type: "result", - seq: 36, - result: { targetId: "tab-131" }, - }); - }); - harness.storageSet.mockClear(); - const releasePairSave = harness.deferNextStorageSet(); + const releaseSave = harness.deferNextStorageSet(); const pairing = sendRuntimeMessage(harness, { type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_TEST_RELAY_KEY}`, accessMode: "all", }); - await vi.waitFor(() => { + await vi.waitFor(() => expect(harness.storageSet).toHaveBeenCalledWith( - expect.objectContaining({ - relayUrl: "ws://127.0.0.1:18798/extension", - token: REPLACEMENT_RELAY_SECRET, - }), - ); - }); + expect.objectContaining({ relayUrl: "ws://127.0.0.1:18798/extension" }), + ), + ); const unpairing = sendRuntimeMessage(harness, { type: "unpair" }); expect(socket.close).toHaveBeenCalledOnce(); - await expect( - sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 131 }), - ).resolves.toEqual({ - accessMode: "all", - accessible: false, - eligible: false, - denied: false, - }); - harness.debuggerEventListener({ tabId: 131 }, "Runtime.consoleAPICalled", { value: 1 }); - expect( - socket.send.mock.calls - .map(([raw]) => JSON.parse(raw)) - .some((frame) => frame.type === "cdpEvent" && frame.method === "Runtime.consoleAPICalled"), - ).toBe(false); + await vi.waitFor(() => expect(harness.storageValues.nativeBootstrapDisabled).toBe(true)); + releaseSave(); - releasePairSave(); - await expect(pairing).resolves.toEqual({ - ok: false, - error: "Pairing was superseded by a newer request.", - }); + await expect(pairing).resolves.toMatchObject({ ok: false }); await expect(unpairing).resolves.toEqual({ ok: true }); - - expect(harness.relaySockets).toHaveLength(1); - expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 131 }); expect(harness.storageValues).not.toHaveProperty("relayUrl"); - expect(harness.storageValues).not.toHaveProperty("token"); - expect(harness.storageValues).not.toHaveProperty("accessMode"); - }); - - it("lets the newest pair supersede an older pair stalled in storage", async () => { - const harness = await loadBackground(); - const original = harness.relaySockets[0]; - if (!original) { - throw new Error("expected original relay socket"); - } - await harness.authenticate(original); - - harness.storageSet.mockClear(); - const releaseFirstSave = harness.deferNextStorageSet(); - const firstPair = sendRuntimeMessage(harness, { - type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, - accessMode: "all", - }); - await vi.waitFor(() => { - expect(harness.storageSet).toHaveBeenCalledWith( - expect.objectContaining({ relayUrl: "ws://127.0.0.1:18798/extension" }), - ); - }); - - const newestSecret = "c".repeat(64); - const secondPair = sendRuntimeMessage(harness, { - type: "pair", - pairingString: `ws://127.0.0.1:18799/extension#${newestSecret}`, - accessMode: "selected", - }); - releaseFirstSave(); - - await expect(firstPair).resolves.toEqual({ - ok: false, - error: "Pairing was superseded by a newer request.", - }); - await expect(secondPair).resolves.toEqual({ ok: true }); - expect(harness.storageValues).toMatchObject({ - relayUrl: "ws://127.0.0.1:18799/extension", - token: newestSecret, - accessMode: "selected", - }); - expect( - harness.relaySockets.some((socket) => socket.url === "ws://127.0.0.1:18798/extension"), - ).toBe(false); - expect( - harness.relaySockets.filter((socket) => socket.url === "ws://127.0.0.1:18799/extension"), - ).toHaveLength(1); - }); -}); - -describe("relay authentication v2 transport", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("offers only the non-secret v2 protocol", async () => { - const harness = await loadBackground(); - const socket = harness.relaySockets[0]; - expect(socket?.protocols).toEqual(["openclaw-extension-relay.v2"]); - expect(JSON.stringify(socket?.protocols)).not.toContain(RELAY_SECRET); - }); - - it("rejects a mismatched negotiated protocol before sending any frame", async () => { - const harness = await loadBackground({ relayNegotiatedProtocol: "" }); - const socket = harness.relaySockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - socket.open(); - await vi.waitFor(() => expect(socket.close).toHaveBeenCalled()); - expect(socket.send).not.toHaveBeenCalled(); - }); - - it("sends no client proof or application hello after a bad server proof", async () => { - const harness = await loadBackground(); - const socket = harness.relaySockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - socket.open(); - await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); - const helloRaw = socket.send.mock.calls[0]?.[0]; - const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string }; - const issuedAtMs = Date.now(); - socket.receive({ - type: "auth.challenge", - v: 2, - keyId: hello.keyId, - instanceId: AUTH_INSTANCE_ID, - sessionId: AUTH_SESSION_ID, - clientNonce: hello.clientNonce, - serverNonce: AUTH_SERVER_NONCE, - issuedAtMs, - expiresAtMs: issuedAtMs + 10_000, - role: "extension", - transport: "websocket", - method: "GET", - resource: "/extension", - flow: "extension", - serverProof: "A".repeat(43), - }); - await vi.waitFor(() => expect(socket.close).toHaveBeenCalled()); - const types = socket.send.mock.calls.map(([raw]) => JSON.parse(raw).type); - expect(types).toEqual(["auth.hello"]); - expect(harness.setBadgeText).not.toHaveBeenLastCalledWith({ text: "ON" }); - }); - - it("rejects application commands before authentication", async () => { - const harness = await loadBackground(); - const socket = harness.relaySockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - socket.open(); - await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); - socket.receive({ type: "attach", seq: 1, tabId: 1 }); - await vi.waitFor(() => expect(socket.close).toHaveBeenCalled()); - expect(harness.debuggerAttach).not.toHaveBeenCalled(); - }); -}); - -async function startPendingPageShare( - harness: Awaited>, - socket = harness.sockets.at(-1), -) { - if (!socket) { - throw new Error("expected the page-share relay socket"); - } - if (socket.readyState !== 1) { - await harness.authenticate(socket); - } - harness.executeScript.mockResolvedValueOnce([ - { - result: { - url: "https://example.com/article", - title: "Example article", - selection: "", - content: "Article body", - }, - }, - ]); - const response = vi.fn(); - expect(harness.messageListener({ type: "sendPageToOpenClaw", tabId: 1 }, {}, response)).toBe( - true, - ); - await vi.waitFor(() => { - expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "pageShare")).toBe(true); - }); - const raw = socket.send.mock.calls.find(([frame]) => JSON.parse(frame).type === "pageShare")?.[0]; - if (typeof raw !== "string") { - throw new Error("expected a sent page-share request"); - } - return { socket, response, requestId: (JSON.parse(raw) as { requestId: number }).requestId }; -} - -describe("relay opening deadline", () => { - beforeEach(() => { - vi.resetModules(); - vi.useFakeTimers(); - vi.setSystemTime(START_TIME_MS); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("closes a stuck connecting socket and retries", async () => { - const harness = await loadBackground(); - expect(harness.sockets).toHaveLength(1); - expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_WATCHDOG_ALARM, { - periodInMinutes: 0.5, - }); - const openingDeadline = harness.createAlarm.mock.calls.find( - ([name]) => name === RELAY_OPENING_DEADLINE_ALARM, - )?.[1]?.when; - if (typeof openingDeadline !== "number") { - throw new Error("expected relay opening deadline alarm"); - } - expect(openingDeadline).toBe(Date.now() + 10_000); - - vi.setSystemTime(openingDeadline); - harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM }); - - expect(harness.sockets[0]?.close).toHaveBeenCalledOnce(); - expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM); - expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "!" }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(harness.sockets).toHaveLength(2); - expect(harness.createAlarm).toHaveBeenLastCalledWith(RELAY_OPENING_DEADLINE_ALARM, { - when: openingDeadline + 11_000, - }); - }); - - it("clears the deadline only after relay authentication completes", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - expect(socket).toBeDefined(); - - const clearsBeforeOpen = harness.clearAlarm.mock.calls.length; - socket?.open(); - expect(harness.clearAlarm).toHaveBeenCalledTimes(clearsBeforeOpen); - expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "…" }); - - if (socket) { - await harness.authenticate(socket); - } - expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM); - expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "ON" }); - - vi.setSystemTime(START_TIME_MS + 60_000); - harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM }); - expect(socket?.close).not.toHaveBeenCalled(); - }); -}); - -describe("copilot panel messaging", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("responds exactly once when the tab cannot be retrieved", async () => { - const harness = await loadBackground(); - harness.tabsGet.mockRejectedValueOnce(new Error("No tab with id: 44.")); - const sendResponse = vi.fn(); - - expect( - harness.messageListener({ type: "prepareCopilotPanel", tabId: 44 }, {}, sendResponse), - ).toBe(true); - - await vi.waitFor(() => { - expect(sendResponse).toHaveBeenCalledOnce(); - }); - expect(harness.tabsGet).toHaveBeenCalledWith(44); - expect(sendResponse).toHaveBeenCalledWith({ - ok: false, - error: "No tab with id: 44.", - }); - }); - - it("responds exactly once with the prepared panel path", async () => { - const harness = await loadBackground(); - const sendResponse = vi.fn(); - - expect( - harness.messageListener({ type: "prepareCopilotPanel", tabId: 44 }, {}, sendResponse), - ).toBe(true); - - await vi.waitFor(() => { - expect(sendResponse).toHaveBeenCalledOnce(); - }); - expect(harness.tabsGet).toHaveBeenCalledWith(44); - expect(sendResponse).toHaveBeenCalledWith({ - ok: true, - path: expect.stringMatching(/^sidepanel\.html\?binding=/), - }); - }); -}); - -describe("popup message failure responses", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("responds exactly once when a selected tab closes before it can be grouped", async () => { - const harness = await loadBackground(); - harness.tabsGet.mockRejectedValueOnce(new Error("No tab with id: 44.")); - const sendResponse = vi.fn(); - - expect( - harness.messageListener( - { type: "toggleTabAccess", tabId: 44, accessMode: "selected", grant: true }, - {}, - sendResponse, - ), - ).toBe(true); - - await vi.waitFor(() => { - expect(sendResponse).toHaveBeenCalledExactlyOnceWith({ - ok: false, - error: "No tab with id: 44.", - }); - }); - expect(harness.tabsGet).toHaveBeenCalledWith(44); - }); - - it.each([ - { action: "share", initiallyShared: false }, - { action: "unshare", initiallyShared: true }, - ])( - "responds exactly once when $action consent reconciliation rejects", - async ({ initiallyShared }) => { - const error = "Could not reconcile browser tab consent."; - const onConsentChanged = vi.fn(async () => { - throw new Error(error); - }); - const harness = await loadBackground({ onConsentChanged }); - if (initiallyShared) { - harness.shareTab(44); - } - const sendResponse = vi.fn(); - - expect( - harness.messageListener( - { - type: "toggleTabAccess", - tabId: 44, - accessMode: "selected", - grant: !initiallyShared, - }, - {}, - sendResponse, - ), - ).toBe(true); - - await vi.waitFor(() => { - expect(onConsentChanged).toHaveBeenCalledOnce(); - }); - if (initiallyShared) { - expect(harness.tabsUngroup).toHaveBeenCalledWith([44]); - } else { - expect(harness.tabsGroup).toHaveBeenCalledWith({ tabIds: [44] }); - } - expect(sendResponse).toHaveBeenCalledExactlyOnceWith({ ok: false, error }); - expect(sendResponse).not.toHaveBeenCalledWith({ - ok: true, - accessible: !initiallyShared, - denied: false, - }); - }, - ); - - it.each([ - { - message: { - type: "pair" as const, - pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, - }, - operation: "set" as const, - error: "Could not save browser pairing.", - }, - { - message: { type: "unpair" as const }, - operation: "remove" as const, - error: "Could not remove browser pairing.", - }, - ])( - "responds exactly once when $message.type storage rejects", - async ({ message, operation, error }) => { - const harness = await loadBackground(); - const storageOperation = operation === "set" ? harness.storageSet : harness.storageRemove; - storageOperation.mockRejectedValueOnce(new Error(error)); - const sendResponse = vi.fn(); - - expect(harness.messageListener(message, {}, sendResponse)).toBe(true); - - await vi.waitFor(() => { - expect(sendResponse).toHaveBeenCalledExactlyOnceWith({ ok: false, error }); - }); - }, - ); -}); - -describe("page-share relay request lifecycle", () => { - beforeEach(() => { - vi.resetModules(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("immediately rejects a page share when its owning relay disconnects", async () => { - const harness = await loadBackground(); - const pending = await startPendingPageShare(harness); - - pending.socket.close(); - - await vi.waitFor(() => { - expect(pending.response).toHaveBeenCalledWith({ - ok: false, - error: "Browser relay disconnected before OpenClaw acknowledged the page share.", - }); - }); - expect(pending.response).toHaveBeenCalledOnce(); - }); - - it("immediately rejects a page share when the user unpairs the relay", async () => { - const harness = await loadBackground({ deferSocketClose: true }); - const pending = await startPendingPageShare(harness); - const unpairResponse = vi.fn(); - - expect(harness.messageListener({ type: "unpair" }, {}, unpairResponse)).toBe(true); - - await vi.waitFor(() => { - expect(unpairResponse).toHaveBeenCalledWith({ ok: true }); - expect(pending.response).toHaveBeenCalledWith({ - ok: false, - error: "Browser relay disconnected before OpenClaw acknowledged the page share.", - }); - }); - expect(pending.socket.close).toHaveBeenCalledOnce(); - expect(pending.socket.readyState).toBe(2); - expect(pending.response).toHaveBeenCalledOnce(); - }); - - it("rejects old page shares before a replacement relay finishes closing", async () => { - const harness = await loadBackground({ deferSocketClose: true }); - const pending = await startPendingPageShare(harness); - const pairResponse = vi.fn(); - - expect( - harness.messageListener( - { - type: "pair", - pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, - }, - {}, - pairResponse, - ), - ).toBe(true); - - await vi.waitFor(() => { - expect(pairResponse).toHaveBeenCalledWith({ ok: true }); - expect(pending.response).toHaveBeenCalledWith({ - ok: false, - error: "Browser relay disconnected before OpenClaw acknowledged the page share.", - }); - }); - expect(pending.socket.close).toHaveBeenCalledOnce(); - expect(pending.socket.readyState).toBe(2); - expect(harness.sockets).toHaveLength(2); - expect(pending.response).toHaveBeenCalledOnce(); - }); - - it("preserves the acknowledgement from the page share's own relay", async () => { - const harness = await loadBackground(); - const pending = await startPendingPageShare(harness); - - pending.socket.receive({ type: "pageShareResult", requestId: pending.requestId, ok: true }); - - await vi.waitFor(() => { - expect(pending.response).toHaveBeenCalledWith({ ok: true }); - }); - pending.socket.close(); - expect(pending.response).toHaveBeenCalledOnce(); - }); - - it("preserves the delivery error returned by the page share's own relay", async () => { - const harness = await loadBackground(); - const pending = await startPendingPageShare(harness); - - pending.socket.receive({ - type: "pageShareResult", - requestId: pending.requestId, - ok: false, - error: "Gateway page-share queue unavailable.", - }); - - await vi.waitFor(() => { - expect(pending.response).toHaveBeenCalledWith({ - ok: false, - error: "Gateway page-share queue unavailable.", - }); - }); - pending.socket.close(); - expect(pending.response).toHaveBeenCalledOnce(); - }); - - it("does not let a stale socket reject a share on the reconnected relay", async () => { - const harness = await loadBackground(); - const original = await startPendingPageShare(harness); - - original.socket.close(); - await vi.advanceTimersByTimeAsync(1_000); - expect(harness.sockets).toHaveLength(2); - const replacement = await startPendingPageShare(harness); - - original.socket.receive({ - type: "pageShareResult", - requestId: replacement.requestId, - ok: false, - error: "Stale relay response.", - }); - original.socket.close(); - expect(replacement.response).not.toHaveBeenCalled(); - - replacement.socket.receive({ - type: "pageShareResult", - requestId: replacement.requestId, - ok: true, - }); - - await vi.waitFor(() => { - expect(original.response).toHaveBeenCalledWith({ - ok: false, - error: "Browser relay disconnected before OpenClaw acknowledged the page share.", - }); - expect(replacement.response).toHaveBeenCalledWith({ ok: true }); - }); - expect(original.response).toHaveBeenCalledOnce(); - expect(replacement.response).toHaveBeenCalledOnce(); }); }); diff --git a/extensions/browser/chrome-extension/bootstrap.chromium.test.ts b/extensions/browser/chrome-extension/bootstrap.chromium.test.ts new file mode 100644 index 000000000000..6f9d0f8abc5d --- /dev/null +++ b/extensions/browser/chrome-extension/bootstrap.chromium.test.ts @@ -0,0 +1,387 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; +import { chromium, type BrowserContext } from "playwright-core"; +import { afterEach, describe, expect, it } from "vitest"; +import { + chromeProductRoots, + generateChromeExtensionIdForPath, + stableChromeExtensionDir, +} from "../src/browser/extension-install-layout.js"; +import { installChromeExtensionBootstrap } from "../src/browser/extension-install.js"; +import { startExtensionRelayServer } from "../src/browser/extension-relay/relay-server.js"; +import { getFreePort } from "../src/browser/test-port.js"; +import { relayTestKey } from "./relay-key.test-support.js"; + +declare const chrome: { + runtime: { sendMessage: (message: unknown) => Promise> }; +}; + +const runE2E = + process.env.OPENCLAW_BROWSER_EXTENSION_E2E === "1" && + (process.platform === "linux" || process.platform === "darwin"); +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup().catch(() => undefined); + } +}); + +async function waitForExtensionId(context: BrowserContext, extensionPath: string): Promise { + const browser = context.browser(); + if (!browser) { + throw new Error("Chromium browser connection unavailable"); + } + const cdp = await browser.newBrowserCDPSession(); + const expected = await fs.realpath(extensionPath); + const deadline = Date.now() + 15_000; + do { + const result = (await cdp.send("Extensions.getExtensions")) as { + extensions: Array<{ id: string; path: string }>; + }; + for (const extension of result.extensions) { + if ( + (await fs.realpath(extension.path).catch(() => path.resolve(extension.path))) === expected + ) { + return extension.id; + } + } + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } while (Date.now() < deadline); + throw new Error("Chromium did not report the loaded OpenClaw extension"); +} + +async function loadUnpackedExtension( + context: BrowserContext, + extensionPath: string, +): Promise { + const browser = context.browser(); + if (!browser) { + throw new Error("Chromium browser connection unavailable"); + } + const cdp = await browser.newBrowserCDPSession(); + await cdp.send("Extensions.loadUnpacked", { path: extensionPath }); +} + +async function exactOwnedManifestsExist( + manifestPaths: string[], + expectedOrigins: string[], +): Promise { + for (const manifestPath of manifestPaths) { + try { + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + name?: unknown; + path?: unknown; + allowed_origins?: unknown; + key?: unknown; + }; + if ( + manifest.name !== "ai.openclaw.browser_bootstrap" || + typeof manifest.path !== "string" || + Object.hasOwn(manifest, "key") || + !Array.isArray(manifest.allowed_origins) || + JSON.stringify(manifest.allowed_origins) !== JSON.stringify(expectedOrigins) || + !(await fs.readFile(manifest.path, "utf8")).includes( + "# OpenClaw native messaging bootstrap v1", + ) + ) { + return false; + } + } catch { + return false; + } + } + return manifestPaths.length > 0; +} + +async function seedLinuxSecurePreferences(params: { + userDataDir: string; + extensionId: string; + extensionPath: string; +}): Promise { + const profileDir = path.join(params.userDataDir, "Default"); + await fs.mkdir(profileDir, { recursive: true, mode: 0o700 }); + await fs.writeFile( + path.join(profileDir, "Secure Preferences"), + `${JSON.stringify({ + extensions: { + settings: { + [params.extensionId]: { location: 4, path: params.extensionPath }, + }, + }, + })}\n`, + { mode: 0o600 }, + ); +} + +function decodeSingleNativeResponse(frame: Buffer): Record { + if (frame.length < 4) { + throw new Error("native host returned no response frame"); + } + const length = os.endianness() === "LE" ? frame.readUInt32LE() : frame.readUInt32BE(); + if (frame.length !== length + 4) { + throw new Error( + `native host did not return exactly one response frame (bytes=${frame.length}, declared=${length})`, + ); + } + const parsed: unknown = JSON.parse(frame.subarray(4).toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("native host returned an invalid response payload"); + } + return parsed as Record; +} + +describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { + it("pre-registers before the first native call, auto-pairs, and revokes a paused tab", async () => { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extension-e2e-")), + ); + cleanups.push(async () => await fs.rm(root, { recursive: true, force: true })); + const homeDir = path.join(root, "home"); + const stateDir = path.join(root, "custom-state"); + const configPath = path.join(root, "custom-config", "openclaw.json"); + const relayPort = await getFreePort(); + const linuxConfigHome = path.join(homeDir, ".config"); + const chromeRootEnv = + process.platform === "linux" + ? { CHROME_CONFIG_HOME: linuxConfigHome, XDG_CONFIG_HOME: linuxConfigHome } + : {}; + const userDataDir = + process.platform === "darwin" + ? path.join(homeDir, "Library", "Application Support", "Google", "Chrome for Testing") + : path.join(linuxConfigHome, "chromium"); + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true, mode: 0o700 }); + await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 }); + const token = relayTestKey(3); + await fs.writeFile( + path.join(stateDir, "credentials", "browser-extension-relay.secret"), + `${token}\n`, + { mode: 0o600 }, + ); + await fs.writeFile( + configPath, + `${JSON.stringify({ browser: { profiles: { e2e: { driver: "extension", cdpPort: relayPort } } } })}\n`, + { mode: 0o600 }, + ); + await withEnvAsync( + { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, + async () => { + const extensionSource = path.dirname(fileURLToPath(import.meta.url)); + const nativeHostPath = await fs.realpath( + path.resolve("extensions/browser/native-host-entry.ts"), + ); + const tsxPath = await fs.realpath(path.resolve("node_modules/.bin/tsx")); + const tsxTsconfigPath = path.resolve("tsconfig.json"); + const deps = { + platform: process.platform, + homeDir, + stateDir, + env: { + HOME: homeDir, + ...chromeRootEnv, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + }, + nodePath: tsxPath, + nativeHostPath, + }; + const relay = await startExtensionRelayServer({ port: relayPort, token }); + cleanups.push(relay.close); + const browserEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: homeDir, + ...chromeRootEnv, + TSX_TSCONFIG_PATH: tsxTsconfigPath, + }; + delete browserEnv.OPENCLAW_STATE_DIR; + delete browserEnv.OPENCLAW_CONFIG_PATH; + delete browserEnv.VITEST; + delete browserEnv.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + + const launchChromium = async () => + await chromium.launchPersistentContext(userDataDir, { + channel: "chromium", + headless: true, + env: browserEnv, + ignoreDefaultArgs: ["--disable-extensions"], + args: ["--enable-unsafe-extension-debugging"], + }); + let context = await launchChromium(); + process.stderr.write("[browser-extension-e2e] chromium launched\n"); + cleanups.push(async () => await context.close()); + const installed = stableChromeExtensionDir(deps); + const predictedId = generateChromeExtensionIdForPath(installed, process.platform); + const expectedOrigins = [ + predictedId, + generateChromeExtensionIdForPath(extensionSource, process.platform), + ] + .toSorted() + .map((id) => `chrome-extension://${id}/`); + const relevantManifestPaths = chromeProductRoots(deps) + .filter((productRoot) => productRoot.userDataDir === userDataDir) + .map((productRoot) => + path.join(productRoot.nativeManifestDir, "ai.openclaw.browser_bootstrap.json"), + ); + const installPromise = installChromeExtensionBootstrap({ + bundledDir: extensionSource, + pluginRoot: path.resolve("extensions/browser"), + waitMs: 15_000, + deps, + }); + await expect + .poll( + async () => await exactOwnedManifestsExist(relevantManifestPaths, expectedOrigins), + { + timeout: 15_000, + }, + ) + .toBe(true); + process.stderr.write("[browser-extension-e2e] deterministic native host pre-registered\n"); + await loadUnpackedExtension(context, installed); + const extensionId = await waitForExtensionId(context, installed); + expect(extensionId).toBe(predictedId); + process.stderr.write("[browser-extension-e2e] unpacked extension loaded\n"); + await context.close(); + if (process.platform === "linux") { + // Linux CDP loads are transient and omit the protected record written by Load unpacked. + // Seed that exact record only after Chromium confirms the path-derived extension ID. + await seedLinuxSecurePreferences({ userDataDir, extensionId, extensionPath: installed }); + } + + const status = await installPromise; + expect(status.manualSetupRequired, JSON.stringify(status)).toBe(false); + expect( + status.discovered.some( + (entry) => entry.extensionPath === installed && entry.extensionId === predictedId, + ), + ).toBe(true); + process.stderr.write("[browser-extension-e2e] Secure Preferences identity verified\n"); + context = await launchChromium(); + await loadUnpackedExtension(context, installed); + expect(await waitForExtensionId(context, installed)).toBe(predictedId); + process.stderr.write("[browser-extension-e2e] persisted extension reloaded\n"); + const controlled = await context.newPage(); + await controlled.goto("data:text/html,OpenClaw E2E

ready

"); + + const extensionPage = await context.newPage(); + await extensionPage.goto(`chrome-extension://${extensionId}/options.html`); + let extensionStatus: Record = {}; + try { + await expect + .poll( + async () => { + extensionStatus = await extensionPage.evaluate( + async () => await chrome.runtime.sendMessage({ type: "getStatus" }), + ); + return extensionStatus.paired; + }, + { timeout: 15_000 }, + ) + .toBe(true); + } catch (error) { + throw new Error(`Extension did not auto-pair: ${JSON.stringify(extensionStatus)}`, { + cause: error, + }); + } + expect(extensionStatus).toMatchObject({ paired: true, accessMode: "all" }); + try { + await expect.poll(() => relay.bridge.extensionConnected, { timeout: 15_000 }).toBe(true); + } catch (error) { + extensionStatus = await extensionPage.evaluate( + async () => await chrome.runtime.sendMessage({ type: "getStatus" }), + ); + throw new Error(`Extension relay did not connect: ${JSON.stringify(extensionStatus)}`, { + cause: error, + }); + } + + const registration = status.registrations.find( + (entry) => relevantManifestPaths.includes(entry.manifestPath) && entry.state === "owned", + ); + if (!registration) { + throw new Error("Active Chromium native host registration missing"); + } + const manifest = JSON.parse(await fs.readFile(registration.manifestPath, "utf8")) as { + path: string; + }; + const requestBody = Buffer.from( + JSON.stringify({ v: 1, op: "bootstrap", nonce: "BwcHBwcHBwcHBwcHBwcHBw" }), + ); + const requestFrame = Buffer.alloc(requestBody.length + 4); + if (os.endianness() === "LE") { + requestFrame.writeUInt32LE(requestBody.length); + } else { + requestFrame.writeUInt32BE(requestBody.length); + } + requestBody.copy(requestFrame, 4); + const hostProbe = spawnSync(manifest.path, [`chrome-extension://${extensionId}/`], { + input: requestFrame, + env: browserEnv, + timeout: 30_000, + }); + expect( + hostProbe.status, + `native host exit=${hostProbe.status} signal=${hostProbe.signal} stderr=${hostProbe.stderr.toString("utf8")}`, + ).toBe(0); + const nativeResponse = decodeSingleNativeResponse(hostProbe.stdout); + if ( + nativeResponse.ok !== true || + nativeResponse.nonce !== "BwcHBwcHBwcHBwcHBwcHBw" || + typeof nativeResponse.pairingString !== "string" + ) { + throw new Error("native host did not bootstrap successfully"); + } + const fragmentAt = nativeResponse.pairingString.lastIndexOf("#"); + if (fragmentAt < 0) { + throw new Error("native host returned an invalid local bootstrap response"); + } + let relayUrl: URL; + try { + relayUrl = new URL(nativeResponse.pairingString.slice(0, fragmentAt)); + } catch { + throw new Error("native host returned an invalid local bootstrap response"); + } + if ( + relayUrl.hostname !== "127.0.0.1" || + relayUrl.port !== String(relayPort) || + nativeResponse.pairingString.slice(fragmentAt + 1) !== token + ) { + throw new Error("native host did not use the custom installation context"); + } + process.stderr.write("[browser-extension-e2e] launcher probe passed\n"); + + await expect + .poll(() => + relay.bridge.accessibleTabs().some((tab) => tab.url.startsWith("data:text/html")), + ) + .toBe(true); + + const tabId = relay.bridge + .accessibleTabs() + .find((tab) => tab.url.startsWith("data:text/html"))?.tabId; + if (tabId === undefined) { + throw new Error("Ungrouped E2E tab was not exposed in All tabs mode"); + } + await extensionPage.evaluate( + async ({ tabId: id }) => + await chrome.runtime.sendMessage({ + type: "toggleTabAccess", + tabId: id, + accessMode: "all", + grant: false, + }), + { tabId }, + ); + await expect + .poll(() => relay.bridge.accessibleTabs().some((tab) => tab.tabId === tabId)) + .toBe(false); + }, + ); + }, 120_000); +}); diff --git a/extensions/browser/chrome-extension/manifest.json b/extensions/browser/chrome-extension/manifest.json index df0fda0752fa..f2df2211f9da 100644 --- a/extensions/browser/chrome-extension/manifest.json +++ b/extensions/browser/chrome-extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "OpenClaw", "version": "2.2.0", - "description": "Let OpenClaw agents use eligible tabs in your signed-in Chrome, with All tabs or Selected tabs access and per-tab pause controls.", + "description": "Securely relay eligible signed-in Chrome tabs to the local OpenClaw browser automation service.", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", @@ -15,17 +15,8 @@ "tabGroups", "storage", "alarms", - "sidePanel", - "contextMenus", - "scripting", - "activeTab" + "nativeMessaging" ], - "commands": { - "send-page": { - "suggested_key": { "default": "Alt+Shift+S" }, - "description": "Send this page to OpenClaw" - } - }, "background": { "service_worker": "background.js", "type": "module" }, "action": { "default_title": "OpenClaw", @@ -37,5 +28,9 @@ "128": "icons/icon128.png" } }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, "minimum_chrome_version": "125" } diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts b/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts deleted file mode 100644 index 99dc000deaac..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { - CopilotArchiveEntry, - CopilotPanelBindingRegistry, -} from "./copilot-session-registry.js"; -import type { BrowserCopilotBinding } from "./panel-core.js"; - -export const PANEL_PATH: string; - -export function resolveSidePanelTabId( - chromeApi: unknown, - port: unknown, - panelBindings: Pick, -): Promise; - -export function archiveCopilotSession( - gateway: { - request(method: string, params: Record): Promise; - }, - entry: CopilotArchiveEntry, -): Promise; - -export function selectCopilotPanelState(options: { - paired: boolean; - accessible: boolean; - abortPending: boolean; - gatewayState: string; -}): string; - -export function sessionKeyFromEvent(event: unknown): string | null; -export function resolveBindingTarget(config: { - relayUrl: string; - gatewayUrl: string; -}): BrowserCopilotBinding["target"]; -export function safeTabLabel(tab: { url?: string }): string; diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.js b/extensions/browser/chrome-extension/modules/copilot-background-shared.js deleted file mode 100644 index 8e3bf60d052f..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-background-shared.js +++ /dev/null @@ -1,128 +0,0 @@ -import { deriveCopilotSessionLabel } from "./panel-core.js"; - -const PANEL_PATH = "sidepanel.html"; - -function parsePanelBindingUrl(chromeApi, raw) { - let url; - try { - url = new URL(raw); - } catch { - return null; - } - const token = url.searchParams.get("binding"); - if ( - url.protocol !== "chrome-extension:" || - url.host !== chromeApi.runtime.id || - !url.pathname.endsWith(`/${PANEL_PATH}`) || - !token || - [...url.searchParams].length !== 1 || - url.hash - ) { - return null; - } - return { token, url: url.toString() }; -} - -export async function resolveSidePanelTabId(chromeApi, port, panelBindings) { - const binding = parsePanelBindingUrl(chromeApi, port.sender?.url); - if (!binding) { - throw new Error("Copilot is available only in a tab-specific side panel."); - } - const tabId = await panelBindings.resolve(binding.token); - if (!Number.isInteger(tabId) || tabId < 0) { - throw new Error("This panel does not hold a live tab binding."); - } - const contexts = await chromeApi.runtime.getContexts({ - contextTypes: ["SIDE_PANEL"], - }); - const documentId = port.sender?.documentId; - // Chrome reports tabId=-1 for SIDE_PANEL contexts. The unguessable URL maps - // to the tab; this live-context check prevents a normal extension page from claiming it. - const context = contexts.find( - (candidate) => - candidate.contextType === "SIDE_PANEL" && - candidate.documentUrl === binding.url && - (typeof documentId !== "string" || candidate.documentId === documentId), - ); - if (!context) { - throw new Error("Chrome did not bind this panel to a tab."); - } - return tabId; -} - -export async function archiveCopilotSession(gateway, entry) { - if (entry.ensureCreated) { - // The worker may have stopped after persisting creation intent but before - // sending it. sessions.create adopts the same key, making cleanup idempotent. - await gateway.request("sessions.create", { - key: entry.sessionKey, - label: deriveCopilotSessionLabel(entry.sessionKey), - }); - } - try { - await gateway.request("sessions.messages.unsubscribe", { key: entry.sessionKey }); - } catch { - // The allowlist is connection-local. A closed socket already stopped delivery. - } - try { - await gateway.request("sessions.abort", { key: entry.sessionKey }); - } catch { - // Archive is authoritative; it will reject while a run is still active and retry later. - } - await gateway.request("sessions.patch", { key: entry.sessionKey, archived: true }); -} - -export function selectCopilotPanelState({ paired, accessible, abortPending, gatewayState }) { - if (!paired) { - return "needs-pairing"; - } - if (!accessible) { - return "needs-sharing"; - } - return abortPending ? "reconciling" : gatewayState; -} - -export function sessionKeyFromEvent(event) { - const payload = event?.payload; - if (!payload || typeof payload !== "object") { - return null; - } - return typeof payload.sessionKey === "string" ? payload.sessionKey : null; -} - -function isLoopbackUrl(raw) { - try { - const host = new URL(raw).hostname.toLowerCase(); - return host === "localhost" || host === "127.0.0.1" || host === "[::1]"; - } catch { - return false; - } -} - -export function resolveBindingTarget(config) { - try { - const relay = new URL(config.relayUrl); - if (relay.pathname.endsWith("/browser/extension")) { - return "host"; - } - if (isLoopbackUrl(config.relayUrl) && isLoopbackUrl(config.gatewayUrl)) { - return "host"; - } - } catch { - // Fall through to the explicit topology denial below. - } - throw new Error( - "Copilot needs a direct Gateway relay. Browser-node routing is not yet supported.", - ); -} - -export function safeTabLabel(tab) { - try { - const url = new URL(tab.url ?? ""); - return url.hostname || url.protocol.replace(":", "") || "Browser tab"; - } catch { - return "Browser tab"; - } -} - -export { PANEL_PATH }; diff --git a/extensions/browser/chrome-extension/modules/copilot-background.d.ts b/extensions/browser/chrome-extension/modules/copilot-background.d.ts deleted file mode 100644 index 959c6d40d615..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-background.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CopilotSessionRegistry } from "./copilot-session-registry.js"; - -export function createCopilotController(options: Record): { - initializeCustody(): Promise; - initialize(): Promise; - preparePanel(tabId: number): Promise<{ path: string }>; - onConsentChanged(changedTabId?: number, options?: { revoked?: boolean }): Promise; - onRelayStatus(status: { ready: boolean; label?: string }): Promise; - onTabRemoved(tabId: number): Promise; - refreshConfig(): Promise; - drainAborts(gatewayScope?: string | null): Promise; - drainArchives(gatewayScope?: string | null): Promise; - drainStaleScopes(): Promise; - registry: CopilotSessionRegistry; -}; diff --git a/extensions/browser/chrome-extension/modules/copilot-background.js b/extensions/browser/chrome-extension/modules/copilot-background.js deleted file mode 100644 index 6ee107be94dd..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-background.js +++ /dev/null @@ -1,741 +0,0 @@ -import { - PANEL_PATH, - resolveBindingTarget, - resolveSidePanelTabId, - safeTabLabel, - selectCopilotPanelState, - sessionKeyFromEvent, -} from "./copilot-background-shared.js"; -import { CopilotGatewayClient } from "./copilot-gateway.js"; -import { createCopilotRecoveryController } from "./copilot-recovery.js"; -import { createCopilotRelayCustodyController } from "./copilot-relay-custody.js"; -import { CopilotPanelBindingRegistry, CopilotSessionRegistry } from "./copilot-session-registry.js"; -import { createCopilotSessionController } from "./copilot-session.js"; -import { gatewayUrlFromPairing } from "./panel-core.js"; - -const PANEL_PORT = "openclaw-copilot-panel"; - -/** Background-owned session custody for all tab-specific panel documents. */ -export function createCopilotController({ - chromeApi = chrome, - getConfig, - isTabAccessible, - grantTabAccess, - attachDebugger, - revokeDebugger, - restoreDebugger, - scheduleTabsSync, - gateway = new CopilotGatewayClient(), - recoveryGatewayFactory = () => new CopilotGatewayClient(), -}) { - const registry = new CopilotSessionRegistry(chromeApi.storage); - const panelBindings = new CopilotPanelBindingRegistry(chromeApi.storage.session); - const portsByTab = new Map(); - const subscribedKeys = new Set(); - const sendsByTab = new Set(); - const ensureByTab = new Map(); - const suspendByTab = new Map(); - const tabRevisions = new Map(); - const portRevisions = new Map(); - const consentRevisions = new Map(); - const consentByTab = new Map(); - const historyTimers = new Map(); - let gatewayStatus = { state: "off", label: "Pair the extension first" }; - let currentConfig = null; - let gatewayRevision = 0; - let gatewayStatusRevision = 0; - let reconciledGatewayStatusRevision = 0; - let lastReadyStatus = null; - let custodyInitialized = null; - let initialized = null; - let lifecycleChain = Promise.resolve(); - let pendingGatewayRevocation = Promise.resolve(); - let configTransitioning = false; - - const { - abortEntry, - clearAbortRetry, - drainAborts, - drainArchives, - drainStaleScopes, - reconcileGatewayReady, - scheduleAbortRetry, - scheduleStaleRecovery, - } = createCopilotRecoveryController({ - gateway, - recoveryGatewayFactory, - registry, - subscribedKeys, - sendsByTab, - currentGatewayScope, - getGatewayStatus: () => gatewayStatus, - getGatewayStatusRevision: () => gatewayStatusRevision, - getLastReadyStatus: () => lastReadyStatus, - isConfigTransitioning: () => configTransitioning, - setReconciledGatewayStatus: (status, revision) => { - gatewayStatus = status; - reconciledGatewayStatusRevision = revision; - }, - restoreDebuggerIfReleased, - broadcastTab, - broadcastStatus, - refreshPanelState, - runLifecycle, - }); - - const relayCustody = createCopilotRelayCustodyController({ - appendGatewayRevocation: (revocation) => { - const previousRevocation = pendingGatewayRevocation; - pendingGatewayRevocation = Promise.allSettled([previousRevocation, revocation]).then( - () => undefined, - ); - }, - broadcastStatus, - currentGatewayScope, - drainAborts, - getGatewayStatus: () => gatewayStatus, - invalidateGatewayEpoch: () => { - gatewayRevision += 1; - }, - markGatewayAbortError: () => { - reconciledGatewayStatusRevision = 0; - gatewayStatus = { state: "error", label: "Could not stop the previous tab run" }; - }, - registry, - revokeActiveBindings, - runLifecycle, - }); - - const { ensureSession, sendMessage } = createCopilotSessionController({ - chromeApi, - gateway, - registry, - ensureByTab, - tabRevisions, - portsByTab, - portRevisions, - sendsByTab, - currentGatewayScope, - getGatewayRevision: () => gatewayRevision, - getCurrentConfig: () => currentConfig, - isConfigTransitioning: () => configTransitioning, - currentReadyEpoch, - readyEpochIsCurrent, - isTabAccessible, - attachDebugger, - revokeDebugger, - restoreDebuggerIfReleased, - subscribe, - unsubscribeTab, - suspendTab, - hydrate, - refreshPanelState, - drainArchives, - scheduleAbortRetry, - }); - - async function initializeCustody() { - if (custodyInitialized) { - return await custodyInitialized; - } - custodyInitialized = (async () => { - const tabs = await chromeApi.tabs.query({}); - await registry.initialize( - new Set(tabs.map((tab) => tab.id).filter((tabId) => typeof tabId === "number")), - ); - await panelBindings.initialize(); - const activeScopes = new Set( - registry - .list() - .filter((entry) => entry.activeRunId) - .map((entry) => entry.gatewayScope), - ); - // MV3 can discard process memory mid-run. Rebuild the debugger deny set - // from durable run custody before relay attachments can resume. - await Promise.allSettled([...activeScopes].map((scope) => revokeActiveBindings(scope))); - })(); - return await custodyInitialized; - } - - async function initialize() { - if (initialized) { - return await initialized; - } - initialized = (async () => { - await initializeCustody(); - await refreshConfig(); - })(); - return await initialized; - } - - function post(port, message) { - try { - port.postMessage(message); - } catch { - // Panel closed between the state read and delivery. - } - } - - function broadcastTab(tabId, message) { - for (const port of portsByTab.get(tabId) ?? []) { - post(port, message); - } - } - - function broadcastStatus(options) { - for (const tabId of portsByTab.keys()) { - void refreshPanelState(tabId, options); - } - } - - function currentGatewayScope() { - return typeof currentConfig?.gatewayUrl === "string" ? currentConfig.gatewayUrl : null; - } - - function currentPanelStatus() { - return relayCustody.currentPanelStatus(); - } - - async function restoreDebuggerIfReleased(tabId) { - if (registry.list().some((entry) => entry.tabId === tabId && entry.activeRunId)) { - return; - } - await restoreDebugger(tabId); - } - - function currentReadyEpoch() { - const gatewayScope = currentGatewayScope(); - if ( - !gatewayScope || - configTransitioning || - !relayCustody.isOperational() || - !gateway.ready || - gatewayStatus.state !== "ready" || - reconciledGatewayStatusRevision !== gatewayStatusRevision - ) { - return null; - } - return { - gatewayScope, - configRevision: gatewayRevision, - statusRevision: gatewayStatusRevision, - }; - } - - function readyEpochIsCurrent(epoch) { - return ( - epoch?.gatewayScope === currentGatewayScope() && - epoch.configRevision === gatewayRevision && - epoch.statusRevision === gatewayStatusRevision && - reconciledGatewayStatusRevision === epoch.statusRevision && - !configTransitioning && - relayCustody.isOperational() && - gateway.ready && - gatewayStatus.state === "ready" - ); - } - - async function applyConfig() { - const nextConfig = await getConfig(); - const nextGatewayScope = gatewayUrlFromPairing(nextConfig.relayUrl, nextConfig.gatewayUrl); - const previousGatewayScope = currentGatewayScope(); - if (!previousGatewayScope) { - const staleScopes = registry.gatewayScopes().filter((scope) => scope !== nextGatewayScope); - if (staleScopes.length > 0) { - for (const staleScope of staleScopes) { - await registry.closeInactiveScope(staleScope); - } - scheduleStaleRecovery(); - } - } - if (previousGatewayScope && previousGatewayScope !== nextGatewayScope) { - configTransitioning = true; - clearAbortRetry(); - lastReadyStatus = null; - gatewayStatusRevision += 1; - reconciledGatewayStatusRevision = 0; - gatewayRevision += 1; - gatewayStatus = { state: "connecting", label: "Changing Gateway" }; - broadcastStatus(); - let needsStaleRecovery = false; - try { - await revokeActiveBindings(previousGatewayScope); - await Promise.allSettled([...ensureByTab.values()].map((entry) => entry.promise)); - await drainAborts(previousGatewayScope); - const hasPendingAborts = registry.pendingAborts(previousGatewayScope).length > 0; - if (hasPendingAborts) { - await registry.closeInactiveScope(previousGatewayScope); - } else { - await registry.closeScope(previousGatewayScope); - } - await drainArchives(previousGatewayScope); - needsStaleRecovery = - hasPendingAborts || registry.pendingArchives(previousGatewayScope).length > 0; - } catch { - // The next Gateway may start, but old-scope custody remains denied and - // the recovery client owns cleanup. Never strand the controller mid-switch. - needsStaleRecovery = true; - } finally { - gateway.stop(); - sendsByTab.clear(); - subscribedKeys.clear(); - configTransitioning = false; - } - if (needsStaleRecovery) { - scheduleStaleRecovery(); - } - } - currentConfig = { ...nextConfig, gatewayUrl: nextGatewayScope }; - configTransitioning = false; - if (!currentConfig.relayUrl || !nextGatewayScope) { - gateway.stop(); - gatewayStatus = { - state: "off", - label: currentConfig.relayUrl - ? "Pair again to add the Gateway endpoint" - : "Pair the extension first", - }; - broadcastStatus(); - return; - } - try { - resolveBindingTarget(currentConfig); - } catch (error) { - clearAbortRetry(); - lastReadyStatus = null; - gatewayStatusRevision += 1; - await registry.closeScope(nextGatewayScope); - await drainArchives(nextGatewayScope); - gateway.stop(); - gatewayStatus = { state: "denied", label: error.message }; - broadcastStatus(); - return; - } - gateway.start(nextGatewayScope); - } - - function runLifecycle(task) { - const pending = lifecycleChain.then(task); - lifecycleChain = pending.catch(() => undefined); - return pending; - } - - function refreshConfig() { - // Config changes and stale-scope recovery share one owner. Otherwise a - // scope can become current while a recovery client is still destroying it. - return runLifecycle(applyConfig); - } - - async function refreshPanelState( - tabId, - { - accessible: knownAccessible, - ensureSetup = false, - hydrateHistory = false, - suspended = false, - } = {}, - ) { - let tab; - try { - tab = await chromeApi.tabs.get(tabId); - } catch { - return; - } - const accessible = - typeof knownAccessible === "boolean" ? knownAccessible : await isTabAccessible(tabId); - const entry = registry.get(tabId, currentGatewayScope()); - const panelStatus = currentPanelStatus(); - const state = selectCopilotPanelState({ - paired: Boolean(currentConfig?.relayUrl), - accessible, - abortPending: Boolean(entry?.abortPending), - gatewayState: panelStatus.state, - }); - const panelState = { - type: "panel.state", - state, - label: - state === "needs-sharing" - ? "Allow OpenClaw on this tab before the copilot can act" - : state === "reconciling" - ? "Stopping the previous tab run" - : panelStatus.label, - requestId: panelStatus.requestId, - tab: { - title: typeof tab.title === "string" ? tab.title : "", - url: typeof tab.url === "string" ? tab.url : "", - label: safeTabLabel(tab), - }, - sessionKey: entry?.sessionKey, - }; - if (!accessible) { - broadcastTab(tabId, panelState); - if (!suspended) { - await suspendTab(tabId, { detachInactive: true }); - } - return; - } - if (state !== "ready") { - broadcastTab(tabId, panelState); - return; - } - const needsSetup = - ensureSetup || !entry || !subscribedKeys.has(entry.sessionKey) || !entry.binding; - if (!needsSetup) { - broadcastTab(tabId, panelState); - return; - } - broadcastTab(tabId, { - ...panelState, - state: "connecting", - label: "Preparing this tab", - }); - try { - const prepared = await ensureSession(tabId, { hydrateHistory }); - if (prepared) { - await refreshPanelState(tabId, { accessible: await isTabAccessible(tabId) }); - } - } catch (error) { - broadcastTab(tabId, { - ...panelState, - state: "error", - label: error?.message || "Could not prepare this tab", - }); - } - } - - async function subscribe(entry) { - if (subscribedKeys.has(entry.sessionKey)) { - return; - } - await gateway.request("sessions.messages.subscribe", { key: entry.sessionKey }); - subscribedKeys.add(entry.sessionKey); - } - - async function unsubscribeTab(tabId, gatewayScope = currentGatewayScope()) { - const entry = registry.get(tabId, gatewayScope); - if (!entry || !subscribedKeys.delete(entry.sessionKey)) { - return; - } - try { - await gateway.request("sessions.messages.unsubscribe", { key: entry.sessionKey }); - } catch { - // Socket closure also clears the server-owned allowlist. - } - } - - async function suspendTab(tabId, { expectedPortRevision, detachInactive = false } = {}) { - if (expectedPortRevision !== undefined && portRevisions.get(tabId) !== expectedPortRevision) { - return; - } - const gatewayScope = currentGatewayScope(); - const entry = registry.get(tabId, gatewayScope); - // Revoke local delivery and CDP access before any fallible Gateway RPC. - const unsubscribing = unsubscribeTab(tabId, gatewayScope); - const detaching = entry?.activeRunId - ? revokeDebugger(tabId) - : detachInactive - ? revokeDebugger(tabId).then(() => restoreDebuggerIfReleased(tabId)) - : Promise.resolve(); - const queued = await registry.queueAbort(tabId, gatewayScope); - sendsByTab.delete(tabId); - await Promise.allSettled([unsubscribing, detaching]); - if (queued && gateway.ready) { - await abortEntry(queued); - } - } - - function scheduleSuspend(tabId, portRevision) { - const pending = suspendTab(tabId, { expectedPortRevision: portRevision }).finally(() => { - if (suspendByTab.get(tabId) === pending) { - suspendByTab.delete(tabId); - } - }); - suspendByTab.set(tabId, pending); - return pending; - } - - async function hydrate(tabId, entry = registry.get(tabId, currentGatewayScope())) { - if (!entry || !portsByTab.has(tabId)) { - return; - } - try { - const history = await gateway.request("chat.history", { - sessionKey: entry.sessionKey, - limit: 200, - }); - broadcastTab(tabId, { - type: "panel.history", - sessionKey: entry.sessionKey, - messages: Array.isArray(history?.messages) ? history.messages : [], - }); - } catch (error) { - broadcastTab(tabId, { type: "panel.error", message: error.message }); - } - } - - function scheduleHydrate(tabId) { - if (historyTimers.has(tabId)) { - return; - } - historyTimers.set( - tabId, - setTimeout(() => { - historyTimers.delete(tabId); - void hydrate(tabId); - }, 100), - ); - } - - async function grantAccess(tabId) { - await grantTabAccess(tabId); - scheduleTabsSync(); - await refreshPanelState(tabId); - } - - async function onTabRemoved(tabId) { - tabRevisions.set(tabId, (tabRevisions.get(tabId) ?? 0) + 1); - consentRevisions.set(tabId, (consentRevisions.get(tabId) ?? 0) + 1); - await initialize(); - portsByTab.delete(tabId); - portRevisions.set(tabId, (portRevisions.get(tabId) ?? 0) + 1); - sendsByTab.delete(tabId); - const timer = historyTimers.get(tabId); - if (timer) { - clearTimeout(timer); - historyTimers.delete(tabId); - } - try { - await ensureByTab.get(tabId)?.promise; - } catch { - // Closing the tab still owns cleanup when a concurrent session setup failed. - } - await registry.closeTab(tabId); - await panelBindings.remove(tabId); - await drainArchives(currentGatewayScope()); - } - - async function onConsentChanged(changedTabId, { revoked = false } = {}) { - await initialize(); - const tabIds = - typeof changedTabId === "number" - ? portsByTab.has(changedTabId) || - registry.list().some((entry) => entry.tabId === changedTabId) - ? [changedTabId] - : [] - : [...new Set([...portsByTab.keys(), ...registry.list().map((entry) => entry.tabId)])]; - await Promise.all( - tabIds.map((tabId) => { - const revision = (consentRevisions.get(tabId) ?? 0) + 1; - consentRevisions.set(tabId, revision); - const previous = consentByTab.get(tabId) ?? Promise.resolve(); - const pending = previous - .catch(() => undefined) - .then(async () => { - // Event-time revocation is sticky even if a later update observes - // access restored. CDP must detach for the revoked interval. - if (revoked) { - await suspendTab(tabId, { detachInactive: true }); - } - if (consentRevisions.get(tabId) !== revision) { - return; - } - let accessible = false; - try { - accessible = await isTabAccessible(tabId); - } catch { - // Missing tab state is treated as revoked consent. - } - if (!accessible) { - await suspendTab(tabId, { detachInactive: true }); - } - if (consentRevisions.get(tabId) !== revision) { - return; - } - try { - accessible = await isTabAccessible(tabId); - } catch { - accessible = false; - } - if (consentRevisions.get(tabId) !== revision) { - return; - } - if (accessible) { - await restoreDebuggerIfReleased(tabId); - } - await refreshPanelState(tabId, { accessible, suspended: !accessible }); - }) - .finally(() => { - if (consentByTab.get(tabId) === pending) { - consentByTab.delete(tabId); - } - }); - consentByTab.set(tabId, pending); - return pending; - }), - ); - } - - async function preparePanel(tabId) { - if (!Number.isInteger(tabId)) { - throw new Error("No active tab."); - } - await chromeApi.tabs.get(tabId); - const binding = await panelBindings.bind(tabId); - return { path: `${PANEL_PATH}?binding=${encodeURIComponent(binding)}` }; - } - - async function connectPort(port) { - await initialize(); - let tabId; - try { - tabId = await resolveSidePanelTabId(chromeApi, port, panelBindings); - } catch (error) { - post(port, { type: "panel.state", state: "denied", label: error.message }); - port.disconnect(); - return; - } - const ports = portsByTab.get(tabId) ?? new Set(); - ports.add(port); - portsByTab.set(tabId, ports); - const portRevision = (portRevisions.get(tabId) ?? 0) + 1; - portRevisions.set(tabId, portRevision); - port.onMessage.addListener((message) => { - void (async () => { - try { - if (message?.type === "panel.send") { - await sendMessage(tabId, port, portRevision, message.message); - } else if (message?.type === "panel.share") { - await grantAccess(tabId); - } else if (message?.type === "panel.refresh") { - await refreshPanelState(tabId); - } - } catch (error) { - post(port, { type: "panel.error", message: error.message }); - } - })(); - }); - port.onDisconnect.addListener(() => { - ports.delete(port); - if (ports.size === 0) { - portsByTab.delete(tabId); - const disconnectedRevision = (portRevisions.get(tabId) ?? 0) + 1; - portRevisions.set(tabId, disconnectedRevision); - void scheduleSuspend(tabId, disconnectedRevision); - } - }); - await suspendByTab.get(tabId); - await refreshPanelState(tabId, { ensureSetup: true, hydrateHistory: true }); - } - - async function revokeActiveBindings(gatewayScope) { - const activeEntries = registry - .list() - .filter((entry) => entry.gatewayScope === gatewayScope && entry.activeRunId); - await Promise.allSettled([ - registry.queueActiveAborts(gatewayScope), - ...activeEntries.map((entry) => revokeDebugger(entry.tabId)), - ]); - } - - gateway.onStatus((status) => { - const statusRevision = ++gatewayStatusRevision; - // A new connection epoch owns its own abort retry timer. - clearAbortRetry(); - subscribedKeys.clear(); - if (status.state === "ready") { - const gatewayScope = currentGatewayScope(); - lastReadyStatus = status; - gatewayStatus = { state: "connecting", label: "Reconciling previous tab runs" }; - broadcastStatus(); - void runLifecycle(() => - reconcileGatewayReady(status, statusRevision, gatewayScope, pendingGatewayRevocation), - ).catch(() => { - if (gatewayScope === currentGatewayScope() && statusRevision === gatewayStatusRevision) { - gatewayStatus = { state: "error", label: "Could not reconcile previous tab runs" }; - broadcastStatus(); - } - }); - return; - } - reconciledGatewayStatusRevision = 0; - const gatewayScope = currentGatewayScope(); - if (gatewayScope) { - pendingGatewayRevocation = revokeActiveBindings(gatewayScope); - } else { - pendingGatewayRevocation = Promise.resolve(); - } - lastReadyStatus = null; - gatewayStatus = status; - broadcastStatus(); - }); - - gateway.onEvent((event) => { - const sessionKey = sessionKeyFromEvent(event); - if (!sessionKey) { - return; - } - for (const [tabId, ports] of portsByTab) { - const entry = registry.get(tabId, currentGatewayScope()); - if (entry?.sessionKey !== sessionKey || !subscribedKeys.has(sessionKey)) { - continue; - } - // A session stays subscribed across turns; only its persisted active run - // may stream or unlock the panel after a delayed earlier Gateway event. - if (event.event === "chat" && event.payload?.runId !== entry.activeRunId) { - continue; - } - for (const port of ports) { - post(port, { type: "panel.event", event }); - } - const state = event.payload?.state; - if (event.event === "session.message") { - scheduleHydrate(tabId); - } - if ( - event.event === "chat" && - (state === "final" || state === "aborted" || state === "error") - ) { - const runId = event.payload?.runId; - if (typeof runId === "string" && entry.activeRunId === runId) { - const gatewayScope = currentGatewayScope(); - sendsByTab.delete(tabId); - scheduleHydrate(tabId); - if (gatewayScope) { - void registry - .finishRun(gatewayScope, entry.sessionKey, runId) - .then(async (finished) => { - if (finished) { - await restoreDebuggerIfReleased(tabId); - void refreshPanelState(tabId); - } - void drainArchives(gatewayScope); - }); - continue; - } - } - void drainArchives(); - } - } - }); - - chromeApi.runtime.onConnect.addListener((port) => { - if (port.name === PANEL_PORT) { - void connectPort(port); - } - }); - - return { - initializeCustody, - initialize, - preparePanel, - onConsentChanged, - onRelayStatus: (status) => relayCustody.onStatus(status), - onTabRemoved, - refreshConfig, - drainAborts, - drainArchives, - drainStaleScopes, - registry, - }; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-background.test.ts b/extensions/browser/chrome-extension/modules/copilot-background.test.ts deleted file mode 100644 index 9659c05eb047..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-background.test.ts +++ /dev/null @@ -1,739 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - archiveCopilotSession, - resolveSidePanelTabId, - selectCopilotPanelState, -} from "./copilot-background-shared.js"; -import { createCopilotController } from "./copilot-background.js"; -import { deriveCopilotSessionLabel } from "./panel-core.js"; - -function eventHook() { - return { addListener: vi.fn() }; -} - -function storageArea(initial: Record = {}) { - const values = { ...initial }; - return { - get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))), - set: vi.fn(async (update: Record) => { - Object.assign(values, update); - }), - }; -} - -describe("browser copilot background", () => { - it("serializes config refreshes so a stale pairing cannot outlive unpair", async () => { - let resolveInitial: ((config: Record) => void) | undefined; - const getConfig = vi - .fn() - .mockImplementationOnce( - async () => - await new Promise>((resolve) => { - resolveInitial = resolve; - }), - ) - .mockResolvedValue({ relayUrl: "", gatewayUrl: "" }); - const gateway = { - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const storage = { local: storageArea(), session: storageArea() }; - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => []) }, - storage, - } as never, - getConfig, - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(), - restoreDebugger: vi.fn(), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - - const initializing = controller.initialize(); - await vi.waitFor(() => expect(getConfig).toHaveBeenCalledTimes(1)); - const unpairing = controller.refreshConfig(); - await Promise.resolve(); - expect(getConfig).toHaveBeenCalledTimes(1); - resolveInitial?.({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: "ws://127.0.0.1:18789", - }); - await Promise.all([initializing, unpairing]); - - expect(gateway.start).toHaveBeenCalledTimes(1); - const lastStop = Math.max(...gateway.stop.mock.invocationCallOrder); - expect(gateway.start.mock.invocationCallOrder[0]).toBeLessThan(lastStop); - }); - - it("serializes stale-scope destruction with config changes", async () => { - const oldScope = "ws://127.0.0.1:18789/"; - const newScope = "ws://127.0.0.1:28789/"; - let releaseRequest: (() => void) | undefined; - const requestGate = new Promise((resolve) => { - releaseRequest = resolve; - }); - const request = vi.fn(async () => { - await requestGate; - return { ok: true }; - }); - let reportRecoveryStatus: ((status: Record) => void) | undefined; - const recoveryGateway = { - onStatus: vi.fn((listener) => { - reportRecoveryStatus = listener; - return vi.fn(); - }), - request, - start: vi.fn(() => reportRecoveryStatus?.({ state: "ready" })), - stop: vi.fn(), - }; - const gateway = { - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const getConfig = vi - .fn() - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:28792/browser/extension", - gatewayUrl: newScope, - }) - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: oldScope, - }); - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 14 }]) }, - storage: { - local: storageArea({ - copilotSessionRegistryV1: { - sessions: { - 14: { - tabId: 14, - browserInstanceId: "browser-instance", - gatewayScope: oldScope, - sessionKey: "session-old", - activeRunId: "run-old", - }, - }, - pendingArchives: [], - }, - }), - session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), - }, - } as never, - getConfig, - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(async () => undefined), - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - recoveryGatewayFactory: () => recoveryGateway as never, - }); - await controller.initialize(); - - const recovery = controller.drainStaleScopes(); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); - const reconfigure = controller.refreshConfig(); - await Promise.resolve(); - expect(getConfig).toHaveBeenCalledTimes(1); - - releaseRequest?.(); - await Promise.all([recovery, reconfigure]); - expect(getConfig).toHaveBeenCalledTimes(2); - expect(gateway.start).toHaveBeenLastCalledWith(oldScope); - }); - - it("gives the new Gateway epoch its own abort retry", async () => { - vi.useFakeTimers(); - try { - const oldScope = "ws://127.0.0.1:18789/"; - const newScope = "ws://127.0.0.1:28789/"; - const request = vi - .fn() - .mockRejectedValueOnce(new Error("old Gateway unavailable")) - .mockRejectedValueOnce(new Error("new Gateway unavailable")) - .mockResolvedValue({ ok: true }); - const gateway = { - ready: true, - onEvent: vi.fn(), - onStatus: vi.fn(), - request, - start: vi.fn(), - stop: vi.fn(), - }; - const getConfig = vi - .fn() - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: oldScope, - }) - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:28792/browser/extension", - gatewayUrl: newScope, - }); - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 1 }, { id: 2 }]) }, - storage: { local: storageArea(), session: storageArea() }, - } as never, - getConfig, - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(async () => undefined), - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - await controller.registry.put(1, { gatewayScope: oldScope, sessionKey: "session-old" }); - await controller.registry.startRun(1, oldScope, "run-old"); - - await controller.refreshConfig(); - await controller.registry.put(2, { gatewayScope: newScope, sessionKey: "session-new" }); - await controller.registry.startRun(2, newScope, "run-new"); - await controller.registry.queueAbort(2, newScope); - await controller.drainAborts(newScope); - expect(request).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(250); - expect(request).toHaveBeenCalledTimes(3); - expect(controller.registry.pendingAborts(newScope)).toEqual([]); - } finally { - vi.useRealTimers(); - } - }); - - it("finishes ready reconciliation before switching Gateway clients", async () => { - const oldScope = "ws://127.0.0.1:18789/"; - const newScope = "ws://127.0.0.1:28789/"; - let reportStatus: ((status: Record) => void) | undefined; - let releaseAbort: (() => void) | undefined; - const abortGate = new Promise((resolve) => { - releaseAbort = resolve; - }); - const request = vi.fn(async () => { - await abortGate; - return { ok: true }; - }); - const gateway = { - ready: true, - onEvent: vi.fn(), - onStatus: vi.fn((listener) => { - reportStatus = listener; - }), - request, - start: vi.fn(), - stop: vi.fn(), - }; - const getConfig = vi - .fn() - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: oldScope, - }) - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:28792/browser/extension", - gatewayUrl: newScope, - }); - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 1 }]) }, - storage: { local: storageArea(), session: storageArea() }, - } as never, - getConfig, - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(async () => undefined), - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - await controller.registry.put(1, { gatewayScope: oldScope, sessionKey: "session-old" }); - await controller.registry.startRun(1, oldScope, "run-old"); - - reportStatus?.({ state: "ready", label: "Connected" }); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); - const reconfigure = controller.refreshConfig(); - await Promise.resolve(); - expect(getConfig).toHaveBeenCalledTimes(1); - - releaseAbort?.(); - await reconfigure; - expect(getConfig).toHaveBeenCalledTimes(2); - expect(gateway.start).toHaveBeenLastCalledWith(newScope); - }); - - it("does not strand the controller when old-scope storage cleanup fails", async () => { - const oldScope = "ws://127.0.0.1:18789/"; - const newScope = "ws://127.0.0.1:28789/"; - const gateway = { - ready: true, - onEvent: vi.fn(), - onStatus: vi.fn(), - request: vi.fn(async () => ({ ok: true })), - start: vi.fn(), - stop: vi.fn(), - }; - const getConfig = vi - .fn() - .mockResolvedValueOnce({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: oldScope, - }) - .mockResolvedValue({ - relayUrl: "ws://127.0.0.1:28792/browser/extension", - gatewayUrl: newScope, - }); - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => []) }, - storage: { local: storageArea(), session: storageArea() }, - } as never, - getConfig, - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(async () => undefined), - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - vi.spyOn(controller.registry, "closeScope").mockRejectedValueOnce( - new Error("storage unavailable"), - ); - - await expect(controller.refreshConfig()).resolves.toBeUndefined(); - expect(gateway.stop).toHaveBeenCalled(); - expect(gateway.start).toHaveBeenLastCalledWith(newScope); - await expect(controller.refreshConfig()).resolves.toBeUndefined(); - }); - - it("processes an observed revocation before a later re-share", async () => { - const gatewayScope = "ws://127.0.0.1:18789/"; - const revokeDebugger = vi.fn(async () => undefined); - const gateway = { - ready: false, - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { - query: vi.fn(async () => [{ id: 12 }]), - get: vi.fn(async () => ({ id: 12, title: "Fixture", url: "https://example.test" })), - }, - storage: { local: storageArea(), session: storageArea() }, - } as never, - getConfig: vi.fn(async () => ({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: gatewayScope, - })), - isTabAccessible: vi.fn(async () => true), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger, - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - await controller.registry.put(12, { gatewayScope, sessionKey: "session-12" }); - await controller.registry.startRun(12, gatewayScope, "run-12"); - - const revoked = controller.onConsentChanged(12, { revoked: true }); - const reshared = controller.onConsentChanged(12); - await Promise.all([revoked, reshared]); - - expect(revokeDebugger).toHaveBeenCalledWith(12); - expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ - expect.objectContaining({ activeRunId: "run-12", abortPending: true }), - ]); - }); - - it("keeps ordinary active runs visible and gates only abort reconciliation", () => { - expect( - selectCopilotPanelState({ - paired: true, - accessible: true, - abortPending: false, - gatewayState: "ready", - }), - ).toBe("ready"); - expect( - selectCopilotPanelState({ - paired: true, - accessible: true, - abortPending: true, - gatewayState: "ready", - }), - ).toBe("reconciling"); - }); - - it("revokes an active debugger binding as soon as the Gateway disconnects", async () => { - let reportStatus: ((status: Record) => void) | undefined; - const revokeDebugger = vi.fn(async () => undefined); - const gateway = { - ready: false, - onEvent: vi.fn(), - onStatus: vi.fn((listener) => { - reportStatus = listener; - }), - start: vi.fn(), - stop: vi.fn(), - }; - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 12 }]) }, - storage: { local: storageArea(), session: storageArea() }, - } as never, - getConfig: vi.fn(async () => ({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: "ws://127.0.0.1:18789", - })), - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger, - restoreDebugger: vi.fn(), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - const gatewayScope = "ws://127.0.0.1:18789/"; - await controller.registry.put(12, { - gatewayScope, - sessionKey: "session-12", - }); - await controller.registry.startRun(12, gatewayScope, "run-12"); - - reportStatus?.({ state: "connecting", label: "Gateway reconnecting" }); - - await vi.waitFor(() => expect(revokeDebugger).toHaveBeenCalledWith(12)); - expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ - expect.objectContaining({ activeRunId: "run-12", abortPending: true }), - ]); - }); - - it("revokes and aborts active custody when the browser relay disconnects", async () => { - const gatewayScope = "ws://127.0.0.1:18789/"; - const revokeDebugger = vi.fn(async () => undefined); - const request = vi.fn(async () => ({ ok: true })); - const localStorage = storageArea(); - const gateway = { - ready: true, - onEvent: vi.fn(), - onStatus: vi.fn(), - request, - start: vi.fn(), - stop: vi.fn(), - }; - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 12 }]) }, - storage: { local: localStorage, session: storageArea() }, - } as never, - getConfig: vi.fn(async () => ({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: gatewayScope, - })), - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger, - restoreDebugger: vi.fn(async () => undefined), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - await controller.initialize(); - await controller.onRelayStatus({ ready: true, label: "Browser relay connected" }); - await controller.registry.put(12, { gatewayScope, sessionKey: "session-12" }); - await controller.registry.startRun(12, gatewayScope, "run-12"); - localStorage.set.mockRejectedValueOnce(new Error("transient session storage failure")); - - await controller.onRelayStatus({ ready: false, label: "Browser relay reconnecting" }); - - expect(revokeDebugger).toHaveBeenCalledWith(12); - expect(request).toHaveBeenCalledWith("sessions.abort", { - key: "session-12", - runId: "run-12", - }); - expect(controller.registry.pendingAborts(gatewayScope)).toEqual([]); - await expect( - controller.onRelayStatus({ ready: true, label: "Browser relay connected" }), - ).resolves.toBeUndefined(); - expect(controller.registry.get(12, gatewayScope)).not.toHaveProperty("activeRunId"); - }); - - it("restores durable debugger denial before a suspended worker reconnects", async () => { - const gatewayScope = "ws://127.0.0.1:18789/"; - const revokeDebugger = vi.fn(async () => undefined); - const gateway = { - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 12 }]) }, - storage: { - local: storageArea({ - copilotSessionRegistryV1: { - sessions: { - 12: { - tabId: 12, - browserInstanceId: "browser-instance", - gatewayScope, - sessionKey: "session-12", - activeRunId: "run-12", - }, - }, - pendingArchives: [], - }, - }), - session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), - }, - } as never, - getConfig: vi.fn(async () => ({ - relayUrl: "ws://127.0.0.1:18792/browser/extension", - gatewayUrl: "ws://127.0.0.1:18789", - })), - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger, - restoreDebugger: vi.fn(), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - - await controller.initialize(); - - expect(revokeDebugger).toHaveBeenCalledWith(12); - expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ - expect.objectContaining({ activeRunId: "run-12", abortPending: true }), - ]); - }); - - it("starts the configured Gateway while cleaning a persisted old scope separately", async () => { - const oldScope = "ws://127.0.0.1:18789/"; - const request = vi.fn(async () => ({ ok: true })); - let reportRecoveryStatus: ((status: Record) => void) | undefined; - const recoveryGateway = { - onStatus: vi.fn((listener) => { - reportRecoveryStatus = listener; - return vi.fn(); - }), - request, - start: vi.fn(() => reportRecoveryStatus?.({ state: "ready" })), - stop: vi.fn(), - }; - const gateway = { - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const restoreDebugger = vi.fn(async () => undefined); - const controller = createCopilotController({ - chromeApi: { - runtime: { onConnect: eventHook() }, - tabs: { query: vi.fn(async () => [{ id: 14 }]) }, - storage: { - local: storageArea({ - copilotSessionRegistryV1: { - sessions: { - 14: { - tabId: 14, - browserInstanceId: "browser-instance", - gatewayScope: oldScope, - sessionKey: "session-old", - activeRunId: "run-old", - }, - }, - pendingArchives: [], - }, - }), - session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), - }, - } as never, - getConfig: vi.fn(async () => ({ - relayUrl: "ws://127.0.0.1:28792/browser/extension", - gatewayUrl: "ws://127.0.0.1:28789", - })), - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(async () => undefined), - restoreDebugger, - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - recoveryGatewayFactory: () => recoveryGateway as never, - }); - - await controller.initialize(); - - expect(gateway.start).toHaveBeenCalledWith("ws://127.0.0.1:28789/"); - expect(recoveryGateway.start).not.toHaveBeenCalled(); - await controller.drainStaleScopes(); - expect(recoveryGateway.start).toHaveBeenCalledWith(oldScope); - expect(request.mock.calls).toEqual([ - ["sessions.abort", { key: "session-old", runId: "run-old" }], - ["sessions.messages.unsubscribe", { key: "session-old" }], - ["sessions.abort", { key: "session-old" }], - ["sessions.patch", { key: "session-old", archived: true }], - ]); - expect(restoreDebugger).toHaveBeenCalledWith(14); - expect(controller.registry.gatewayScopes()).toEqual([]); - }); - - it("accepts only capability-bound live side-panel contexts", async () => { - const chromeApi = { - runtime: { - id: "extension-id", - getContexts: vi.fn(async () => [ - { - contextType: "SIDE_PANEL", - documentId: "doc-a", - documentUrl: "chrome-extension://extension-id/sidepanel.html?binding=cap-a", - tabId: -1, - }, - ]), - }, - }; - const panelBindings = { resolve: vi.fn(async (token) => (token === "cap-a" ? 12 : null)) }; - await expect( - resolveSidePanelTabId( - chromeApi as never, - { - sender: { - documentId: "doc-a", - url: "chrome-extension://extension-id/sidepanel.html?binding=cap-a", - }, - } as never, - panelBindings as never, - ), - ).resolves.toBe(12); - await expect( - resolveSidePanelTabId( - chromeApi as never, - { - sender: { - url: "chrome-extension://extension-id/sidepanel.html?binding=forged", - }, - } as never, - panelBindings as never, - ), - ).rejects.toThrow("live tab binding"); - }); - - it("prepares a unique tab-specific panel path without a global option", async () => { - vi.spyOn(crypto, "randomUUID").mockReturnValue("44444444-4444-4444-8444-444444444444"); - const gateway = { - onEvent: vi.fn(), - onStatus: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - const chromeApi = { - runtime: { onConnect: eventHook() }, - tabs: { get: vi.fn(async () => ({ id: 44 })) }, - storage: { local: storageArea(), session: storageArea() }, - }; - const controller = createCopilotController({ - chromeApi: chromeApi as never, - getConfig: vi.fn(), - isTabAccessible: vi.fn(), - grantTabAccess: vi.fn(), - attachDebugger: vi.fn(), - detachDebugger: vi.fn(), - revokeDebugger: vi.fn(), - restoreDebugger: vi.fn(), - scheduleTabsSync: vi.fn(), - gateway: gateway as never, - }); - - await expect(controller.preparePanel(44)).resolves.toEqual({ - path: "sidepanel.html?binding=44444444-4444-4444-8444-444444444444", - }); - }); - - it("stops delivery and archives after aborting active work", async () => { - const request = vi.fn(async () => ({ ok: true })); - await archiveCopilotSession( - { request } as never, - { sessionKey: "session-7", sessionId: "id-7" } as never, - ); - expect(request.mock.calls).toEqual([ - ["sessions.messages.unsubscribe", { key: "session-7" }], - ["sessions.abort", { key: "session-7" }], - ["sessions.patch", { key: "session-7", archived: true }], - ]); - }); - - it("still attempts the authoritative archive when unsubscribe and abort fail", async () => { - const request = vi - .fn() - .mockRejectedValueOnce(new Error("socket allowlist already gone")) - .mockRejectedValueOnce(new Error("no active run")) - .mockResolvedValueOnce({ ok: true }); - await expect( - archiveCopilotSession( - { request } as never, - { sessionKey: "session-8", sessionId: "id-8" } as never, - ), - ).resolves.toBeUndefined(); - expect(request).toHaveBeenLastCalledWith("sessions.patch", { - key: "session-8", - archived: true, - }); - }); - - it("replays ambiguous session creation before archiving its key", async () => { - const sessionKey = - "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111"; - const request = vi.fn(async () => ({ ok: true })); - await archiveCopilotSession({ request } as never, { sessionKey, ensureCreated: true } as never); - expect(request.mock.calls).toEqual([ - ["sessions.create", { key: sessionKey, label: deriveCopilotSessionLabel(sessionKey) }], - ["sessions.messages.unsubscribe", { key: sessionKey }], - ["sessions.abort", { key: sessionKey }], - ["sessions.patch", { key: sessionKey, archived: true }], - ]); - }); -}); diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts deleted file mode 100644 index 2adcbf02b773..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -type StorageArea = { - get(keys: string[]): Promise>; - set(update: Record): Promise; -}; - -type CopilotIdentity = { - deviceId: string; - publicKey: string; - sign(payload: string): Promise; -}; - -type TokenParams = { - clientId: string; - deviceId: string; - role: string; -}; - -type StoredToken = { - token: string; - scopes: string[]; -}; - -export function loadOrCreateCopilotIdentity( - storage: StorageArea, - gatewayScope: string, -): Promise; - -export function createCopilotTokenStore( - storage: StorageArea, - gatewayScope: string, -): { - load: (params: TokenParams) => Promise; - store: (params: TokenParams & StoredToken) => Promise; - clear: (params: TokenParams) => Promise; -}; - -export function resolveCopilotClose(context: { - connectFailure?: { - error?: { - details?: { code?: string; pauseReconnect?: boolean }; - }; - }; -}): { - retry: boolean; - notify: boolean; - pendingError: unknown; -}; diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js deleted file mode 100644 index eb63299a13d5..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js +++ /dev/null @@ -1,131 +0,0 @@ -import { ed25519Utils, getPublicKeyAsync, signAsync } from "./copilot-runtime.js"; - -const IDENTITIES_KEY = "copilotDeviceIdentitiesV1"; -const TOKENS_KEY = "copilotDeviceTokensV1"; -// Main and stale-scope clients share one Chrome storage map. Serialize the -// full read-modify-write or a late client can erase another scope's credential. -const credentialStorageTails = new WeakMap(); - -function withCredentialStorage(storage, operation) { - const previous = credentialStorageTails.get(storage) ?? Promise.resolve(); - const result = previous.catch(() => undefined).then(operation); - const tail = result.then( - () => undefined, - () => undefined, - ); - credentialStorageTails.set(storage, tail); - return result.finally(() => { - if (credentialStorageTails.get(storage) === tail) { - credentialStorageTails.delete(storage); - } - }); -} - -function toBase64Url(bytes) { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function fromBase64Url(value) { - const padded = value - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(Math.ceil(value.length / 4) * 4, "="); - return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); -} - -async function sha256Hex(bytes) { - const digest = await crypto.subtle.digest("SHA-256", bytes); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -export async function loadOrCreateCopilotIdentity(storage, gatewayScope) { - return await withCredentialStorage(storage, async () => { - const identities = (await storage.get([IDENTITIES_KEY]))[IDENTITIES_KEY]; - const stored = identities?.[gatewayScope]; - if ( - typeof stored?.deviceId === "string" && - typeof stored?.publicKey === "string" && - typeof stored?.secretKey === "string" - ) { - const secretKey = fromBase64Url(stored.secretKey); - return { - deviceId: stored.deviceId, - publicKey: stored.publicKey, - sign: async (payload) => - toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)), - }; - } - const secretKey = ed25519Utils.randomSecretKey(); - const publicKeyBytes = await getPublicKeyAsync(secretKey); - const identity = { - deviceId: await sha256Hex(publicKeyBytes), - publicKey: toBase64Url(publicKeyBytes), - secretKey: toBase64Url(secretKey), - }; - await storage.set({ - [IDENTITIES_KEY]: { - ...(identities && typeof identities === "object" ? identities : {}), - [gatewayScope]: identity, - }, - }); - return { - deviceId: identity.deviceId, - publicKey: identity.publicKey, - sign: async (payload) => - toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)), - }; - }); -} - -function tokenKey(gatewayScope, { clientId, deviceId, role }) { - return `${gatewayScope}\n${clientId}:${deviceId}:${role}`; -} - -export function createCopilotTokenStore(storage, gatewayScope) { - return { - async load(params) { - return await withCredentialStorage(storage, async () => { - const tokens = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; - const record = tokens?.[tokenKey(gatewayScope, params)]; - return typeof record?.token === "string" && Array.isArray(record.scopes) ? record : null; - }); - }, - async store(params) { - await withCredentialStorage(storage, async () => { - const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; - const tokens = current && typeof current === "object" ? { ...current } : {}; - tokens[tokenKey(gatewayScope, params)] = { - token: params.token, - scopes: [...params.scopes], - }; - await storage.set({ [TOKENS_KEY]: tokens }); - }); - }, - async clear(params) { - await withCredentialStorage(storage, async () => { - const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; - if (!current || typeof current !== "object") { - return; - } - const tokens = { ...current }; - delete tokens[tokenKey(gatewayScope, params)]; - await storage.set({ [TOKENS_KEY]: tokens }); - }); - }, - }; -} - -export function resolveCopilotClose(context) { - const details = context.connectFailure?.error?.details; - const tokenMismatch = details?.code === "AUTH_DEVICE_TOKEN_MISMATCH"; - return { - retry: - details?.code === "PAIRING_REQUIRED" || (!tokenMismatch && details?.pauseReconnect !== true), - notify: !context.connectFailure, - pendingError: context.connectFailure?.error, - }; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts b/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts deleted file mode 100644 index 882aba2ce520..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -type StorageArea = { - get(keys: string[]): Promise>; - set(update: Record): Promise; -}; - -export function isDefinitiveGatewayRejection(error: unknown): boolean; -export function waitForCopilotGatewayReady( - client: CopilotGatewayClient, - gatewayScope: string, -): Promise; - -export class CopilotGatewayClient { - constructor(options?: { storage?: StorageArea; WebSocketImpl?: typeof WebSocket }); - ready: boolean; - hello: Record | null; - onEvent(listener: (event: unknown) => void): () => void; - onStatus(listener: (status: Record) => void): () => void; - start(url: string): void; - stop(): void; - request(method: string, params: unknown, options?: unknown): Promise; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.js b/extensions/browser/chrome-extension/modules/copilot-gateway.js deleted file mode 100644 index 0fdeb954f493..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-gateway.js +++ /dev/null @@ -1,347 +0,0 @@ -import { - createCopilotTokenStore, - loadOrCreateCopilotIdentity, - resolveCopilotClose, -} from "./copilot-gateway-lifecycle.js"; -import { - GATEWAY_CLIENT_CAPS, - GATEWAY_CLIENT_IDS, - GATEWAY_CLIENT_MODES, - GatewayBrowserDeviceAuthLifecycle, - GatewayProtocolClient, - GatewayProtocolRequestError, - MIN_CLIENT_PROTOCOL_VERSION, - PROTOCOL_VERSION, -} from "./copilot-runtime.js"; -import { normalizeGatewayUrl } from "./panel-core.js"; - -const CLIENT_ID = GATEWAY_CLIENT_IDS.BROWSER_COPILOT; -const CLIENT_MODE = GATEWAY_CLIENT_MODES.UI; -const ROLE = "operator"; -const SCOPES = ["operator.read", "operator.write"]; -// Keep browser opening bounded by the Gateway's default preauth deadline. -const COPILOT_GATEWAY_OPENING_TIMEOUT_MS = 15_000; -export function isDefinitiveGatewayRejection(error) { - return error instanceof GatewayProtocolRequestError; -} - -export async function waitForCopilotGatewayReady(client, gatewayScope) { - await new Promise((resolve, reject) => { - let settled = false; - const finish = (error) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - unsubscribe(); - if (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } else { - resolve(); - } - }; - const unsubscribe = client.onStatus((status) => { - if (status.state === "ready") { - finish(); - } else if ( - status.state === "approval" || - status.state === "denied" || - status.state === "error" - ) { - finish(new Error(status.label || "Gateway recovery failed")); - } - }); - const timer = setTimeout(() => finish(new Error("Gateway recovery timed out")), 30_000); - client.start(gatewayScope); - }); -} - -function createBrowserSocket(url, handlers, WebSocketImpl) { - const socket = new WebSocketImpl(url); - let opening = true; - let openingTimedOut = false; - let openingTimer; - const finishOpening = () => { - opening = false; - if (openingTimer !== undefined) { - clearTimeout(openingTimer); - openingTimer = undefined; - } - }; - socket.addEventListener("open", () => { - finishOpening(); - handlers.open(); - }); - socket.addEventListener("message", (event) => handlers.message(String(event.data))); - socket.addEventListener("close", (event) => { - finishOpening(); - handlers.close(event.code, event.reason ?? ""); - }); - socket.addEventListener("error", () => { - finishOpening(); - if (!openingTimedOut) { - handlers.error(new Error("Gateway WebSocket error")); - } - }); - openingTimer = setTimeout(() => { - openingTimer = undefined; - if (!opening) { - return; - } - opening = false; - openingTimedOut = true; - try { - handlers.error( - new Error( - `Gateway WebSocket opening timed out after ${COPILOT_GATEWAY_OPENING_TIMEOUT_MS}ms`, - ), - ); - } finally { - socket.close(); - } - }, COPILOT_GATEWAY_OPENING_TIMEOUT_MS); - return { - isOpen: () => socket.readyState === WebSocketImpl.OPEN, - send: (data) => socket.send(data), - close: (code, reason) => { - finishOpening(); - // Browsers reject client-initiated policy close 1008; 4008 is wire-safe. - socket.close(code === 1008 ? 4008 : code, reason); - }, - }; -} - -/** Dedicated browser-copilot Gateway client. It never accepts or stores shared auth. */ -export class CopilotGatewayClient { - constructor({ storage = chrome.storage.local, WebSocketImpl = WebSocket } = {}) { - this.storage = storage; - this.WebSocketImpl = WebSocketImpl; - this.protocol = null; - this.url = null; - this.ready = false; - this.hello = null; - this.listeners = new Set(); - this.statusListeners = new Set(); - this.lifecycle = null; - this.tokenRecovery = null; - this.tickWatchTimer = null; - this.lastInboundActivityAtMs = null; - } - - onEvent(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - onStatus(listener) { - this.statusListeners.add(listener); - return () => this.statusListeners.delete(listener); - } - - start(url) { - const gatewayScope = normalizeGatewayUrl(url); - if (!gatewayScope) { - this.stop(); - this.#emitStatus({ state: "error", label: "Invalid Gateway endpoint" }); - return; - } - if (this.protocol && this.url === gatewayScope) { - return; - } - this.stop(); - this.url = gatewayScope; - const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ - loadIdentity: () => loadOrCreateCopilotIdentity(this.storage, gatewayScope), - tokenStore: createCopilotTokenStore(this.storage, gatewayScope), - }); - this.lifecycle = lifecycle; - this.#emitStatus({ state: "connecting", label: "Connecting to Gateway" }); - const protocol = new GatewayProtocolClient({ - createSocket: (handlers) => createBrowserSocket(gatewayScope, handlers, this.WebSocketImpl), - createRequestId: () => crypto.randomUUID(), - buildConnectPlan: ({ nonce, challengeTs }) => - lifecycle.buildPlan({ - client: { - id: CLIENT_ID, - version: chrome.runtime.getManifest().version, - platform: "chrome", - deviceFamily: "extension", - mode: CLIENT_MODE, - }, - role: ROLE, - defaultScopes: SCOPES, - nonce, - challengeTs, - }), - buildConnectParams: (plan) => ({ - minProtocol: MIN_CLIENT_PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: CLIENT_ID, - version: chrome.runtime.getManifest().version, - platform: "chrome", - deviceFamily: "extension", - mode: CLIENT_MODE, - }, - role: ROLE, - scopes: plan.scopes, - caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], - auth: plan.auth, - device: plan.device, - userAgent: navigator.userAgent, - locale: navigator.language, - }), - onConnectHello: (hello, { plan }) => { - void lifecycle.acceptHello(hello, plan); - }, - onHello: (hello) => { - this.ready = true; - this.hello = hello; - this.#startTickWatch(hello, protocol); - this.#emitStatus({ state: "ready", label: "Gateway connected", hello }); - }, - onConnectFailure: (error, { plan }) => { - const details = error.details && typeof error.details === "object" ? error.details : {}; - if (details.code === "AUTH_DEVICE_TOKEN_MISMATCH") { - const cleared = lifecycle.clearStoredToken(plan); - void cleared.catch(() => undefined); - this.tokenRecovery = { gatewayScope, protocol, cleared }; - } - this.#emitStatus({ - state: details.code === "PAIRING_REQUIRED" ? "approval" : "error", - label: error.message, - requestId: typeof details.requestId === "string" ? details.requestId : undefined, - }); - return { - closeCode: 4008, - closeReason: "connect failed", - reconnectDelayMs: details.code === "PAIRING_REQUIRED" ? 2_000 : undefined, - stop: - details.code === "AUTH_DEVICE_TOKEN_MISMATCH" || - (details.pauseReconnect === true && details.code !== "PAIRING_REQUIRED"), - }; - }, - resolveClose: resolveCopilotClose, - onClose: (_context, decision) => { - if (this.protocol !== protocol) { - return; - } - this.#stopTickWatch(); - this.ready = false; - this.hello = null; - if (!decision.retry) { - this.protocol = null; - this.lifecycle = null; - } - const recovery = this.tokenRecovery; - if (!decision.retry && recovery?.protocol === protocol) { - /** @param {unknown} error */ - const onClearRejected = (error) => { - if (this.tokenRecovery !== recovery) { - return; - } - this.tokenRecovery = null; - this.#emitStatus({ - state: "error", - label: - error instanceof Error - ? error.message - : "Could not clear the rejected device token", - }); - }; - void recovery.cleared.then(() => { - if ( - this.tokenRecovery !== recovery || - this.protocol || - this.url !== recovery.gatewayScope - ) { - return; - } - this.tokenRecovery = null; - this.start(recovery.gatewayScope); - }, onClearRejected); - } - if (decision.notify) { - this.#emitStatus({ state: "connecting", label: "Gateway reconnecting" }); - } - }, - onConnectError: (error) => - this.#emitStatus({ state: "error", label: error.message || "Gateway unavailable" }), - onActivity: () => { - if (this.protocol === protocol && this.ready) { - this.lastInboundActivityAtMs = Date.now(); - } - }, - onEvent: (event) => { - for (const listener of this.listeners) { - listener(event); - } - }, - handshake: { mode: "require-challenge", timeoutMs: 5_000 }, - reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }, - requestTimeoutMs: 30_000, - }); - this.protocol = protocol; - protocol.start(); - } - - stop() { - this.#stopTickWatch(); - this.ready = false; - this.hello = null; - this.tokenRecovery = null; - const protocol = this.protocol; - this.protocol = null; - protocol?.stop(); - this.lifecycle = null; - this.url = null; - } - - request(method, params, options) { - if (!this.ready || !this.protocol) { - return Promise.reject(new Error("Gateway is not ready")); - } - return this.protocol.request(method, params, options); - } - - #startTickWatch(hello, protocol) { - this.#stopTickWatch(); - const advertised = hello?.policy?.tickIntervalMs; - // Gateway policy is remote input; clamp it before allocating a browser timer. - const intervalMs = Math.min( - 2_147_483_647, - Math.max( - 1_000, - typeof advertised === "number" && Number.isFinite(advertised) && advertised > 0 - ? Math.floor(advertised) - : 30_000, - ), - ); - this.lastInboundActivityAtMs = Date.now(); - this.tickWatchTimer = setInterval(() => { - if ( - this.protocol === protocol && - this.ready && - this.lastInboundActivityAtMs !== null && - Date.now() - this.lastInboundActivityAtMs > intervalMs * 2 - ) { - protocol.closeSocket(4000, "tick timeout"); - } - }, intervalMs); - } - - #stopTickWatch() { - if (this.tickWatchTimer !== null) { - clearInterval(this.tickWatchTimer); - this.tickWatchTimer = null; - } - this.lastInboundActivityAtMs = null; - } - - #emitStatus(status) { - for (const listener of this.statusListeners) { - listener(status); - } - } -} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts b/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts deleted file mode 100644 index 6b9b09207838..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { once } from "node:events"; -import { describe, expect, it, vi } from "vitest"; -import { WebSocket, WebSocketServer, type RawData } from "ws"; -import { - createCopilotTokenStore, - loadOrCreateCopilotIdentity, - resolveCopilotClose, -} from "./copilot-gateway-lifecycle.js"; -import { CopilotGatewayClient, isDefinitiveGatewayRejection } from "./copilot-gateway.js"; -import { GatewayProtocolRequestError } from "./copilot-runtime.js"; - -function storageArea() { - const values: Record = {}; - return { - async get(keys: string[]) { - return Object.fromEntries(keys.map((key) => [key, values[key]])); - }, - async set(update: Record) { - Object.assign(values, update); - }, - }; -} - -function controllableStorageArea() { - const values: Record = {}; - let nextWrite: { release: Promise; started: () => void } | undefined; - const storage = { - get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))), - set: vi.fn(async (update: Record) => { - const blocked = nextWrite; - nextWrite = undefined; - if (blocked) { - blocked.started(); - await blocked.release; - } - Object.assign(values, update); - }), - }; - return { - storage, - blockNextWrite() { - let markStarted: (() => void) | undefined; - let releaseWrite: (() => void) | undefined; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - const release = new Promise((resolve) => { - releaseWrite = resolve; - }); - nextWrite = { release, started: () => markStarted?.() }; - return { started, release: () => releaseWrite?.() }; - }, - }; -} - -class FakeWebSocket { - static OPEN = 1; - static instances: FakeWebSocket[] = []; - static autoOpen = true; - - readyState = 0; - sent: Array> = []; - closeCalls: Array<{ code: number; reason: string }> = []; - private listeners = new Map) => void>>(); - - constructor() { - FakeWebSocket.instances.push(this); - queueMicrotask(() => { - if (!FakeWebSocket.autoOpen || this.readyState === 3) { - return; - } - this.readyState = FakeWebSocket.OPEN; - this.emit("open", {}); - }); - } - - addEventListener(name: string, listener: (event: Record) => void) { - const listeners = this.listeners.get(name) ?? new Set(); - listeners.add(listener); - this.listeners.set(name, listeners); - } - - send(data: string) { - this.sent.push(JSON.parse(data) as Record); - } - - close(code = 1000, reason = "") { - if (code !== 1000 && (code < 3000 || code > 4999)) { - throw new DOMException("Invalid WebSocket close code", "InvalidAccessError"); - } - if (this.readyState === 3) { - return; - } - this.closeCalls.push({ code, reason }); - this.readyState = 3; - queueMicrotask(() => this.emit("close", { code, reason })); - } - - message(frame: Record) { - this.emit("message", { data: JSON.stringify(frame) }); - } - - private emit(name: string, event: Record) { - for (const listener of this.listeners.get(name) ?? []) { - listener(event); - } - } -} - -type GatewayFixtureRequest = { - id: string; - method: string; - params?: Record; -}; - -function gatewayHello(tickIntervalMs: number) { - return { - type: "hello-ok", - protocol: 4, - auth: { role: "operator", scopes: ["operator.read", "operator.write"] }, - policy: { tickIntervalMs }, - }; -} - -function rawDataText(data: RawData): string { - if (Array.isArray(data)) { - return Buffer.concat(data).toString("utf8"); - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); -} - -async function startLoopbackGateway(tickIntervalMs = 1_000) { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - await once(server, "listening"); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("test Gateway did not bind a loopback port"); - } - const sockets: WebSocket[] = []; - const requests: GatewayFixtureRequest[] = []; - const closeCodes: number[] = []; - server.on("connection", (socket) => { - sockets.push(socket); - socket.once("close", (code) => closeCodes.push(code)); - socket.send( - JSON.stringify({ - type: "event", - event: "connect.challenge", - payload: { nonce: `copilot-live-${sockets.length}`, ts: Date.now() }, - }), - ); - socket.on("message", (data) => { - const request = JSON.parse(rawDataText(data)) as GatewayFixtureRequest; - requests.push(request); - if (request.method === "connect") { - socket.send( - JSON.stringify({ - type: "res", - id: request.id, - ok: true, - payload: gatewayHello(tickIntervalMs), - }), - ); - } - }); - }); - return { - server, - sockets, - requests, - closeCodes, - url: `ws://127.0.0.1:${address.port}/`, - async close() { - for (const socket of server.clients) { - socket.terminate(); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - }, - }; -} - -async function completeFakeGatewayHello(socket: FakeWebSocket, tickIntervalMs = 1_000) { - socket.message({ - type: "event", - event: "connect.challenge", - payload: { nonce: "copilot-watchdog-nonce", ts: 1_777_777_777_000 }, - }); - await vi.waitFor(() => expect(socket.sent).toHaveLength(1), { interval: 1 }); - socket.message({ - type: "res", - id: socket.sent[0]?.id, - ok: true, - payload: gatewayHello(tickIntervalMs), - }); - await vi.advanceTimersByTimeAsync(0); -} - -describe("browser copilot Gateway custody", () => { - it("scopes device identities and issued tokens to one Gateway", async () => { - const storage = storageArea(); - const gatewayA = "ws://127.0.0.1:18789/"; - const gatewayB = "ws://127.0.0.1:28789/"; - const identityA = await loadOrCreateCopilotIdentity(storage, gatewayA); - const identityAAgain = await loadOrCreateCopilotIdentity(storage, gatewayA); - const identityB = await loadOrCreateCopilotIdentity(storage, gatewayB); - - expect(identityAAgain.deviceId).toBe(identityA.deviceId); - expect(identityB.deviceId).not.toBe(identityA.deviceId); - - const tokenParams = { - clientId: "openclaw-browser-copilot", - deviceId: identityA.deviceId, - role: "operator", - }; - const tokenA = createCopilotTokenStore(storage, gatewayA); - const tokenB = createCopilotTokenStore(storage, gatewayB); - await tokenA.store({ ...tokenParams, token: "test-token", scopes: ["operator.read"] }); - - await expect(tokenA.load(tokenParams)).resolves.toEqual({ - token: "test-token", - scopes: ["operator.read"], - }); - await expect(tokenB.load(tokenParams)).resolves.toBeNull(); - }); - - it("serializes shared credential maps across concurrent Gateway clients", async () => { - const controlled = controllableStorageArea(); - const gatewayA = "ws://127.0.0.1:18789/"; - const gatewayB = "ws://127.0.0.1:28789/"; - - const identityWrite = controlled.blockNextWrite(); - const firstIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayA); - await identityWrite.started; - const secondIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayB); - identityWrite.release(); - const [identityA, identityB] = await Promise.all([firstIdentity, secondIdentity]); - await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayA)).resolves.toMatchObject({ - deviceId: identityA.deviceId, - }); - await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayB)).resolves.toMatchObject({ - deviceId: identityB.deviceId, - }); - - const tokenParams = (deviceId: string) => ({ - clientId: "openclaw-browser-copilot", - deviceId, - role: "operator", - }); - const tokenA = createCopilotTokenStore(controlled.storage, gatewayA); - const tokenB = createCopilotTokenStore(controlled.storage, gatewayB); - const storeGate = controlled.blockNextWrite(); - const firstStore = tokenA.store({ - ...tokenParams(identityA.deviceId), - token: "test-token-placeholder", - scopes: ["operator.read"], - }); - await storeGate.started; - const secondStore = tokenB.store({ - ...tokenParams(identityB.deviceId), - token: "test-token-placeholder", - scopes: ["operator.write"], - }); - storeGate.release(); - await Promise.all([firstStore, secondStore]); - await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toMatchObject({ - token: "test-token-placeholder", - }); - await expect(tokenB.load(tokenParams(identityB.deviceId))).resolves.toMatchObject({ - token: "test-token-placeholder", - }); - - const replacementWrite = controlled.blockNextWrite(); - const replacing = tokenA.store({ - ...tokenParams(identityA.deviceId), - token: "test-token-placeholder", - scopes: ["operator.read", "operator.write"], - }); - await replacementWrite.started; - const clearing = tokenA.clear(tokenParams(identityA.deviceId)); - replacementWrite.release(); - await Promise.all([replacing, clearing]); - await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toBeNull(); - }); - - it("keeps the pairing approval state when the failed socket closes", () => { - const error = { details: { code: "PAIRING_REQUIRED", pauseReconnect: true } }; - - expect(resolveCopilotClose({ connectFailure: { error } })).toEqual({ - retry: true, - notify: false, - pendingError: error, - }); - expect( - resolveCopilotClose({ - connectFailure: { error: { details: { pauseReconnect: true } } }, - }).retry, - ).toBe(false); - expect( - resolveCopilotClose({ - connectFailure: { - error: { - details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: false }, - }, - }, - }).retry, - ).toBe(false); - expect(resolveCopilotClose({})).toEqual({ - retry: true, - notify: true, - pendingError: undefined, - }); - }); - - it("clears a rejected device token before starting a fresh connection", async () => { - const values: Record = {}; - let releaseClear: (() => void) | undefined; - let markClearStarted: (() => void) | undefined; - const clearStarted = new Promise((resolve) => { - markClearStarted = resolve; - }); - let blockNextSet = false; - const storage = { - async get(keys: string[]) { - return Object.fromEntries(keys.map((key) => [key, values[key]])); - }, - async set(update: Record) { - if (blockNextSet) { - blockNextSet = false; - markClearStarted?.(); - await new Promise((resolve) => { - releaseClear = resolve; - }); - } - Object.assign(values, update); - }, - }; - const gatewayScope = "ws://127.0.0.1:18789/"; - const identity = await loadOrCreateCopilotIdentity(storage, gatewayScope); - const tokenStore = createCopilotTokenStore(storage, gatewayScope); - const tokenParams = { - clientId: "openclaw-browser-copilot", - deviceId: identity.deviceId, - role: "operator", - }; - await tokenStore.store({ - ...tokenParams, - token: "test-token", - scopes: ["operator.read", "operator.write"], - }); - blockNextSet = true; - FakeWebSocket.instances = []; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage, - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start(gatewayScope); - await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); - const first = FakeWebSocket.instances[0]; - first?.message({ - type: "event", - event: "connect.challenge", - payload: { nonce: "first-nonce", ts: 1_777_777_777_000 }, - }); - await vi.waitFor(() => expect(first?.sent).toHaveLength(1)); - const firstConnect = first?.sent[0] as { - id?: string; - params?: { auth?: { token?: string } }; - }; - expect(firstConnect.params?.auth?.token).toBe("test-token"); - first?.message({ - type: "res", - id: firstConnect.id, - ok: false, - error: { - code: "UNAVAILABLE", - message: "device token rejected", - details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: true }, - }, - }); - await clearStarted; - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - expect(FakeWebSocket.instances).toHaveLength(1); - - releaseClear?.(); - await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); - const second = FakeWebSocket.instances[1]; - second?.message({ - type: "event", - event: "connect.challenge", - payload: { nonce: "second-nonce", ts: 1_777_777_778_000 }, - }); - await vi.waitFor(() => expect(second?.sent).toHaveLength(1)); - const secondConnect = second?.sent[0] as { params?: { auth?: { token?: string } } }; - expect(secondConnect.params?.auth?.token).toBeUndefined(); - await expect(tokenStore.load(tokenParams)).resolves.toBeNull(); - } finally { - releaseClear?.(); - client.stop(); - vi.unstubAllGlobals(); - } - }); - - it("reconnects after a malformed challenge with a browser-valid policy close", async () => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - FakeWebSocket.autoOpen = true; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.advanceTimersByTimeAsync(0); - const first = FakeWebSocket.instances[0]; - expect(first).toBeDefined(); - - first?.message({ type: "event", event: "connect.challenge", payload: {} }); - await vi.advanceTimersByTimeAsync(0); - - expect(first?.closeCalls).toContainEqual({ - code: 4008, - reason: "connect challenge missing nonce", - }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(FakeWebSocket.instances).toHaveLength(2); - } finally { - client.stop(); - FakeWebSocket.autoOpen = true; - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it("rejects a device challenge with a malformed Gateway timestamp", async () => { - FakeWebSocket.instances = []; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); - const socket = FakeWebSocket.instances[0]; - socket?.message({ - type: "event", - event: "connect.challenge", - payload: { nonce: "invalid-time", ts: "not-a-number" }, - }); - await vi.waitFor(() => - expect(socket?.closeCalls).toContainEqual({ - code: 4008, - reason: "connect failed", - }), - ); - expect(socket?.sent).toHaveLength(0); - } finally { - client.stop(); - vi.unstubAllGlobals(); - } - }); - - it("closes and reconnects when the browser socket never opens", async () => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - FakeWebSocket.autoOpen = false; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - const statuses: Array> = []; - client.onStatus((status) => { - statuses.push(status); - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.advanceTimersByTimeAsync(15_000); - - expect(statuses).toContainEqual( - expect.objectContaining({ - state: "error", - label: "Gateway WebSocket opening timed out after 15000ms", - }), - ); - expect(FakeWebSocket.instances[0]?.closeCalls).toEqual([{ code: 1000, reason: "" }]); - - await vi.advanceTimersByTimeAsync(1_000); - expect(FakeWebSocket.instances).toHaveLength(2); - } finally { - client.stop(); - FakeWebSocket.autoOpen = true; - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it("reconnects a real Gateway-protocol socket when it stops sending heartbeats", async () => { - const gateway = await startLoopbackGateway(); - vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: WebSocket as never, - }); - const states: string[] = []; - let resolveReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - resolveReady = resolve; - }); - client.onStatus((status) => { - if (typeof status.state === "string") { - states.push(status.state); - } - if (status.state === "ready") { - resolveReady?.(); - } - }); - - try { - client.start(gateway.url); - await ready; - expect(gateway.requests[0]).toMatchObject({ - method: "connect", - params: { - role: "operator", - client: { id: "openclaw-browser-copilot" }, - device: { nonce: "copilot-live-1", signature: expect.any(String) }, - }, - }); - expect(gateway.sockets[0]?.readyState).toBe(WebSocket.OPEN); - await expect( - client.request( - "chat.send", - { sessionKey: "agent:main:main", message: "live proof" }, - { - timeoutMs: 25, - }, - ), - ).rejects.toThrow("gateway request timed out after 25ms: chat.send"); - expect(gateway.requests.map((request) => request.method)).toEqual(["connect", "chat.send"]); - - await vi.advanceTimersByTimeAsync(3_000); - await new Promise((resolve) => { - setTimeout(resolve, 1_150); - }); - - expect({ - connections: gateway.sockets.length, - firstSocketOpen: gateway.sockets[0]?.readyState === WebSocket.OPEN, - closeCodes: gateway.closeCodes, - statuses: states, - }).toEqual({ - connections: 2, - firstSocketOpen: false, - closeCodes: [4000], - statuses: ["connecting", "ready", "connecting", "ready"], - }); - } finally { - client.stop(); - await gateway.close(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it("keeps a real Gateway-protocol socket connected while live tick events arrive", async () => { - const gateway = await startLoopbackGateway(); - vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: WebSocket as never, - }); - let resolveReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - resolveReady = resolve; - }); - client.onStatus((status) => { - if (status.state === "ready") { - resolveReady?.(); - } - }); - - try { - client.start(gateway.url); - await ready; - for (let seq = 1; seq <= 5; seq += 1) { - await vi.advanceTimersByTimeAsync(1_000); - const observed = new Promise((resolve) => { - const unsubscribe = client.onEvent((event) => { - if ( - event && - typeof event === "object" && - "event" in event && - "seq" in event && - event.event === "tick" && - event.seq === seq - ) { - unsubscribe(); - resolve(); - } - }); - }); - gateway.sockets[0]?.send(JSON.stringify({ type: "event", event: "tick", seq })); - await observed; - } - - expect(client.ready).toBe(true); - expect(gateway.sockets[0]?.readyState).toBe(WebSocket.OPEN); - expect(gateway.closeCodes).toEqual([]); - } finally { - client.stop(); - await gateway.close(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it("keeps a replacement Gateway watchdog independent of its retired connection", async () => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - FakeWebSocket.autoOpen = true; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.advanceTimersByTimeAsync(0); - const retired = FakeWebSocket.instances[0]; - expect(retired).toBeDefined(); - await completeFakeGatewayHello(retired!); - await vi.advanceTimersByTimeAsync(1_000); - - client.start("ws://127.0.0.1:38789/"); - await vi.advanceTimersByTimeAsync(0); - const replacement = FakeWebSocket.instances[1]; - expect(replacement).toBeDefined(); - await completeFakeGatewayHello(replacement!); - await vi.advanceTimersByTimeAsync(2_000); - retired?.message({ type: "event", event: "tick", seq: 1 }); - expect(replacement?.closeCalls).toEqual([]); - - await vi.advanceTimersByTimeAsync(1_000); - expect(replacement?.closeCalls).toEqual([{ code: 4000, reason: "tick timeout" }]); - } finally { - client.stop(); - FakeWebSocket.autoOpen = true; - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it("removes its heartbeat watchdog when the current Gateway is stopped", async () => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - FakeWebSocket.autoOpen = true; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.advanceTimersByTimeAsync(0); - const socket = FakeWebSocket.instances[0]; - expect(socket).toBeDefined(); - await completeFakeGatewayHello(socket!); - - client.stop(); - await vi.advanceTimersByTimeAsync(60_000); - - expect(socket?.closeCalls).toEqual([{ code: 1000, reason: "" }]); - expect(FakeWebSocket.instances).toHaveLength(1); - expect(vi.getTimerCount()).toBe(0); - } finally { - client.stop(); - FakeWebSocket.autoOpen = true; - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); - - it.each([ - { advertised: 1, expected: 1_000 }, - { advertised: Number.MAX_SAFE_INTEGER, expected: 2_147_483_647 }, - ])( - "bounds the Gateway-advertised heartbeat policy ($advertised)", - async ({ advertised, expected }) => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - FakeWebSocket.autoOpen = true; - vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); - vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); - const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); - const client = new CopilotGatewayClient({ - storage: storageArea(), - WebSocketImpl: FakeWebSocket as never, - }); - - try { - client.start("ws://127.0.0.1:28789/"); - await vi.advanceTimersByTimeAsync(0); - const socket = FakeWebSocket.instances[0]; - expect(socket).toBeDefined(); - await completeFakeGatewayHello(socket!, advertised); - - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), expected); - } finally { - client.stop(); - setIntervalSpy.mockRestore(); - FakeWebSocket.autoOpen = true; - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }, - ); - - it("distinguishes server rejection from ambiguous transport failure", () => { - expect( - isDefinitiveGatewayRejection( - new GatewayProtocolRequestError({ code: "INVALID_REQUEST", message: "fixture rejection" }), - ), - ).toBe(true); - expect(isDefinitiveGatewayRejection(new Error("fixture socket closed"))).toBe(false); - }); -}); diff --git a/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts b/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts deleted file mode 100644 index 448c9debc32b..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CopilotGatewayClient } from "./copilot-gateway.js"; -import type { CopilotSessionEntry, CopilotSessionRegistry } from "./copilot-session-registry.js"; - -export function createCopilotRecoveryController( - options: Record & { - gateway: CopilotGatewayClient; - registry: CopilotSessionRegistry; - }, -): { - abortEntry: (entry: CopilotSessionEntry) => Promise; - clearAbortRetry: () => void; - drainAborts: (gatewayScope?: string | null) => Promise; - drainArchives: (gatewayScope?: string | null) => Promise; - drainStaleScopes: () => Promise; - reconcileGatewayReady: ( - status: Record, - statusRevision: number, - gatewayScope: string | null, - revocation: Promise, - ) => Promise; - scheduleAbortRetry: (gatewayScope?: string | null) => void; - scheduleStaleRecovery: () => void; -}; diff --git a/extensions/browser/chrome-extension/modules/copilot-recovery.js b/extensions/browser/chrome-extension/modules/copilot-recovery.js deleted file mode 100644 index 10d251e20cda..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-recovery.js +++ /dev/null @@ -1,269 +0,0 @@ -import { archiveCopilotSession } from "./copilot-background-shared.js"; -import { waitForCopilotGatewayReady } from "./copilot-gateway.js"; - -/** Gateway cleanup owner. All destructive scope recovery runs through the lifecycle queue. */ -export function createCopilotRecoveryController({ - gateway, - recoveryGatewayFactory, - registry, - subscribedKeys, - sendsByTab, - currentGatewayScope, - getGatewayStatus, - getGatewayStatusRevision, - getLastReadyStatus, - isConfigTransitioning, - setReconciledGatewayStatus, - restoreDebuggerIfReleased, - broadcastTab, - broadcastStatus, - refreshPanelState, - runLifecycle, -}) { - let abortRetryTimer = null; - let abortRetryDelayMs = 250; - let staleRecovery = null; - let staleRecoveryRetryTimer = null; - - async function drainArchives(gatewayScope = currentGatewayScope()) { - if (!gateway.ready || !gatewayScope) { - return; - } - for (const entry of registry.pendingArchives(gatewayScope)) { - try { - await archiveCopilotSession(gateway, entry); - subscribedKeys.delete(entry.sessionKey); - await registry.resolveArchive(gatewayScope, entry.sessionKey); - if (typeof entry.tabId === "number") { - await restoreDebuggerIfReleased(entry.tabId); - } - } catch { - // The watchdog retries after reconnect or after an active run reaches terminal state. - } - } - } - - async function abortEntry(entry) { - try { - await gateway.request("sessions.abort", { - key: entry.sessionKey, - runId: entry.activeRunId, - }); - } catch { - scheduleAbortRetry(entry.gatewayScope); - return false; - } - sendsByTab.delete(entry.tabId); - const finished = await registry.finishRun( - entry.gatewayScope, - entry.sessionKey, - entry.activeRunId, - ); - if (finished) { - await restoreDebuggerIfReleased(entry.tabId); - broadcastTab(entry.tabId, { type: "panel.turn-reset" }); - void refreshPanelState(entry.tabId); - } - return true; - } - - async function drainAborts(gatewayScope = currentGatewayScope()) { - if (!gateway.ready || !gatewayScope) { - return; - } - for (const entry of registry.pendingAborts(gatewayScope)) { - await abortEntry(entry); - } - } - - function clearAbortRetry() { - if (abortRetryTimer) { - clearTimeout(abortRetryTimer); - abortRetryTimer = null; - } - abortRetryDelayMs = 250; - } - - function scheduleAbortRetry(gatewayScope = currentGatewayScope()) { - const statusRevision = getGatewayStatusRevision(); - if ( - abortRetryTimer || - !gateway.ready || - !gatewayScope || - isConfigTransitioning() || - currentGatewayScope() !== gatewayScope - ) { - return; - } - const delayMs = abortRetryDelayMs; - abortRetryTimer = setTimeout(() => { - abortRetryTimer = null; - void (async () => { - if ( - currentGatewayScope() !== gatewayScope || - getGatewayStatusRevision() !== statusRevision || - !gateway.ready - ) { - return; - } - await drainAborts(gatewayScope); - if ( - currentGatewayScope() !== gatewayScope || - getGatewayStatusRevision() !== statusRevision || - !gateway.ready - ) { - return; - } - if (registry.pendingAborts(gatewayScope).length > 0) { - abortRetryDelayMs = Math.min(abortRetryDelayMs * 2, 5_000); - scheduleAbortRetry(); - } else { - abortRetryDelayMs = 250; - const readyStatus = getLastReadyStatus(); - if (getGatewayStatus().state === "error" && readyStatus) { - setReconciledGatewayStatus(readyStatus, statusRevision); - broadcastStatus({ ensureSetup: true, hydrateHistory: true }); - } - } - })(); - }, delayMs); - } - - async function reconcileGatewayReady(status, statusRevision, gatewayScope, revocation) { - await revocation; - if ( - !gatewayScope || - statusRevision !== getGatewayStatusRevision() || - isConfigTransitioning() || - !gateway.ready || - currentGatewayScope() !== gatewayScope - ) { - return; - } - // A connection gap loses terminal events. Abort durable active custody - // before panels can send again. - await registry.queueActiveAborts(gatewayScope); - await drainAborts(gatewayScope); - await drainArchives(gatewayScope); - if ( - statusRevision !== getGatewayStatusRevision() || - isConfigTransitioning() || - !gateway.ready || - currentGatewayScope() !== gatewayScope - ) { - return; - } - const hasPendingAborts = registry.pendingAborts(gatewayScope).length > 0; - setReconciledGatewayStatus( - hasPendingAborts ? { state: "error", label: "Could not stop the previous tab run" } : status, - hasPendingAborts ? 0 : statusRevision, - ); - broadcastStatus(hasPendingAborts ? undefined : { ensureSetup: true, hydrateHistory: true }); - } - - function scheduleStaleRecovery() { - if (staleRecoveryRetryTimer) { - return; - } - staleRecoveryRetryTimer = setTimeout(() => { - staleRecoveryRetryTimer = null; - void drainStaleScopes(); - }, 5_000); - } - - function drainStaleScopes() { - if (staleRecovery) { - return staleRecovery; - } - if (staleRecoveryRetryTimer) { - clearTimeout(staleRecoveryRetryTimer); - staleRecoveryRetryTimer = null; - } - let retry = false; - const pending = runLifecycle(async () => { - const currentScope = currentGatewayScope(); - const staleScopes = registry.gatewayScopes().filter((scope) => scope !== currentScope); - for (const staleScope of staleScopes) { - if (await recoverPersistedScope(staleScope)) { - continue; - } - await registry.closeInactiveScope(staleScope); - retry = true; - } - if (gateway.ready && getGatewayStatus().state === "ready") { - broadcastStatus({ ensureSetup: true, hydrateHistory: true }); - } - }).catch(() => { - retry = true; - }); - staleRecovery = pending; - void pending.then(() => { - if (staleRecovery === pending) { - staleRecovery = null; - } - if (retry) { - scheduleStaleRecovery(); - } - }); - return pending; - } - - async function recoverPersistedScope(gatewayScope) { - const scopedEntries = registry.list().filter((entry) => entry.gatewayScope === gatewayScope); - const needsGateway = - registry.pendingArchives(gatewayScope).length > 0 || - scopedEntries.some( - (entry) => !entry.provisional || entry.creationPending || entry.activeRunId, - ); - if (!needsGateway) { - await registry.closeScope(gatewayScope); - return true; - } - const recoveryGateway = recoveryGatewayFactory(); - try { - await waitForCopilotGatewayReady(recoveryGateway, gatewayScope); - await registry.queueActiveAborts(gatewayScope); - for (const entry of registry.pendingAborts(gatewayScope)) { - await recoveryGateway.request("sessions.abort", { - key: entry.sessionKey, - runId: entry.activeRunId, - }); - const finished = await registry.finishRun( - entry.gatewayScope, - entry.sessionKey, - entry.activeRunId, - ); - if (finished) { - await restoreDebuggerIfReleased(entry.tabId); - } - } - await registry.closeScope(gatewayScope); - for (const entry of registry.pendingArchives(gatewayScope)) { - await archiveCopilotSession(recoveryGateway, entry); - await registry.resolveArchive(gatewayScope, entry.sessionKey); - if (typeof entry.tabId === "number") { - await restoreDebuggerIfReleased(entry.tabId); - } - } - return ( - registry.pendingAborts(gatewayScope).length === 0 && - registry.pendingArchives(gatewayScope).length === 0 - ); - } catch { - return false; - } finally { - recoveryGateway.stop(); - } - } - - return { - abortEntry, - clearAbortRetry, - drainAborts, - drainArchives, - drainStaleScopes, - reconcileGatewayReady, - scheduleAbortRetry, - scheduleStaleRecovery, - }; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts b/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts deleted file mode 100644 index 2c3090bcbb2c..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function createCopilotRelayCustodyController(options: Record): { - currentPanelStatus(): { state: string; label: string; requestId?: string }; - isOperational(): boolean; - onStatus(status: { ready: boolean; label?: string }): Promise; -}; diff --git a/extensions/browser/chrome-extension/modules/copilot-relay-custody.js b/extensions/browser/chrome-extension/modules/copilot-relay-custody.js deleted file mode 100644 index 46ab7b31ea55..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-relay-custody.js +++ /dev/null @@ -1,89 +0,0 @@ -/** Relay/run boundary owner for the tab-bound copilot. */ -export function createCopilotRelayCustodyController({ - appendGatewayRevocation, - broadcastStatus, - currentGatewayScope, - drainAborts, - getGatewayStatus, - invalidateGatewayEpoch, - markGatewayAbortError, - registry, - revokeActiveBindings, - runLifecycle, -}) { - let ready = false; - let label = "Connecting to browser relay"; - let statusRevision = 0; - let reconciledStatusRevision = 0; - let pendingRevocation = Promise.resolve(); - - function isOperational() { - return ready && reconciledStatusRevision === statusRevision; - } - - function currentPanelStatus() { - const gatewayStatus = getGatewayStatus(); - return gatewayStatus.state === "ready" && !isOperational() - ? { state: "connecting", label } - : gatewayStatus; - } - - async function onStatus(status) { - const nextReady = status.ready === true; - const readinessChanged = ready !== nextReady; - ready = nextReady; - label = status.label || "Browser relay reconnecting"; - if (!readinessChanged) { - broadcastStatus(); - return; - } - // Relay availability is part of the run epoch. Reconcile debugger/run - // custody before a reconnected tool route can admit panel work again. - invalidateGatewayEpoch(); - const revision = ++statusRevision; - if (nextReady) { - broadcastStatus(); - await pendingRevocation; - await runLifecycle(async () => { - const gatewayScope = currentGatewayScope(); - if (revision === statusRevision && ready && gatewayScope) { - await drainAborts(gatewayScope); - } - }); - if (revision !== statusRevision || !ready) { - return; - } - reconciledStatusRevision = revision; - const gatewayScope = currentGatewayScope(); - if ( - gatewayScope && - registry.pendingAborts(gatewayScope).length > 0 && - getGatewayStatus().state === "ready" - ) { - markGatewayAbortError(); - broadcastStatus(); - return; - } - broadcastStatus({ ensureSetup: true, hydrateHistory: true }); - return; - } - reconciledStatusRevision = 0; - broadcastStatus(); - const gatewayScope = currentGatewayScope(); - if (!gatewayScope) { - return; - } - const revocation = revokeActiveBindings(gatewayScope); - appendGatewayRevocation(revocation); - const cleanup = runLifecycle(async () => { - await revocation; - if (gatewayScope === currentGatewayScope() && !ready) { - await drainAborts(gatewayScope); - } - }); - pendingRevocation = cleanup.catch(() => undefined); - await pendingRevocation; - } - - return { currentPanelStatus, isOperational, onStatus }; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts b/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts deleted file mode 100644 index 6f1e2b9350cd..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const GATEWAY_CLIENT_CAPS: Record; -export const GATEWAY_CLIENT_IDS: Record; -export const GATEWAY_CLIENT_MODES: Record; -export const MIN_CLIENT_PROTOCOL_VERSION: number; -export const PROTOCOL_VERSION: number; - -export const ed25519Utils: { - randomSecretKey(): Uint8Array; -}; -export function getPublicKeyAsync(secretKey: Uint8Array): Promise; -export function signAsync(message: Uint8Array, secretKey: Uint8Array): Promise; - -export class GatewayProtocolRequestError extends Error { - constructor(error: Record); -} - -export class GatewayProtocolClient { - constructor(options: Record); - start(): void; - stop(): void; - closeSocket(code?: number, reason?: string): void; - request(method: string, params: unknown, options?: unknown): Promise; -} - -export class GatewayBrowserDeviceAuthLifecycle { - constructor(options: Record); - buildPlan(options: Record): Promise>; - acceptHello(hello: unknown, plan: unknown): Promise; - clearStoredToken(plan: unknown): Promise; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.js b/extensions/browser/chrome-extension/modules/copilot-runtime.js deleted file mode 100644 index 31ca5e90bb52..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-runtime.js +++ /dev/null @@ -1 +0,0 @@ -function normalizeDeviceMetadataForAuth(value){if(typeof value!="string")return"";let trimmed=value.trim();return trimmed?trimmed.replace(/[A-Z]/g,char=>String.fromCharCode(char.charCodeAt(0)+32)):""}function buildDeviceAuthPayloadV3(params){let scopes=params.scopes.join(","),token=params.token??"",platform=normalizeDeviceMetadataForAuth(params.platform),deviceFamily=normalizeDeviceMetadataForAuth(params.deviceFamily);return["v3",params.deviceId,params.clientId,params.clientMode,params.role,scopes,String(params.signedAtMs),token,params.nonce,platform,deviceFamily].join("|")}function isProtocolRecord(value){return!!value&&typeof value=="object"&&!Array.isArray(value)}function isNonEmptyProtocolString(value){return typeof value=="string"&&value.length>0}function normalized(value){return typeof value=="string"&&value.trim()||void 0}function selectGatewayConnectAuth(params){let authToken=normalized(params.token),bootstrapToken=normalized(params.bootstrapToken),explicitDeviceToken=normalized(params.deviceToken),authPassword=normalized(params.password),storedToken=normalized(params.storedToken),stored={storedToken,storedScopes:params.storedScopes};if(params.preferBootstrapToken&&bootstrapToken)return{authBootstrapToken:bootstrapToken,authPassword,...stored};let useRetryToken=params.pendingDeviceTokenRetry===!0&&!explicitDeviceToken&&!!(authToken&&storedToken&¶ms.trustedDeviceTokenRetry),resolvedDeviceToken=explicitDeviceToken??(useRetryToken||!(authToken||authPassword)&&(!bootstrapToken||storedToken)?storedToken:void 0),usingStoredDeviceToken=!!(resolvedDeviceToken&&!explicitDeviceToken&&storedToken)&&resolvedDeviceToken===storedToken,selectedToken=authToken??resolvedDeviceToken,authBootstrapToken=!authToken&&!resolvedDeviceToken&&!authPassword?bootstrapToken:void 0;return{authToken:selectedToken,authBootstrapToken,authDeviceToken:useRetryToken?storedToken:void 0,authPassword,authApprovalRuntimeToken:normalized(params.approvalRuntimeToken),authAgentRuntimeIdentityToken:normalized(params.agentRuntimeIdentityToken),signatureToken:selectedToken??authBootstrapToken,resolvedDeviceToken,usingStoredDeviceToken,...stored}}function buildGatewayConnectAuth(selected){let auth={token:selected.authToken,bootstrapToken:selected.authBootstrapToken,deviceToken:selected.authDeviceToken??selected.resolvedDeviceToken,password:selected.authPassword,approvalRuntimeToken:selected.authApprovalRuntimeToken,agentRuntimeIdentityToken:selected.authAgentRuntimeIdentityToken};return Object.values(auth).some(Boolean)?auth:void 0}function resolveGatewayConnectScopes(params){return params.requestedScopes??(params.usingStoredDeviceToken&¶ms.storedScopes?.length?params.storedScopes:[...params.defaultScopes])}var GatewayBrowserDeviceAuthLifecycle=class{constructor(deps){this.deps=deps}async buildPlan(params){let identity=await this.deps.loadIdentity(),stored=identity?await this.deps.tokenStore.load({clientId:params.client.id,deviceId:identity.deviceId,role:params.role}):null,storedValue=stored?.token,selectedAuth=selectGatewayConnectAuth({token:params.token,bootstrapToken:params.bootstrapToken,password:params.password,storedToken:storedValue,storedScopes:stored?.scopes,pendingDeviceTokenRetry:params.pendingDeviceTokenRetry,trustedDeviceTokenRetry:params.trustedDeviceTokenRetry,preferBootstrapToken:params.preferBootstrapToken}),{usingStoredDeviceToken}=selectedAuth,scopes=resolveGatewayConnectScopes({requestedScopes:selectedAuth.authBootstrapToken&¶ms.bootstrapScopes?[...params.bootstrapScopes]:void 0,usingStoredDeviceToken,storedScopes:selectedAuth.storedScopes,defaultScopes:params.defaultScopes});if(!identity)return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth)};let signedAtMs=params.challengeTs===void 0?this.deps.nowMs?.()??Date.now():params.challengeTs;if(typeof signedAtMs!="number"||!Number.isSafeInteger(signedAtMs)||signedAtMs<0)throw new Error("gateway connect challenge timestamp invalid");let nonce=params.nonce??"",{authBootstrapToken:primary,signatureToken:signed}=selectedAuth,token=null;primary?token=primary:signed&&(token=signed);let payload=buildDeviceAuthPayloadV3({deviceId:identity.deviceId,clientId:params.client.id,clientMode:params.client.mode,role:params.role,scopes,signedAtMs,token,nonce,platform:params.client.platform,deviceFamily:params.client.deviceFamily});return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth),device:{id:identity.deviceId,publicKey:identity.publicKey,signature:await identity.sign(payload),signedAt:signedAtMs,nonce}}}async acceptHello(hello,plan){let token=hello.auth?.deviceToken?.trim();if(!token||!plan.identity)return;let role=hello.auth?.role??plan.role,stored=await this.deps.tokenStore.load({clientId:plan.clientId,deviceId:plan.identity.deviceId,role});await this.deps.tokenStore.store({clientId:plan.clientId,deviceId:plan.identity.deviceId,role,token,scopes:stored?.token===token?stored.scopes:hello.auth?.scopes??[]})}async clearStoredToken(plan){plan.identity&&await this.deps.tokenStore.clear({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:plan.role})}};function isNonNegativeInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=0}function isGatewayErrorShape(value){return!isProtocolRecord(value)||!isNonEmptyProtocolString(value.code)||!isNonEmptyProtocolString(value.message)||value.retryable!==void 0&&typeof value.retryable!="boolean"?!1:value.retryAfterMs===void 0||isNonNegativeInteger(value.retryAfterMs)}function isGatewayEventFrame(value){return!isProtocolRecord(value)||value.type!=="event"||!isNonEmptyProtocolString(value.event)?!1:value.seq===void 0||isNonNegativeInteger(value.seq)}function isGatewayResponseFrame(value){return!isProtocolRecord(value)||value.type!=="res"||!isNonEmptyProtocolString(value.id)||typeof value.ok!="boolean"?!1:value.error===void 0||isGatewayErrorShape(value.error)}function computeBackoff(policy,attempt){let base=Math.min(policy.maxMs,policy.initialMs*policy.factor**Math.max(attempt-1,0)),jitter=base*policy.jitter*Math.random();return Math.min(policy.maxMs,Math.round(base+jitter))}async function sleepWithAbort(ms,abortSignal,options={}){if(!Number.isFinite(ms)||ms<=0)return;let delayMs=Math.min(Math.max(Math.floor(ms),1),2147e6);await new Promise((resolve,reject)=>{let settled=!1,timer=null,cleanup=()=>abortSignal?.removeEventListener("abort",onAbort),onAbort=()=>{if(settled)return;settled=!0,timer&&clearTimeout(timer),timer=null,cleanup();let error=new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")});error.name="AbortError",reject(error)};if(abortSignal?.addEventListener("abort",onAbort,{once:!0}),abortSignal?.aborted){onAbort();return}timer=setTimeout(()=>{settled=!0,cleanup(),timer=null,resolve()},delayMs),options.ref===!1&&timer.unref?.(),abortSignal?.aborted&&onAbort()})}var RetrySupervisor=class{constructor(policy,maxAttempts=Number.POSITIVE_INFINITY){this.policy=policy;this.maxAttempts=maxAttempts;this.attempts=0;this.initialMs=policy.initialMs}reset(initialMs=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=initialMs,this.nextDelayOverrideMs=void 0}cancel(reason=new Error("retry cancelled")){this.pendingAbort?.abort(reason),this.pendingAbort=void 0}next(abortSignal){let override=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,override===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let attempt=Math.max(this.attempts,1),delayMs=override??computeBackoff({...this.policy,initialMs:this.initialMs},attempt);this.cancel();let pendingAbort=new AbortController;return this.pendingAbort=pendingAbort,{attempt,delayMs,signal:abortSignal?AbortSignal.any([pendingAbort.signal,abortSignal]):pendingAbort.signal}}},DEFAULT_RETRY_CONFIG={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},defaultSleep=ms=>new Promise(resolve=>{setTimeout(resolve,ms)});function asFiniteNumber(value){return typeof value=="number"&&Number.isFinite(value)?value:void 0}function clampNumber(value,fallback,min,max){let next=asFiniteNumber(value);return next===void 0?fallback:Math.min(Math.max(next,min??Number.NEGATIVE_INFINITY),max??Number.POSITIVE_INFINITY)}function resolveAttemptCount(value,fallback){return Math.max(1,Math.round(asFiniteNumber(value)??fallback))}function resolveRetryDelayMs(value){let finite=value===Number.POSITIVE_INFINITY?2147e6:asFiniteNumber(value)??0;return Math.min(Math.max(Math.round(finite),0),2147e6)}function resolveJitterConfig(value,fallback){if(value==="full")return"full";let fraction=asFiniteNumber(value);return fraction===void 0?fallback:Math.min(Math.max(fraction,0),1)}function resolveRetryConfig(defaults=DEFAULT_RETRY_CONFIG,overrides){let attempts=resolveAttemptCount(overrides?.attempts,defaults.attempts),minDelayMs=resolveRetryDelayMs(clampNumber(overrides?.minDelayMs,defaults.minDelayMs,0)),maxDelayMs=Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(overrides?.maxDelayMs,defaults.maxDelayMs,0)));return{attempts,minDelayMs,maxDelayMs,jitter:resolveJitterConfig(overrides?.jitter,defaults.jitter)}}function applyJitter(delayMs,jitter,mode,random){if(jitter==="full")return mode==="symmetric"?Math.max(0,Math.round(delayMs*(.5+random()*.5))):Math.max(0,Math.ceil(delayMs*(1+random())));if(jitter<=0)return mode==="positive"?Math.ceil(delayMs):delayMs;let fraction=random(),offset=mode==="positive"?fraction*jitter:(fraction*2-1)*jitter,raw=delayMs*(1+offset);return Math.max(0,mode==="positive"?Math.ceil(raw):Math.round(raw))}function toRetryError(value,fallbackMessage="Non-Error thrown"){if(value instanceof Error)return value;if(typeof value=="string")return new Error(value);let error=new Error(fallbackMessage,{cause:value});return(typeof value=="object"&&value!==null||typeof value=="function")&&Object.assign(error,value),error}function createRetryRunner(runtime={}){let runtimeSleep=runtime.sleep??defaultSleep,runtimeRandom=runtime.random??Math.random,createFailure=runtime.createFailure??(errors=>toRetryError(errors.at(-1)??new Error("Retry failed")));return async function(fn,attemptsOrOptions=3,initialDelayMs=300){let attemptErrors=[];if(typeof attemptsOrOptions=="number"){let attempts=resolveAttemptCount(attemptsOrOptions,DEFAULT_RETRY_CONFIG.attempts);for(let index=0;index0?resolved.maxDelayMs:Number.POSITIVE_INFINITY,retryAfterMaxDelayMs=options.retryAfterMaxDelayMs===void 0?maxDelayMs:Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(options.retryAfterMaxDelayMs,maxDelayMs,0))),random=options.random??runtimeRandom,sleep=options.sleep??runtimeSleep,shouldRetry=options.shouldRetry??(()=>!0);for(let attempt=1;attempt<=maxAttempts;attempt+=1)try{return await fn()}catch(err2){if(attemptErrors.push(err2),attempt>=maxAttempts||!shouldRetry(err2,attempt))break;let context={attempt,maxAttempts,err:err2,label:options.label},retryAfterMs=options.retryAfterMs?.(err2),hasRetryAfter=typeof retryAfterMs=="number"&&Number.isFinite(retryAfterMs),configuredDelay=typeof options.delayMs=="function"?options.delayMs(context):options.delayMs,resolvedConfiguredDelay=configuredDelay===void 0?void 0:resolveRetryDelayMs(configuredDelay),baseDelay=hasRetryAfter?Math.max(retryAfterMs,minDelayMs):resolvedConfiguredDelay===void 0?minDelayMs*2**(attempt-1):Math.max(resolvedConfiguredDelay,minDelayMs),delayCap=hasRetryAfter?retryAfterMaxDelayMs:maxDelayMs,delay=Math.min(baseDelay,delayCap),canHonorRetryAfter=hasRetryAfter&&(retryAfterMs??0)<=delayCap,wantsPositiveDraw=resolved.jitter==="full"&&!hasRetryAfter||canHonorRetryAfter;delay=applyJitter(delay,resolved.jitter,wantsPositiveDraw?"positive":"symmetric",random),delay=Math.min(Math.max(delay,minDelayMs),delayCap),await options.onRetry?.({...context,delayMs:delay}),delay>0&&await sleep(delay)}throw createFailure(attemptErrors)}}var retryAsync=createRetryRunner();var GatewayEventListeners=class{constructor(){this.listeners=new Map}add(listener){let subscription=this.listeners.get(listener)??{};return this.listeners.set(listener,subscription),()=>{this.listeners.get(listener)===subscription&&this.listeners.delete(listener)}}snapshot(){return[...this.listeners]}isCurrent(listener,subscription){return this.listeners.get(listener)===subscription}};var GatewayProtocolRequestError=class extends Error{constructor(error){super(error.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=error.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=error.details,this.retryable=error.retryable===!0,this.retryAfterMs=error.retryAfterMs}};var DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS=15e3;function startGatewayConnectTimeout(onTimeout){let timer=setTimeout(onTimeout,DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);return timer.unref?.(),timer}function clearGatewayConnectTimeout(timer){return timer!==null&&clearTimeout(timer),null}var GatewayProtocolClient=class{constructor(opts){this.opts=opts;this.socket=null;this.pending=new Map;this.listeners=new GatewayEventListeners;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.reconnectSignal=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new RetrySupervisor({initialMs:opts.reconnect.initialMs,maxMs:opts.reconnect.maxMs,factor:opts.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(pending=>pending.unbounded)}start(){this.socket||this.reconnectSignal||(this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect())}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSignal=null,this.reconnectSupervisor.reset();let socket=this.socket;socket&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),socket?.close()}request(method,params,options){let socket=this.socket;if(!socket?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof method!="string"||method.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let id=this.opts.createRequestId(),timeoutMs=options?.timeoutMs===null?void 0:options?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((resolve,reject)=>{let timeout,requestSent=!1,pending={resolve:value=>resolve(value),reject,expectFinal:options?.expectFinal===!0,acceptedNotified:!1,onAccepted:options?.onAccepted,unbounded:timeoutMs===void 0,method,startedAtMs:this.nowMs()},onAbort=()=>{this.pending.delete(id),pending.cleanup?.(),this.finishRequestTiming(id,pending,!1,"CLIENT_ABORTED"),reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`))},cleanup=()=>{timeout&&clearTimeout(timeout),options?.signal?.removeEventListener("abort",onAbort)};if(options?.signal?.aborted){reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`));return}pending.cleanup=cleanup,timeoutMs!==void 0&&timeoutMs>=0&&(timeout=setTimeout(()=>{this.pending.get(id)===pending&&(this.pending.delete(id),options?.signal?.removeEventListener("abort",onAbort),this.finishRequestTiming(id,pending,!1,"CLIENT_TIMEOUT"),reject(this.opts.createRequestTimeoutError?.(method,timeoutMs,requestSent)??new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`)))},timeoutMs),timeout.unref?.()),options?.signal?.addEventListener("abort",onAbort,{once:!0}),this.pending.set(id,pending);try{socket.send(JSON.stringify({type:"req",id,method,params})),requestSent=!0,this.invoke("sent",()=>options?.onSent?.())}catch(error){this.pending.delete(id),cleanup(),this.finishRequestTiming(id,pending,!1,"CLIENT_SEND_ERROR"),reject(error instanceof Error?error:new Error(String(error)))}})}addEventListener(listener){return this.listeners.add(listener)}closeSocket(code,reason){this.socket?.close(code,reason)}resetReconnectBackoff(initialMs){this.reconnectSignal=null,this.reconnectSupervisor.reset(initialMs)}recordTiming(phase,generation,plan,detail){let now=this.nowMs(),state=this.connectTiming;!state||state.generation!==generation||(state.hasChallenge||=phase==="challenge",state.usedFallback||=phase==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase,generation,durationMs:Math.max(0,now-state.startedAtMs),phaseDurationMs:Math.max(0,now-state.lastAtMs),hasChallenge:state.hasChallenge,usedFallback:state.usedFallback,plan,detail})),state.lastAtMs=now,(phase==="hello"||phase==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let generation=this.generation+1;this.lastSeq=null,this.connectNonce=null,this.connectChallengeTs=void 0,this.connectSent=this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let socket;try{socket=this.opts.createSocket({open:()=>this.handleOpen(socket,generation),message:data=>this.handleMessage(socket,generation,data),close:(code,reason)=>this.handleClose(socket,generation,code,reason),error:error=>this.handleSocketError(socket,generation,error)})}catch(error){let normalized2=error instanceof Error?error:new Error(String(error));if(this.opts.onSocketFactoryError?.(normalized2),this.opts.onConnectError?.(normalized2),this.opts.rethrowSocketFactoryError?.(normalized2))throw normalized2;this.opts.shouldRetrySocketFactoryError?.(normalized2)&&!this.stopped&&!this.socket&&!this.reconnectSignal&&this.scheduleReconnect();return}this.generation=generation,this.socket=socket;let now=this.nowMs();this.connectTiming={generation,startedAtMs:now,lastAtMs:now,hasChallenge:!1,usedFallback:!1}}handleOpen(socket,generation){if(this.isActive(socket,generation)){if(this.socketOpened=!0,this.recordTiming("socket-open",generation),this.connectNonce){this.sendConnect(socket,generation);return}this.armHandshakeTimer(socket,generation)}}armHandshakeTimer(socket,generation){this.clearHandshakeTimer();let armedAt=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(socket,generation)||this.connectSent||!socket.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",generation),this.sendConnect(socket,generation);return}let elapsedMs=Date.now()-armedAt,error=new Error(this.opts.handshake.timeoutMessage?.(elapsedMs)??`gateway connect challenge timeout after ${elapsedMs}ms`);this.opts.onConnectError?.(error),socket.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(socket,generation){if(!this.isActive(socket,generation)||!socket.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer(),this.handshakeTimer=startGatewayConnectTimeout(()=>{this.isActive(socket,generation)&&!this.helloReceived&&socket.close(4e3,"connect timeout")});let planOrPromise;try{planOrPromise=this.opts.buildConnectPlan({nonce:this.connectNonce,challengeTs:this.connectChallengeTs,generation})}catch(error){this.handleConnectPlanError(socket,generation,error);return}if(planOrPromise instanceof Promise){planOrPromise.then(plan=>this.sendConnectPlan(socket,generation,plan)).catch(error=>this.handleConnectPlanError(socket,generation,error));return}this.sendConnectPlan(socket,generation,planOrPromise)}handleConnectPlanError(socket,generation,error){if(!this.isActive(socket,generation))return;let normalized2=error instanceof Error?error:new Error(String(error)),outcome=this.opts.onConnectPlanError?.(normalized2)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(outcome.error??normalized2),outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)}sendConnectPlan(socket,generation,plan){if(!this.isActive(socket,generation)||!socket.isOpen())return;let context={generation,nonce:this.connectNonce,challengeTs:this.connectChallengeTs,plan};this.recordTiming("connect-plan-ready",generation,plan),this.recordTiming("request-sent",generation,plan),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(plan)).then(hello=>{this.isActive(socket,generation)&&(this.helloReceived=!0,this.clearHandshakeTimer(),this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",generation,plan),this.opts.onConnectHello?.(hello,context),this.invoke("hello",()=>this.opts.onHello?.(hello)))}).catch(error=>{if(!this.isActive(socket,generation))return;let requestError=error instanceof GatewayProtocolRequestError?error:new GatewayProtocolRequestError({message:String(error)}),outcome=this.opts.onConnectFailure?.(requestError,context)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:requestError,reconnectDelayMs:outcome.reconnectDelayMs},outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)})}handleMessage(socket,generation,raw){if(!this.isActive(socket,generation))return;let parsed;try{parsed=JSON.parse(raw)}catch(error){this.opts.onParseError?.(error);return}if(isGatewayEventFrame(parsed)){if(this.opts.onActivity?.(),parsed.event==="connect.challenge"){let payload=parsed.payload,nonce=typeof payload?.nonce=="string"?payload.nonce.trim():"";if(!nonce){if(this.opts.handshake.mode==="require-challenge"){let error=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(error),socket.close(1008,"connect challenge missing nonce")}return}this.connectNonce=nonce;let challengeTs=payload?.ts;this.connectChallengeTs=typeof challengeTs=="number"&&Number.isSafeInteger(challengeTs)&&challengeTs>=0?challengeTs:null,this.recordTiming("challenge",generation),this.sendConnect(socket,generation);return}let seq=typeof parsed.seq=="number"?parsed.seq:null;if(seq!==null){if(this.lastSeq!==null&&seq>this.lastSeq+1){let expected=this.lastSeq+1;if(this.invoke("gap",()=>this.opts.onGap?.({expected,received:seq})),!this.isActive(socket,generation))return}this.lastSeq=seq}let listeners=this.listeners.snapshot();this.invoke("event",()=>this.opts.onEvent?.(parsed));for(let[listener,subscription]of listeners){if(!this.isActive(socket,generation))return;this.listeners.isCurrent(listener,subscription)&&this.invoke("event listener",()=>listener(parsed))}return}isGatewayResponseFrame(parsed)&&(this.opts.onActivity?.(),this.handleResponse(parsed))}handleResponse(frame){let pending=this.pending.get(frame.id);if(!pending)return;let status=frame.payload?.status;if(pending.expectFinal&&status==="accepted"){pending.acceptedNotified||(pending.acceptedNotified=!0,this.invoke("accepted",()=>pending.onAccepted?.(frame.payload)));return}if(this.pending.delete(frame.id),pending.cleanup?.(),frame.ok){this.finishRequestTiming(frame.id,pending,!0),pending.resolve(frame.payload);return}this.finishRequestTiming(frame.id,pending,!1,frame.error?.code),pending.reject(this.opts.createRequestError?.(frame.error??{})??new GatewayProtocolRequestError(frame.error??{}))}handleClose(socket,generation,code,reason){if(this.socket!==socket){if(this.stoppedSocket?.socket===socket){let context2={...this.stoppedSocket.context,code,reason};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(context2,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let context={...this.closeContext(),code,reason,generation};this.connectFailure=void 0;let decision=this.opts.resolveClose(context);this.flushRequests(decision.pendingError??context.connectFailure?.error??new Error(`gateway closed (${code}): ${reason}`)),this.invoke("close",()=>this.opts.onClose?.(context,decision)),decision.retry&&!this.stopped&&this.scheduleReconnect(decision.reconnectDelayMs??context.connectFailure?.reconnectDelayMs)}handleSocketError(socket,generation,error){!this.isActive(socket,generation)||this.connectSent||this.opts.onConnectError?.(error)}flushRequests(error){for(let[id,pending]of this.pending)this.finishRequestTiming(id,pending,!1,"CLIENT_CLOSED"),pending.cleanup?.(),pending.reject(error);this.pending.clear()}finishRequestTiming(id,pending,ok,errorCode){let endedAtMs=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id,method:pending.method,ok,durationMs:Math.max(0,endedAtMs-pending.startedAtMs),startedAtMs:pending.startedAtMs,endedAtMs,errorCode}))}scheduleReconnect(overrideMs){overrideMs!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=overrideMs);let retry=this.reconnectSupervisor.next();retry&&(this.reconnectSignal=retry.signal,sleepWithAbort(retry.delayMs,retry.signal).then(()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null,this.connect())},()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null)}))}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(socket,generation){return!this.stopped&&this.socket===socket&&this.generation===generation}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer=clearGatewayConnectTimeout(this.handshakeTimer)}invoke(label,callback){try{callback()}catch(error){this.opts.onCallbackError?.(label,error)}}};var GATEWAY_CLIENT_IDS={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",LINUX_APP:"openclaw-linux",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var GATEWAY_CLIENT_MODES={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},GATEWAY_CLIENT_CAPS={AGENT_KIND:"agent-kind",APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},GATEWAY_CLIENT_ID_SET=new Set(Object.values(GATEWAY_CLIENT_IDS)),GATEWAY_CLIENT_MODE_SET=new Set(Object.values(GATEWAY_CLIENT_MODES));var PROTOCOL_VERSION=4,MIN_CLIENT_PROTOCOL_VERSION=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var ed25519_CURVE=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:P,n:N,Gx,Gy,a:_a,d:_d,h}=ed25519_CURVE,L=32,captureTrace=(...args)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...args)},err=(message="")=>{let e=new Error(message);throw captureTrace(e,err),e},isBig=n=>typeof n=="bigint",isStr=s=>typeof s=="string",isBytes=a=>a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in a&&a.BYTES_PER_ELEMENT===1,abytes=(value,length,title="")=>{let bytes=isBytes(value),len=value?.length,needsLen=length!==void 0;if(!bytes||needsLen&&len!==length){let prefix=title&&`"${title}" `,ofLen=needsLen?` of length ${length}`:"",got=bytes?`length=${len}`:`type=${typeof value}`,msg=prefix+"expected Uint8Array"+ofLen+", got "+got;throw bytes?new RangeError(msg):new TypeError(msg)}return value},u8n=len=>new Uint8Array(len),u8fr=buf=>Uint8Array.from(buf),padh=(n,pad)=>n.toString(16).padStart(pad,"0"),bytesToHex=b=>Array.from(abytes(b)).map(e=>padh(e,2)).join(""),C={_0:48,_9:57,A:65,F:70,a:97,f:102},_ch=ch=>{if(ch>=C._0&&ch<=C._9)return ch-C._0;if(ch>=C.A&&ch<=C.F)return ch-(C.A-10);if(ch>=C.a&&ch<=C.f)return ch-(C.a-10)},hexToBytes=hex=>{let e="hex invalid";if(!isStr(hex))return err(e);let hl=hex.length,al=hl/2;if(hl%2)return err(e);let array=u8n(al);for(let ai=0,hi=0;aiglobalThis?.crypto,subtle=()=>cr()?.subtle??err("crypto.subtle must be defined, consider polyfill"),concatBytes=(...arrs)=>{let len=0;for(let a of arrs)len+=abytes(a).length;let r=u8n(len),pad=0;return arrs.forEach(a=>{r.set(a,pad),pad+=a.length}),r},randomBytes=(len=L)=>cr().getRandomValues(u8n(len)),big=BigInt,assertRange=(n,min,max,msg="bad number: out of range")=>{if(!isBig(n))throw new TypeError(msg);if(min<=n&&n{let r=a%b;return r>=0n?r:b+r},P_MASK=(1n<<255n)-1n,modP=num=>{num<0n&&err("negative coordinate");let r=(num>>255n)*19n+(num&P_MASK);return r=(r>>255n)*19n+(r&P_MASK),r%P},modN=a=>M(a,N),invert=(num,md)=>{(num===0n||md<=0n)&&err("no inverse n="+num+" mod="+md);let a=M(num,md),b=md,x=0n,y=1n,u=1n,v=0n;for(;a!==0n;){let q=b/a,r=b%a,m=x-u*q,n=y-v*q;b=a,a=r,x=u,y=v,u=m,v=n}return b===1n?M(x,md):err("no inverse")},callHash=name=>{let fn=hashes[name];return typeof fn!="function"&&err("hashes."+name+" not set"),fn},checkDigest=value=>abytes(value,64,"digest");var apoint=p=>p instanceof Point?p:err("Point expected"),B256=2n**256n,Point=class _Point{static BASE;static ZERO;X;Y;Z;T;constructor(X,Y,Z,T){let max=B256;this.X=assertRange(X,0n,max),this.Y=assertRange(Y,0n,max),this.Z=assertRange(Z,1n,max),this.T=assertRange(T,0n,max),Object.freeze(this)}static CURVE(){return ed25519_CURVE}static fromAffine(p){return new _Point(p.x,p.y,1n,modP(p.x*p.y))}static fromBytes(hex,zip215=!1){let d=_d,normed=u8fr(abytes(hex,L)),lastByte=hex[31];normed[31]=lastByte&-129;let y=bytesToNumberLE(normed);assertRange(y,0n,zip215?B256:P);let y2=modP(y*y),u=M(y2-1n),v=modP(d*y2+1n),{isValid,value:x}=uvRatio(u,v);isValid||err("bad point: y not sqrt");let isXOdd=(x&1n)===1n,isLastByteOdd=(lastByte&128)!==0;return!zip215&&x===0n&&isLastByteOdd&&err("bad point: x==0, isLastByteOdd"),isLastByteOdd!==isXOdd&&(x=M(-x)),new _Point(x,y,1n,modP(x*y))}static fromHex(hex,zip215){return _Point.fromBytes(hexToBytes(hex),zip215)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let a=_a,d=_d,p=this;if(p.is0())return err("bad point: ZERO");let{X,Y,Z,T}=p,X2=modP(X*X),Y2=modP(Y*Y),Z2=modP(Z*Z),Z4=modP(Z2*Z2),aX2=modP(X2*a),left=modP(Z2*(aX2+Y2)),right=M(Z4+modP(d*modP(X2*Y2)));if(left!==right)return err("bad point: equation left != right (1)");let XY=modP(X*Y),ZT=modP(Z*T);return XY!==ZT?err("bad point: equation left != right (2)"):this}equals(other){let{X:X1,Y:Y1,Z:Z1}=this,{X:X2,Y:Y2,Z:Z2}=apoint(other),X1Z2=modP(X1*Z2),X2Z1=modP(X2*Z1),Y1Z2=modP(Y1*Z2),Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(I)}negate(){return new _Point(M(-this.X),this.Y,this.Z,M(-this.T))}double(){let{X:X1,Y:Y1,Z:Z1}=this,a=_a,A=modP(X1*X1),B=modP(Y1*Y1),C2=modP(2n*Z1*Z1),D=modP(a*A),x1y1=M(X1+Y1),E=M(modP(x1y1*x1y1)-A-B),G2=M(D+B),F=M(G2-C2),H=M(D-B),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}add(other){let{X:X1,Y:Y1,Z:Z1,T:T1}=this,{X:X2,Y:Y2,Z:Z2,T:T2}=apoint(other),a=_a,d=_d,A=modP(X1*X2),B=modP(Y1*Y2),C2=modP(modP(T1*d)*T2),D=modP(Z1*Z2),E=M(modP(M(X1+Y1)*M(X2+Y2))-A-B),F=M(D-C2),G2=M(D+C2),H=M(B-modP(a*A)),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}subtract(other){return this.add(apoint(other).negate())}multiply(n,safe=!0){if(!safe&&n===0n||(assertRange(n,1n,N),!safe&&this.is0()))return I;if(n===1n)return this;if(this.equals(G))return wNAF(n).p;let p=I,f=G;for(let d=this;n>0n;d=d.double(),n>>=1n)n&1n?p=p.add(d):safe&&(f=f.add(d));return p}multiplyUnsafe(scalar){return this.multiply(scalar,!1)}toAffine(){let{X,Y,Z}=this;if(this.equals(I))return{x:0n,y:1n};let iz=invert(Z,P);modP(Z*iz)!==1n&&err("invalid inverse");let x=modP(X*iz),y=modP(Y*iz);return{x,y}}toBytes(){let{x,y}=this.toAffine(),b=numTo32bLE(y);return b[31]|=x&1n?128:0,b}toHex(){return bytesToHex(this.toBytes())}clearCofactor(){return this.multiply(big(h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let p=this.multiply(N/2n,!1).double();return N%2n&&(p=p.add(this)),p.is0()}},G=new Point(Gx,Gy,1n,M(Gx*Gy)),I=new Point(0n,1n,1n,0n);Point.BASE=G;Point.ZERO=I;var numTo32bLE=num=>hexToBytes(padh(assertRange(num,0n,B256),64)).reverse(),bytesToNumberLE=b=>big("0x"+bytesToHex(u8fr(abytes(b)).reverse())),pow2=(x,power)=>{let r=x;for(;power-- >0n;)r=modP(r*r);return r},pow_2_252_3=x=>{let x2=modP(x*x),b2=modP(x2*x),b4=modP(pow2(b2,2n)*b2),b5=modP(pow2(b4,1n)*x),b10=modP(pow2(b5,5n)*b5),b20=modP(pow2(b10,10n)*b10),b40=modP(pow2(b20,20n)*b20),b80=modP(pow2(b40,40n)*b40),b160=modP(pow2(b80,80n)*b80),b240=modP(pow2(b160,80n)*b80),b250=modP(pow2(b240,10n)*b10);return{pow_p_5_8:modP(pow2(b250,2n)*x),b2}},RM1=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,uvRatio=(u,v)=>{let v3=modP(v*modP(v*v)),v7=modP(modP(v3*v3)*v),pow=pow_2_252_3(modP(u*v7)).pow_p_5_8,x=modP(u*modP(v3*pow)),vx2=modP(v*modP(x*x)),root1=x,root2=modP(x*RM1),useRoot1=vx2===u,useRoot2=vx2===M(-u),noRoot=vx2===M(-u*RM1);return useRoot1&&(x=root1),(useRoot2||noRoot)&&(x=root2),(M(x)&1n)===1n&&(x=M(-x)),{isValid:useRoot1||useRoot2,value:x}},modL_LE=hash=>modN(bytesToNumberLE(hash)),sha512a=(...m)=>Promise.resolve(callHash("sha512Async")(concatBytes(...m))).then(checkDigest),sha512s=(...m)=>checkDigest(callHash("sha512")(concatBytes(...m))),hash2extK=hashed=>{let copy=u8fr(hashed),head=copy.slice(0,32);head[0]&=248,head[31]&=127,head[31]|=64;let prefix=copy.slice(32,64),scalar=modL_LE(head),point=G.multiply(scalar),pointBytes=point.toBytes();return{head,prefix,scalar,point,pointBytes}},getExtendedPublicKeyAsync=secretKey=>sha512a(abytes(secretKey,L)).then(hash2extK),getExtendedPublicKey=secretKey=>hash2extK(sha512s(abytes(secretKey,L))),getPublicKeyAsync=secretKey=>getExtendedPublicKeyAsync(secretKey).then(p=>p.pointBytes);var hashFinishA=res=>sha512a(res.hashable).then(res.finish);var _sign=(e,rBytes,msg)=>{let{pointBytes:P2,scalar:s}=e,r=modL_LE(rBytes),R=G.multiply(r).toBytes();return{hashable:concatBytes(R,P2,msg),finish:hashed=>{let S=modN(r+modL_LE(hashed)*s);return abytes(concatBytes(R,numTo32bLE(S)),64)}}},signAsync=async(message,secretKey)=>{let m=abytes(message),e=await getExtendedPublicKeyAsync(secretKey),rBytes=await sha512a(e.prefix,m);return hashFinishA(_sign(e,rBytes,m))};var hashes={sha512Async:async message=>{let s=subtle(),m=concatBytes(message);return u8n(await s.digest("SHA-512",m.buffer))},sha512:void 0},randomSecretKey=seed=>(seed=seed===void 0?randomBytes(L):seed,abytes(seed,L));var utils=Object.freeze({getExtendedPublicKeyAsync,getExtendedPublicKey,randomSecretKey}),W=8,scalarBits=256,pwindows=Math.ceil(scalarBits/W)+1,pwindowSize=2**(W-1),precompute=()=>{let points=[],p=G,b=p;for(let w=0;w{let n=p.negate();return cnd?n:p},wNAF=n=>{let comp=Gpows||(Gpows=precompute()),p=I,f=G,pow_2_w=2**W,maxNum=pow_2_w,mask=big(pow_2_w-1),shiftBy=big(W);for(let w=0;w>=shiftBy,wbits>pwindowSize&&(wbits-=maxNum,n+=1n);let off=w*pwindowSize,offF=off,offP=off+Math.abs(wbits)-1,isEven=w%2!==0,isNeg=wbits<0;wbits===0?f=f.add(ctneg(isEven,comp[offF])):p=p.add(ctneg(isNeg,comp[offP]))}return n!==0n&&err("invalid wnaf"),{p,f}};export{GATEWAY_CLIENT_CAPS,GATEWAY_CLIENT_IDS,GATEWAY_CLIENT_MODES,GatewayBrowserDeviceAuthLifecycle,GatewayProtocolClient,GatewayProtocolRequestError,MIN_CLIENT_PROTOCOL_VERSION,PROTOCOL_VERSION,utils as ed25519Utils,getPublicKeyAsync,signAsync}; diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts b/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts deleted file mode 100644 index aa108ecea0cc..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { BrowserCopilotBinding } from "./panel-core.js"; - -export type CopilotSessionEntry = { - tabId: number; - browserInstanceId: string; - gatewayScope: string; - sessionKey: string; - sessionId?: string; - binding?: BrowserCopilotBinding; - createdAt?: number; - provisional?: boolean; - creationPending?: boolean; - activeRunId?: string; - abortPending?: boolean; -}; - -export type CopilotArchiveEntry = { - tabId?: number; - gatewayScope: string; - sessionKey: string; - sessionId?: string; - ensureCreated?: boolean; - queuedAt: number; -}; - -export class CopilotPanelBindingRegistry { - constructor(storage?: unknown); - initialize(): Promise; - bind(tabId: number): Promise; - resolve(token: string): Promise; - remove(tabId: number): Promise; -} - -export class CopilotSessionRegistry { - constructor(storage?: unknown); - initialize(existingTabIds: Set): Promise; - get(tabId: number, gatewayScope: string): CopilotSessionEntry | null; - list(): CopilotSessionEntry[]; - gatewayScopes(): string[]; - pendingArchives(gatewayScope: string): CopilotArchiveEntry[]; - put( - tabId: number, - entry: Omit, - ): Promise; - updateBinding(tabId: number, gatewayScope: string, binding: BrowserCopilotBinding): Promise; - confirmSession( - tabId: number, - gatewayScope: string, - sessionId?: string, - ): Promise; - markSessionCreationPending( - tabId: number, - gatewayScope: string, - ): Promise; - discardProvisionalSession(tabId: number, gatewayScope: string): Promise; - startRun(tabId: number, gatewayScope: string, runId: string): Promise; - queueAbort(tabId: number, gatewayScope: string): Promise; - queueActiveAborts(gatewayScope: string): Promise; - pendingAborts(gatewayScope: string): CopilotSessionEntry[]; - finishRun(gatewayScope: string, sessionKey: string, runId: string): Promise; - closeTab(tabId: number): Promise; - closeScope(gatewayScope: string): Promise; - closeInactiveScope(gatewayScope: string): Promise; - resolveArchive(gatewayScope: string, sessionKey: string): Promise; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.js b/extensions/browser/chrome-extension/modules/copilot-session-registry.js deleted file mode 100644 index 93a297ebfa34..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session-registry.js +++ /dev/null @@ -1,357 +0,0 @@ -const LOCAL_KEY = "copilotSessionRegistryV1"; -const INSTANCE_KEY = "copilotBrowserInstanceV1"; -const PANEL_BINDINGS_KEY = "copilotPanelBindingsV1"; - -function emptyState() { - return { sessions: {}, pendingArchives: [] }; -} - -function createStorageWriteQueue() { - let tail = Promise.resolve(); - return (operation) => { - const result = tail.then(operation); - // Preserve this caller's error while keeping later writes ordered and runnable. - tail = result.catch(() => undefined); - return result; - }; -} - -/** Browser-instance-only capabilities bind same-path panel documents to tabs. */ -export class CopilotPanelBindingRegistry { - constructor(storage = chrome.storage.session) { - this.storage = storage; - this.byTab = {}; - this.ready = null; - this.enqueueWrite = createStorageWriteQueue(); - } - - async initialize() { - if (!this.ready) { - this.ready = (async () => { - const stored = (await this.storage.get([PANEL_BINDINGS_KEY]))[PANEL_BINDINGS_KEY]; - this.byTab = stored && typeof stored === "object" ? stored : {}; - })(); - } - await this.ready; - } - - async bind(tabId) { - await this.initialize(); - return await this.enqueueWrite(async () => { - const key = String(tabId); - const current = this.byTab[key]; - if (typeof current === "string" && current) { - return current; - } - const token = crypto.randomUUID(); - this.byTab[key] = token; - try { - await this.storage.set({ [PANEL_BINDINGS_KEY]: this.byTab }); - } catch (error) { - // An undurable capability must not make a queued bind skip persistence. - delete this.byTab[key]; - throw error; - } - return token; - }); - } - - async resolve(token) { - await this.initialize(); - for (const [rawTabId, candidate] of Object.entries(this.byTab)) { - if (candidate === token) { - return Number(rawTabId); - } - } - return null; - } - - async remove(tabId) { - await this.initialize(); - await this.#mutate(() => { - delete this.byTab[String(tabId)]; - }); - } - - async #mutate(run) { - await this.enqueueWrite(async () => { - run(); - await this.storage.set({ [PANEL_BINDINGS_KEY]: this.byTab }); - }); - } -} - -/** Durable registry: worker suspension preserves bindings; browser restart archives orphans. */ -export class CopilotSessionRegistry { - constructor(storage = chrome.storage) { - this.storage = storage; - this.state = emptyState(); - this.instanceId = null; - this.ready = null; - this.enqueueWrite = createStorageWriteQueue(); - } - - async initialize(existingTabIds) { - if (this.ready) { - return await this.ready; - } - this.ready = this.#initialize(existingTabIds); - return await this.ready; - } - - async #initialize(existingTabIds) { - const sessionStored = await this.storage.session.get([INSTANCE_KEY]); - this.instanceId = sessionStored[INSTANCE_KEY]; - if (typeof this.instanceId !== "string" || !this.instanceId) { - this.instanceId = crypto.randomUUID(); - await this.storage.session.set({ [INSTANCE_KEY]: this.instanceId }); - } - const localStored = await this.storage.local.get([LOCAL_KEY]); - const candidate = localStored[LOCAL_KEY]; - this.state = - candidate && typeof candidate === "object" - ? { - sessions: - candidate.sessions && typeof candidate.sessions === "object" - ? candidate.sessions - : {}, - pendingArchives: Array.isArray(candidate.pendingArchives) - ? candidate.pendingArchives - : [], - } - : emptyState(); - for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { - const tabId = Number(rawTabId); - if (entry?.browserInstanceId === this.instanceId && existingTabIds.has(tabId)) { - continue; - } - this.#queueArchive(entry); - delete this.state.sessions[rawTabId]; - } - await this.#persist(); - return this.instanceId; - } - - get(tabId, gatewayScope) { - const entry = this.state.sessions[String(tabId)] ?? null; - return entry?.gatewayScope === gatewayScope ? entry : null; - } - - list() { - return Object.values(this.state.sessions); - } - - gatewayScopes() { - return [ - ...new Set([ - ...this.list().map((entry) => entry.gatewayScope), - ...this.state.pendingArchives.map((entry) => entry.gatewayScope), - ]), - ].filter((scope) => typeof scope === "string" && scope); - } - - pendingArchives(gatewayScope) { - return this.state.pendingArchives.filter((entry) => entry.gatewayScope === gatewayScope); - } - - async put(tabId, entry) { - await this.#mutate(() => { - const current = this.state.sessions[String(tabId)]; - if (current && current.gatewayScope !== entry.gatewayScope) { - // The write queue transfers old-scope custody to the durable archive - // queue before replacement, so concurrent recovery cannot lose it. - this.#queueArchive(current); - } - this.state.sessions[String(tabId)] = { - ...entry, - tabId, - browserInstanceId: this.instanceId, - }; - }); - return this.get(tabId, entry.gatewayScope); - } - - async updateBinding(tabId, gatewayScope, binding) { - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (current) { - current.binding = { ...binding }; - } - }); - } - - async confirmSession(tabId, gatewayScope, sessionId) { - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (!current) { - return; - } - if (typeof sessionId === "string" && sessionId) { - current.sessionId = sessionId; - } - delete current.provisional; - delete current.creationPending; - }); - return this.get(tabId, gatewayScope); - } - - async markSessionCreationPending(tabId, gatewayScope) { - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (current?.provisional) { - current.creationPending = true; - } - }); - return this.get(tabId, gatewayScope); - } - - async discardProvisionalSession(tabId, gatewayScope) { - let discarded = false; - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (!current?.provisional) { - return; - } - delete this.state.sessions[String(tabId)]; - discarded = true; - }); - return discarded; - } - - async startRun(tabId, gatewayScope, runId) { - let started = null; - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (!current || current.activeRunId) { - return; - } - current.activeRunId = runId; - current.abortPending = false; - started = current; - }); - return started; - } - - async queueAbort(tabId, gatewayScope) { - let queued = null; - await this.#mutate(() => { - const current = this.get(tabId, gatewayScope); - if (!current?.activeRunId) { - return; - } - current.abortPending = true; - queued = current; - }); - return queued; - } - - async queueActiveAborts(gatewayScope) { - await this.#mutate(() => { - for (const entry of Object.values(this.state.sessions)) { - if (entry?.gatewayScope === gatewayScope && entry.activeRunId) { - entry.abortPending = true; - } - } - }); - } - - pendingAborts(gatewayScope) { - return this.list().filter( - (entry) => entry.gatewayScope === gatewayScope && entry.activeRunId && entry.abortPending, - ); - } - - async finishRun(gatewayScope, sessionKey, runId) { - let finished = false; - await this.#mutate(() => { - const current = this.list().find( - (entry) => entry.gatewayScope === gatewayScope && entry.sessionKey === sessionKey, - ); - if (!current || current.activeRunId !== runId) { - return; - } - delete current.activeRunId; - delete current.abortPending; - finished = true; - }); - return finished; - } - - async closeTab(tabId) { - let closed = null; - await this.#mutate(() => { - closed = this.state.sessions[String(tabId)] ?? null; - if (closed) { - this.#queueArchive(closed); - delete this.state.sessions[String(tabId)]; - } - }); - return closed; - } - - async closeScope(gatewayScope) { - await this.#mutate(() => { - for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { - if (entry?.gatewayScope !== gatewayScope) { - continue; - } - this.#queueArchive(entry); - delete this.state.sessions[rawTabId]; - } - }); - } - - async closeInactiveScope(gatewayScope) { - await this.#mutate(() => { - for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { - if (entry?.gatewayScope !== gatewayScope || entry.activeRunId) { - continue; - } - this.#queueArchive(entry); - delete this.state.sessions[rawTabId]; - } - }); - } - - async resolveArchive(gatewayScope, sessionKey) { - await this.#mutate(() => { - this.state.pendingArchives = this.state.pendingArchives.filter( - (entry) => entry.gatewayScope !== gatewayScope || entry.sessionKey !== sessionKey, - ); - }); - } - - #queueArchive(entry) { - if (!entry?.sessionKey || !entry?.gatewayScope) { - return; - } - if (entry.provisional && entry.creationPending !== true) { - return; - } - const existing = this.state.pendingArchives.some( - (candidate) => - candidate.gatewayScope === entry.gatewayScope && candidate.sessionKey === entry.sessionKey, - ); - if (!existing) { - this.state.pendingArchives.push({ - gatewayScope: entry.gatewayScope, - sessionKey: entry.sessionKey, - sessionId: entry.sessionId, - tabId: entry.tabId, - ensureCreated: entry.provisional === true, - queuedAt: Date.now(), - }); - } - } - - async #mutate(run) { - await this.enqueueWrite(async () => { - run(); - await this.#persist(); - }); - } - - async #persist() { - await this.storage.local.set({ [LOCAL_KEY]: this.state }); - } -} diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts b/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts deleted file mode 100644 index 12e3453a940b..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { CopilotPanelBindingRegistry, CopilotSessionRegistry } from "./copilot-session-registry.js"; - -const GATEWAY_SCOPE = "ws://127.0.0.1:18789/"; - -function storageArea(initial: Record = {}) { - const values = { ...initial }; - const setCalls: Record[] = []; - return { - setCalls, - values, - async get(keys: string[]) { - return Object.fromEntries(keys.map((key) => [key, values[key]])); - }, - async set(update: Record) { - setCalls.push(update); - Object.assign(values, update); - }, - }; -} - -function storage(localInitial: Record = {}, sessionInitial = {}) { - return { local: storageArea(localInitial), session: storageArea(sessionInitial) }; -} - -describe("CopilotSessionRegistry", () => { - it("archives prior-browser and missing-tab sessions during recovery", async () => { - const mock = storage( - { - copilotSessionRegistryV1: { - sessions: { - 1: { - browserInstanceId: "old", - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-old", - sessionId: "id-old", - }, - 2: { - browserInstanceId: "current", - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-closed", - sessionId: "id-closed", - }, - 3: { - browserInstanceId: "current", - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-live", - sessionId: "id-live", - }, - }, - pendingArchives: [], - }, - }, - { copilotBrowserInstanceV1: "current" }, - ); - const registry = new CopilotSessionRegistry(mock as never); - - await registry.initialize(new Set([1, 3])); - - expect(registry.get(1, GATEWAY_SCOPE)).toBeNull(); - expect(registry.get(2, GATEWAY_SCOPE)).toBeNull(); - expect(registry.get(3, GATEWAY_SCOPE)?.sessionKey).toBe("session-live"); - expect(registry.pendingArchives(GATEWAY_SCOPE).map((entry) => entry.sessionKey)).toEqual([ - "session-old", - "session-closed", - ]); - }); - - it("moves a closed tab to the durable archive queue exactly once", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([8])); - await registry.put(8, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-8", - sessionId: "id-8", - }); - - await registry.closeTab(8); - await registry.closeTab(8); - - expect(registry.get(8, GATEWAY_SCOPE)).toBeNull(); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([ - expect.objectContaining({ sessionKey: "session-8", tabId: 8 }), - ]); - await registry.resolveArchive(GATEWAY_SCOPE, "session-8"); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); - }); - - it("keeps a provisional session key until Gateway creation is confirmed", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([11])); - await registry.put(11, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-provisional", - provisional: true, - }); - - expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ - provisional: true, - sessionKey: "session-provisional", - }); - await registry.markSessionCreationPending(11, GATEWAY_SCOPE); - expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ creationPending: true }); - await registry.confirmSession(11, GATEWAY_SCOPE, "id-provisional"); - expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ - sessionId: "id-provisional", - sessionKey: "session-provisional", - }); - expect(registry.get(11, GATEWAY_SCOPE)).not.toHaveProperty("provisional"); - expect(registry.get(11, GATEWAY_SCOPE)).not.toHaveProperty("creationPending"); - }); - - it("archives a provisional key only after its creation RPC can have reached Gateway", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([11, 12])); - await registry.put(11, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-not-attempted", - provisional: true, - creationPending: false, - }); - await registry.closeTab(11); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); - - await registry.put(12, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-attempted", - provisional: true, - creationPending: false, - }); - await registry.markSessionCreationPending(12, GATEWAY_SCOPE); - await registry.closeTab(12); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([ - expect.objectContaining({ - sessionKey: "session-attempted", - tabId: 12, - ensureCreated: true, - }), - ]); - }); - - it("drops a definitively rejected provisional session without archiving it", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([13])); - await registry.put(13, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-rejected", - provisional: true, - creationPending: true, - }); - - await expect(registry.discardProvisionalSession(13, GATEWAY_SCOPE)).resolves.toBe(true); - expect(registry.get(13, GATEWAY_SCOPE)).toBeNull(); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); - }); - - it("never reuses or drains session custody across Gateways", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - const otherGateway = "ws://127.0.0.1:28789/"; - await registry.initialize(new Set([9])); - await registry.put(9, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-a", - }); - await registry.put(9, { - gatewayScope: otherGateway, - sessionKey: "session-b", - }); - - expect(registry.get(9, GATEWAY_SCOPE)).toBeNull(); - expect(registry.get(9, otherGateway)?.sessionKey).toBe("session-b"); - expect(registry.pendingArchives(otherGateway)).toEqual([]); - expect(registry.pendingArchives(GATEWAY_SCOPE).map((entry) => entry.sessionKey)).toEqual([ - "session-a", - ]); - await registry.resolveArchive(otherGateway, "session-a"); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toHaveLength(1); - await registry.resolveArchive(GATEWAY_SCOPE, "session-a"); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); - }); - - it("persists active-run cancellation until the owning Gateway resolves it", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([10])); - await registry.put(10, { - gatewayScope: GATEWAY_SCOPE, - sessionKey: "session-10", - }); - - await expect(registry.startRun(10, GATEWAY_SCOPE, "run-10")).resolves.toMatchObject({ - activeRunId: "run-10", - }); - await registry.queueActiveAborts(GATEWAY_SCOPE); - expect(registry.pendingAborts(GATEWAY_SCOPE)).toEqual([ - expect.objectContaining({ - abortPending: true, - activeRunId: "run-10", - sessionKey: "session-10", - }), - ]); - await expect(registry.finishRun(GATEWAY_SCOPE, "session-10", "stale-run")).resolves.toBe(false); - expect(registry.pendingAborts(GATEWAY_SCOPE)).toHaveLength(1); - await expect(registry.finishRun(GATEWAY_SCOPE, "session-10", "run-10")).resolves.toBe(true); - expect(registry.pendingAborts(GATEWAY_SCOPE)).toEqual([]); - }); - - it("continues queued lifecycle writes after preserving a storage failure", async () => { - const mock = storage(); - const registry = new CopilotSessionRegistry(mock as never); - await registry.initialize(new Set([10])); - await registry.put(10, { gatewayScope: GATEWAY_SCOPE, sessionKey: "session-10" }); - await registry.startRun(10, GATEWAY_SCOPE, "run-10"); - const baselineWrites = mock.local.setCalls.length; - const persist = mock.local.set.bind(mock.local); - const failure = new Error("transient session storage failure"); - let rejectWrite!: () => void; - const failedWrite = new Promise((_resolve, reject) => { - rejectWrite = () => reject(failure); - }); - let failNextWrite = true; - mock.local.set = async (update) => { - if (failNextWrite) { - failNextWrite = false; - await failedWrite; - return; - } - await persist(update); - }; - - const failedCancellation = registry.queueAbort(10, GATEWAY_SCOPE); - const finishedRun = registry.finishRun(GATEWAY_SCOPE, "session-10", "run-10"); - const closedTab = registry.closeTab(10); - await vi.waitFor(() => expect(registry.pendingAborts(GATEWAY_SCOPE)).toHaveLength(1)); - expect(registry.get(10, GATEWAY_SCOPE)).toHaveProperty("activeRunId", "run-10"); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); - - const outcomes = Promise.allSettled([failedCancellation, finishedRun, closedTab]); - rejectWrite(); - const [cancellationOutcome, finishedOutcome, closedOutcome] = await outcomes; - expect(cancellationOutcome).toEqual({ status: "rejected", reason: failure }); - expect(finishedOutcome).toEqual({ status: "fulfilled", value: true }); - expect(closedOutcome).toMatchObject({ - status: "fulfilled", - value: { sessionKey: "session-10" }, - }); - - expect(mock.local.setCalls).toHaveLength(baselineWrites + 2); - expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([ - expect.objectContaining({ sessionKey: "session-10", tabId: 10 }), - ]); - }); -}); - -describe("CopilotPanelBindingRegistry", () => { - it("mints one browser-instance capability per tab and removes it on close", async () => { - const area = storageArea(); - const bindings = new CopilotPanelBindingRegistry(area as never); - - const [first, second] = await Promise.all([bindings.bind(7), bindings.bind(7)]); - - expect(first).toBe(second); - expect(area.setCalls).toHaveLength(1); - await expect(bindings.bind(7)).resolves.toBe(first); - expect(area.setCalls).toHaveLength(1); - await expect(bindings.resolve(first)).resolves.toBe(7); - await bindings.remove(7); - await expect(bindings.resolve(first)).resolves.toBeNull(); - }); - - it("rolls back an undurable capability and persists the queued retry", async () => { - const area = storageArea(); - const persist = area.set.bind(area); - const failure = new Error("transient panel storage failure"); - let failNextWrite = true; - area.set = async (update) => { - if (failNextWrite) { - failNextWrite = false; - throw failure; - } - await persist(update); - }; - const failedToken = "00000000-0000-4000-8000-000000000001"; - const durableToken = "00000000-0000-4000-8000-000000000002"; - const randomUUID = vi - .spyOn(crypto, "randomUUID") - .mockReturnValueOnce(failedToken) - .mockReturnValueOnce(durableToken); - try { - const bindings = new CopilotPanelBindingRegistry(area as never); - const failedBinding = bindings.bind(17); - const recoveredBinding = bindings.bind(17); - - const [failedOutcome, recoveredOutcome] = await Promise.allSettled([ - failedBinding, - recoveredBinding, - ]); - expect(failedOutcome).toEqual({ status: "rejected", reason: failure }); - expect(recoveredOutcome).toEqual({ status: "fulfilled", value: durableToken }); - await expect(bindings.resolve(failedToken)).resolves.toBeNull(); - await expect(bindings.resolve(durableToken)).resolves.toBe(17); - expect(area.setCalls).toHaveLength(1); - - await bindings.remove(17); - expect(area.setCalls).toHaveLength(2); - await expect(bindings.resolve(durableToken)).resolves.toBeNull(); - } finally { - randomUUID.mockRestore(); - } - }); -}); diff --git a/extensions/browser/chrome-extension/modules/copilot-session.d.ts b/extensions/browser/chrome-extension/modules/copilot-session.d.ts deleted file mode 100644 index 910e2939fd6c..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CopilotSessionEntry, CopilotSessionRegistry } from "./copilot-session-registry.js"; - -export function createCopilotSessionController( - options: Record & { - registry: CopilotSessionRegistry; - }, -): { - ensureSession: ( - tabId: number, - options?: { hydrateHistory?: boolean }, - ) => Promise; - sendMessage: ( - tabId: number, - port: unknown, - portRevision: number, - text: string, - ) => Promise; -}; diff --git a/extensions/browser/chrome-extension/modules/copilot-session.js b/extensions/browser/chrome-extension/modules/copilot-session.js deleted file mode 100644 index e5ef4ab97769..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session.js +++ /dev/null @@ -1,318 +0,0 @@ -import { resolveBindingTarget } from "./copilot-background-shared.js"; -import { isDefinitiveGatewayRejection } from "./copilot-gateway.js"; -import { - buildCopilotChatSendParams, - deriveCopilotSessionLabel, - deriveTabSessionKey, -} from "./panel-core.js"; - -/** Session/run owner for one tab-bound panel. */ -export function createCopilotSessionController({ - chromeApi, - gateway, - registry, - ensureByTab, - tabRevisions, - portsByTab, - portRevisions, - sendsByTab, - currentGatewayScope, - getGatewayRevision, - getCurrentConfig, - isConfigTransitioning, - currentReadyEpoch, - readyEpochIsCurrent, - isTabAccessible, - attachDebugger, - revokeDebugger, - restoreDebuggerIfReleased, - subscribe, - unsubscribeTab, - suspendTab, - hydrate, - refreshPanelState, - drainArchives, - scheduleAbortRetry, -}) { - function sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) { - return ( - (tabRevisions.get(tabId) ?? 0) === tabRevision && - !isConfigTransitioning() && - getGatewayRevision() === configRevision && - currentGatewayScope() === gatewayScope - ); - } - - async function sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope) { - if ( - !sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) || - !portsByTab.has(tabId) - ) { - return false; - } - try { - const accessible = await isTabAccessible(tabId); - return ( - accessible && - portsByTab.has(tabId) && - sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) - ); - } catch { - return false; - } - } - - async function suspendUnauthorizedSetup(tabId) { - let accessible = false; - try { - accessible = await isTabAccessible(tabId); - } catch { - // Missing or unreadable tab state is not authorized to retain CDP access. - } - await suspendTab(tabId, { detachInactive: !accessible }); - if (portsByTab.has(tabId)) { - void refreshPanelState(tabId); - } - } - - async function ensureSessionInner( - tabId, - tabRevision, - configRevision, - gatewayScope, - hydrateHistory, - ) { - if (!gateway.ready || !(await isTabAccessible(tabId))) { - return null; - } - const staleActiveSession = registry - .list() - .find( - (entry) => - entry.tabId === tabId && entry.gatewayScope !== gatewayScope && entry.activeRunId, - ); - if (staleActiveSession) { - throw new Error("This tab is still stopping a run from its previous Gateway."); - } - const { targetId } = await attachDebugger(tabId); - if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { - return null; - } - if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { - await suspendUnauthorizedSetup(tabId); - return null; - } - const binding = { - kind: "tab", - tabId, - target: resolveBindingTarget(getCurrentConfig()), - profile: "chrome", - targetId, - }; - let entry = registry.get(tabId, gatewayScope); - if (entry) { - await registry.updateBinding(tabId, gatewayScope, binding); - entry = registry.get(tabId, gatewayScope); - } else { - const mainSessionKey = gateway.hello?.snapshot?.sessionDefaults?.mainSessionKey; - const sessionKey = deriveTabSessionKey(mainSessionKey, crypto.randomUUID()); - if (!sessionKey) { - throw new Error("Gateway did not provide a main session key."); - } - entry = await registry.put(tabId, { - gatewayScope, - sessionKey, - binding, - createdAt: Date.now(), - provisional: true, - creationPending: false, - }); - } - if (entry?.provisional) { - if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { - await registry.closeTab(tabId); - await drainArchives(gatewayScope); - return null; - } - // Persist the generated key before the RPC. Retrying sessions.create with - // that key adopts a commit whose response was lost instead of leaking it. - entry = await registry.markSessionCreationPending(tabId, gatewayScope); - if (!entry) { - return null; - } - let created; - try { - created = await gateway.request("sessions.create", { - key: entry.sessionKey, - label: deriveCopilotSessionLabel(entry.sessionKey), - }); - } catch (error) { - if (isDefinitiveGatewayRejection(error)) { - await registry.discardProvisionalSession(tabId, gatewayScope); - } - throw error; - } - entry = await registry.confirmSession(tabId, gatewayScope, created?.sessionId); - if (!entry) { - return null; - } - try { - await chromeApi.tabs.get(tabId); - } catch { - await registry.closeTab(tabId); - await drainArchives(gatewayScope); - return null; - } - } - if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { - await registry.closeTab(tabId); - await drainArchives(gatewayScope); - return null; - } - if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { - await suspendUnauthorizedSetup(tabId); - return null; - } - await subscribe(entry); - if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { - await unsubscribeTab(tabId, gatewayScope); - await registry.closeTab(tabId); - await drainArchives(gatewayScope); - return null; - } - if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { - await suspendUnauthorizedSetup(tabId); - return null; - } - if (hydrateHistory) { - await hydrate(tabId, entry); - } - if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { - await unsubscribeTab(tabId, gatewayScope); - await registry.closeTab(tabId); - await drainArchives(gatewayScope); - return null; - } - if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { - await suspendUnauthorizedSetup(tabId); - return null; - } - return entry; - } - - async function ensureSession(tabId, { hydrateHistory = true } = {}) { - const current = ensureByTab.get(tabId); - if (current) { - current.hydrateHistory ||= hydrateHistory; - return await current.promise; - } - const readyEpoch = currentReadyEpoch(); - if (!readyEpoch) { - return null; - } - const gatewayScope = readyEpoch.gatewayScope; - const tabRevision = tabRevisions.get(tabId) ?? 0; - const configRevision = readyEpoch.configRevision; - const request = { hydrateHistory, promise: null }; - const pending = ensureSessionInner(tabId, tabRevision, configRevision, gatewayScope, false) - .then(async (entry) => { - if (entry && request.hydrateHistory) { - await hydrate(tabId, entry); - } - return entry; - }) - .finally(() => { - if (ensureByTab.get(tabId) === request) { - ensureByTab.delete(tabId); - } - }); - request.promise = pending; - ensureByTab.set(tabId, request); - return await pending; - } - - function panelOwnsSend(tabId, port, portRevision) { - return portRevisions.get(tabId) === portRevision && portsByTab.get(tabId)?.has(port) === true; - } - - async function sendMessage(tabId, port, portRevision, text) { - if (!panelOwnsSend(tabId, port, portRevision)) { - throw new Error("This panel is no longer attached to the tab."); - } - if (sendsByTab.has(tabId)) { - throw new Error("Wait for the current turn to finish."); - } - const readyEpoch = currentReadyEpoch(); - if (!readyEpoch) { - throw new Error("Gateway is still reconciling this tab."); - } - if (!(await isTabAccessible(tabId))) { - throw new Error("This tab is not available to OpenClaw."); - } - const entry = await ensureSession(tabId, { hydrateHistory: false }); - if (!entry) { - throw new Error("This tab no longer exists."); - } - if (!readyEpochIsCurrent(readyEpoch) || entry.gatewayScope !== readyEpoch.gatewayScope) { - throw new Error("Gateway connection changed while preparing this tab."); - } - const params = buildCopilotChatSendParams({ - binding: entry.binding, - message: text, - sessionId: entry.sessionId, - sessionKey: entry.sessionKey, - }); - if (!readyEpochIsCurrent(readyEpoch)) { - throw new Error("Gateway connection changed while preparing this tab."); - } - const started = await registry.startRun(tabId, entry.gatewayScope, params.idempotencyKey); - if (!started) { - throw new Error("Wait for the current turn to finish."); - } - let submitted = false; - try { - const stillAccessible = await isTabAccessible(tabId); - const stillOwnsPanel = panelOwnsSend(tabId, port, portRevision); - const stillOwnsGateway = readyEpochIsCurrent(readyEpoch); - if (!stillAccessible || !stillOwnsPanel || !stillOwnsGateway) { - if (!stillAccessible || !stillOwnsPanel) { - await suspendTab(tabId, { detachInactive: !stillAccessible }); - } - throw new Error( - !stillAccessible - ? "This tab is not available to OpenClaw." - : !stillOwnsPanel - ? "This panel is no longer attached to the tab." - : "Gateway connection changed while preparing this tab.", - ); - } - sendsByTab.add(tabId); - submitted = true; - return await gateway.request("chat.send", params); - } catch (error) { - sendsByTab.delete(tabId); - if (!submitted || isDefinitiveGatewayRejection(error)) { - const finished = await registry.finishRun( - entry.gatewayScope, - entry.sessionKey, - params.idempotencyKey, - ); - if (finished) { - await restoreDebuggerIfReleased(tabId); - } - } else { - await revokeDebugger(tabId); - const queued = await registry.queueAbort(tabId, entry.gatewayScope); - if (queued) { - scheduleAbortRetry(); - } else { - await restoreDebuggerIfReleased(tabId); - } - } - await refreshPanelState(tabId); - throw error; - } - } - - return { ensureSession, sendMessage }; -} diff --git a/extensions/browser/chrome-extension/modules/copilot-session.test.ts b/extensions/browser/chrome-extension/modules/copilot-session.test.ts deleted file mode 100644 index 3d4f76915be4..000000000000 --- a/extensions/browser/chrome-extension/modules/copilot-session.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createCopilotSessionController } from "./copilot-session.js"; - -function createHarness(accessible: boolean) { - const gatewayScope = "ws://127.0.0.1:18789/"; - const entry = { - tabId: 41, - gatewayScope, - sessionKey: "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111", - sessionId: "session-id", - provisional: false, - binding: undefined as Record | undefined, - }; - let present = false; - const registry = { - list: vi.fn(() => (present ? [entry] : [])), - get: vi.fn(() => (present ? entry : null)), - put: vi.fn(async (_tabId: number, value: Record) => { - present = true; - Object.assign(entry, value, { tabId: 41 }); - return entry; - }), - updateBinding: vi.fn( - async (_tabId: number, _scope: string, binding: Record) => { - entry.binding = binding; - }, - ), - markSessionCreationPending: vi.fn(async () => entry), - confirmSession: vi.fn(async () => { - entry.provisional = false; - return entry; - }), - closeTab: vi.fn(), - }; - const request = vi.fn(async (method: string) => - method === "sessions.create" ? { sessionId: "session-id" } : {}, - ); - const gateway = { - ready: true, - hello: { snapshot: { sessionDefaults: { mainSessionKey: "agent:main:main" } } }, - request, - }; - const attachDebugger = vi.fn(async () => ({ targetId: "target-41" })); - const portsByTab = new Map([[41, new Set([{}])]]); - const controller = createCopilotSessionController({ - chromeApi: { tabs: { get: vi.fn(async () => ({ id: 41 })) } }, - gateway, - registry: registry as never, - ensureByTab: new Map(), - tabRevisions: new Map(), - portsByTab, - portRevisions: new Map(), - sendsByTab: new Set(), - currentGatewayScope: () => gatewayScope, - getGatewayRevision: () => 1, - getCurrentConfig: () => ({ - relayUrl: "ws://127.0.0.1:18797/extension", - gatewayUrl: gatewayScope, - }), - isConfigTransitioning: () => false, - currentReadyEpoch: () => ({ gatewayScope, configRevision: 1, statusRevision: 1 }), - readyEpochIsCurrent: () => true, - isTabAccessible: vi.fn(async () => accessible), - attachDebugger, - revokeDebugger: vi.fn(), - restoreDebuggerIfReleased: vi.fn(), - subscribe: vi.fn(async () => undefined), - unsubscribeTab: vi.fn(), - suspendTab: vi.fn(), - hydrate: vi.fn(), - refreshPanelState: vi.fn(), - drainArchives: vi.fn(), - scheduleAbortRetry: vi.fn(), - }); - return { attachDebugger, controller, request }; -} - -describe("tab copilot access policy", () => { - it("prepares a session for an access-authorized ungrouped all-mode tab", async () => { - const harness = createHarness(true); - await expect(harness.controller.ensureSession(41)).resolves.toMatchObject({ - tabId: 41, - binding: expect.objectContaining({ kind: "tab", tabId: 41, targetId: "target-41" }), - }); - expect(harness.attachDebugger).toHaveBeenCalledWith(41); - expect(harness.request).toHaveBeenCalledWith( - "sessions.create", - expect.objectContaining({ key: expect.stringContaining("browser-copilot") }), - ); - }); - - it.each(["paused", "restricted"])("does not attach a %s tab", async () => { - const harness = createHarness(false); - await expect(harness.controller.ensureSession(41)).resolves.toBeNull(); - expect(harness.attachDebugger).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts new file mode 100644 index 000000000000..3052777dfc5f --- /dev/null +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts @@ -0,0 +1,59 @@ +type NativeMessagePort = { + disconnect(): void; + onDisconnect: { addListener(listener: () => void): void }; + onMessage: { addListener(listener: (response: unknown) => void): void }; + postMessage(request: unknown): void; +}; + +type NativeBootstrapStorageArea = { + get(keys: string[]): Promise>; + set(values: Record): Promise; + remove(keys: string[]): Promise; +}; + +type NativeBootstrapResult = { + status: + | "disabled" + | "enabled" + | "existing" + | "manual_required" + | "paired" + | "retrying" + | "superseded"; + code?: string; +}; + +export function createNativeBootstrapController(params: { + chromeApi?: { + runtime: { + connectNative(name: string): NativeMessagePort; + lastError?: { message?: string }; + }; + storage: { local: NativeBootstrapStorageArea }; + }; + getPairing(): Promise<{ relayUrl?: string } | null>; + applyPairing(params: { + pairing: { relayUrl: string; token: string; gatewayUrl?: string }; + accessMode: "all"; + source: "native"; + generation: number; + }): Promise<{ ok?: boolean; existing?: boolean } | undefined>; +}): { + attempt(): Promise; + disableSynchronously(): Promise; + enable(options?: { attemptNow?: boolean }): Promise; + status(): Promise<{ disabled: boolean; state: string; failureCode?: string }>; +}; + +type RetiredCopilotStorage = { + storage: { + local: Pick; + session: Pick; + }; +}; + +export function prepareRetiredCopilotState( + chromeApi?: RetiredCopilotStorage, +): Promise<{ blocked: boolean }>; + +export function discardRetiredCopilotState(chromeApi?: RetiredCopilotStorage): Promise; diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.js b/extensions/browser/chrome-extension/modules/native-bootstrap.js new file mode 100644 index 000000000000..5c000a2b72e6 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.js @@ -0,0 +1,292 @@ +import { ACCESS_MODE_ALL, parsePairingString } from "./relay-core.js"; + +const NATIVE_HOST_NAME = "ai.openclaw.browser_bootstrap"; +const DISABLED_KEY = "nativeBootstrapDisabled"; +const STATE_KEY = "nativeBootstrapState"; +const FAILURE_KEY = "nativeBootstrapFailureCode"; +const NATIVE_MESSAGE_TIMEOUT_MS = 30_000; +const NATIVE_MESSAGE_TIMEOUT = Symbol("native_message_timeout"); +const RETRYABLE_HOST_ERRORS = [ + "native messaging host not found", + "specified native messaging host not found", + "failed to start native messaging host", +]; + +const FAILURE_CODES = new Set([ + "invalid_frame", + "invalid_utf8", + "invalid_request", + "origin_forbidden", + "manifest_invalid", + "manual_required", + "pairing_unavailable", +]); + +function hasExactKeys(value, expected) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === expected.length && + expected.every((key) => Object.hasOwn(value, key)) + ); +} + +function nativeResponse(value, nonce) { + if ( + hasExactKeys(value, ["v", "ok", "nonce", "pairingString"]) && + value.v === 1 && + value.ok === true && + value.nonce === nonce && + typeof value.pairingString === "string" + ) { + const pairing = parsePairingString(value.pairingString); + return pairing ? { kind: "success", pairing } : { kind: "malformed" }; + } + if ( + hasExactKeys(value, ["v", "ok", "code"]) && + value.v === 1 && + value.ok === false && + FAILURE_CODES.has(value.code) + ) { + return { kind: "failure", code: value.code }; + } + return { kind: "malformed" }; +} + +function randomNonce() { + const hex = crypto.randomUUID().replaceAll("-", ""); + let binary = ""; + for (let offset = 0; offset < hex.length; offset += 2) { + binary += String.fromCharCode(Number.parseInt(hex.slice(offset, offset + 2), 16)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function isHostMissing(error) { + const message = String(error?.message ?? error).toLowerCase(); + return RETRYABLE_HOST_ERRORS.some((candidate) => message.includes(candidate)); +} + +function sendNativeBootstrap(chromeApi, request) { + return new Promise((resolve, reject) => { + const port = chromeApi.runtime.connectNative(NATIVE_HOST_NAME); + let settled = false; + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + port.disconnect(); + callback(value); + }; + const timeoutId = setTimeout( + () => finish(reject, NATIVE_MESSAGE_TIMEOUT), + NATIVE_MESSAGE_TIMEOUT_MS, + ); + port.onMessage.addListener((response) => finish(resolve, response)); + port.onDisconnect.addListener(() => { + const message = chromeApi.runtime.lastError?.message; + finish(reject, new Error(message || "Native messaging host disconnected.")); + }); + try { + port.postMessage(request); + } catch (error) { + finish(reject, error); + } + }); +} + +/** Own coalescing, retry policy, opt-out, and late-response revocation. */ +export function createNativeBootstrapController({ chromeApi = chrome, getPairing, applyPairing }) { + let inFlight = null; + let disabledNow = false; + let generation = 0; + + async function readState() { + const stored = await chromeApi.storage.local.get([DISABLED_KEY, STATE_KEY, FAILURE_KEY]); + disabledNow ||= stored[DISABLED_KEY] === true; + return { + disabled: disabledNow, + state: + stored[STATE_KEY] === "ready" || + stored[STATE_KEY] === "retrying" || + stored[STATE_KEY] === "manual_required" || + stored[STATE_KEY] === "disabled" + ? stored[STATE_KEY] + : "waiting", + failureCode: typeof stored[FAILURE_KEY] === "string" ? stored[FAILURE_KEY] : "", + }; + } + + async function writeState(state, failureCode = "") { + await chromeApi.storage.local.set({ + [STATE_KEY]: state, + ...(failureCode ? { [FAILURE_KEY]: failureCode } : {}), + }); + if (!failureCode) { + await chromeApi.storage.local.remove([FAILURE_KEY]); + } + } + + async function attempt() { + if (inFlight) { + return await inFlight; + } + const ownedGeneration = generation; + inFlight = (async () => { + const pairing = await getPairing(); + if (pairing?.relayUrl) { + await writeState("ready"); + return { status: "existing" }; + } + const state = await readState(); + if (disabledNow) { + return { status: "disabled" }; + } + if (state.state === "manual_required") { + return { status: "manual_required", code: state.failureCode }; + } + const nonce = randomNonce(); + let response; + try { + response = await sendNativeBootstrap(chromeApi, { + v: 1, + op: "bootstrap", + nonce, + }); + } catch (error) { + if (error === NATIVE_MESSAGE_TIMEOUT || isHostMissing(error)) { + const code = error === NATIVE_MESSAGE_TIMEOUT ? "native_host_timeout" : "host_not_found"; + await writeState("retrying", code); + return { status: "retrying", code }; + } + await writeState("manual_required", "native_host_error"); + return { status: "manual_required", code: "native_host_error" }; + } + if (ownedGeneration !== generation || disabledNow) { + return { status: "superseded" }; + } + const parsed = nativeResponse(response, nonce); + if (parsed.kind === "malformed") { + await writeState("manual_required", "malformed_response"); + return { status: "manual_required", code: "malformed_response" }; + } + if (parsed.kind === "failure") { + const retrying = parsed.code === "pairing_unavailable"; + await writeState(retrying ? "retrying" : "manual_required", parsed.code); + return { status: retrying ? "retrying" : "manual_required", code: parsed.code }; + } + const current = await getPairing(); + if (current?.relayUrl || ownedGeneration !== generation || disabledNow) { + return { status: "superseded" }; + } + const applied = await applyPairing({ + pairing: parsed.pairing, + accessMode: ACCESS_MODE_ALL, + source: "native", + generation: ownedGeneration, + }); + if (!applied?.ok) { + if (applied?.existing) { + return { status: "existing" }; + } + await writeState("manual_required", "pairing_rejected"); + return { status: "manual_required", code: "pairing_rejected" }; + } + await writeState("ready"); + return { status: "paired" }; + })().finally(() => { + inFlight = null; + }); + return await inFlight; + } + + function disableSynchronously() { + disabledNow = true; + generation += 1; + return chromeApi.storage.local.set({ + [DISABLED_KEY]: true, + [STATE_KEY]: "disabled", + }); + } + + async function enable({ attemptNow = true } = {}) { + disabledNow = false; + generation += 1; + await chromeApi.storage.local.remove([DISABLED_KEY, STATE_KEY, FAILURE_KEY]); + return attemptNow ? await attempt() : { status: "enabled" }; + } + + async function status() { + const pairing = await getPairing(); + const state = await readState(); + return { + disabled: state.disabled, + state: pairing?.relayUrl ? "ready" : state.state, + ...(state.failureCode ? { failureCode: state.failureCode } : {}), + }; + } + + return { attempt, disableSynchronously, enable, status }; +} + +const COPILOT_LOCAL_KEYS = [ + "copilotSessionRegistryV1", + "copilotDeviceIdentitiesV1", + "copilotDeviceTokensV1", +]; +const COPILOT_SESSION_KEYS = ["copilotBrowserInstanceV1", "copilotPanelBindingsV1"]; +const RETIRED_COPILOT_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; + +function canClearRetiredCopilotRegistry(value) { + return ( + hasExactKeys(value, ["sessions", "pendingArchives"]) && + value.sessions !== null && + typeof value.sessions === "object" && + !Array.isArray(value.sessions) && + Object.keys(value.sessions).length === 0 && + Array.isArray(value.pendingArchives) && + value.pendingArchives.length === 0 + ); +} + +/** Explicitly discard every retired copilot key. */ +export async function discardRetiredCopilotState(chromeApi = chrome) { + await chromeApi.storage.local.set({ [RETIRED_COPILOT_CUSTODY_BLOCKED_KEY]: true }); + await chromeApi.storage.session.remove(COPILOT_SESSION_KEYS); + await chromeApi.storage.local.remove(COPILOT_LOCAL_KEYS); + await chromeApi.storage.local.remove([RETIRED_COPILOT_CUSTODY_BLOCKED_KEY]); +} + +/** Clear harmless retired state, or preserve custody and fail closed. */ +export async function prepareRetiredCopilotState(chromeApi = chrome) { + let stored; + try { + stored = await chromeApi.storage.local.get([ + RETIRED_COPILOT_CUSTODY_BLOCKED_KEY, + COPILOT_LOCAL_KEYS[0], + ]); + } catch { + return { blocked: true }; + } + if (stored === null || typeof stored !== "object" || Array.isArray(stored)) { + return { blocked: true }; + } + if (Object.hasOwn(stored, RETIRED_COPILOT_CUSTODY_BLOCKED_KEY)) { + return { blocked: true }; + } + if ( + Object.hasOwn(stored, COPILOT_LOCAL_KEYS[0]) && + !canClearRetiredCopilotRegistry(stored[COPILOT_LOCAL_KEYS[0]]) + ) { + return { blocked: true }; + } + try { + await discardRetiredCopilotState(chromeApi); + } catch { + return { blocked: true }; + } + return { blocked: false }; +} diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts new file mode 100644 index 000000000000..f5feb18e15d2 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createNativeBootstrapController, + discardRetiredCopilotState, + prepareRetiredCopilotState, +} from "./native-bootstrap.js"; + +const COPILOT_LOCAL_KEYS = [ + "copilotSessionRegistryV1", + "copilotDeviceIdentitiesV1", + "copilotDeviceTokensV1", +]; +const COPILOT_SESSION_KEYS = ["copilotBrowserInstanceV1", "copilotPanelBindingsV1"]; +const CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; +const RETAINED_LOCAL = { + relayUrl: "ws://127.0.0.1:18789/extension", + token: "test-relay-key", + accessMode: "selected", + deniedTabIdsV1: [7], + nativeBootstrapDisabled: true, +}; + +type CleanupFailureStage = + | "marker_set" + | "session_remove" + | "retired_local_remove" + | "marker_remove"; + +function cleanupStorage(params: { + registry?: unknown; + registryPresent?: boolean; + readError?: Error; + failureStage?: CleanupFailureStage; + marker?: boolean; +}) { + let failureStage = params.failureStage; + const operations: string[] = []; + const localValues: Record = { + copilotDeviceIdentitiesV1: { device: "identity" }, + copilotDeviceTokensV1: { device: "token" }, + ...RETAINED_LOCAL, + }; + if (params.registryPresent !== false) { + localValues.copilotSessionRegistryV1 = params.registry; + } + if (params.marker === true) { + localValues[CUSTODY_BLOCKED_KEY] = true; + } + const sessionValues: Record = { + copilotBrowserInstanceV1: "browser-instance", + copilotPanelBindingsV1: { 7: "panel-binding" }, + }; + const localSet = vi.fn(async (values: Record) => { + operations.push("marker_set"); + if (failureStage === "marker_set") { + throw new Error("marker set failed"); + } + Object.assign(localValues, values); + }); + const localRemove = vi.fn(async (keys: string[]) => { + const stage = keys.includes(CUSTODY_BLOCKED_KEY) ? "marker_remove" : "retired_local_remove"; + operations.push(stage); + if (failureStage === stage) { + throw new Error(`${stage} failed`); + } + for (const key of keys) { + delete localValues[key]; + } + }); + const sessionRemove = vi.fn(async (keys: string[]) => { + operations.push("session_remove"); + if (failureStage === "session_remove") { + throw new Error("session remove failed"); + } + for (const key of keys) { + delete sessionValues[key]; + } + }); + return { + chromeApi: { + storage: { + local: { + get: vi.fn(async (keys: string[]) => { + if (params.readError) { + throw params.readError; + } + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(localValues, key)) + .map((key) => [key, localValues[key]]), + ); + }), + set: localSet, + remove: localRemove, + }, + session: { remove: sessionRemove }, + }, + }, + localSet, + localRemove, + localValues, + operations, + setFailureStage: (next: CleanupFailureStage | undefined) => { + failureStage = next; + }, + sessionRemove, + sessionValues, + }; +} + +describe("retired copilot custody", () => { + it.each([ + { label: "no registry", registryPresent: false, registry: undefined }, + { + label: "an exact empty registry", + registryPresent: true, + registry: { sessions: {}, pendingArchives: [] }, + }, + ])("removes all retired keys for $label", async ({ registry, registryPresent }) => { + const storage = cleanupStorage({ registry, registryPresent }); + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ + blocked: false, + }); + + expect(storage.localSet).toHaveBeenCalledWith({ [CUSTODY_BLOCKED_KEY]: true }); + expect(storage.localRemove).toHaveBeenCalledTimes(2); + expect(storage.localRemove).toHaveBeenNthCalledWith(1, COPILOT_LOCAL_KEYS); + expect(storage.localRemove).toHaveBeenNthCalledWith(2, [CUSTODY_BLOCKED_KEY]); + expect(storage.sessionRemove).toHaveBeenCalledOnce(); + expect(storage.sessionRemove).toHaveBeenCalledWith(COPILOT_SESSION_KEYS); + expect(storage.localValues).toEqual(RETAINED_LOCAL); + expect(storage.sessionValues).toEqual({}); + expect(storage.operations).toEqual([ + "marker_set", + "session_remove", + "retired_local_remove", + "marker_remove", + ]); + }); + + it.each([ + { + label: "session creation is pending", + registry: { + sessions: { + 7: { + tabId: 7, + browserInstanceId: "browser-instance", + gatewayScope: "ws://127.0.0.1:18789/", + sessionKey: "browser:tab:7", + creationPending: true, + }, + }, + pendingArchives: [], + }, + }, + { + label: "a confirmed session remains", + registry: { + sessions: { + 7: { + tabId: 7, + browserInstanceId: "browser-instance", + gatewayScope: "ws://127.0.0.1:18789/", + sessionKey: "browser:tab:7", + sessionId: "session-7", + }, + }, + pendingArchives: [], + }, + }, + { + label: "an active session remains", + registry: { + sessions: { + 7: { + tabId: 7, + browserInstanceId: "browser-instance", + sessionKey: "browser:tab:7", + active: true, + }, + }, + pendingArchives: [], + }, + }, + { + label: "a pending archive", + registry: { + sessions: {}, + pendingArchives: [ + { + tabId: 7, + gatewayScope: "ws://127.0.0.1:18789/", + sessionKey: "browser:tab:7", + queuedAt: 1, + }, + ], + }, + }, + { label: "a malformed registry", registry: { sessions: [], pendingArchives: [] } }, + { + label: "an unrecognized registry", + registry: { sessions: {}, pendingArchives: [], futureCustody: {} }, + }, + ])("preserves every retired key for $label", async ({ registry }) => { + const storage = cleanupStorage({ registry }); + const beforeLocal = structuredClone(storage.localValues); + const beforeSession = structuredClone(storage.sessionValues); + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ blocked: true }); + + expect(storage.localRemove).not.toHaveBeenCalled(); + expect(storage.sessionRemove).not.toHaveBeenCalled(); + expect(storage.localValues).toEqual(beforeLocal); + expect(storage.sessionValues).toEqual(beforeSession); + expect(storage.localValues).toMatchObject(RETAINED_LOCAL); + }); + + it("preserves every retired key when the registry read fails", async () => { + const storage = cleanupStorage({ readError: new Error("storage unavailable") }); + const beforeLocal = structuredClone(storage.localValues); + const beforeSession = structuredClone(storage.sessionValues); + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ blocked: true }); + + expect(storage.localRemove).not.toHaveBeenCalled(); + expect(storage.sessionRemove).not.toHaveBeenCalled(); + expect(storage.localValues).toEqual(beforeLocal); + expect(storage.sessionValues).toEqual(beforeSession); + expect(storage.localValues).toMatchObject(RETAINED_LOCAL); + }); + + it("explicitly discards every retired key", async () => { + const storage = cleanupStorage({ + registry: { + sessions: { 7: { creationPending: true } }, + pendingArchives: [{ tabId: 7 }], + }, + }); + + await expect(discardRetiredCopilotState(storage.chromeApi)).resolves.toBeUndefined(); + + expect(storage.localValues).toEqual(RETAINED_LOCAL); + expect(storage.sessionValues).toEqual({}); + expect(storage.operations).toEqual([ + "marker_set", + "session_remove", + "retired_local_remove", + "marker_remove", + ]); + }); + + it("blocks when a prior destructive cleanup left its durable marker", async () => { + const storage = cleanupStorage({ registryPresent: false, marker: true }); + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ blocked: true }); + + expect(storage.operations).toEqual([]); + expect(storage.localValues[CUSTODY_BLOCKED_KEY]).toBe(true); + }); + + it.each([ + "marker_set", + "session_remove", + "retired_local_remove", + "marker_remove", + ])("keeps cleanup restart-safe when %s fails and an explicit retry finishes", async (stage) => { + const storage = cleanupStorage({ + registry: { sessions: { 7: { creationPending: true } }, pendingArchives: [] }, + failureStage: stage, + }); + + await expect(discardRetiredCopilotState(storage.chromeApi)).rejects.toThrow("failed"); + if (stage === "marker_set") { + expect(storage.localValues).toHaveProperty("copilotSessionRegistryV1"); + expect(storage.localValues).not.toHaveProperty(CUSTODY_BLOCKED_KEY); + } else { + expect(storage.localValues[CUSTODY_BLOCKED_KEY]).toBe(true); + } + if (stage === "session_remove" || stage === "retired_local_remove") { + expect(storage.localValues).toHaveProperty("copilotSessionRegistryV1"); + } + if (stage === "marker_remove") { + expect(storage.localValues).not.toHaveProperty("copilotSessionRegistryV1"); + } + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ blocked: true }); + storage.setFailureStage(undefined); + await expect(discardRetiredCopilotState(storage.chromeApi)).resolves.toBeUndefined(); + expect(storage.localValues).toEqual(RETAINED_LOCAL); + expect(storage.sessionValues).toEqual({}); + }); + + it("keeps authority blocked when automatic empty-state cleanup fails", async () => { + const storage = cleanupStorage({ + registry: { sessions: {}, pendingArchives: [] }, + failureStage: "retired_local_remove", + }); + + await expect(prepareRetiredCopilotState(storage.chromeApi)).resolves.toEqual({ blocked: true }); + + expect(storage.localValues[CUSTODY_BLOCKED_KEY]).toBe(true); + expect(storage.localValues).toHaveProperty("copilotSessionRegistryV1"); + }); +}); + +describe("native bootstrap timeout", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("bounds a stuck native call and leaves status retryable", async () => { + vi.useFakeTimers(); + vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => "00112233-4455-6677-8899-aabbccddeeff"), + }); + const stored: Record = {}; + let onDisconnect = () => {}; + const disconnect = vi.fn(() => onDisconnect()); + const chromeApi = { + runtime: { + connectNative: vi.fn(() => ({ + disconnect, + onDisconnect: { + addListener: (listener: () => void) => { + onDisconnect = listener; + }, + }, + onMessage: { addListener: vi.fn() }, + postMessage: vi.fn(), + })), + }, + storage: { + local: { + get: vi.fn(async (keys: string[]) => + Object.fromEntries( + keys.filter((key) => Object.hasOwn(stored, key)).map((key) => [key, stored[key]]), + ), + ), + set: vi.fn(async (values: Record) => { + Object.assign(stored, values); + }), + remove: vi.fn(async (keys: string[]) => { + for (const key of keys) { + delete stored[key]; + } + }), + }, + }, + }; + const controller = createNativeBootstrapController({ + chromeApi, + getPairing: async () => null, + applyPairing: vi.fn(), + }); + + const attempt = controller.attempt(); + await vi.advanceTimersByTimeAsync(0); + expect(chromeApi.runtime.connectNative.mock.results[0]?.value.postMessage).toHaveBeenCalledWith( + { + v: 1, + op: "bootstrap", + nonce: "ABEiM0RVZneImaq7zN3u_w", + }, + ); + await vi.advanceTimersByTimeAsync(29_999); + expect(stored).toEqual({}); + await vi.advanceTimersByTimeAsync(1); + + await expect(attempt).resolves.toEqual({ + status: "retrying", + code: "native_host_timeout", + }); + await expect(controller.status()).resolves.toEqual({ + disabled: false, + state: "retrying", + failureCode: "native_host_timeout", + }); + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/page-share-background.js b/extensions/browser/chrome-extension/modules/page-share-background.js deleted file mode 100644 index 17da48d69b59..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-background.js +++ /dev/null @@ -1,84 +0,0 @@ -import { buildPageSharePayload, capturePageShare } from "./page-share-core.js"; - -/** Own popup/command/context-menu page sharing and its transient badge. */ -export function createPageShareController({ - chromeApi = chrome, - ensureRelayReady, - sendPageShareRequest, - restoreBadge, -}) { - let badgeTimer = null; - - function flashBadge(ok) { - if (badgeTimer) { - clearTimeout(badgeTimer); - } - void chromeApi.action.setBadgeText({ text: ok ? "✓" : "!" }); - void chromeApi.action.setBadgeBackgroundColor({ color: ok ? "#0F9D58" : "#B91C1C" }); - badgeTimer = setTimeout( - () => { - badgeTimer = null; - restoreBadge(); - }, - ok ? 2_000 : 3_000, - ); - } - - async function sendPage(tabId, note) { - await ensureRelayReady(); - const tab = await chromeApi.tabs.get(tabId); - const capture = await capturePageShare(tab); - const payload = buildPageSharePayload({ ...capture, note }); - if (!payload.content && !payload.selection) { - throw new Error("Nothing to send on this page."); - } - await sendPageShareRequest(payload); - } - - async function sendSelectionSnapshot(tab, selection) { - await ensureRelayReady(); - await sendPageShareRequest( - buildPageSharePayload({ - url: tab.url ?? "", - title: tab.title ?? "", - content: "", - selection, - note: "", - }), - ); - } - - function withBadge(promise) { - return promise.then( - () => flashBadge(true), - () => flashBadge(false), - ); - } - - async function installContextMenu() { - await chromeApi.contextMenus.removeAll(); - chromeApi.contextMenus.create({ - id: "openclaw-send-page", - title: "Send page to OpenClaw", - contexts: ["page", "selection"], - }); - } - - chromeApi.commands.onCommand.addListener((command) => { - if (command !== "send-page") { - return; - } - void chromeApi.tabs - .query({ active: true, lastFocusedWindow: true }) - .then(([tab]) => (typeof tab?.id === "number" ? withBadge(sendPage(tab.id, "")) : undefined)); - }); - chromeApi.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId !== "openclaw-send-page" || typeof tab?.id !== "number") { - return; - } - const selection = info.selectionText?.trim() ?? ""; - void withBadge(selection ? sendSelectionSnapshot(tab, selection) : sendPage(tab.id, "")); - }); - - return { installContextMenu, sendPage }; -} diff --git a/extensions/browser/chrome-extension/modules/page-share-core.d.ts b/extensions/browser/chrome-extension/modules/page-share-core.d.ts deleted file mode 100644 index ceaee0b18bd0..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-core.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type PageSharePayload = { - url: string; - title: string; - content: string; - selection?: string; - note?: string; -}; - -export type PageCapture = { - url: string; - title: string; - selection: string; - content: string; -}; - -export function waitForCondition(condition: () => boolean, timeoutMs: number): Promise; -export function buildPageSharePayload(params: { - url: string; - title: string; - content: string; - selection?: string; - note?: string; -}): PageSharePayload; -export function capturePageShare(tab: { - id?: number; - url?: string; - title?: string; -}): Promise; diff --git a/extensions/browser/chrome-extension/modules/page-share-core.js b/extensions/browser/chrome-extension/modules/page-share-core.js deleted file mode 100644 index 332b7369974f..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-core.js +++ /dev/null @@ -1,244 +0,0 @@ -// Keep these limits in sync with browser/extension-relay/relay-protocol.ts. -const PAGE_SHARE_MAX_CONTENT_CHARS = 120_000; -const PAGE_SHARE_MAX_NOTE_CHARS = 2_000; -const PAGE_SHARE_MAX_TITLE_CHARS = 500; -const PAGE_SHARE_MAX_URL_CHARS = 2_000; -// Export precedes relay delivery, so its deadline owns capture settlement. -const GOOGLE_DOC_EXPORT_TIMEOUT_MS = 30_000; - -function googleDocIdFromUrl(url) { - let parsed; - try { - parsed = new URL(String(url ?? "")); - } catch { - return null; - } - if (parsed.hostname !== "docs.google.com") { - return null; - } - return /^\/document\/d\/([^/]+)/u.exec(parsed.pathname)?.[1] ?? null; -} - -function truncateShareText(text, maxChars) { - const value = String(text ?? ""); - if (value.length <= maxChars) { - return value; - } - const marker = `\n\n[Truncated: original was ${value.length} characters]`; - let truncated = value.slice(0, maxChars - marker.length); - const lastCodeUnit = truncated.charCodeAt(truncated.length - 1); - // Keep the complete marker inside the field cap without splitting an emoji. - if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { - truncated = truncated.slice(0, -1); - } - return `${truncated}${marker}`; -} - -export async function waitForCondition(condition, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (condition()) { - return true; - } - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - } - return condition(); -} - -export function buildPageSharePayload({ url, title, content, selection, note }) { - // Collapse horizontal whitespace only. Newlines remain meaningful for - // articles and extracted social threads. - const normalizedContent = (typeof content === "string" ? content : "") - .replace(/[ \t]+/gu, " ") - .trim(); - const normalizedSelection = typeof selection === "string" ? selection.trim() : ""; - const normalizedNote = typeof note === "string" ? note.trim() : ""; - return { - url: (typeof url === "string" ? url : "").trim().slice(0, PAGE_SHARE_MAX_URL_CHARS), - title: (typeof title === "string" ? title : "").trim().slice(0, PAGE_SHARE_MAX_TITLE_CHARS), - content: truncateShareText(normalizedContent, PAGE_SHARE_MAX_CONTENT_CHARS), - ...(normalizedSelection - ? { selection: truncateShareText(normalizedSelection, PAGE_SHARE_MAX_CONTENT_CHARS) } - : {}), - ...(normalizedNote ? { note: normalizedNote.slice(0, PAGE_SHARE_MAX_NOTE_CHARS) } : {}), - }; -} - -export async function capturePageShare(tab) { - const tabId = tab.id; - if (typeof tabId !== "number") { - throw new Error("No tab."); - } - const docId = googleDocIdFromUrl(tab.url); - if (docId) { - // Selection wins over the export: never send a whole private document - // when the user asked for a highlighted passage. - const selection = await captureDomSelection(tabId); - if (selection) { - return { url: tab.url ?? "", title: tab.title ?? "", selection, content: "" }; - } - const [injection] = await chrome.scripting.executeScript({ - target: { tabId }, - func: fetchGoogleDocExportInTab, - args: [docId, GOOGLE_DOC_EXPORT_TIMEOUT_MS], - }); - const result = injection?.result; - if (!result || result.error) { - throw new Error(result?.error || "Google Docs export failed."); - } - return { - url: tab.url ?? "", - title: tab.title ?? "", - selection: "", - content: result.text, - }; - } - - const [injection] = await chrome.scripting.executeScript({ - target: { tabId }, - func: capturePageContent, - }); - if (!injection?.result) { - throw new Error("Could not read this page."); - } - return injection.result; -} - -async function captureDomSelection(tabId) { - // Main frame only, deliberately: all-frame probes reject wholesale on one - // inaccessible frame and return child frames in nondeterministic order. - // Child-frame selections are served by the context menu's selectionText; - // toolbar/shortcut on a child-frame selection sends the full page instead. - try { - const [injection] = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => window.getSelection()?.toString().trim() ?? "", - }); - return typeof injection?.result === "string" ? injection.result : ""; - } catch { - return ""; - } -} - -/** Self-contained because chrome.scripting serializes this function source. */ -function capturePageContent() { - const selection = window.getSelection()?.toString().trim() ?? ""; - const textOf = (node) => - String(Reflect.get(node ?? {}, "innerText") || node?.textContent || "").trim(); - const removeNoise = (root) => { - const selectors = [ - "script", - "style", - "noscript", - "nav", - "footer", - "header", - "aside", - "form", - "button", - "input", - "textarea", - "svg", - "canvas", - "iframe", - '[role="navigation"]', - '[role="banner"]', - '[role="contentinfo"]', - '[aria-hidden="true"]', - ".ad", - ".ads", - ".advert", - ".advertisement", - ".promo", - ".subscribe", - ".newsletter", - ]; - for (const node of root.querySelectorAll(selectors.join(","))) { - node.remove(); - } - }; - - let content = ""; - const hostname = window.location.hostname.toLowerCase(); - const isTwitter = /^(?:(?:www|mobile)\.)?(?:x\.com|twitter\.com)$/u.test(hostname); - if (isTwitter) { - const primaryColumn = document.querySelector('div[data-testid="primaryColumn"]'); - if (primaryColumn) { - const clone = primaryColumn.cloneNode(true); - removeNoise(clone); - for (const node of clone.querySelectorAll( - '[role="button"], [role="progressbar"], [data-testid="sidebarColumn"], [data-testid="BottomBar"]', - )) { - node.remove(); - } - content = textOf(clone); - } - if (!content) { - const seen = new Set(); - const tweets = []; - for (const article of document.querySelectorAll('article[data-testid="tweet"]')) { - const user = textOf(article.querySelector('[data-testid="User-Name"]')); - const tweetText = textOf(article.querySelector('[data-testid="tweetText"]')); - const rendered = textOf(article); - if (!rendered) { - continue; - } - const key = `${user}|${(tweetText || rendered).slice(0, 240)}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - tweets.push(`${article.getAttribute("tabindex") === "-1" ? ">>> " : ""}${rendered}`); - } - content = tweets.join("\n\n---\n\n"); - } - } else { - const clone = document.body?.cloneNode(true); - if (clone) { - removeNoise(clone); - let bestText = ""; - let bestScore = 0; - for (const candidate of clone.querySelectorAll( - 'article, main, [role="main"], section, div', - )) { - const text = textOf(candidate); - const wordCount = text.split(/\s+/u).filter(Boolean).length; - if (wordCount <= 80) { - continue; - } - const score = wordCount + Math.min(2_000, text.length) / 10; - if (score > bestScore) { - bestScore = score; - bestText = text; - } - } - content = bestText || textOf(clone); - } - content ||= textOf(document.body); - } - - return { - url: window.location.href, - title: document.title, - selection, - content, - }; -} - -/** Self-contained so the request runs in the tab with the user's Google cookies. */ -async function fetchGoogleDocExportInTab(docId, timeoutMs) { - try { - const response = await fetch( - `https://docs.google.com/document/d/${encodeURIComponent(docId)}/export?format=txt`, - { credentials: "include", signal: AbortSignal.timeout(timeoutMs) }, - ); - if (!response.ok) { - return { error: `Google Docs export failed (${response.status}).` }; - } - return { text: await response.text() }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } -} diff --git a/extensions/browser/chrome-extension/modules/page-share-core.test.ts b/extensions/browser/chrome-extension/modules/page-share-core.test.ts deleted file mode 100644 index 992dc853d585..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-core.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildPageSharePayload, capturePageShare } from "./page-share-core.js"; - -const PAGE_SHARE_MAX_CONTENT_CHARS = 120_000; -const PAGE_SHARE_MAX_NOTE_CHARS = 2_000; -const PAGE_SHARE_MAX_TITLE_CHARS = 500; -const PAGE_SHARE_MAX_URL_CHARS = 2_000; - -describe("page share core", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("exports Google documents in the tab using the document id", async () => { - const executeScript = vi - .fn() - .mockResolvedValueOnce([{ result: "" }]) - .mockResolvedValueOnce([{ result: { text: "Document body" } }]); - vi.stubGlobal("chrome", { scripting: { executeScript } }); - - await expect( - capturePageShare({ - id: 17, - url: "https://docs.google.com/document/d/document-id_123/edit?tab=t.0", - title: "Document", - }), - ).resolves.toEqual({ - url: "https://docs.google.com/document/d/document-id_123/edit?tab=t.0", - title: "Document", - selection: "", - content: "Document body", - }); - expect(executeScript.mock.calls[1]?.[0]).toMatchObject({ - target: { tabId: 17 }, - args: ["document-id_123", 30_000], - }); - }); - - it.each([ - ["response headers", false], - ["response body", true], - ] as const)("aborts a Google Docs export stalled during %s", async (_phase, headersReceived) => { - type ExecuteScriptDetails = { - args?: unknown[]; - func: (...args: unknown[]) => unknown; - }; - const executeScript = vi.fn(async (details: ExecuteScriptDetails) => { - if (!details.args) { - return [{ result: "" }]; - } - const result = await details.func(details.args[0], 1); - return [{ result }]; - }); - const fetchImpl = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { - const signal = init?.signal; - if (!(signal instanceof AbortSignal)) { - throw new Error("Expected export timeout signal"); - } - if (!headersReceived) { - return new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(new Error(String(signal.reason))), { - once: true, - }); - }); - } - return Promise.resolve( - new Response( - new ReadableStream({ - start(controller) { - signal.addEventListener("abort", () => controller.error(signal.reason), { - once: true, - }); - }, - }), - ), - ); - }); - vi.stubGlobal("chrome", { scripting: { executeScript } }); - vi.stubGlobal("fetch", fetchImpl); - - await expect( - capturePageShare({ - id: 17, - url: "https://docs.google.com/document/d/document-id_123/edit", - title: "Document", - }), - ).rejects.toThrow(/timeout|abort/i); - expect(fetchImpl).toHaveBeenCalledWith( - "https://docs.google.com/document/d/document-id_123/export?format=txt", - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it("keeps text at the boundary and marks truncation beyond it", () => { - const atBoundary = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: "x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS), - }); - const beyondBoundary = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: "x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS + 1), - }); - expect(atBoundary.content).toHaveLength(PAGE_SHARE_MAX_CONTENT_CHARS); - expect(beyondBoundary.content).toHaveLength(PAGE_SHARE_MAX_CONTENT_CHARS); - expect( - beyondBoundary.content.endsWith( - `[Truncated: original was ${PAGE_SHARE_MAX_CONTENT_CHARS + 1} characters]`, - ), - ).toBe(true); - }); - - it.each(["content", "selection"] as const)( - "preserves exact-boundary %s without truncating it", - (field) => { - const text = "x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS); - const payload = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: field === "content" ? text : "", - ...(field === "selection" ? { selection: text } : {}), - }); - const sharedText = field === "selection" ? payload.selection : payload.content; - - expect(sharedText).toBe(text); - expect(sharedText).toHaveLength(PAGE_SHARE_MAX_CONTENT_CHARS); - }, - ); - - it("keeps oversized selected text and its truncation marker within the producer field cap", () => { - const selection = "x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS + 1); - const payload = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: "", - selection, - }); - - expect(payload.selection).toHaveLength(PAGE_SHARE_MAX_CONTENT_CHARS); - expect( - payload.selection?.endsWith(`[Truncated: original was ${selection.length} characters]`), - ).toBe(true); - }); - - it.each(["content", "selection"] as const)( - "never leaves a split Unicode surrogate while truncating %s", - (field) => { - const originalLength = PAGE_SHARE_MAX_CONTENT_CHARS + 1; - const marker = `\n\n[Truncated: original was ${originalLength} characters]`; - const text = - `${"x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS - marker.length - 1)}😀` + - "x".repeat(marker.length); - expect(text).toHaveLength(originalLength); - - const payload = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: field === "content" ? text : "", - ...(field === "selection" ? { selection: text } : {}), - }); - const sharedText = field === "selection" ? payload.selection : payload.content; - - expect(sharedText?.length).toBeLessThanOrEqual(PAGE_SHARE_MAX_CONTENT_CHARS); - expect(sharedText?.endsWith(marker)).toBe(true); - expect(sharedText?.slice(0, -marker.length)).not.toMatch(/[\uD800-\uDBFF]$/u); - }, - ); - - it.each(["content", "selection"] as const)( - "preserves a newline after a non-terminal surrogate while truncating %s", - (field) => { - const originalLength = PAGE_SHARE_MAX_CONTENT_CHARS + 1; - const marker = `\n\n[Truncated: original was ${originalLength} characters]`; - const text = - `${"x".repeat(PAGE_SHARE_MAX_CONTENT_CHARS - marker.length - 2)}\uD83D\n` + - "x".repeat(marker.length + 1); - expect(text).toHaveLength(originalLength); - - const payload = buildPageSharePayload({ - url: "https://example.com", - title: "Example", - content: field === "content" ? text : "", - ...(field === "selection" ? { selection: text } : {}), - }); - const sharedText = field === "selection" ? payload.selection : payload.content; - - expect(sharedText).toHaveLength(PAGE_SHARE_MAX_CONTENT_CHARS); - expect(sharedText?.endsWith(marker)).toBe(true); - expect(sharedText?.slice(0, -marker.length).endsWith("\uD83D\n")).toBe(true); - }, - ); - - it("trims fields, preserves newlines, applies caps, and drops empty optionals", () => { - const payload = buildPageSharePayload({ - url: ` https://example.com/${"u".repeat(PAGE_SHARE_MAX_URL_CHARS)} `, - title: ` ${"t".repeat(PAGE_SHARE_MAX_TITLE_CHARS + 10)} `, - content: ` first line \n second\tline ${"c".repeat(PAGE_SHARE_MAX_CONTENT_CHARS)} `, - selection: " ", - note: ` ${"n".repeat(PAGE_SHARE_MAX_NOTE_CHARS + 10)} `, - }); - - expect(payload.url).toHaveLength(PAGE_SHARE_MAX_URL_CHARS); - expect(payload.title).toHaveLength(PAGE_SHARE_MAX_TITLE_CHARS); - expect(payload.content).toContain("first line \n second line"); - expect(payload.content).toContain("[Truncated: original was"); - expect(payload.note).toHaveLength(PAGE_SHARE_MAX_NOTE_CHARS); - expect(payload).not.toHaveProperty("selection"); - }); - - it("keeps the injected capture function self-contained", async () => { - const executeScript = vi.fn().mockResolvedValue([ - { - result: { - url: "https://example.com", - title: "Example", - selection: "", - content: "Body", - }, - }, - ]); - vi.stubGlobal("chrome", { scripting: { executeScript } }); - - await capturePageShare({ id: 9, url: "https://example.com", title: "Example" }); - const source = String(executeScript.mock.calls[0]?.[0].func); - expect(source).not.toMatch(/\b(?:import|require)\b/u); - }); -}); diff --git a/extensions/browser/chrome-extension/modules/page-share-relay.d.ts b/extensions/browser/chrome-extension/modules/page-share-relay.d.ts deleted file mode 100644 index b9aabfa3cf1d..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-relay.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PageSharePayload } from "./page-share-core.js"; - -export type PageShareRelayResult = { - requestId: number; - ok: boolean; - error?: string; -}; - -export type PageShareRelay = { - rejectSocket(socket: WebSocket): void; - send(socket: WebSocket, payload: PageSharePayload): Promise; - settle(socket: WebSocket, result: PageShareRelayResult): void; -}; - -export function createPageShareRelay(): PageShareRelay; diff --git a/extensions/browser/chrome-extension/modules/page-share-relay.js b/extensions/browser/chrome-extension/modules/page-share-relay.js deleted file mode 100644 index 48f6900a9a2b..000000000000 --- a/extensions/browser/chrome-extension/modules/page-share-relay.js +++ /dev/null @@ -1,56 +0,0 @@ -const PAGE_SHARE_TIMEOUT_MS = 10_000; -const PAGE_SHARE_DISCONNECTED_ERROR = - "Browser relay disconnected before OpenClaw acknowledged the page share."; - -export function createPageShareRelay() { - let nextRequestId = 1; - /** @type {Map void, reject: (error: Error) => void, timer: ReturnType}>} */ - const pending = new Map(); - - function rejectSocket(socket) { - // Socket ownership prevents a late close from rejecting a request that - // belongs to a relay connection that has already replaced it. - for (const [requestId, request] of pending) { - if (request.socket !== socket) { - continue; - } - pending.delete(requestId); - clearTimeout(request.timer); - request.reject(new Error(PAGE_SHARE_DISCONNECTED_ERROR)); - } - } - - function settle(socket, result) { - const request = pending.get(result.requestId); - if (!request || request.socket !== socket) { - return; - } - pending.delete(result.requestId); - clearTimeout(request.timer); - if (result.ok) { - request.resolve(); - } else { - request.reject(new Error(result.error || "Page share failed.")); - } - } - - function send(socket, payload) { - const requestId = nextRequestId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(requestId); - reject(new Error("Timed out waiting for OpenClaw.")); - }, PAGE_SHARE_TIMEOUT_MS); - pending.set(requestId, { socket, resolve, reject, timer }); - try { - socket.send(JSON.stringify({ type: "pageShare", requestId, payload })); - } catch (error) { - pending.delete(requestId); - clearTimeout(timer); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - return { rejectSocket, send, settle }; -} diff --git a/extensions/browser/chrome-extension/modules/panel-core.d.ts b/extensions/browser/chrome-extension/modules/panel-core.d.ts deleted file mode 100644 index bd714e8d4e74..000000000000 --- a/extensions/browser/chrome-extension/modules/panel-core.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type BrowserCopilotBinding = { - kind: "tab"; - tabId: number; - target: "host"; - profile: string; - targetId: string; -}; - -export type ChatStream = { - runId: string | null; - full: string; - segmentStart: number; -}; - -export function deriveTabSessionKey(mainSessionKey: unknown, sessionId: unknown): string | null; -export function deriveCopilotSessionLabel(sessionKey: unknown): string; -export function gatewayUrlFromPairing( - relayUrl: unknown, - explicitGatewayUrl: unknown, -): string | null; -export function normalizeGatewayUrl(raw: unknown): string | null; -export function buildCopilotChatSendParams(params: { - binding: BrowserCopilotBinding; - message: string; - sessionId?: string; - sessionKey: string; -}): { - sessionKey: string; - sessionId?: string; - message: string; - idempotencyKey: string; - deliver: false; - toolBindings: { browser: BrowserCopilotBinding }; -}; -export function createChatStream(): ChatStream; -export function resetChatStream(stream: ChatStream): void; -export function applyChatDelta( - stream: ChatStream, - payload: unknown, -): { text: string; newBubble: boolean } | null; -export function renderMarkdownLite(text: unknown): string; -export function readMessageText(message: unknown): string; diff --git a/extensions/browser/chrome-extension/modules/panel-core.js b/extensions/browser/chrome-extension/modules/panel-core.js deleted file mode 100644 index e2dabbcd98e4..000000000000 --- a/extensions/browser/chrome-extension/modules/panel-core.js +++ /dev/null @@ -1,151 +0,0 @@ -// Chrome-free browser-copilot helpers. Kept small so the session/binding and -// rendering invariants run in the normal extension Vitest lane. - -/** Mint an isolated child thread without exposing the tab id in Gateway state. */ -export function deriveTabSessionKey(mainSessionKey, sessionId) { - if (typeof mainSessionKey !== "string" || !mainSessionKey.trim()) { - return null; - } - if (typeof sessionId !== "string" || !/^[0-9a-f-]{36}$/i.test(sessionId)) { - return null; - } - const threadIndex = mainSessionKey.indexOf(":thread:"); - const base = threadIndex === -1 ? mainSessionKey : mainSessionKey.slice(0, threadIndex); - return `${base}:thread:browser-copilot-${sessionId.toLowerCase()}`; -} - -/** Keep the human label unique while preserving the session UUID across retries. */ -export function deriveCopilotSessionLabel(sessionKey) { - const match = - typeof sessionKey === "string" - ? sessionKey.match(/:thread:browser-copilot-([0-9a-f-]{36})$/i) - : null; - if (!match) { - throw new Error("Browser copilot session key is invalid."); - } - return `Browser copilot ${match[1].toLowerCase()}`; -} - -/** Derive the direct Gateway endpoint embedded by the pairing command. */ -export function gatewayUrlFromPairing(relayUrl, explicitGatewayUrl) { - if (typeof explicitGatewayUrl === "string" && explicitGatewayUrl.trim()) { - return normalizeGatewayUrl(explicitGatewayUrl); - } - let parsed; - try { - parsed = new URL(String(relayUrl ?? "")); - } catch { - return null; - } - const suffix = "/browser/extension"; - if (!parsed.pathname.endsWith(suffix)) { - return null; - } - parsed.pathname = parsed.pathname.slice(0, -suffix.length) || "/"; - parsed.search = ""; - parsed.hash = ""; - return normalizeGatewayUrl(parsed.toString()); -} - -export function normalizeGatewayUrl(raw) { - let parsed; - try { - parsed = new URL(String(raw ?? "").trim()); - } catch { - return null; - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - return null; - } - const host = parsed.hostname.toLowerCase(); - const loopback = host === "localhost" || host === "127.0.0.1" || host === "[::1]"; - if (parsed.protocol !== "wss:" && !(parsed.protocol === "ws:" && loopback)) { - return null; - } - parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; - return parsed.toString(); -} - -/** The panel supplies text only; Chrome-owned state supplies every routing fact. */ -export function buildCopilotChatSendParams({ binding, message, sessionId, sessionKey }) { - const text = typeof message === "string" ? message.trim() : ""; - if (!text) { - throw new Error("Message required."); - } - return { - sessionKey, - sessionId, - message: text, - idempotencyKey: crypto.randomUUID(), - deliver: false, - toolBindings: { browser: { ...binding } }, - }; -} - -export function createChatStream() { - return { runId: null, full: "", segmentStart: 0 }; -} - -export function resetChatStream(stream) { - stream.runId = null; - stream.full = ""; - stream.segmentStart = 0; -} - -/** Apply one cumulative/incremental chat event without duplicating text. */ -export function applyChatDelta(stream, payload) { - if (!payload || typeof payload !== "object") { - return null; - } - let newBubble = false; - if (payload.runId !== stream.runId) { - stream.runId = payload.runId ?? null; - stream.full = ""; - stream.segmentStart = 0; - newBubble = true; - } - const first = payload.message?.content?.[0]; - const snapshot = typeof first?.text === "string" ? first.text : null; - const deltaText = typeof payload.deltaText === "string" ? payload.deltaText : ""; - const next = snapshot ?? (payload.replace === true ? deltaText : stream.full + deltaText); - if (!next.startsWith(stream.full)) { - const currentSegment = stream.full.slice(stream.segmentStart); - stream.segmentStart = 0; - if (!(currentSegment && next.startsWith(currentSegment))) { - newBubble = true; - } - } - stream.full = next; - const text = stream.full.slice(stream.segmentStart); - return text ? { text, newBubble } : null; -} - -/** Escape first; then add only the small formatting subset the panel owns. */ -export function renderMarkdownLite(text) { - let rendered = String(text ?? "") - .replace(/&/g, "&") - .replace(//g, ">"); - const fenced = []; - rendered = rendered.replace(/```(?:[a-z0-9_-]+)?\n?([\s\S]*?)```/gi, (_match, code) => { - fenced.push(`
${code.trim()}
`); - return ``; - }); - rendered = rendered.replace(/`([^`]+)`/g, "$1"); - rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); - rendered = rendered.replace(/\n/g, "
"); - return rendered.replace(//g, (_match, index) => fenced[Number(index)]); -} - -export function readMessageText(message) { - if (typeof message?.content === "string") { - return message.content; - } - if (!Array.isArray(message?.content)) { - return ""; - } - return message.content - .map((part) => (typeof part?.text === "string" ? part.text : "")) - .filter(Boolean) - .join("\n"); -} diff --git a/extensions/browser/chrome-extension/modules/panel-core.test.ts b/extensions/browser/chrome-extension/modules/panel-core.test.ts deleted file mode 100644 index d97b11acf9a7..000000000000 --- a/extensions/browser/chrome-extension/modules/panel-core.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - applyChatDelta, - buildCopilotChatSendParams, - createChatStream, - deriveCopilotSessionLabel, - deriveTabSessionKey, - gatewayUrlFromPairing, - normalizeGatewayUrl, - readMessageText, - renderMarkdownLite, -} from "./panel-core.js"; - -describe("browser copilot panel contracts", () => { - it("mints isolated thread keys without exposing reusable tab ids", () => { - const first = deriveTabSessionKey("agent:main:main", "11111111-1111-4111-8111-111111111111"); - const second = deriveTabSessionKey( - "agent:main:main:thread:old", - "22222222-2222-4222-8222-222222222222", - ); - expect(first).toBe( - "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111", - ); - expect(second).toBe( - "agent:main:main:thread:browser-copilot-22222222-2222-4222-8222-222222222222", - ); - expect(first).not.toBe(second); - expect(deriveTabSessionKey("agent:main:main", "tab-7")).toBeNull(); - }); - - it("derives deterministic unique labels from the session UUID", () => { - const first = "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111"; - const second = "agent:main:main:thread:browser-copilot-22222222-2222-4222-8222-222222222222"; - expect(deriveCopilotSessionLabel(first)).toBe( - "Browser copilot 11111111-1111-4111-8111-111111111111", - ); - expect(deriveCopilotSessionLabel(first)).toBe(deriveCopilotSessionLabel(first)); - expect(deriveCopilotSessionLabel(first)).not.toBe(deriveCopilotSessionLabel(second)); - expect(() => deriveCopilotSessionLabel("agent:main:main")).toThrow( - "Browser copilot session key is invalid.", - ); - }); - - it("derives only secure remote or loopback Gateway endpoints", () => { - expect(gatewayUrlFromPairing("wss://gateway.example/base/browser/extension", undefined)).toBe( - "wss://gateway.example/base", - ); - expect(gatewayUrlFromPairing("ws://127.0.0.1:18792/extension", "ws://127.0.0.1:18789")).toBe( - "ws://127.0.0.1:18789/", - ); - expect(normalizeGatewayUrl("ws://gateway.example")).toBeNull(); - const credentialed = new URL("wss://gateway.example"); - credentialed.username = "fixture-user"; - credentialed.password = "test-password"; - expect(normalizeGatewayUrl(credentialed.toString())).toBeNull(); - }); - - it("builds a local-only delivery with the trusted browser binding", () => { - vi.spyOn(crypto, "randomUUID").mockReturnValue("33333333-3333-4333-8333-333333333333"); - const binding = { - kind: "tab", - tabId: 7, - target: "host", - profile: "chrome", - targetId: "target-7", - } as const; - expect( - buildCopilotChatSendParams({ - binding, - message: " inspect this ", - sessionId: "session-7", - sessionKey: "agent:main:main:thread:browser-copilot-x", - }), - ).toEqual({ - sessionKey: "agent:main:main:thread:browser-copilot-x", - sessionId: "session-7", - message: "inspect this", - idempotencyKey: "33333333-3333-4333-8333-333333333333", - deliver: false, - toolBindings: { browser: binding }, - }); - }); - - it("renders cumulative deltas once and escapes page-controlled markup", () => { - const stream = createChatStream(); - expect(applyChatDelta(stream, { runId: "run", deltaText: "Hello" })).toEqual({ - text: "Hello", - newBubble: true, - }); - expect( - applyChatDelta(stream, { - runId: "run", - message: { content: [{ text: "Hello world" }] }, - }), - ).toEqual({ text: "Hello world", newBubble: false }); - expect(renderMarkdownLite(" **safe**")).toBe( - "<img src=x> safe", - ); - }); - - it("projects only visible text from history content", () => { - expect(readMessageText({ content: [{ type: "text", text: "one" }, { text: "two" }] })).toBe( - "one\ntwo", - ); - expect(readMessageText({ content: [{ type: "image", data: "secret" }] })).toBe(""); - }); -}); diff --git a/extensions/browser/chrome-extension/modules/popup-background.js b/extensions/browser/chrome-extension/modules/popup-background.js index dcdeebd37be9..020fba342987 100644 --- a/extensions/browser/chrome-extension/modules/popup-background.js +++ b/extensions/browser/chrome-extension/modules/popup-background.js @@ -14,7 +14,7 @@ function errorResponse(sendResponse, error) { sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); } -/** Own pairing/settings/popup messages; authority stays in the injected access policy. */ +/** Own manual/native pairing transactions and compact popup/options messages. */ export function createPopupMessageHandler({ chromeApi = chrome, pairingConfigStore, @@ -23,6 +23,13 @@ export function createPopupMessageHandler({ getConfig, getRelayState, getRelayStatusHint, + getNativeBootstrapStatus, + enableNativeBootstrap, + onManualPairing, + onUnpairStart, + isRetiredCopilotCustodyBlocked, + requireAutomationAllowed, + discardRetiredCopilotCustody, resetRelayState, suspendRelayConnections, resumeRelayConnections, @@ -35,14 +42,12 @@ export function createPopupMessageHandler({ closeRelaySocket, connectRelay, setBadge, - getCopilot, attachingTabs, detachDebugger, removeTabFromOpenClawGroup, addTabToOpenClawGroup, scheduleTabsSync, pauseTab, - pageShare, }) { let pairingGeneration = 0; @@ -52,7 +57,99 @@ export function createPopupMessageHandler({ } }; - return (msg, reply) => { + async function applyPairing({ pairing, pairingString, accessMode, source = "manual" }) { + await requireAutomationAllowed(); + const parsed = pairing ?? parsePairingString(pairingString); + if (!parsed) { + return { ok: false, error: "Invalid pairing string." }; + } + if (source === "native" && (await getConfig()).relayUrl) { + return { ok: false, existing: true }; + } + if (source === "manual") { + await onManualPairing(); + } + const generation = ++pairingGeneration; + suspendRelayConnections(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + await accessReady; + assertPairingCurrent(generation); + await runAccessMutation(async () => { + assertPairingCurrent(generation); + if (source === "native" && (await getConfig()).relayUrl) { + return; + } + suspendRelayConnections(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + const normalizedMode = + accessMode === ACCESS_MODE_SELECTED ? ACCESS_MODE_SELECTED : ACCESS_MODE_ALL; + const downgrading = + policy.mode === ACCESS_MODE_ALL && normalizedMode === ACCESS_MODE_SELECTED; + if (downgrading) { + policy.beginTransition(); + } + try { + await pairingConfigStore.save(parsed, nearestGroupColor(), normalizedMode); + assertPairingCurrent(generation); + await reconcileAccessMode(normalizedMode, { transitioning: downgrading }); + assertPairingCurrent(generation); + policy.setEnabled(true); + } catch (error) { + if (downgrading) { + policy.endTransition(); + } + throw error; + } + resetRelayState(); + assertPairingCurrent(generation); + resumeRelayConnections(); + await connectRelay(() => generation === pairingGeneration); + if (generation !== pairingGeneration) { + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + assertPairingCurrent(generation); + } + }); + return { ok: true }; + } + + async function unpair() { + pairingGeneration += 1; + const disabledPersisted = onUnpairStart(); + policy.setEnabled(false); + policy.invalidateAll(); + suspendRelayConnections(); + resetRelayState(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + await accessReady; + policy.setEnabled(false); + policy.invalidateAll(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + await runAccessMutation(async () => { + policy.setEnabled(false); + const detaching = detachAllDebuggerSessions(); + await syncTabsToRelay(); + await disabledPersisted; + await pairingConfigStore.clear(); + await policy.clearDenied(); + await detaching; + await discardRetiredCopilotCustody(); + resetRelayState(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + }); + return { ok: true }; + } + + const handler = (msg, reply) => { let settled = false; const sendResponse = (response) => { if (!settled) { @@ -65,6 +162,8 @@ export function createPopupMessageHandler({ switch (msg?.type) { case "getStatus": { await accessReady; + const retiredCopilotCustodyBlocked = isRetiredCopilotCustodyBlocked(); + const nativeBootstrap = await getNativeBootstrapStatus(); const { relayUrl, accessMode } = await getConfig(); await reconcilePairingInvalidation(); const accessible = await policy.listAccessibleTabs(); @@ -75,124 +174,48 @@ export function createPopupMessageHandler({ accessMode, accessibleTabCount: accessible.length, relayUrl: relayUrl ?? "", + nativeBootstrap, + retiredCopilotCustodyBlocked, ...(hint ? { hint } : {}), }); return; } - case "pair": { - const parsed = parsePairingString(msg.pairingString); - if (!parsed) { - sendResponse({ ok: false, error: "Invalid pairing string." }); + case "pair": + sendResponse( + await applyPairing({ + pairingString: msg.pairingString, + accessMode: msg.accessMode, + source: "manual", + }), + ); + return; + case "unpair": + sendResponse(await unpair()); + return; + case "setNativeBootstrapEnabled": + if (typeof msg.enabled !== "boolean") { + sendResponse({ ok: false, error: "Invalid automatic setup setting." }); return; } - const generation = ++pairingGeneration; - suspendRelayConnections(); - clearRelayOpeningDeadline(); - closeRelaySocket(); - await accessReady; - assertPairingCurrent(generation); - await runAccessMutation(async () => { - assertPairingCurrent(generation); - // A newer request may have waited behind an older save. Reassert - // transport custody when this generation reaches the queue head. - suspendRelayConnections(); - clearRelayOpeningDeadline(); - closeRelaySocket(); - // A replacement pairing must never inherit a later, wider policy. - // Retire its authenticated socket before storage or mode can yield. - const accessMode = - msg.accessMode === ACCESS_MODE_SELECTED ? ACCESS_MODE_SELECTED : ACCESS_MODE_ALL; - const downgrading = - policy.mode === ACCESS_MODE_ALL && accessMode === ACCESS_MODE_SELECTED; - if (downgrading) { - policy.beginTransition(); - } - try { - await pairingConfigStore.save( - parsed, - nearestGroupColor(msg.groupColor), - accessMode, - ); - assertPairingCurrent(generation); - await reconcileAccessMode(accessMode, { transitioning: downgrading }); - assertPairingCurrent(generation); - policy.setEnabled(true); - } catch (error) { - if (downgrading) { - policy.endTransition(); - } - throw error; - } - resetRelayState(); - await getCopilot().refreshConfig(); - assertPairingCurrent(generation); - resumeRelayConnections(); - await connectRelay(() => generation === pairingGeneration); - if (generation !== pairingGeneration) { - clearRelayOpeningDeadline(); - closeRelaySocket(); - setBadge("off"); - assertPairingCurrent(generation); - } - }); - sendResponse({ ok: true }); + sendResponse({ ok: true, result: await enableNativeBootstrap(msg.enabled) }); return; - } - case "unpair": { - pairingGeneration += 1; - // Revocation is synchronous. Queued storage and debugger cleanup - // must not leave the old authority or relay alive in the meantime. - policy.setEnabled(false); - policy.invalidateAll(); - suspendRelayConnections(); - resetRelayState(); - clearRelayOpeningDeadline(); - closeRelaySocket(); - setBadge("off"); - await accessReady; - // Initialization can finish after the synchronous revoke above. - // Reassert it before waiting on any older queued bookkeeping. - policy.setEnabled(false); - policy.invalidateAll(); - clearRelayOpeningDeadline(); - closeRelaySocket(); - setBadge("off"); - await runAccessMutation(async () => { - // Initialization or an older mutation may have completed while - // this request was waiting for the queue; keep revocation sticky. - policy.setEnabled(false); - const detaching = detachAllDebuggerSessions(); - await syncTabsToRelay(); - await pairingConfigStore.clear(); - await policy.clearDenied(); - await detaching; - resetRelayState(); - clearRelayOpeningDeadline(); - closeRelaySocket(); - setBadge("off"); - await getCopilot().refreshConfig(); - }); - sendResponse({ ok: true }); - return; - } case "setAccessMode": { if (msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) { sendResponse({ ok: false, error: "Invalid access mode." }); return; } + await requireAutomationAllowed(); const restricting = msg.accessMode === ACCESS_MODE_SELECTED; if (restricting) { - // A queued widening may not have updated policy.mode yet. Every - // Selected request revokes before older mutations can hold the queue. policy.beginTransition(); } - let accessMode; + let storedMode; try { await accessReady; - accessMode = await runAccessMutation(async () => { - const storedMode = await pairingConfigStore.setAccessMode(msg.accessMode); - await reconcileAccessMode(storedMode, { transitioning: restricting }); - return storedMode; + storedMode = await runAccessMutation(async () => { + const mode = await pairingConfigStore.setAccessMode(msg.accessMode); + await reconcileAccessMode(mode, { transitioning: restricting }); + return mode; }); } catch (error) { if (restricting) { @@ -200,16 +223,13 @@ export function createPopupMessageHandler({ } throw error; } - sendResponse({ ok: true, accessMode }); + sendResponse({ ok: true, accessMode: storedMode }); return; } case "toggleTabAccess": { const tabId = msg.tabId; - if (!isValidTabId(tabId)) { - sendResponse({ ok: false, error: "No tab." }); - return; - } if ( + !isValidTabId(tabId) || (msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) || typeof msg.grant !== "boolean" ) { @@ -217,49 +237,41 @@ export function createPopupMessageHandler({ return; } await accessReady; + await requireAutomationAllowed(); if (policy.mode !== msg.accessMode) { sendResponse({ ok: false, error: "Browser access mode changed. Refresh and retry." }); return; } const revocation = policy.beginRevocation(tabId); - let restoredAccess = false; try { await runAccessMutation(async () => { if (policy.mode !== msg.accessMode) { throw new Error("Browser access mode changed. Refresh and retry."); } if (policy.mode === ACCESS_MODE_ALL) { - const denied = policy.isDenied(tabId); - if (msg.grant && denied) { + if (msg.grant && policy.isDenied(tabId)) { await policy.allow(tabId); - restoredAccess = true; - } else if (!msg.grant && !denied) { + } else if (!msg.grant && !policy.isDenied(tabId)) { await pauseTab(tabId); } } else { - const wasSelected = await isTabSelected(await chromeApi.tabs.get(tabId)); - if (!msg.grant && wasSelected) { + const selected = await isTabSelected(await chromeApi.tabs.get(tabId)); + if (!msg.grant && selected) { policy.invalidateTab(tabId); await Promise.allSettled([attachingTabs.get(tabId)]); await detachDebugger(tabId); await removeTabFromOpenClawGroup(tabId); - scheduleTabsSync(); - await getCopilot().onConsentChanged(tabId, { revoked: true }); - } else if (msg.grant && !wasSelected) { + } else if (msg.grant && !selected) { policy.invalidateTab(tabId); await addTabToOpenClawGroup(tabId); - restoredAccess = true; } } + scheduleTabsSync(); + await syncTabsToRelay(); }); } finally { policy.endRevocation(revocation); } - if (restoredAccess) { - scheduleTabsSync(); - await syncTabsToRelay(); - await getCopilot().onConsentChanged(tabId, { revoked: false }); - } const state = await policy.inspectTab(tabId); sendResponse({ ok: true, accessible: state.accessible, denied: state.denied }); return; @@ -275,20 +287,6 @@ export function createPopupMessageHandler({ }); return; } - case "sendPageToOpenClaw": { - if (typeof msg.tabId !== "number") { - sendResponse({ ok: false, error: "No tab." }); - return; - } - await pageShare.sendPage(msg.tabId, msg.note); - sendResponse({ ok: true }); - return; - } - case "prepareCopilotPanel": { - const options = await getCopilot().preparePanel(msg.tabId); - sendResponse({ ok: true, ...options }); - return; - } default: sendResponse({ ok: false, error: "unknown message" }); } @@ -298,4 +296,8 @@ export function createPopupMessageHandler({ })(); return true; }; + + handler.applyPairing = applyPairing; + handler.unpair = unpair; + return handler; } diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.d.ts b/extensions/browser/chrome-extension/modules/tab-access-events.d.ts index 7dc1e26411ba..13d7f3953080 100644 --- a/extensions/browser/chrome-extension/modules/tab-access-events.d.ts +++ b/extensions/browser/chrome-extension/modules/tab-access-events.d.ts @@ -36,20 +36,13 @@ export type TabAccessEventPolicy = { replaceTab(addedTabId: number, removedTabId: number): Promise; }; -export type TabAccessEventCopilot = { - onConsentChanged(tabId?: number, options?: { revoked?: boolean }): void | Promise; - onTabRemoved(tabId: number): void | Promise; -}; - export function registerTabAccessEvents(options: { chromeApi?: TabAccessEventsChromeApi; accessReady: Promise; policy: TabAccessEventPolicy; attachedTabs: Set; attachedAccessEpochs: Map; - copilotDeniedTabs: Set; attachingTabs: Map>; - getCopilot(): TabAccessEventCopilot; send(message: Record): void; scheduleTabsSync(): void; detachDebugger(tabId: number): Promise; diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.js b/extensions/browser/chrome-extension/modules/tab-access-events.js index d923ea9c14b4..b6fe836c42d0 100644 --- a/extensions/browser/chrome-extension/modules/tab-access-events.js +++ b/extensions/browser/chrome-extension/modules/tab-access-events.js @@ -7,9 +7,7 @@ export function registerTabAccessEvents({ policy, attachedTabs, attachedAccessEpochs, - copilotDeniedTabs, attachingTabs, - getCopilot, send, scheduleTabsSync, detachDebugger, @@ -56,7 +54,6 @@ export function registerTabAccessEvents({ policy.invalidateTab(source.tabId); await removeTabFromOpenClawGroup(source.tabId); scheduleTabsSync(); - await getCopilot()?.onConsentChanged(source.tabId, { revoked: true }); } } finally { policy.endRevocation(revocation); @@ -70,10 +67,8 @@ export function registerTabAccessEvents({ policy.invalidateTab(tabId); attachedTabs.delete(tabId); attachedAccessEpochs.delete(tabId); - copilotDeniedTabs.delete(tabId); scheduleTabsSync(); await policy.forgetTab(tabId).catch(() => undefined); - await getCopilot().onTabRemoved(tabId); })(); }); @@ -82,18 +77,13 @@ export function registerTabAccessEvents({ policy.invalidateTab(removedTabId); attachedTabs.delete(removedTabId); attachedAccessEpochs.delete(removedTabId); - copilotDeniedTabs.delete(removedTabId); scheduleTabsSync(); void (async () => { try { await accessReady; - const pauseTransferred = await policy.replaceTab(addedTabId, removedTabId); + await policy.replaceTab(addedTabId, removedTabId); await Promise.allSettled([attachingTabs.get(removedTabId), attachingTabs.get(addedTabId)]); await Promise.allSettled([detachDebugger(removedTabId), detachDebugger(addedTabId)]); - await getCopilot().onTabRemoved(removedTabId); - if (pauseTransferred) { - await getCopilot().onConsentChanged(addedTabId, { revoked: true }); - } } finally { policy.endRevocation(revocation); scheduleTabsSync(); @@ -128,16 +118,10 @@ export function registerTabAccessEvents({ return; } await detachDebugger(tabId); - if (!eventIsCurrent()) { - return; - } - await getCopilot().onConsentChanged(tabId, { revoked: true }); - return; } if (attachedTabs.has(tabId) && attachedAccessEpochs.has(tabId)) { attachedAccessEpochs.set(tabId, eventEpoch); } - await getCopilot().onConsentChanged(tabId, { revoked: false }); })(); }); @@ -203,9 +187,7 @@ export function registerTabAccessEvents({ } if (newerTabEventOwnsAccess) { onGroupChanged(); - return; } - await getCopilot().onConsentChanged(); }); }; chromeApi.tabGroups.onUpdated.addListener(onGroupChanged); diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.test.ts b/extensions/browser/chrome-extension/modules/tab-access-events.test.ts index 0f95f0a7bfb5..7cfbe4dcb9c8 100644 --- a/extensions/browser/chrome-extension/modules/tab-access-events.test.ts +++ b/extensions/browser/chrome-extension/modules/tab-access-events.test.ts @@ -28,8 +28,6 @@ function createHarness( const attachedAccessEpochs = new Map([[7, { revision: 0, tabRevision: 0 }]]); const attachingTabs = new Map>(); const send = vi.fn(); - const onConsentChanged = vi.fn(async () => undefined); - const onTabRemoved = vi.fn(async () => undefined); const policy = { mode, beginRevocation: vi.fn(() => Symbol("revocation")), @@ -99,9 +97,7 @@ function createHarness( policy, attachedTabs, attachedAccessEpochs, - copilotDeniedTabs: new Set(), attachingTabs, - getCopilot: () => ({ onConsentChanged, onTabRemoved }), send, scheduleTabsSync: vi.fn(), detachDebugger, @@ -125,8 +121,6 @@ function createHarness( debuggerDetachListener, debuggerEventListener, groupUpdatedListener, - onConsentChanged, - onTabRemoved, policy, pauseTab, removeTabFromOpenClawGroup, @@ -204,8 +198,6 @@ describe("tab access event epochs", () => { await Promise.resolve(); expect(harness.detachDebugger).not.toHaveBeenCalled(); - expect(harness.onConsentChanged).not.toHaveBeenCalledWith(7, { revoked: true }); - expect(harness.onConsentChanged).toHaveBeenCalledWith(7, { revoked: false }); harness.debuggerEventListener({ tabId: 7 }, "Runtime.consoleAPICalled", {}); expect(harness.send).toHaveBeenCalledWith( expect.objectContaining({ type: "cdpEvent", tabId: 7 }), @@ -251,7 +243,6 @@ describe("tab access event epochs", () => { harness.tabsUpdatedListener(7, secondChange); await vi.waitFor(() => { expect(harness.detachDebugger).toHaveBeenCalledTimes(1); - expect(harness.onConsentChanged).toHaveBeenCalledWith(7, { revoked: true }); }); firstInspection.resolve({ accessible: false }); @@ -259,7 +250,6 @@ describe("tab access event epochs", () => { await Promise.resolve(); expect(harness.detachDebugger).toHaveBeenCalledTimes(1); - expect(harness.onConsentChanged).toHaveBeenCalledTimes(1); }, ); @@ -273,8 +263,6 @@ describe("tab access event epochs", () => { expect(harness.policy.replaceTab).toHaveBeenCalledWith(8, 7); expect(harness.detachDebugger).toHaveBeenCalledWith(7); expect(harness.detachDebugger).toHaveBeenCalledWith(8); - expect(harness.onTabRemoved).toHaveBeenCalledWith(7); - expect(harness.onConsentChanged).toHaveBeenCalledWith(8, { revoked: true }); }); }); @@ -311,7 +299,7 @@ describe("tab access event epochs", () => { harness.groupUpdatedListener(); await vi.waitFor(() => expect(harness.policy.listAccessibleTabs).toHaveBeenCalledTimes(1)); harness.groupUpdatedListener(); - await vi.waitFor(() => expect(harness.onConsentChanged).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(harness.policy.listAccessibleTabs).toHaveBeenCalledTimes(2)); harness.setAccessible(false); harness.policy.invalidateTab(); firstList.resolve([{ id: 7 }]); diff --git a/extensions/browser/chrome-extension/options.html b/extensions/browser/chrome-extension/options.html new file mode 100644 index 000000000000..c5e803da3502 --- /dev/null +++ b/extensions/browser/chrome-extension/options.html @@ -0,0 +1,147 @@ + + + + + + OpenClaw Browser Settings + + + +

OpenClaw Browser Settings

+

+ The extension only relays browser automation. Pairing keys and URLs are never shown here. +

+ + + +
+

Connection

+

Checking…

+

+ + +
+ +
+

Access

+ + +
+ +
+

Advanced manual pairing

+

+ Use this only for a direct remote Gateway or when automatic setup reports that manual action + is required. +

+ + +
+ +
+

Diagnostics

+

+ openclaw browser extension status --json
openclaw browser doctor --browser-profile chrome +

+ +
+

+ + + diff --git a/extensions/browser/chrome-extension/options.js b/extensions/browser/chrome-extension/options.js new file mode 100644 index 000000000000..4b844830d747 --- /dev/null +++ b/extensions/browser/chrome-extension/options.js @@ -0,0 +1,95 @@ +const connectionStatus = document.getElementById("connectionStatus"); +const bootstrapStatus = document.getElementById("bootstrapStatus"); +const automaticSetup = document.getElementById("automaticSetup"); +const accessMode = document.getElementById("accessMode"); +const pairingString = document.getElementById("pairingString"); +const pair = document.getElementById("pair"); +const useLocal = document.getElementById("useLocal"); +const disconnect = document.getElementById("disconnect"); +const message = document.getElementById("message"); +const retiredCustody = document.getElementById("retiredCustody"); + +async function refresh() { + const status = await chrome.runtime.sendMessage({ type: "getStatus" }); + const custodyBlocked = status.retiredCopilotCustodyBlocked === true; + retiredCustody.classList.toggle("hidden", !custodyBlocked); + connectionStatus.textContent = status.paired + ? custodyBlocked + ? "Paired; automation paused" + : status.state === "on" + ? "Connected" + : "Paired; relay unavailable" + : "Not paired"; + automaticSetup.checked = !status.nativeBootstrap?.disabled && !custodyBlocked; + bootstrapStatus.textContent = custodyBlocked + ? "Retired recovery state requires confirmation" + : status.nativeBootstrap?.disabled + ? "Automatic setup disabled" + : status.nativeBootstrap?.state === "manual_required" + ? `Manual setup required (${status.nativeBootstrap.failureCode ?? "unsupported topology"})` + : status.nativeBootstrap?.state === "retrying" + ? "Waiting for the local native host" + : "Automatic bootstrap ready"; + accessMode.value = status.accessMode === "selected" ? "selected" : "all"; + automaticSetup.disabled = custodyBlocked; + useLocal.disabled = custodyBlocked; + accessMode.disabled = !status.paired || custodyBlocked; + pairingString.disabled = custodyBlocked; + pair.disabled = custodyBlocked; + disconnect.disabled = !status.paired && !custodyBlocked; +} + +async function showResult(task, success) { + try { + const result = await task(); + if (result?.ok === false) { + throw new Error(result.error ?? "Operation failed."); + } + message.textContent = success; + } catch (error) { + message.textContent = error instanceof Error ? error.message : String(error); + } + await refresh(); +} + +automaticSetup.addEventListener("change", () => { + void showResult( + () => + chrome.runtime.sendMessage({ + type: "setNativeBootstrapEnabled", + enabled: automaticSetup.checked, + }), + automaticSetup.checked ? "Automatic setup enabled." : "Automatic setup disabled.", + ); +}); +useLocal.addEventListener("click", () => { + void showResult( + () => chrome.runtime.sendMessage({ type: "setNativeBootstrapEnabled", enabled: true }), + "Looking for local OpenClaw…", + ); +}); +accessMode.addEventListener("change", () => { + void showResult( + () => chrome.runtime.sendMessage({ type: "setAccessMode", accessMode: accessMode.value }), + "Access mode updated.", + ); +}); +pair.addEventListener("click", () => { + void showResult( + () => + chrome.runtime.sendMessage({ + type: "pair", + pairingString: pairingString.value, + accessMode: accessMode.value, + }), + "Manual pairing saved.", + ); +}); +disconnect.addEventListener("click", () => { + void showResult( + () => chrome.runtime.sendMessage({ type: "unpair" }), + "Disconnected. Automatic setup is disabled.", + ); +}); + +void refresh(); diff --git a/extensions/browser/chrome-extension/package.contract.test.ts b/extensions/browser/chrome-extension/package.contract.test.ts new file mode 100644 index 000000000000..f1fe136f066d --- /dev/null +++ b/extensions/browser/chrome-extension/package.contract.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const extensionDir = path.dirname(fileURLToPath(import.meta.url)); + +describe("simplified Chrome extension package", () => { + it("declares only relay, access, storage, watchdog, and native bootstrap permissions", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.permissions).toEqual([ + "debugger", + "tabs", + "tabGroups", + "storage", + "alarms", + "nativeMessaging", + ]); + expect(manifest).not.toHaveProperty("commands"); + expect(manifest.options_ui).toEqual({ page: "options.html", open_in_tab: true }); + }); + + it("contains no copilot, page-share, or side-panel runtime", () => { + const files = fs + .readdirSync(extensionDir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(entry.parentPath, entry.name).slice(extensionDir.length + 1)) + .filter((entry) => !entry.endsWith(".test.ts")); + + expect(files.join("\n")).not.toMatch(/copilot|page-share|sidepanel/iu); + }); + + it("ships redacted retired-custody recovery guidance", () => { + const options = fs.readFileSync(path.join(extensionDir, "options.html"), "utf8"); + const popup = fs.readFileSync(path.join(extensionDir, "popup.js"), "utf8"); + + expect(options).toContain("Automation is paused to protect a pre-upgrade copilot session."); + expect(options).toContain("Confirm old runs are finished"); + expect(options).toContain("Disconnect and disable automatic setup"); + expect(options).toContain("Use local OpenClaw"); + expect(popup).toContain("Automation paused; open Settings"); + expect(options).not.toMatch(/copilotSessionRegistryV1|sessionId|sessionKey|deviceToken/u); + }); +}); diff --git a/extensions/browser/chrome-extension/page-share.e2e.test.ts b/extensions/browser/chrome-extension/page-share.e2e.test.ts deleted file mode 100644 index c5ed46e89aaa..000000000000 --- a/extensions/browser/chrome-extension/page-share.e2e.test.ts +++ /dev/null @@ -1,927 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import { createServer, type Server } from "node:http"; -import path from "node:path"; -import { chromium, type CDPSession } from "playwright-core"; -import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; -import { - EXTENSION_RELAY_MAX_PAYLOAD_BYTES, - startExtensionRelayServer, - type ExtensionRelayHandle, -} from "../src/browser/extension-relay/relay-server.js"; -import { useAutoCleanupTempDirTracker } from "../test-support.js"; -import { - copyCopilotSidepanelExtension, - createRelayHarness, - rawDataText, - waitForContextExtensionId, - waitForLoadedExtensionId, -} from "./sidepanel.e2e-support.js"; - -declare const chrome: { - runtime: { - sendMessage(message: Record): Promise<{ - accessMode?: "all" | "selected"; - accessible?: boolean; - denied?: boolean; - ok?: boolean; - error?: string; - }>; - }; - storage: { - local: { - get(keys: string[]): Promise>; - set(values: Record): Promise; - }; - }; - tabGroups: { - get(groupId: number): Promise<{ title?: string }>; - }; - tabs: { - get(tabId: number): Promise<{ - active?: boolean; - groupId?: number; - id?: number; - url?: string; - windowId?: number; - }>; - query(query: Record): Promise>; - remove(tabId: number): Promise; - ungroup(tabIds: number[]): Promise; - update(tabId: number, update: { active: boolean }): Promise; - }; - windows: { - update(windowId: number, update: { focused: boolean }): Promise; - }; -}; - -const runE2E = process.env.OPENCLAW_BROWSER_COPILOT_E2E === "1"; -const PAGE_SHARE_RELAY_SECRET = "c".repeat(64); -const cleanups: Array<() => Promise> = []; -const tempDirs = useAutoCleanupTempDirTracker(afterEach); -let nextPopupCommandId = 0; - -type ChromeTarget = { targetId: string; type: string; url: string }; - -afterEach(async () => { - for (const cleanup of cleanups.splice(0).toReversed()) { - await cleanup().catch(() => undefined); - } -}); - -async function listen(server: Server): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("page-share test server did not bind a TCP port"); - } - return address.port; -} - -async function configureRelayCredential(token: string): Promise { - const priorStateDir = process.env.OPENCLAW_STATE_DIR; - const stateDir = tempDirs.make("openclaw-extension-relay-state-"); - const credentialsDir = path.join(stateDir, "credentials"); - await fs.mkdir(credentialsDir, { recursive: true }); - await fs.writeFile(path.join(credentialsDir, "browser-extension-relay.secret"), `${token}\n`, { - mode: 0o600, - }); - process.env.OPENCLAW_STATE_DIR = stateDir; - cleanups.push(async () => { - if (priorStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = priorStateDir; - } - }); -} - -async function evaluateToolbarPopup( - browserCdp: CDPSession, - sessionId: string, - expression: string, -): Promise { - const id = ++nextPopupCommandId; - let listener: ((event: { message: string; sessionId: string }) => void) | undefined; - const response = new Promise>((resolve, reject) => { - listener = (event) => { - if (event.sessionId !== sessionId) { - return; - } - const message = JSON.parse(event.message) as { - error?: { message?: string }; - id?: number; - result?: Record; - }; - if (message.id !== id) { - return; - } - if (message.error) { - reject(new Error(message.error.message ?? "Chrome toolbar popup evaluation failed.")); - return; - } - resolve(message.result ?? {}); - }; - browserCdp.on("Target.receivedMessageFromTarget", listener); - }); - - try { - await browserCdp.send("Target.sendMessageToTarget", { - sessionId, - message: JSON.stringify({ - id, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true }, - }), - }); - const result = await response; - const exception = result.exceptionDetails as { text?: string } | undefined; - if (exception) { - throw new Error(exception.text ?? "Chrome toolbar popup evaluation failed."); - } - return (result.result as { value?: T } | undefined)?.value as T; - } finally { - if (listener) { - browserCdp.off("Target.receivedMessageFromTarget", listener); - } - } -} - -describe.runIf(runE2E)("Chrome extension relay authorization", () => { - it("sends no client proof or raw key to a malicious loopback listener", async () => { - const server = createServer(); - const port = await listen(server); - const wss = new WebSocketServer({ - noServer: true, - maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES, - handleProtocols: (protocols) => - protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false, - }); - const protocolHeaders: string[] = []; - const receivedTypes: string[] = []; - server.on("upgrade", (request, socket, head) => { - const protocolHeader = request.headers["sec-websocket-protocol"]; - protocolHeaders.push( - Array.isArray(protocolHeader) ? protocolHeader.join(", ") : (protocolHeader ?? ""), - ); - wss.handleUpgrade(request, socket, head, (client) => wss.emit("connection", client, request)); - }); - wss.on("connection", (socket) => { - socket.on("message", (data) => { - const message = JSON.parse(rawDataText(data)) as Record; - receivedTypes.push(String(message.type)); - if (message.type !== "auth.hello") { - return; - } - const issuedAtMs = Date.now(); - socket.send( - JSON.stringify({ - type: "auth.challenge", - v: 2, - keyId: createHash("sha256") - .update(Buffer.from(PAGE_SHARE_RELAY_SECRET, "hex")) - .digest("base64url") - .slice(0, 22), - instanceId: "ICEiIyQlJicoKSorLC0uLw", - sessionId: "MDEyMzQ1Njc4OTo7PD0-Pw", - clientNonce: message.clientNonce, - serverNonce: "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8", - issuedAtMs, - expiresAtMs: issuedAtMs + 10_000, - role: "extension", - transport: "websocket", - method: "GET", - resource: "/extension", - flow: "extension", - serverProof: "A".repeat(43), - }), - ); - }); - }); - cleanups.push(async () => { - for (const client of wss.clients) { - client.terminate(); - } - await new Promise((resolve) => { - wss.close(() => resolve()); - }); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }); - - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const context = await chromium.launchPersistentContext( - tempDirs.make("openclaw-extension-malicious-relay-profile-"), - { - channel: "chromium", - headless: true, - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }, - ); - cleanups.push(async () => await context.close()); - const extensionId = await waitForContextExtensionId(context, unpackedExtension); - const launcher = context.pages()[0] ?? (await context.newPage()); - await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - await launcher.evaluate( - async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }), - `ws://127.0.0.1:${port}/extension#${PAGE_SHARE_RELAY_SECRET}`, - ); - - await expect.poll(() => receivedTypes.length, { timeout: 10_000 }).toBeGreaterThan(0); - await new Promise((resolve) => { - setTimeout(resolve, 750); - }); - expect(new Set(receivedTypes)).toEqual(new Set(["auth.hello"])); - expect(new Set(protocolHeaders)).toEqual(new Set(["openclaw-extension-relay.v2"])); - expect(protocolHeaders.join("\n")).not.toContain(PAGE_SHARE_RELAY_SECRET); - }, 60_000); - - it("clears an invalid persisted pairing before reconnecting after restart", async () => { - const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET); - cleanups.push(relay.close); - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const userDataDir = tempDirs.make("openclaw-extension-persisted-auth-profile-"); - const launchOptions: Parameters[1] = { - channel: "chromium", - headless: true, - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }; - const initialContext = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await initialContext.close()); - const initialExtensionId = await waitForContextExtensionId(initialContext, unpackedExtension); - const initialLauncher = initialContext.pages()[0] ?? (await initialContext.newPage()); - await initialLauncher.goto(`chrome-extension://${initialExtensionId}/e2e-launcher.html`); - await initialLauncher.evaluate( - async ({ relayPort }) => - await chrome.storage.local.set({ - relayUrl: `ws://127.0.0.1:${relayPort}/extension`, - token: "legacy-unsafe-token", - gatewayUrl: "", - groupColor: "orange", - }), - { relayPort: relay.port }, - ); - await initialContext.close(); - - const reloadedContext = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await reloadedContext.close()); - const extensionId = await waitForContextExtensionId(reloadedContext, unpackedExtension); - expect(extensionId).toBe(initialExtensionId); - const launcher = reloadedContext.pages()[0] ?? (await reloadedContext.newPage()); - await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - - await expect - .poll( - async () => - await launcher.evaluate( - async () => - await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token", "authVersion"]), - ), - { timeout: 10_000 }, - ) - .toEqual({}); - expect( - await launcher.evaluate(async () => await chrome.runtime.sendMessage({ type: "getStatus" })), - ).toMatchObject({ paired: false, relayUrl: "", state: "off" }); - await new Promise((resolve) => { - setTimeout(resolve, 1_500); - }); - expect(relay.connectionCount).toBe(0); - }, 60_000); - - it("migrates an existing pairing to selected access after a browser restart", async () => { - const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET); - cleanups.push(relay.close); - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const userDataDir = tempDirs.make("openclaw-extension-selected-migration-profile-"); - const launchOptions: Parameters[1] = { - channel: "chromium", - headless: true, - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }; - const initialContext = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await initialContext.close()); - const initialExtensionId = await waitForContextExtensionId(initialContext, unpackedExtension); - const initialLauncher = initialContext.pages()[0] ?? (await initialContext.newPage()); - await initialLauncher.goto(`chrome-extension://${initialExtensionId}/e2e-launcher.html`); - await initialLauncher.evaluate( - async ({ relayPort, token }) => - await chrome.storage.local.set({ - relayUrl: `ws://127.0.0.1:${relayPort}/extension`, - token, - gatewayUrl: "", - groupColor: "orange", - }), - { relayPort: relay.port, token: PAGE_SHARE_RELAY_SECRET }, - ); - await initialContext.close(); - - const context = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await context.close()); - const extensionId = await waitForContextExtensionId(context, unpackedExtension); - const launcher = context.pages()[0] ?? (await context.newPage()); - await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); - await expect - .poll( - async () => - await launcher.evaluate( - async () => await chrome.storage.local.get(["authVersion", "accessMode"]), - ), - { timeout: 10_000 }, - ) - .toEqual({ authVersion: 2, accessMode: "selected" }); - - const ordinary = await context.newPage(); - await ordinary.goto("data:text/html,Selected migration fixture"); - const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); - const tabId = await worker.evaluate(async (expectedUrl) => { - const tab = (await chrome.tabs.query({})).find((candidate) => candidate.url === expectedUrl); - if (typeof tab?.id !== "number") { - throw new Error("Chromium did not expose the migration fixture tab"); - } - return tab.id; - }, ordinary.url()); - await expect(relay.command({ type: "attach", tabId })).rejects.toThrow( - `tab ${tabId} is not in the OpenClaw tab group`, - ); - }, 60_000); - - it("controls and pauses an ungrouped ordinary tab in new all-tabs mode", async () => { - const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET); - cleanups.push(relay.close); - const fixture = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end("Authorization fixture"); - }); - const fixturePort = await listen(fixture); - cleanups.push( - async () => - await new Promise((resolve, reject) => { - fixture.close((error) => (error ? reject(error) : resolve())); - }), - ); - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const context = await chromium.launchPersistentContext( - tempDirs.make("openclaw-extension-auth-profile-"), - { - channel: "chromium", - headless: true, - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }, - ); - cleanups.push(async () => await context.close()); - const extensionId = await waitForContextExtensionId(context, unpackedExtension); - const launcher = context.pages()[0] ?? (await context.newPage()); - await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); - - const invalidPairing = await launcher.evaluate( - async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }), - `ws://gateway.example.com/extension#${PAGE_SHARE_RELAY_SECRET}`, - ); - expect(invalidPairing).toEqual({ ok: false, error: "Invalid pairing string." }); - expect(relay.connectionCount).toBe(0); - - const validPairing = await launcher.evaluate( - async (pairingString) => - await chrome.runtime.sendMessage({ type: "pair", pairingString, accessMode: "all" }), - `ws://127.0.0.1:${relay.port}/extension#${PAGE_SHARE_RELAY_SECRET}`, - ); - expect(validPairing).toEqual({ ok: true }); - await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); - - const ordinary = await context.newPage(); - await ordinary.goto(`http://127.0.0.1:${fixturePort}/authorization`); - const tabId = await worker.evaluate(async (expectedUrl) => { - const tab = (await chrome.tabs.query({})).find((candidate) => candidate.url === expectedUrl); - if (typeof tab?.id !== "number") { - throw new Error("Chromium did not expose the all-tabs fixture tab"); - } - return tab.id; - }, ordinary.url()); - expect( - (await worker.evaluate(async (targetTabId) => await chrome.tabs.get(targetTabId), tabId)) - .groupId, - ).toBe(-1); - await expect - .poll( - () => - relay.tabRefreshes.some( - (refresh) => - Array.isArray(refresh.tabs) && - refresh.tabs.some( - (target) => - typeof target === "object" && - target !== null && - (target as { tabId?: unknown }).tabId === tabId, - ), - ), - { timeout: 10_000 }, - ) - .toBe(true); - await relay.command({ type: "attach", tabId }); - await expect( - relay.command({ - type: "cdp", - tabId, - method: "Runtime.evaluate", - params: { expression: "document.title", returnByValue: true }, - }), - ).resolves.toMatchObject({ result: { value: "Authorization fixture" } }); - - expect( - await launcher.evaluate( - async (targetTabId) => - await chrome.runtime.sendMessage({ - type: "toggleTabAccess", - tabId: targetTabId, - accessMode: "all", - grant: false, - }), - tabId, - ), - ).toMatchObject({ ok: true, accessible: false, denied: true }); - await expect - .poll( - () => { - const latest = relay.tabRefreshes.at(-1); - return ( - Array.isArray(latest?.tabs) && - latest.tabs.some( - (target) => - typeof target === "object" && - target !== null && - (target as { tabId?: unknown }).tabId === tabId, - ) - ); - }, - { timeout: 10_000 }, - ) - .toBe(false); - await expect( - relay.command({ type: "cdp", tabId, method: "Runtime.evaluate", params: {} }), - ).rejects.toThrow(`tab ${tabId} is paused for OpenClaw`); - await expect(relay.command({ type: "activateTab", tabId })).rejects.toThrow( - `tab ${tabId} is paused for OpenClaw`, - ); - await expect(relay.command({ type: "closeTab", tabId })).rejects.toThrow( - `tab ${tabId} is paused for OpenClaw`, - ); - expect( - await worker.evaluate(async (targetTabId) => await chrome.tabs.get(targetTabId), tabId), - ).toMatchObject({ id: tabId }); - - await expect(relay.command({ type: "detach", tabId })).resolves.toEqual({}); - await ordinary.close(); - }, 60_000); -}); - -describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay", () => { - it.each([ - { label: "relay disconnection", unpair: false }, - { label: "user unpair", unpair: true }, - ])("immediately reports $label instead of leaving the popup sending", async ({ unpair }) => { - const receivedShares: Array<{ url: string; content: string }> = []; - let releaseDelivery: () => void = () => {}; - const delivery = new Promise((resolve) => { - releaseDelivery = resolve; - }); - await configureRelayCredential(PAGE_SHARE_RELAY_SECRET); - const relay = await startExtensionRelayServer({ - port: 0, - token: PAGE_SHARE_RELAY_SECRET, - onPageShare: async (payload) => { - receivedShares.push({ url: payload.url, content: payload.content }); - await delivery; - }, - }); - let relayClosed = false; - const closeRelay = async (handle: ExtensionRelayHandle) => { - if (!relayClosed) { - relayClosed = true; - await handle.close(); - } - }; - cleanups.push(async () => { - releaseDelivery(); - await closeRelay(relay); - }); - - const fixture = createServer((_request, response) => { - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end( - "Page-share relay article
Page-share relay article body.
", - ); - }); - const fixturePort = await listen(fixture); - cleanups.push( - async () => - await new Promise((resolve, reject) => { - fixture.close((error) => (error ? reject(error) : resolve())); - }), - ); - - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const context = await chromium.launchPersistentContext( - tempDirs.make("openclaw-page-share-disconnect-profile-"), - { - channel: "chromium", - headless: true, - // Playwright disables extensions by default, which overrides the unpacked fixture below. - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }, - ); - cleanups.push(async () => await context.close()); - - const browser = context.browser(); - if (!browser) { - throw new Error("Chromium browser connection unavailable"); - } - const browserCdp = await browser.newBrowserCDPSession(); - const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension); - const pairingPage = context.pages()[0] ?? (await context.newPage()); - await pairingPage.goto(`chrome-extension://${extensionId}/popup.html`); - const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); - - const pairing = await pairingPage.evaluate( - async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }), - `ws://127.0.0.1:${relay.port}/extension#${relay.token}`, - ); - expect(pairing).toEqual({ ok: true }); - await expect.poll(() => relay.bridge.extensionConnected, { timeout: 10_000 }).toBe(true); - - const article = await context.newPage(); - await article.goto(`http://127.0.0.1:${fixturePort}/article`); - const articleTabId = await worker.evaluate(async (expectedUrl) => { - const tabs = await chrome.tabs.query({}); - const articleTab = tabs.find((tab) => tab.url === expectedUrl); - if (typeof articleTab?.id !== "number") { - throw new Error("Chrome did not expose the page-share article tab"); - } - return articleTab.id; - }, article.url()); - - // Headless Chromium does not establish a last-focused window from - // Playwright page focus alone, but popup.js intentionally queries one. - await worker.evaluate(async (tabId) => { - const tab = await chrome.tabs.get(tabId); - if (typeof tab.windowId !== "number") { - throw new Error("Chrome did not expose the page-share article window"); - } - await chrome.windows.update(tab.windowId, { focused: true }); - await chrome.tabs.update(tabId, { active: true }); - }, articleTabId); - await article.bringToFront(); - await expect - .poll( - async () => - await worker.evaluate(async (expectedTabId) => { - const [activeTab] = await chrome.tabs.query({ - active: true, - lastFocusedWindow: true, - }); - return activeTab?.id === expectedTabId; - }, articleTabId), - { timeout: 10_000 }, - ) - .toBe(true); - const prior = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - const articleTarget = prior.targetInfos.find( - (target) => target.type === "tab" && target.url === article.url(), - ); - if (!articleTarget) { - throw new Error("Chromium did not expose the actual page-share article tab target"); - } - const priorTargetIds = new Set(prior.targetInfos.map((target) => target.targetId)); - - // CDP invokes the actual toolbar action, including Chrome's activeTab - // consent grant; navigating popup.html directly cannot grant page access. - await browserCdp.send("Extensions.triggerAction", { - id: extensionId, - targetId: articleTarget.targetId, - }); - - await expect - .poll( - async () => { - const targets = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - return targets.targetInfos.find( - (target) => - !priorTargetIds.has(target.targetId) && - target.url === `chrome-extension://${extensionId}/popup.html`, - ); - }, - { timeout: 10_000 }, - ) - .toBeTruthy(); - - const targets = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - const popupTarget = targets.targetInfos.find( - (target) => - !priorTargetIds.has(target.targetId) && - target.url === `chrome-extension://${extensionId}/popup.html`, - ); - if (!popupTarget) { - throw new Error("Chromium did not open the actual OpenClaw toolbar popup"); - } - const attached = (await browserCdp.send("Target.attachToTarget", { - targetId: popupTarget.targetId, - flatten: false, - })) as { sessionId: string }; - await expect - .poll( - async () => - await evaluateToolbarPopup(browserCdp, attached.sessionId, "document.readyState"), - { timeout: 10_000 }, - ) - .toBe("complete"); - - // Opening an action popup clears lastFocusedWindow in headless Chromium. - // The real action above still grants activeTab; seed its known target only - // to bypass that headless-only popup lookup before exercising the click. - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - `(() => { - const button = document.querySelector("#sendPageButton"); - button.dataset.tabId = ${JSON.stringify(String(articleTabId))}; - button.disabled = false; - button.click(); - })()`, - ); - - await expect - .poll( - async () => ({ - receivedShares: receivedShares.length, - popupStatus: await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#pageShareStatus")?.textContent', - ), - }), - { timeout: 10_000 }, - ) - .toEqual({ receivedShares: 1, popupStatus: "Sending…" }); - expect(receivedShares[0]).toEqual({ - url: article.url(), - content: "Page-share relay article body.", - }); - - if (unpair) { - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#unpairButton").click()', - ); - } else { - await closeRelay(relay); - } - - await expect - .poll( - async () => - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#pageShareStatus")?.textContent', - ), - { timeout: 1_500, interval: 25 }, - ) - .toBe("Browser relay disconnected before OpenClaw acknowledged the page share."); - expect( - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#pageShareStatus")?.classList.contains("error")', - ), - ).toBe(true); - releaseDelivery(); - }); - - it("keeps a real stale-tab sharing error visible across the popup status poll", async () => { - await configureRelayCredential(PAGE_SHARE_RELAY_SECRET); - const relay = await startExtensionRelayServer({ - port: 0, - token: PAGE_SHARE_RELAY_SECRET, - }); - cleanups.push(async () => await relay.close()); - - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const context = await chromium.launchPersistentContext( - tempDirs.make("openclaw-popup-consent-profile-"), - { - channel: "chromium", - headless: true, - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }, - ); - cleanups.push(async () => await context.close()); - - const browser = context.browser(); - if (!browser) { - throw new Error("Chromium browser connection unavailable"); - } - const browserCdp = await browser.newBrowserCDPSession(); - const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension); - const pairingPage = context.pages()[0] ?? (await context.newPage()); - await pairingPage.goto(`chrome-extension://${extensionId}/popup.html`); - const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); - - const pairing = await pairingPage.evaluate( - async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }), - `ws://127.0.0.1:${relay.port}/extension#${relay.token}`, - ); - expect(pairing).toEqual({ ok: true }); - await expect.poll(() => relay.bridge.extensionConnected, { timeout: 10_000 }).toBe(true); - - const missingTabId = 999_999_999; - const expectedError = await worker.evaluate(async (tabId) => { - try { - await chrome.tabs.get(tabId); - return null; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - }, missingTabId); - expect(expectedError).toContain(String(missingTabId)); - - const activePage = await context.newPage(); - await activePage.goto("data:text/html,OpenClaw popup consent fixture"); - await activePage.bringToFront(); - const prior = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - const activeTarget = prior.targetInfos.find( - (target) => target.type === "tab" && target.url === activePage.url(), - ); - if (!activeTarget) { - throw new Error("Chromium did not expose the actual popup consent tab target"); - } - const priorTargetIds = new Set(prior.targetInfos.map((target) => target.targetId)); - - await browserCdp.send("Extensions.triggerAction", { - id: extensionId, - targetId: activeTarget.targetId, - }); - await expect - .poll( - async () => { - const targets = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - return targets.targetInfos.find( - (target) => - !priorTargetIds.has(target.targetId) && - target.url === `chrome-extension://${extensionId}/popup.html`, - ); - }, - { timeout: 10_000 }, - ) - .toBeTruthy(); - - const targets = (await browserCdp.send("Target.getTargets", { - filter: [{}], - })) as { targetInfos: ChromeTarget[] }; - const target = targets.targetInfos.find( - (candidate) => - !priorTargetIds.has(candidate.targetId) && - candidate.url === `chrome-extension://${extensionId}/popup.html`, - ); - if (!target) { - throw new Error("Chromium did not open the actual OpenClaw toolbar popup"); - } - const attached = (await browserCdp.send("Target.attachToTarget", { - targetId: target.targetId, - flatten: false, - })) as { sessionId: string }; - await expect - .poll( - async () => - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#statusLine")?.textContent', - ), - { timeout: 10_000 }, - ) - .toContain("Connected"); - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - `(() => { - const relayValue = document.querySelector("#relayValue"); - const button = document.querySelector("#shareButton"); - if (!relayValue || !button) throw new Error("Chrome popup action controls are missing"); - window.__openclawPopupRefreshes = 0; - new MutationObserver(() => { window.__openclawPopupRefreshes += 1; }) - .observe(relayValue, { childList: true }); - button.dataset.tabId = ${JSON.stringify(String(missingTabId))}; - button.dataset.accessMode = "all"; - button.dataset.grant = "false"; - button.classList.remove("hidden"); - button.disabled = false; - button.click(); - })()`, - ); - - await expect - .poll( - async () => - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - 'document.querySelector("#statusLine")?.textContent', - ), - { timeout: 1_500, interval: 25 }, - ) - .toBe(expectedError); - - await expect - .poll( - async () => - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - "window.__openclawPopupRefreshes", - ), - { timeout: 5_000, interval: 50 }, - ) - .toBeGreaterThan(0); - const actionRefreshes = await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - "window.__openclawPopupRefreshes", - ); - await expect - .poll( - async () => - await evaluateToolbarPopup( - browserCdp, - attached.sessionId, - "window.__openclawPopupRefreshes", - ), - { timeout: 5_000, interval: 50 }, - ) - .toBeGreaterThan(actionRefreshes); - const observed = await evaluateToolbarPopup<{ - refreshes: number; - status: string; - visible: boolean; - }>( - browserCdp, - attached.sessionId, - `({ - refreshes: window.__openclawPopupRefreshes, - status: document.querySelector("#statusLine")?.textContent, - visible: document.querySelector("#statusLine")?.closest(".hidden") === null, - })`, - ); - - expect(observed.refreshes).toBeGreaterThan(actionRefreshes); - expect(observed.status).toBe(expectedError); - expect(observed.visible).toBe(true); - }); -}); diff --git a/extensions/browser/chrome-extension/popup-errors.test.ts b/extensions/browser/chrome-extension/popup-errors.test.ts deleted file mode 100644 index f7243821a8e2..000000000000 --- a/extensions/browser/chrome-extension/popup-errors.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -/* @vitest-environment jsdom */ - -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -type PopupMessage = { - type: string; - tabId?: number; - pairingString?: string; - accessMode?: string; - grant?: boolean; -}; - -type PopupState = { - paired?: boolean; - shared?: boolean; - denied?: boolean; - eligible?: boolean; - accessMode?: "all" | "selected"; - statusHint?: string; - failures: Partial< - Record<"getStatus" | "pair" | "unpair" | "toggleTabAccess" | "setAccessMode", string> - >; - onFailure?: (message: PopupMessage) => void; -}; - -async function loadPopup(params: PopupState) { - const markup = await fs.readFile( - path.join(process.cwd(), "extensions/browser/chrome-extension/popup.html"), - "utf8", - ); - const parsed = new DOMParser().parseFromString(markup, "text/html"); - document.head.innerHTML = parsed.head.innerHTML; - document.body.innerHTML = parsed.body.innerHTML; - - const sendMessage = vi.fn(async (message: PopupMessage) => { - const failure = params.failures[message.type as keyof typeof params.failures]; - if (failure) { - params.onFailure?.(message); - return { ok: false, error: failure }; - } - switch (message.type) { - case "getStatus": - return { - paired: params.paired !== false, - state: "on", - accessMode: params.accessMode ?? "selected", - accessibleTabCount: params.shared ? 1 : 0, - relayUrl: "ws://127.0.0.1:18797/extension", - ...(params.statusHint ? { hint: params.statusHint } : {}), - }; - case "prepareCopilotPanel": - return { ok: true, path: "sidepanel.html?binding=fixture" }; - case "getTabAccess": - return { - accessMode: params.accessMode ?? "selected", - accessible: params.shared === true, - denied: params.denied === true, - eligible: params.eligible !== false, - }; - case "setAccessMode": - params.accessMode = message.accessMode === "selected" ? "selected" : "all"; - return { ok: true, accessMode: params.accessMode }; - case "toggleTabAccess": - if ((params.accessMode ?? "selected") === "all") { - params.denied = message.grant !== true; - params.shared = message.grant === true; - } else { - params.shared = message.grant === true; - } - return { ok: true }; - default: - return { ok: true }; - } - }); - - vi.stubGlobal("chrome", { - runtime: { - getManifest: vi.fn(() => ({ version: "2.1.0" })), - sendMessage, - }, - tabs: { query: vi.fn(async () => [{ id: 44 }]) }, - sidePanel: { - setOptions: vi.fn(async () => undefined), - open: vi.fn(async () => undefined), - }, - }); - - const popupModulePath = "./popup.js"; - await import(popupModulePath); - await vi.waitFor(() => { - if (params.paired === false) { - expect(sendMessage).toHaveBeenCalledWith({ type: "getStatus" }); - return; - } - expect(sendMessage).toHaveBeenCalledWith({ type: "getTabAccess", tabId: 44 }); - }); - - return { sendMessage }; -} - -function popupElement(id: string): HTMLElement { - const element = document.getElementById(id); - if (!element) { - throw new Error(`Popup element ${id} is missing`); - } - return element; -} - -async function expectVisibleErrorAfterStatusRefresh( - error: string, - sendMessage: Awaited>["sendMessage"], -) { - const initialPollCount = sendMessage.mock.calls.filter( - ([message]) => message.type === "getStatus", - ).length; - - await vi.advanceTimersByTimeAsync(2_000); - - const refreshedPollCount = sendMessage.mock.calls.filter( - ([message]) => message.type === "getStatus", - ).length; - expect(refreshedPollCount).toBeGreaterThan(initialPollCount); - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("statusLine").closest(".hidden")).toBeNull(); -} - -describe("Chrome extension popup action errors", () => { - beforeEach(() => { - vi.resetModules(); - vi.useFakeTimers(); - }); - - it("shows persisted re-pair guidance after an unsupported pairing is cleared", async () => { - const hint = - "Stored proxy-prefixed browser relay pairing is no longer supported. Re-run openclaw browser extension pair with a Gateway URL that has no path prefix."; - await loadPopup({ paired: false, statusHint: hint, failures: {} }); - - expect(popupElement("statusLine").textContent).toBe(hint); - expect(popupElement("pairSection").classList.contains("hidden")).toBe(false); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - document.head.innerHTML = ""; - document.body.innerHTML = ""; - }); - - it("keeps a rejected unpair visible while preserving the open settings panel", async () => { - const error = "Could not remove browser pairing."; - const popup = { - paired: true, - failures: { unpair: error } as Partial>, - }; - const { sendMessage } = await loadPopup(popup); - - popupElement("settingsButton").click(); - await vi.waitFor(() => { - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - }); - popupElement("unpairButton").click(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ type: "unpair" }); - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("statusLine").closest(".hidden")).toBeNull(); - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - }); - - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - - delete popup.failures.unpair; - popup.paired = false; - popupElement("unpairButton").click(); - - await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe("Not paired with a gateway"); - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(true); - }); - }); - - it("shows a rejected share-toggle error in the visible connected popup", async () => { - const error = "No tab with id: 44."; - const failures: Partial> = { - toggleTabAccess: error, - }; - const { sendMessage } = await loadPopup({ failures }); - - popupElement("shareButton").click(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ - type: "toggleTabAccess", - tabId: 44, - accessMode: "selected", - grant: true, - }); - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("statusLine").closest(".hidden")).toBeNull(); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - }); - - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - - failures.getStatus = "Could not refresh browser status."; - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - - delete failures.getStatus; - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - - delete failures.toggleTabAccess; - popupElement("shareButton").click(); - - await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab shared"); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - }); - }); - - it("shows all-mode availability and toggles the tab between Pause and Allow", async () => { - const popup: PopupState = { accessMode: "all", shared: true, failures: {} }; - const { sendMessage } = await loadPopup(popup); - expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab available"); - expect(popupElement("shareButton").textContent).toBe("Pause OpenClaw on this tab"); - - popupElement("shareButton").click(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ - type: "toggleTabAccess", - tabId: 44, - accessMode: "all", - grant: false, - }); - expect(popupElement("shareButton").textContent).toBe("Allow OpenClaw on this tab"); - }); - }); - - it.each([ - { accessMode: "all" as const, denied: true, label: "paused all-mode" }, - { accessMode: "selected" as const, denied: false, label: "unselected selected-mode" }, - ])("disables Copilot for an inaccessible $label tab", async ({ accessMode, denied }) => { - await loadPopup({ accessMode, denied, shared: false, failures: {} }); - - await vi.waitFor(() => { - expect(popupElement("shareButton").dataset.tabId).toBe("44"); - }); - expect((popupElement("copilotButton") as HTMLButtonElement).disabled).toBe(true); - }); - - it("changes Access immediately in settings and shows the all-tabs warning", async () => { - const popup: PopupState = { accessMode: "selected", failures: {} }; - const { sendMessage } = await loadPopup(popup); - popupElement("settingsButton").click(); - await vi.waitFor(() => { - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - }); - const select = popupElement("accessModeSelect") as HTMLSelectElement; - select.value = "all"; - select.dispatchEvent(new Event("change")); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ type: "setAccessMode", accessMode: "all" }); - expect(popupElement("accessWarning").classList.contains("hidden")).toBe(false); - }); - }); - - it.each([ - { action: "share", initiallyShared: false, nextLabel: "Stop sharing this tab" }, - { action: "unshare", initiallyShared: true, nextLabel: "Share this tab with OpenClaw" }, - ])( - "refreshes the actual tab controls immediately after a partially failed $action", - async ({ initiallyShared, nextLabel }) => { - const error = "Could not reconcile browser tab consent."; - const popup: PopupState = { - shared: initiallyShared, - failures: { toggleTabAccess: error }, - }; - popup.onFailure = () => { - popup.shared = !popup.shared; - }; - const { sendMessage } = await loadPopup(popup); - const previousPollCount = sendMessage.mock.calls.filter( - ([message]) => message.type === "getStatus", - ).length; - - popupElement("shareButton").click(); - - await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("shareButton").textContent).toBe(nextLabel); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - expect( - sendMessage.mock.calls.filter(([message]) => message.type === "getStatus").length, - ).toBeGreaterThan(previousPollCount); - }); - }, - ); - - it("clears a partial-unpair error after successfully pairing again", async () => { - const error = "Could not reconcile browser pairing."; - const popup: PopupState = { - paired: true, - failures: { unpair: error }, - }; - popup.onFailure = () => { - popup.paired = false; - }; - const { sendMessage } = await loadPopup(popup); - - popupElement("settingsButton").click(); - await vi.waitFor(() => { - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - }); - popupElement("unpairButton").click(); - await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - expect(popupElement("unpairButton").classList.contains("hidden")).toBe(true); - }); - - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - popupElement("settingsButton").click(); - await vi.waitFor(() => { - expect(popupElement("pairSection").classList.contains("hidden")).toBe(false); - expect(popupElement("statusLine").textContent).toBe(error); - }); - - popup.paired = true; - popupElement("pairButton").click(); - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ - type: "pair", - pairingString: "", - accessMode: "all", - }); - expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared"); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - }); - }); - - it("keeps a partially persisted pairing failure visible after entering the connected view", async () => { - const error = "Could not finish browser pairing."; - const popup: PopupState = { - paired: false, - failures: { pair: error }, - }; - popup.onFailure = () => { - popup.paired = true; - }; - const { sendMessage } = await loadPopup(popup); - const pairingInput = popupElement("pairingString") as HTMLTextAreaElement; - pairingInput.value = "ws://127.0.0.1:18797/extension#fixture-token"; - - popupElement("pairButton").click(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ - type: "pair", - pairingString: pairingInput.value, - accessMode: "all", - }); - expect(popupElement("error").textContent).toBe(error); - expect(popupElement("pairSection").classList.contains("hidden")).toBe(true); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - expect(popupElement("statusLine").textContent).toBe(error); - expect(popupElement("statusLine").closest(".hidden")).toBeNull(); - }); - - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - popupElement("shareButton").click(); - - await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab shared"); - expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); - }); - }); - - it.each([ - { view: "connected", section: "connectedSection", settings: false }, - { view: "settings", section: "settingsSection", settings: true }, - ])( - "keeps the existing $view view when a status poll fails and recovers", - async ({ section, settings }) => { - const error = "Could not read browser pairing."; - const failures: Partial> = {}; - const { sendMessage } = await loadPopup({ failures }); - if (settings) { - popupElement("settingsButton").click(); - await vi.waitFor(() => { - expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); - }); - } - const previousStatusClass = popupElement("statusDot").className; - - failures.getStatus = error; - await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - expect(popupElement(section).classList.contains("hidden")).toBe(false); - expect(popupElement("pairSection").classList.contains("hidden")).toBe(true); - expect(popupElement("statusDot").className).toBe(previousStatusClass); - - delete failures.getStatus; - await vi.advanceTimersByTimeAsync(2_000); - - expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared"); - expect(popupElement(section).classList.contains("hidden")).toBe(false); - }, - ); - - it("preserves the existing visible pairing failure", async () => { - const error = "Could not save browser pairing."; - const { sendMessage } = await loadPopup({ paired: false, failures: { pair: error } }); - const pairingInput = popupElement("pairingString") as HTMLTextAreaElement; - pairingInput.value = "ws://127.0.0.1:18797/extension#fixture-token"; - - popupElement("pairButton").click(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ - type: "pair", - pairingString: pairingInput.value, - accessMode: "all", - }); - expect(popupElement("error").textContent).toBe(error); - expect(popupElement("error").closest(".hidden")).toBeNull(); - }); - }); -}); diff --git a/extensions/browser/chrome-extension/popup.html b/extensions/browser/chrome-extension/popup.html index 8acaef392944..6b8cb274dd64 100644 --- a/extensions/browser/chrome-extension/popup.html +++ b/extensions/browser/chrome-extension/popup.html @@ -2,461 +2,97 @@ + OpenClaw -
- -
-
OPENCLAW
-
Checking status…
-
- - +
+ +

OpenClaw Browser

-
- - - +
+

Checking connection…

+ + +
- + diff --git a/extensions/browser/chrome-extension/popup.js b/extensions/browser/chrome-extension/popup.js index 867deeb54bb3..5519dd5c7467 100644 --- a/extensions/browser/chrome-extension/popup.js +++ b/extensions/browser/chrome-extension/popup.js @@ -1,234 +1,80 @@ -// Popup: pairing, connection status, access mode, per-tab control, and settings. - -const statusDot = document.getElementById("statusDot"); -const pairSection = document.getElementById("pairSection"); -const connectedSection = document.getElementById("connectedSection"); -const settingsSection = document.getElementById("settingsSection"); -const settingsButton = document.getElementById("settingsButton"); -const pairingInput = document.getElementById("pairingString"); -const pairButton = document.getElementById("pairButton"); -const unpairButton = document.getElementById("unpairButton"); -const shareButton = document.getElementById("shareButton"); -const copilotButton = document.getElementById("copilotButton"); -const statusLine = document.getElementById("statusLine"); +const statusLine = document.getElementById("status"); +const pairedDetails = document.getElementById("pairedDetails"); +const accessMode = document.getElementById("accessMode"); +const tabAction = document.getElementById("tabAction"); +const settings = document.getElementById("settings"); const errorLine = document.getElementById("error"); -const pageNote = document.getElementById("pageNote"); -const sendPageButton = document.getElementById("sendPageButton"); -const pageShareStatus = document.getElementById("pageShareStatus"); -const versionValue = document.getElementById("versionValue"); -const statusHint = document.getElementById("statusHint"); -const unpairNote = document.getElementById("unpairNote"); -const relayValue = document.getElementById("relayValue"); -const accessModeSelect = document.getElementById("accessModeSelect"); -const accessWarning = document.getElementById("accessWarning"); -let sendingPage = false; -let settingsOpen = false; -// Preserve rejected actions across status polls until a successful retry. -let actionError = null; - -const STATE_LABEL = { - on: "Connected", - connecting: "Connecting…", - error: "Relay unreachable", - off: "Not connected", -}; - -versionValue.textContent = `v${chrome.runtime.getManifest().version}`; async function activeTab() { const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); return tab ?? null; } -function relayHost(relayUrl) { - try { - return new URL(relayUrl).host; - } catch { - return "—"; +function unpairedLabel(nativeBootstrap) { + if (nativeBootstrap?.disabled) { + return "Automatic setup disabled"; } + if (nativeBootstrap?.state === "manual_required") { + return "Manual setup required"; + } + return "Waiting for local OpenClaw"; } async function refresh() { const status = await chrome.runtime.sendMessage({ type: "getStatus" }); if (status?.ok === false) { - statusLine.textContent = actionError ?? status.error ?? "Could not refresh browser status."; + statusLine.textContent = status.error ?? "Could not read browser status."; + return; + } + pairedDetails.classList.toggle("hidden", !status.paired); + if (status.retiredCopilotCustodyBlocked === true) { + statusLine.textContent = "Automation paused; open Settings"; + tabAction.classList.add("hidden"); return; } - statusDot.className = `status-dot ${status.state}`; - pairSection.classList.toggle("hidden", status.paired || settingsOpen); - connectedSection.classList.toggle("hidden", !status.paired || settingsOpen); - settingsSection.classList.toggle("hidden", !settingsOpen); - settingsButton.classList.toggle("active", settingsOpen); - relayValue.textContent = status.paired ? relayHost(status.relayUrl) : "—"; - accessModeSelect.value = status.accessMode === "selected" ? "selected" : "all"; - accessModeSelect.disabled = !status.paired; - accessWarning.classList.toggle("hidden", status.accessMode !== "all" || !status.paired); - unpairButton.classList.toggle("hidden", !status.paired); - unpairNote.classList.toggle("hidden", !status.paired); if (!status.paired) { - statusLine.textContent = actionError ?? status.hint ?? "Not paired with a gateway"; + statusLine.textContent = unpairedLabel(status.nativeBootstrap); + tabAction.classList.add("hidden"); return; } - const label = STATE_LABEL[status.state] ?? STATE_LABEL.off; statusLine.textContent = - actionError ?? - `${label} · ${status.accessibleTabCount} tab${status.accessibleTabCount === 1 ? "" : "s"} ${status.accessMode === "all" ? "available" : "shared"}`; - statusHint.textContent = - status.hint || "Relay unreachable — is the OpenClaw gateway running and up to date?"; - statusHint.classList.toggle("hidden", status.state !== "error"); + status.state === "on" + ? "Connected" + : status.state === "connecting" + ? "Connecting…" + : "OpenClaw relay unavailable"; + accessMode.textContent = status.accessMode === "selected" ? "Selected tabs" : "All tabs"; const tab = await activeTab(); if (tab?.id === undefined) { - shareButton.classList.add("hidden"); - copilotButton.disabled = true; - sendPageButton.disabled = true; - delete sendPageButton.dataset.tabId; - delete shareButton.dataset.accessMode; - delete shareButton.dataset.grant; + tabAction.classList.add("hidden"); return; } - sendPageButton.dataset.tabId = String(tab.id); - sendPageButton.disabled = sendingPage || status.state !== "on"; - const panel = await chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: tab.id }); - copilotButton.disabled = !panel?.ok; - copilotButton.dataset.tabId = String(tab.id); - copilotButton.dataset.path = panel?.path ?? ""; - const tabAccess = await chrome.runtime.sendMessage({ type: "getTabAccess", tabId: tab.id }); - shareButton.classList.toggle("hidden", !tabAccess.eligible); - copilotButton.disabled = !panel?.ok || !tabAccess.accessible; - shareButton.textContent = - status.accessMode === "all" - ? tabAccess.denied - ? "Allow OpenClaw on this tab" - : "Pause OpenClaw on this tab" - : tabAccess.accessible - ? "Stop sharing this tab" - : "Share this tab with OpenClaw"; - shareButton.dataset.tabId = String(tab.id); - shareButton.dataset.accessMode = status.accessMode === "selected" ? "selected" : "all"; - shareButton.dataset.grant = String(!tabAccess.accessible); + const access = await chrome.runtime.sendMessage({ type: "getTabAccess", tabId: tab.id }); + tabAction.classList.toggle("hidden", !access.eligible); + tabAction.textContent = access.accessible ? "Pause on this tab" : "Allow on this tab"; + tabAction.dataset.tabId = String(tab.id); + tabAction.dataset.mode = status.accessMode; + tabAction.dataset.grant = String(!access.accessible); } -async function onSendPage() { - const tabId = Number.parseInt(sendPageButton.dataset.tabId ?? "", 10); - if (!Number.isInteger(tabId) || sendingPage) { - return; - } - sendingPage = true; - sendPageButton.disabled = true; - pageShareStatus.textContent = "Sending…"; - pageShareStatus.classList.remove("hidden", "error"); - try { - const result = await chrome.runtime.sendMessage({ - type: "sendPageToOpenClaw", - tabId, - note: pageNote.value, - }); - if (!result?.ok) { - throw new Error(result?.error ?? "Could not send this page."); - } - pageNote.value = ""; - pageShareStatus.textContent = "Sent ✓"; - } catch (error) { - pageShareStatus.textContent = error instanceof Error ? error.message : String(error); - pageShareStatus.classList.add("error"); - } finally { - sendingPage = false; - await refresh(); - } -} - -async function onPair() { +async function toggleActiveTabAccess() { errorLine.classList.add("hidden"); const result = await chrome.runtime.sendMessage({ - type: "pair", - pairingString: pairingInput.value, - accessMode: - document.querySelector('input[name="pairAccessMode"]:checked')?.value === "selected" - ? "selected" - : "all", - }); - if (!result.ok) { - actionError = result.error ?? "Pairing failed."; - errorLine.textContent = actionError; - errorLine.classList.remove("hidden"); - statusLine.textContent = actionError; - await refresh(); - return; - } - actionError = null; - await refresh(); -} - -async function onUnpair() { - const result = await chrome.runtime.sendMessage({ type: "unpair" }); - if (result?.ok === false) { - actionError = result.error ?? "Could not unpair this browser."; - statusLine.textContent = actionError; - await refresh(); - return; - } - actionError = null; - settingsOpen = false; - await refresh(); -} - -async function onToggleTabAccess() { - const tabId = Number.parseInt(shareButton.dataset.tabId ?? "", 10); - const accessMode = shareButton.dataset.accessMode; - const grant = shareButton.dataset.grant === "true"; - if (Number.isFinite(tabId) && (accessMode === "all" || accessMode === "selected")) { - const result = await chrome.runtime.sendMessage({ - type: "toggleTabAccess", - tabId, - accessMode, - grant, - }); - if (result?.ok === false) { - actionError = result.error ?? "Could not update browser tab sharing."; - statusLine.textContent = actionError; - await refresh(); - return; - } - actionError = null; - } - await refresh(); -} - -async function onAccessModeChange() { - const result = await chrome.runtime.sendMessage({ - type: "setAccessMode", - accessMode: accessModeSelect.value, + type: "toggleTabAccess", + tabId: Number(tabAction.dataset.tabId), + accessMode: tabAction.dataset.mode, + grant: tabAction.dataset.grant === "true", }); if (!result?.ok) { - actionError = result?.error ?? "Could not update browser access."; - statusLine.textContent = actionError; - } else { - actionError = null; + errorLine.textContent = result?.error ?? "Could not update tab access."; + errorLine.classList.remove("hidden"); } await refresh(); } -async function onOpenCopilot() { - const tabId = Number.parseInt(copilotButton.dataset.tabId ?? "", 10); - const path = copilotButton.dataset.path; - if (!Number.isInteger(tabId) || !path) { - return; - } - await chrome.sidePanel.setOptions({ tabId, path, enabled: true }); - await chrome.sidePanel.open({ tabId }); - window.close(); -} - -settingsButton.addEventListener("click", () => { - settingsOpen = !settingsOpen; - void refresh(); +tabAction.addEventListener("click", () => { + void toggleActiveTabAccess(); }); -pairButton.addEventListener("click", () => void onPair()); -unpairButton.addEventListener("click", () => void onUnpair()); -shareButton.addEventListener("click", () => void onToggleTabAccess()); -copilotButton.addEventListener("click", () => void onOpenCopilot()); -sendPageButton.addEventListener("click", () => void onSendPage()); -accessModeSelect.addEventListener("change", () => void onAccessModeChange()); +settings.addEventListener("click", () => chrome.runtime.openOptionsPage()); void refresh(); -setInterval(() => void refresh(), 2000); diff --git a/extensions/browser/chrome-extension/relay-key.test-support.ts b/extensions/browser/chrome-extension/relay-key.test-support.ts new file mode 100644 index 000000000000..0db18df856b0 --- /dev/null +++ b/extensions/browser/chrome-extension/relay-key.test-support.ts @@ -0,0 +1,7 @@ +export function relayTestKey(seed: number): string { + let key = ""; + for (let byteIndex = 0; byteIndex < 32; byteIndex += 1) { + key += ((seed + byteIndex * 17) & 0xff).toString(16).padStart(2, "0"); + } + return key; +} diff --git a/extensions/browser/chrome-extension/sidepanel.css b/extensions/browser/chrome-extension/sidepanel.css deleted file mode 100644 index 4557ff5cd9a1..000000000000 --- a/extensions/browser/chrome-extension/sidepanel.css +++ /dev/null @@ -1,356 +0,0 @@ -:root { - color-scheme: dark; - font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; - background: #141312; - color: #f4efe8; - --ink: #f4efe8; - --muted: #9f978d; - --line: #34302c; - --panel: #1d1b19; - --panel-raised: #25221f; - --orange: #ff6437; - --orange-soft: #3a2119; - --green: #54d18b; - --red: #ff6b64; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 280px; - height: 100vh; - display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; - overflow: hidden; - background: #141312; -} - -button, -textarea { - font: inherit; -} - -.topbar { - height: 62px; - display: flex; - align-items: center; - gap: 11px; - padding: 10px 14px; - border-bottom: 1px solid var(--line); - background: #181614; -} - -.mark { - width: 35px; - height: 35px; - display: grid; - place-items: center; - flex: 0 0 auto; - border: 1px solid #75402f; - border-radius: 10px 4px 10px 4px; - background: var(--orange-soft); - color: #ffad8f; - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 11px; - font-weight: 800; - letter-spacing: -0.05em; -} - -.identity { - min-width: 0; - flex: 1; -} - -.eyebrow, -.gate-kicker { - color: var(--orange); - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 9px; - font-weight: 700; - letter-spacing: 0.15em; -} - -.tab-title { - margin-top: 3px; - overflow: hidden; - color: var(--ink); - font-size: 13px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status-dot { - width: 8px; - height: 8px; - flex: 0 0 auto; - border-radius: 50%; - background: #706960; - box-shadow: 0 0 0 3px #27231f; -} - -.status-dot.ready { - background: var(--green); - box-shadow: 0 0 0 3px #183226; -} - -.status-dot.error, -.status-dot.denied { - background: var(--red); - box-shadow: 0 0 0 3px #381d1b; -} - -.scope-strip { - min-height: 34px; - display: flex; - align-items: center; - gap: 8px; - padding: 7px 14px; - overflow: hidden; - border-bottom: 1px solid #292622; - background: #191715; - color: var(--muted); - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 10px; - white-space: nowrap; -} - -.scope-strip span:last-child { - overflow: hidden; - text-overflow: ellipsis; -} - -.scope-icon { - color: var(--orange); -} - -#conversation { - min-height: 0; - overflow-y: auto; - scrollbar-color: #4b4540 transparent; -} - -.gate { - min-height: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - padding: 34px 24px 46px; -} - -.gate::before { - content: ""; - width: 44px; - height: 3px; - margin-bottom: 18px; - background: var(--orange); -} - -.gate h1 { - max-width: 310px; - margin: 8px 0 10px; - font-size: clamp(21px, 7vw, 29px); - font-weight: 650; - letter-spacing: -0.035em; - line-height: 1.08; -} - -.gate p { - max-width: 330px; - margin: 0 0 18px; - color: var(--muted); - font-size: 13px; - line-height: 1.55; -} - -.request-id { - display: block; - max-width: 100%; - margin: 0 0 16px; - padding: 7px 9px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 6px; - color: #c8c0b7; - font-size: 10px; - text-overflow: ellipsis; -} - -.primary { - border: 1px solid #ff835f; - border-radius: 7px; - padding: 9px 13px; - background: var(--orange); - color: #1a0d09; - font-size: 12px; - font-weight: 750; - cursor: pointer; -} - -.messages { - min-height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - gap: 11px; - padding: 18px 14px 22px; -} - -.message { - max-width: 88%; - padding: 10px 12px; - border: 1px solid #322e2a; - border-radius: 5px 13px 13px 13px; - background: var(--panel); - color: #e9e3dc; - font-size: 13px; - line-height: 1.5; - overflow-wrap: anywhere; -} - -.message.user { - align-self: flex-end; - border-color: #69402f; - border-radius: 13px 5px 13px 13px; - background: var(--orange-soft); - color: #ffe7de; -} - -.message.system { - align-self: center; - border: 0; - background: transparent; - color: var(--muted); - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 10px; - text-align: center; -} - -.message.streaming::after { - content: ""; - display: inline-block; - width: 6px; - height: 13px; - margin-left: 3px; - vertical-align: -2px; - background: var(--orange); - animation: blink 900ms step-end infinite; -} - -.message pre { - margin: 8px 0 2px; - padding: 9px; - overflow-x: auto; - border: 1px solid #393531; - border-radius: 6px; - background: #11100f; -} - -.message code { - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 0.9em; -} - -.message :not(pre) > code { - padding: 1px 4px; - border-radius: 4px; - background: #0f0e0d; -} - -.composer-shell { - padding: 10px 12px 12px; - border-top: 1px solid var(--line); - background: #181614; -} - -.session-note, -.binding-note { - overflow: hidden; - color: var(--muted); - font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-note { - margin: 0 2px 7px; -} - -.binding-note { - margin: 7px 2px 0; - color: #766f68; -} - -.composer { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 7px 7px 7px 10px; - border: 1px solid #3b3631; - border-radius: 11px; - background: var(--panel-raised); -} - -.composer:focus-within { - border-color: #86503b; - box-shadow: 0 0 0 2px #3a2119; -} - -textarea { - min-height: 24px; - max-height: 130px; - flex: 1; - resize: none; - border: 0; - outline: 0; - background: transparent; - color: var(--ink); - font-size: 13px; - line-height: 1.45; -} - -textarea::placeholder { - color: #797168; -} - -.send { - width: 31px; - height: 31px; - display: grid; - place-items: center; - flex: 0 0 auto; - border: 0; - border-radius: 8px; - background: var(--orange); - color: #1b0c08; - font-size: 18px; - font-weight: 900; - cursor: pointer; -} - -button:disabled, -textarea:disabled { - cursor: default; - opacity: 0.4; -} - -.hidden { - display: none !important; -} - -@keyframes blink { - 50% { - opacity: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .message.streaming::after { - animation: none; - } -} diff --git a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts b/extensions/browser/chrome-extension/sidepanel.e2e-support.ts deleted file mode 100644 index 99c3f49650c8..000000000000 --- a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; -import { createServer } from "node:http"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { BrowserContext, CDPSession, Page } from "playwright-core"; -import type { expect as VitestExpect } from "vitest"; -import { WebSocketServer, type RawData } from "ws"; -import { - computeRelayAuthProof, - deriveRelayAuthKeyId, - type RelayAuthProofFields, -} from "./modules/relay-auth-v2-crypto.js"; - -type CopilotTurnIsolationGateway = { - chatSends: Array>; - requests: Array<{ method: string }>; - emitEvent: (event: string, payload: Record) => void; -}; - -type CopilotTurnIsolationPanel = { - allText: (selector: string) => Promise; - click: (selector: string) => Promise; - disabled: (selector: string) => Promise; - fill: (selector: string, value: string) => Promise; -}; - -type TargetInfo = { targetId: string; type: string; url: string }; - -export type PanelTarget = { - allText: (selector: string) => Promise; - click: (selector: string) => Promise; - disabled: (selector: string) => Promise; - fill: (selector: string, value: string) => Promise; - hidden: (selector: string) => Promise; - pressEnter: ( - selector: string, - isComposing: boolean, - ) => Promise<{ - defaultPrevented: boolean; - value: string; - }>; - screenshot: (targetPath: string) => Promise; - text: (selector: string) => Promise; - wakeBackground: () => Promise; -}; - -export function textValue(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -export function countCopilotHistoryRequests( - gateway: Pick, -): number { - return gateway.requests.filter((request) => request.method === "chat.history").length; -} - -export function rawDataText(data: RawData): string { - if (Array.isArray(data)) { - return Buffer.concat(data).toString("utf8"); - } - return data instanceof ArrayBuffer - ? Buffer.from(new Uint8Array(data)).toString("utf8") - : data.toString("utf8"); -} - -type RelayHarness = { - readonly connectionCount: number; - hellos: Array>; - tabRefreshes: Array>; - port: number; - close: () => Promise; - command: (body: Record) => Promise; - setAvailable: (available: boolean) => void; -}; - -export async function createRelayHarness(token = "a".repeat(64)): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("extension relay test server did not bind a TCP port"); - } - const wss = new WebSocketServer({ - noServer: true, - maxPayload: 1_000_000, - handleProtocols: (protocols) => - protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false, - }); - const hellos: Array> = []; - const tabRefreshes: Array> = []; - const pendingCommands = new Map< - number, - { reject: (error: Error) => void; resolve: (result: unknown) => void } - >(); - let available = true; - let connectionCount = 0; - let nextCommandSeq = 0; - const authenticated = new Set(); - server.on("upgrade", (request, socket, head) => { - if (!available) { - socket.destroy(); - return; - } - wss.handleUpgrade(request, socket, head, (client) => { - wss.emit("connection", client, request); - }); - }); - wss.on("connection", (socket) => { - let authState: - | { kind: "hello" } - | { - kind: "response"; - fields: RelayAuthProofFields; - clientProof?: string; - } - | { kind: "authenticated" } = { kind: "hello" }; - const handleMessage = async (data: RawData) => { - const message = JSON.parse(rawDataText(data)) as Record; - if (authState.kind === "hello") { - if ( - message.type !== "auth.hello" || - message.v !== 2 || - typeof message.keyId !== "string" || - typeof message.clientNonce !== "string" - ) { - socket.close(4001, "expected auth.hello"); - return; - } - const keyId = await deriveRelayAuthKeyId(token); - if (message.keyId !== keyId) { - socket.close(4001, "keyId mismatch"); - return; - } - const issuedAtMs = Date.now(); - const fields: RelayAuthProofFields = { - keyId, - instanceId: randomBytes(16).toString("base64url"), - sessionId: randomBytes(16).toString("base64url"), - clientNonce: message.clientNonce, - serverNonce: randomBytes(32).toString("base64url"), - issuedAtMs, - expiresAtMs: issuedAtMs + 10_000, - role: "extension", - transport: "websocket", - method: "GET", - resource: "/extension", - flow: "extension", - }; - authState = { kind: "response", fields }; - socket.send( - JSON.stringify({ - type: "auth.challenge", - v: 2, - ...fields, - serverProof: await computeRelayAuthProof(token, "server", fields), - }), - ); - return; - } - if (authState.kind === "response") { - if ( - message.type !== "auth.response" || - message.v !== 2 || - message.sessionId !== authState.fields.sessionId || - typeof message.clientProof !== "string" - ) { - socket.close(4001, "expected auth.response"); - return; - } - const fields = authState.fields; - const expectedClientProof = await computeRelayAuthProof(token, "client", fields); - if (message.clientProof !== expectedClientProof) { - socket.close(4001, "clientProof mismatch"); - return; - } - authState = { kind: "authenticated" }; - authenticated.add(socket); - socket.send( - JSON.stringify({ - type: "auth.ok", - v: 2, - sessionId: fields.sessionId, - acceptProof: await computeRelayAuthProof(token, "accept", fields, message.clientProof), - }), - ); - return; - } - if (message.type === "hello") { - connectionCount += 1; - hellos.push(message); - return; - } - if (message.type === "tabs") { - tabRefreshes.push(message); - return; - } - const seq = typeof message.seq === "number" ? message.seq : undefined; - if (seq === undefined || (message.type !== "result" && message.type !== "error")) { - return; - } - const pending = pendingCommands.get(seq); - if (!pending) { - return; - } - pendingCommands.delete(seq); - if (message.type === "error") { - pending.reject(new Error(textValue(message.message) || "extension relay command failed")); - } else { - pending.resolve(message.result); - } - }; - socket.on("message", (data) => { - void handleMessage(data); - }); - socket.on("close", () => authenticated.delete(socket)); - }); - return { - get connectionCount() { - return connectionCount; - }, - hellos, - tabRefreshes, - port: address.port, - command: async (body) => { - const client = [...authenticated].find((candidate) => candidate.readyState === 1); - if (!client) { - throw new Error("extension relay client is not connected"); - } - const seq = ++nextCommandSeq; - const result = new Promise((resolve, reject) => { - pendingCommands.set(seq, { resolve, reject }); - }); - client.send(JSON.stringify({ ...body, seq })); - return await result; - }, - setAvailable: (nextAvailable) => { - available = nextAvailable; - if (!available) { - for (const client of wss.clients) { - client.terminate(); - } - } - }, - close: async () => { - for (const pending of pendingCommands.values()) { - pending.reject(new Error("extension relay harness closed")); - } - pendingCommands.clear(); - for (const client of wss.clients) { - client.terminate(); - } - await new Promise((resolve) => { - wss.close(() => resolve()); - }); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }, - }; -} - -export async function assertCopilotStaleRunIsolation(params: { - expect: typeof VitestExpect; - gateway: CopilotTurnIsolationGateway; - panel: CopilotTurnIsolationPanel; -}): Promise { - const { expect, gateway, panel } = params; - const initialSendCount = gateway.chatSends.length; - - await panel.fill("#message-input", "completed turn marker"); - await panel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(initialSendCount + 1); - await expect - .poll(() => panel.allText(".message.assistant"), { timeout: 10_000 }) - .toContain("Isolated reply: completed turn marker"); - await expect.poll(() => panel.disabled("#message-input"), { timeout: 10_000 }).toBe(false); - - const completedRun = gateway.chatSends[initialSendCount]; - const completedRunId = textValue(completedRun?.idempotencyKey); - const sessionKey = textValue(completedRun?.sessionKey); - expect(completedRunId).not.toBe(""); - expect(sessionKey).not.toBe(""); - const originalAssistantMessages = await panel.allText(".message.assistant"); - const originalSystemMessages = await panel.allText(".message.system"); - - await panel.fill("#message-input", "active turn linger marker"); - await panel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(initialSendCount + 2); - const activeRun = gateway.chatSends[initialSendCount + 1]; - const activeRunId = textValue(activeRun?.idempotencyKey); - expect(activeRunId).not.toBe(""); - expect(activeRunId).not.toBe(completedRunId); - expect(await panel.disabled("#message-input")).toBe(true); - - const historyRequestsBeforeStaleEvents = countCopilotHistoryRequests(gateway); - gateway.emitEvent("chat", { - sessionKey, - runId: completedRunId, - state: "delta", - deltaText: "Stale text from the completed turn", - }); - gateway.emitEvent("chat", { - sessionKey, - runId: completedRunId, - state: "error", - errorMessage: "Stale error from the completed turn", - }); - gateway.emitEvent("chat", { sessionKey, runId: completedRunId, state: "aborted" }); - gateway.emitEvent("chat", { sessionKey, runId: completedRunId, state: "final" }); - // The ordered history event proves preceding stale frames were consumed - // before checking that the active run still owns the composer. - gateway.emitEvent("session.message", { sessionKey }); - await expect - .poll(() => countCopilotHistoryRequests(gateway), { timeout: 10_000 }) - .toBeGreaterThan(historyRequestsBeforeStaleEvents); - expect(await panel.disabled("#message-input")).toBe(true); - expect(await panel.allText(".message.assistant")).toEqual(originalAssistantMessages); - expect(await panel.allText(".message.system")).toEqual(originalSystemMessages); - - gateway.emitEvent("chat", { - sessionKey, - runId: activeRunId, - state: "delta", - deltaText: "Current turn remains live", - }); - await expect - .poll(() => panel.allText(".message.assistant"), { timeout: 10_000 }) - .toEqual([...originalAssistantMessages, "Current turn remains live"]); - gateway.emitEvent("chat", { sessionKey, runId: activeRunId, state: "final" }); - await expect.poll(() => panel.disabled("#message-input"), { timeout: 10_000 }).toBe(false); - - await panel.fill("#message-input", "next normal turn marker"); - await panel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(initialSendCount + 3); - await expect - .poll(() => panel.allText(".message.assistant"), { timeout: 10_000 }) - .toContain("Isolated reply: next normal turn marker"); -} - -function isSidePanelTarget(target: { url: string }): boolean { - try { - return new URL(target.url).pathname.endsWith("/sidepanel.html"); - } catch { - return false; - } -} - -function createPanelTarget(root: CDPSession, sessionId: string): PanelTarget { - let commandId = 0; - const pending = new Map< - number, - { reject: (error: Error) => void; resolve: (result: Record) => void } - >(); - root.on("Target.receivedMessageFromTarget", (event: { message: string; sessionId: string }) => { - if (event.sessionId !== sessionId) { - return; - } - const message = JSON.parse(event.message) as { - error?: { message?: string }; - id?: number; - result?: Record; - }; - if (typeof message.id !== "number") { - return; - } - const waiter = pending.get(message.id); - if (!waiter) { - return; - } - pending.delete(message.id); - if (message.error) { - waiter.reject(new Error(message.error.message ?? "CDP panel command failed")); - } else { - waiter.resolve(message.result ?? {}); - } - }); - - async function send(method: string, params: Record = {}) { - const id = ++commandId; - const result = new Promise>((resolve, reject) => { - pending.set(id, { resolve, reject }); - }); - await root.send("Target.sendMessageToTarget", { - sessionId, - message: JSON.stringify({ id, method, params }), - }); - return await result; - } - - async function evaluate(expression: string): Promise { - const result = await send("Runtime.evaluate", { - expression, - awaitPromise: true, - returnByValue: true, - }); - const exception = result.exceptionDetails as { text?: string } | undefined; - if (exception) { - throw new Error(exception.text ?? "side-panel evaluation failed"); - } - return (result.result as { value?: T } | undefined)?.value as T; - } - - const selectorExpression = (selector: string) => JSON.stringify(selector); - return { - allText: async (selector) => - await evaluate( - `[...document.querySelectorAll(${selectorExpression(selector)})].map((node) => node.textContent ?? "")`, - ), - click: async (selector) => { - await evaluate(`document.querySelector(${selectorExpression(selector)})?.click()`); - }, - disabled: async (selector) => - await evaluate( - `Boolean(document.querySelector(${selectorExpression(selector)})?.disabled)`, - ), - fill: async (selector, value) => { - await evaluate(`(() => { - const input = document.querySelector(${selectorExpression(selector)}); - input.value = ${JSON.stringify(value)}; - input.dispatchEvent(new Event("input", { bubbles: true })); - })()`); - }, - hidden: async (selector) => - await evaluate( - `document.querySelector(${selectorExpression(selector)})?.classList.contains("hidden") === true`, - ), - pressEnter: async (selector, isComposing) => - await evaluate<{ defaultPrevented: boolean; value: string }>(`(() => { - const input = document.querySelector(${selectorExpression(selector)}); - const event = new KeyboardEvent("keydown", { - key: "Enter", bubbles: true, cancelable: true, isComposing: ${isComposing}, - }); - input.dispatchEvent(event); - return { defaultPrevented: event.defaultPrevented, value: input.value }; - })()`), - screenshot: async (targetPath) => { - await send("Page.enable"); - const result = await send("Page.captureScreenshot", { format: "png", fromSurface: true }); - await fs.writeFile(targetPath, Buffer.from(String(result.data), "base64")); - }, - text: async (selector) => - await evaluate( - `document.querySelector(${selectorExpression(selector)})?.textContent ?? ""`, - ), - wakeBackground: async () => { - await evaluate( - `chrome.runtime.sendMessage({ type: "copilot.e2e.wake" }).catch(() => undefined)`, - ); - }, - }; -} - -export async function openTabPanel(params: { - browserCdp: CDPSession; - expect: typeof VitestExpect; - extensionId: string; - page: Page; -}): Promise { - const prior = (await params.browserCdp.send("Target.getTargets")) as { - targetInfos: TargetInfo[]; - }; - const priorTargetIds = new Set(prior.targetInfos.map((target) => target.targetId)); - await params.page.goto(`chrome-extension://${params.extensionId}/e2e-launcher.html`); - await params.expect - .poll(async () => await params.page.locator("body").getAttribute("data-ready")) - .toBe("true"); - await params.page.locator("#open").click(); - await params.expect - .poll( - async () => - await params.page.locator("body").evaluate((body) => ({ - error: body.dataset.error, - opened: body.dataset.opened, - })), - { timeout: 5_000 }, - ) - .toEqual({ error: undefined, opened: "true" }); - await params.expect - .poll( - async () => { - const targets = (await params.browserCdp.send("Target.getTargets")) as { - targetInfos: TargetInfo[]; - }; - return targets.targetInfos.find( - (target) => !priorTargetIds.has(target.targetId) && isSidePanelTarget(target), - ); - }, - { timeout: 15_000 }, - ) - .toBeTruthy(); - const targets = (await params.browserCdp.send("Target.getTargets")) as { - targetInfos: TargetInfo[]; - }; - const target = targets.targetInfos.find( - (candidate) => !priorTargetIds.has(candidate.targetId) && isSidePanelTarget(candidate), - ); - if (!target) { - throw new Error("Chrome did not expose the tab-specific side-panel target"); - } - const attached = (await params.browserCdp.send("Target.attachToTarget", { - targetId: target.targetId, - flatten: false, - })) as { sessionId: string }; - return createPanelTarget(params.browserCdp, attached.sessionId); -} - -// Distro Chromium can omit the Extensions CDP domain these tests require. -// Honor an explicit compatible override; otherwise use Playwright's pinned build. -export async function resolveChromiumExecutableOverride(): Promise { - const override = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); - if (!override) { - return undefined; - } - await fs.access(override); - return override; -} - -export async function waitForLoadedExtensionId( - browserCdp: CDPSession, - extensionPath: string, -): Promise { - const canonicalPath = async (candidate: string): Promise => { - try { - return await fs.realpath(candidate); - } catch { - return path.resolve(candidate); - } - }; - const expectedPath = await canonicalPath(extensionPath); - const deadline = Date.now() + 10_000; - do { - const result = (await browserCdp.send("Extensions.getExtensions")) as { - extensions: Array<{ id: string; path: string }>; - }; - for (const extension of result.extensions) { - // macOS reports canonical /private paths even when the fixture was created through /tmp. - if ((await canonicalPath(extension.path)) === expectedPath) { - return extension.id; - } - } - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - } while (Date.now() < deadline); - throw new Error("Chromium did not report the loaded browser copilot extension"); -} - -export async function waitForContextExtensionId( - context: BrowserContext, - extensionPath: string, -): Promise { - const browser = context.browser(); - if (!browser) { - throw new Error("Chromium browser connection unavailable"); - } - return await waitForLoadedExtensionId(await browser.newBrowserCDPSession(), extensionPath); -} - -export async function copyCopilotSidepanelExtension(tempDirs: { - make: (prefix: string) => string; -}): Promise { - const extensionDir = path.dirname(fileURLToPath(import.meta.url)); - const target = tempDirs.make("openclaw-copilot-extension-"); - await fs.cp(extensionDir, target, { - recursive: true, - filter: (source) => - !source.endsWith(".test.ts") && - !source.endsWith(".test-support.ts") && - !source.endsWith(".test-harness.ts"), - }); - await fs.writeFile( - path.join(target, "e2e-launcher.html"), - '', - ); - await fs.writeFile( - path.join(target, "e2e-launcher.js"), - `const tab = await chrome.tabs.getCurrent(); - const panel = await chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: tab.id }); - if (!panel?.ok) throw new Error(panel?.error ?? "panel prepare failed"); - document.body.dataset.ready = "true"; - document.querySelector("#open").addEventListener("click", async () => { - try { - await chrome.sidePanel.setOptions({ tabId: tab.id, path: panel.path, enabled: true }); - await chrome.sidePanel.open({ tabId: tab.id }); - document.body.dataset.opened = "true"; - } catch (error) { - document.body.dataset.error = error instanceof Error ? error.message : String(error); - } - });\n`, - ); - return target; -} diff --git a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts deleted file mode 100644 index 1f3e274a7594..000000000000 --- a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts +++ /dev/null @@ -1,979 +0,0 @@ -import fs from "node:fs/promises"; -import { createServer, type Server } from "node:http"; -import os from "node:os"; -import path from "node:path"; -import { chromium, type CDPSession, type Worker } from "playwright-core"; -import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer, type WebSocket } from "ws"; -import { - GATEWAY_CLIENT_CAPS, - GATEWAY_CLIENT_IDS, -} from "../../../packages/gateway-protocol/src/client-info.js"; -import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js"; -import { useAutoCleanupTempDirTracker } from "../test-support.js"; -import { - assertCopilotStaleRunIsolation, - countCopilotHistoryRequests, - copyCopilotSidepanelExtension, - createRelayHarness, - openTabPanel, - rawDataText, - resolveChromiumExecutableOverride, - textValue, - type PanelTarget, - waitForContextExtensionId, - waitForLoadedExtensionId, -} from "./sidepanel.e2e-support.js"; - -declare const chrome: { - runtime: { - sendMessage(message: Record): Promise; - getContexts(filter: { contextTypes: string[] }): Promise< - Array<{ - contextType: string; - documentId?: string; - documentUrl: string; - tabId: number; - }> - >; - }; - storage: { - local: { - set(values: Record): Promise; - }; - session: { - get(keys: string[]): Promise>; - set(values: Record): Promise; - }; - }; - sidePanel: { - setOptions(options: { tabId: number; enabled: boolean }): Promise; - }; - tabs: { - get(tabId: number): Promise<{ - active?: boolean; - groupId?: number; - id?: number; - url?: string; - windowId?: number; - }>; - getCurrent(): Promise<{ id?: number }>; - remove(tabId: number): Promise; - ungroup(tabIds: number[]): Promise; - }; - tabGroups: { - get(groupId: number): Promise<{ title?: string }>; - }; -}; - -const runE2E = process.env.OPENCLAW_BROWSER_COPILOT_E2E === "1"; -const RELAY_SECRET = "a".repeat(64); - -type RequestFrame = { - id: string; - method: string; - params?: Record; - type: "req"; -}; - -type GatewayHarness = { - archived: Set; - chatSends: Array>; - connectParams: Array>; - histories: Map>>; - labels: Map; - port: number; - requests: RequestFrame[]; - close: () => Promise; - disconnectClients: () => void; - emitEvent: (event: string, payload: Record) => void; - failNextAbort: () => void; - holdNextSubscription: () => () => void; -}; - -const cleanups: Array<() => Promise> = []; -const tempDirs = useAutoCleanupTempDirTracker(afterEach); - -afterEach(async () => { - for (const cleanup of cleanups.splice(0).toReversed()) { - await cleanup().catch(() => undefined); - } -}); - -async function listen(server: Server): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("test server did not bind a TCP port"); - } - return address.port; -} - -function sendResponse(socket: WebSocket, id: string, payload: unknown): void { - socket.send(JSON.stringify({ type: "res", id, ok: true, payload })); -} - -function sendError( - socket: WebSocket, - id: string, - message: string, - { code = "UNAVAILABLE", retryable = true } = {}, -): void { - socket.send( - JSON.stringify({ - type: "res", - id, - ok: false, - error: { code, message, retryable }, - }), - ); -} - -async function createGatewayHarness(): Promise { - const server = createServer(); - const port = await listen(server); - const wss = new WebSocketServer({ server }); - const histories = new Map>>(); - const archived = new Set(); - const labels = new Map(); - const requests: RequestFrame[] = []; - const connectParams: Array> = []; - const chatSends: Array> = []; - let heldSubscription: Promise | null = null; - let rejectNextAbort = false; - - wss.on("connection", (socket) => { - socket.send( - JSON.stringify({ - type: "event", - event: "connect.challenge", - payload: { nonce: "browser-copilot-e2e-nonce", ts: 1_777_777_777_000 }, - }), - ); - socket.on("message", (data) => { - const frame = JSON.parse(rawDataText(data)) as RequestFrame; - requests.push(frame); - const params = frame.params ?? {}; - if (frame.method === "connect") { - connectParams.push(params); - sendResponse(socket, frame.id, { - type: "hello-ok", - protocol: PROTOCOL_VERSION, - server: { version: "e2e", connId: "browser-copilot-e2e" }, - features: { methods: [], events: ["chat"] }, - snapshot: { - sessionDefaults: { - defaultAgentId: "main", - mainKey: "main", - mainSessionKey: "agent:main:main", - }, - }, - auth: { - deviceToken: "test-device-token", - role: "operator", - scopes: ["operator.read", "operator.write"], - }, - policy: { - maxPayload: 1_000_000, - maxBufferedBytes: 1_000_000, - tickIntervalMs: 60_000, - }, - }); - return; - } - const key = textValue(params.key) || textValue(params.sessionKey); - if (frame.method === "sessions.create") { - const label = textValue(params.label); - const existingKey = labels.get(label); - if (label && existingKey && existingKey !== key) { - sendError(socket, frame.id, `label already in use: ${label}`, { - code: "INVALID_REQUEST", - retryable: false, - }); - return; - } - if (label) { - labels.set(label, key); - } - if (!histories.has(key)) { - histories.set(key, []); - } - sendResponse(socket, frame.id, { ok: true, key, sessionId: `id-${histories.size}` }); - return; - } - if (frame.method === "chat.history") { - sendResponse(socket, frame.id, { messages: histories.get(key) ?? [] }); - return; - } - if (frame.method === "sessions.messages.subscribe" && heldSubscription) { - const pending = heldSubscription; - heldSubscription = null; - void pending.then(() => sendResponse(socket, frame.id, { ok: true })); - return; - } - if (frame.method === "chat.send") { - chatSends.push(params); - const message = textValue(params.message); - const history = histories.get(key) ?? []; - history.push({ role: "user", content: [{ type: "text", text: message }] }); - const runId = textValue(params.idempotencyKey); - if (message === "ambiguous linger marker") { - histories.set(key, history); - socket.terminate(); - return; - } - if (message.endsWith("linger marker")) { - histories.set(key, history); - sendResponse(socket, frame.id, { runId, status: "started" }); - return; - } - const reply = `Isolated reply: ${message}`; - history.push({ role: "assistant", content: [{ type: "text", text: reply }] }); - histories.set(key, history); - sendResponse(socket, frame.id, { runId, status: "started" }); - socket.send( - JSON.stringify({ - type: "event", - event: "chat", - payload: { sessionKey: key, runId, state: "delta", deltaText: reply }, - }), - ); - socket.send( - JSON.stringify({ - type: "event", - event: "chat", - payload: { sessionKey: key, runId, state: "final" }, - }), - ); - return; - } - if (frame.method === "sessions.abort" && rejectNextAbort) { - rejectNextAbort = false; - sendError(socket, frame.id, "fixture abort retry"); - return; - } - if (frame.method === "sessions.patch" && params.archived === true) { - archived.add(key); - } - sendResponse(socket, frame.id, { ok: true }); - }); - }); - - return { - archived, - chatSends, - connectParams, - histories, - labels, - port, - requests, - disconnectClients: () => { - for (const client of wss.clients) { - client.terminate(); - } - }, - emitEvent: (event, payload) => { - for (const client of wss.clients) { - client.send(JSON.stringify({ type: "event", event, payload })); - } - }, - failNextAbort: () => { - rejectNextAbort = true; - }, - holdNextSubscription: () => { - let release: () => void = () => void 0; - heldSubscription = new Promise((resolve) => { - release = resolve; - }); - return release; - }, - close: async () => { - for (const client of wss.clients) { - client.terminate(); - } - await new Promise((resolve) => { - wss.close(() => resolve()); - }); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }, - }; -} - -async function createFixtureServer(): Promise<{ baseUrl: string; close: () => Promise }> { - const server = createServer((request, response) => { - const name = request.url === "/beta" ? "Beta" : "Alpha"; - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end( - `Fixture ${name}

${name} workspace

Sanitized local fixture.

`, - ); - }); - const port = await listen(server); - return { - baseUrl: `http://127.0.0.1:${port}`, - close: async () => - await new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function restartServiceWorker( - browserCdp: CDPSession, - worker: Worker, - panel: PanelTarget, -): Promise { - const targets = (await browserCdp.send("Target.getTargets")) as { - targetInfos: Array<{ targetId: string; type: string; url: string }>; - }; - const target = targets.targetInfos.find( - (candidate) => candidate.type === "service_worker" && candidate.url === worker.url(), - ); - if (!target) { - throw new Error("Chromium did not expose the extension service worker target"); - } - const closed = (await browserCdp.send("Target.closeTarget", { - targetId: target.targetId, - })) as { success?: boolean }; - if (closed.success !== true) { - throw new Error("Chromium did not stop the extension service worker"); - } - // A real extension message wakes the terminated worker. The panel must then - // reconnect its long-lived port before it can become ready again. - await panel.wakeBackground(); -} - -async function disableTabPanel(worker: Worker, tabId: number): Promise { - await worker.evaluate(async (boundTabId) => { - await chrome.sidePanel.setOptions({ tabId: boundTabId, enabled: false }); - }, tabId); - await expect - .poll( - async () => - await worker.evaluate(async () => { - const contexts = await chrome.runtime.getContexts({ contextTypes: ["SIDE_PANEL"] }); - return contexts.length; - }), - { timeout: 10_000 }, - ) - .toBe(0); -} - -async function unshareTab(worker: Worker, tabId: number): Promise { - await worker.evaluate(async (boundTabId) => { - await chrome.tabs.ungroup([boundTabId]); - }, tabId); -} - -describe.runIf(runE2E)("browser copilot Chromium side panel", () => { - it("survives an unpacked-extension reload across a browser restart", async () => { - const gateway = await createGatewayHarness(); - cleanups.push(gateway.close); - const relay = await createRelayHarness(); - cleanups.push(relay.close); - const fixture = await createFixtureServer(); - cleanups.push(fixture.close); - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const userDataDir = tempDirs.make("openclaw-copilot-reload-profile-"); - const executablePath = await resolveChromiumExecutableOverride(); - const launchOptions: Parameters[1] = { - ...(executablePath ? { executablePath } : { channel: "chromium" }), - headless: true, - // Playwright disables extensions by default, which overrides the unpacked fixture below. - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }; - const initialContext = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await initialContext.close()); - const browser = initialContext.browser(); - if (!browser) { - throw new Error("Chromium browser connection unavailable"); - } - const browserCdp = await browser.newBrowserCDPSession(); - const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension); - const launcher = initialContext.pages()[0] ?? (await initialContext.newPage()); - await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - await launcher.evaluate( - async ({ gatewayPort, relayPort, relaySecret }) => - await chrome.runtime.sendMessage({ - type: "pair", - pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`, - groupColor: "#ff7020", - accessMode: "selected", - }), - { gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET }, - ); - await expect.poll(() => gateway.connectParams.length, { timeout: 10_000 }).toBe(1); - - const tabId = await launcher.evaluate(async () => (await chrome.tabs.getCurrent()).id); - if (typeof tabId !== "number") { - throw new Error("Chrome did not expose the extension tab id"); - } - const oldSessionKey = - "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111"; - gateway.labels.set("Browser copilot", oldSessionKey); - gateway.histories.set(oldSessionKey, []); - await launcher.evaluate( - async ({ gatewayScope, archivedSessionKey, currentTabId }) => { - await chrome.storage.local.set({ - copilotSessionRegistryV1: { - sessions: { - [currentTabId]: { - tabId: currentTabId, - browserInstanceId: "beta-5-browser-instance", - gatewayScope, - sessionKey: archivedSessionKey, - sessionId: "beta-5-session", - }, - }, - pendingArchives: [], - }, - }); - await chrome.storage.session.set({ - copilotBrowserInstanceV1: "beta-5-browser-instance", - }); - }, - { - archivedSessionKey: oldSessionKey, - currentTabId: tabId, - gatewayScope: `ws://127.0.0.1:${gateway.port}/`, - }, - ); - - await initialContext.close(); - const reloadedContext = await chromium.launchPersistentContext(userDataDir, launchOptions); - cleanups.push(async () => await reloadedContext.close()); - const reloadedBrowser = reloadedContext.browser(); - if (!reloadedBrowser) { - throw new Error("Reloaded Chromium browser connection unavailable"); - } - const reloadedBrowserCdp = await reloadedBrowser.newBrowserCDPSession(); - const reloadedExtensionId = await waitForLoadedExtensionId( - reloadedBrowserCdp, - unpackedExtension, - ); - expect(reloadedExtensionId).toBe(extensionId); - const reloadedLauncher = reloadedContext.pages()[0] ?? (await reloadedContext.newPage()); - await reloadedLauncher.goto(`chrome-extension://${reloadedExtensionId}/e2e-launcher.html`); - await expect.poll(() => gateway.connectParams.length, { timeout: 15_000 }).toBe(2); - const browserInstanceId = await reloadedLauncher.evaluate(async () => { - const stored = await chrome.storage.session.get(["copilotBrowserInstanceV1"]); - return stored.copilotBrowserInstanceV1; - }); - expect(browserInstanceId).not.toBe("beta-5-browser-instance"); - - const panel = await openTabPanel({ - browserCdp: reloadedBrowserCdp, - expect, - extensionId: reloadedExtensionId, - page: reloadedLauncher, - }); - await reloadedLauncher.goto(`${fixture.baseUrl}/reload`); - await panel.click("#gate-action"); - await expect - .poll(async () => !(await panel.disabled("#message-input")), { timeout: 15_000 }) - .toBe(true); - await expect.poll(() => gateway.archived.has(oldSessionKey), { timeout: 10_000 }).toBe(true); - - const created = gateway.requests.filter((request) => request.method === "sessions.create"); - const fresh = created.find((request) => request.params?.key !== oldSessionKey); - expect(fresh?.params).toEqual({ - key: expect.stringMatching(/:thread:browser-copilot-[0-9a-f-]{36}$/), - label: expect.stringMatching(/^Browser copilot [0-9a-f-]{36}$/), - }); - expect(fresh?.params?.label).not.toBe("Browser copilot"); - expect(gateway.labels.get("Browser copilot")).toBe(oldSessionKey); - - await panel.fill("#message-input", "reload recovery marker"); - await panel.click("#send-button"); - await expect - .poll(async () => await panel.allText(".message.assistant"), { timeout: 10_000 }) - .toContain("Isolated reply: reload recovery marker"); - }, 90_000); - - it("returns one error response when a panel's tab disappears", async () => { - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const userDataDir = tempDirs.make("openclaw-copilot-missing-tab-profile-"); - const executablePath = await resolveChromiumExecutableOverride(); - const context = await chromium.launchPersistentContext(userDataDir, { - ...(executablePath ? { executablePath } : { channel: "chromium" }), - headless: true, - // Playwright disables extensions by default, which overrides the unpacked fixture below. - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }); - cleanups.push(async () => await context.close()); - const extensionId = await waitForContextExtensionId(context, unpackedExtension); - const popup = context.pages()[0] ?? (await context.newPage()); - await popup.goto(`chrome-extension://${extensionId}/popup.html`); - - const outcome = await popup.evaluate(async () => { - const currentTab = await chrome.tabs.getCurrent(); - if (typeof currentTab?.id !== "number") { - throw new Error("Chrome did not expose the extension tab"); - } - const valid = await chrome.runtime.sendMessage({ - type: "prepareCopilotPanel", - tabId: currentTab.id, - }); - const missing = await Promise.race([ - chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: 2_147_483_000 }).then( - (response) => ({ kind: "response", response }), - (error: unknown) => ({ - kind: "rejection", - error: error instanceof Error ? error.message : String(error), - }), - ), - new Promise((resolve) => { - setTimeout(() => resolve({ kind: "timeout" }), 1_200); - }), - ]); - return { valid, missing }; - }); - - expect(outcome.valid).toEqual({ - ok: true, - path: expect.stringMatching(/^sidepanel\.html\?binding=/), - }); - expect(outcome.missing).toEqual({ - kind: "response", - response: { - ok: false, - error: expect.stringMatching(/No tab with id: 2147483000/), - }, - }); - }); - - it("isolates two tab sessions, enforces bindings, denies revoked access, and archives on close", async () => { - const gateway = await createGatewayHarness(); - cleanups.push(gateway.close); - const relay = await createRelayHarness(); - cleanups.push(relay.close); - const fixture = await createFixtureServer(); - cleanups.push(fixture.close); - const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); - const userDataDir = tempDirs.make("openclaw-copilot-profile-"); - const executablePath = await resolveChromiumExecutableOverride(); - const context = await chromium.launchPersistentContext(userDataDir, { - ...(executablePath ? { executablePath } : { channel: "chromium" }), - headless: true, - // Playwright disables extensions by default, which overrides the unpacked fixture below. - ignoreDefaultArgs: ["--disable-extensions"], - args: [ - "--enable-unsafe-extension-debugging", - `--disable-extensions-except=${unpackedExtension}`, - `--load-extension=${unpackedExtension}`, - ], - }); - cleanups.push(async () => await context.close()); - const browser = context.browser(); - if (!browser) { - throw new Error("Chromium browser connection unavailable"); - } - const browserCdp = await browser.newBrowserCDPSession(); - const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension); - const alphaTab = context.pages()[0] ?? (await context.newPage()); - await alphaTab.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); - const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); - await alphaTab.evaluate( - async ({ gatewayPort, relayPort, relaySecret }) => - await chrome.runtime.sendMessage({ - type: "pair", - pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`, - groupColor: "#ff7020", - accessMode: "selected", - }), - { gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET }, - ); - await expect.poll(() => gateway.connectParams.length, { timeout: 10_000 }).toBe(1); - await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); - await expect.poll(() => relay.hellos.length, { timeout: 10_000 }).toBe(1); - - const artifactDir = - process.env.OPENCLAW_BROWSER_COPILOT_ARTIFACT_DIR ?? - path.join(os.tmpdir(), "openclaw-browser-copilot-artifacts"); - await fs.mkdir(artifactDir, { recursive: true }); - - const alphaPanel = await openTabPanel({ browserCdp, expect, extensionId, page: alphaTab }); - const alphaContextProof = await alphaTab.evaluate(async () => { - const tab = await chrome.tabs.getCurrent(); - const contexts = await chrome.runtime.getContexts({ contextTypes: ["SIDE_PANEL"] }); - return { - currentTabId: tab?.id, - contexts: contexts.map((panelContext) => ({ - contextType: panelContext.contextType, - hasDocumentId: Boolean(panelContext.documentId), - pathname: new URL(panelContext.documentUrl).pathname, - queryKeys: [...new URL(panelContext.documentUrl).searchParams.keys()], - tabId: panelContext.tabId, - })), - }; - }); - expect(alphaContextProof).toEqual({ - currentTabId: expect.any(Number), - contexts: [ - { - contextType: "SIDE_PANEL", - hasDocumentId: true, - pathname: "/sidepanel.html", - queryKeys: ["binding"], - tabId: -1, - }, - ], - }); - await alphaTab.goto(`${fixture.baseUrl}/alpha`); - await expect - .poll( - async () => ({ - detail: await alphaPanel.text("#gate-detail"), - title: await alphaPanel.text("#gate-title"), - }), - { timeout: 10_000 }, - ) - .toEqual({ - detail: - "Use the current access mode to allow OpenClaw here. Restricted and incognito tabs remain unavailable.", - title: "Allow this tab", - }); - expect(await alphaPanel.disabled("#message-input")).toBe(true); - await alphaPanel.screenshot(path.join(artifactDir, "before-access.png")); - await alphaPanel.click("#gate-action"); - await expect - .poll(async () => !(await alphaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - await alphaPanel.fill("#message-input", "にほん"); - expect(await alphaPanel.pressEnter("#message-input", true)).toEqual({ - defaultPrevented: false, - value: "にほん", - }); - expect(gateway.chatSends).toHaveLength(0); - expect(await alphaPanel.allText(".message.user")).toEqual([]); - await alphaPanel.fill("#message-input", "alpha marker"); - await expect.poll(async () => !(await alphaPanel.disabled("#send-button"))).toBe(true); - expect(await alphaPanel.pressEnter("#message-input", false)).toEqual({ - defaultPrevented: true, - value: "", - }); - await expect - .poll( - async () => ({ - chatSends: gateway.chatSends.length, - users: await alphaPanel.allText(".message.user"), - }), - { timeout: 10_000 }, - ) - .toEqual({ chatSends: 1, users: ["alpha marker"] }); - await expect - .poll(async () => await alphaPanel.allText(".message.assistant"), { timeout: 10_000 }) - .toContain("Isolated reply: alpha marker"); - - const betaTab = await context.newPage(); - const betaPanel = await openTabPanel({ browserCdp, expect, extensionId, page: betaTab }); - const betaTabId = await betaTab.evaluate(async () => (await chrome.tabs.getCurrent()).id); - if (typeof betaTabId !== "number") { - throw new Error("Chrome did not expose the beta tab id"); - } - await betaTab.goto(`${fixture.baseUrl}/beta`); - await expect.poll(async () => await betaPanel.text("#gate-title")).toBe("Allow this tab"); - await betaPanel.click("#gate-action"); - await expect - .poll(async () => !(await betaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - expect(await betaPanel.text("#messages")).not.toContain("alpha marker"); - await betaPanel.fill("#message-input", "beta marker"); - await expect.poll(async () => !(await betaPanel.disabled("#send-button"))).toBe(true); - await betaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(2); - await expect - .poll(async () => await betaPanel.allText(".message.assistant"), { timeout: 10_000 }) - .toContain("Isolated reply: beta marker"); - await betaPanel.screenshot(path.join(artifactDir, "after-isolated.png")); - - expect(gateway.chatSends).toHaveLength(2); - const [alphaSend, betaSend] = gateway.chatSends; - if (!alphaSend || !betaSend) { - throw new Error("expected one isolated send per tab"); - } - expect(alphaSend.sessionKey).not.toBe(betaSend.sessionKey); - for (const send of gateway.chatSends) { - expect(send.deliver).toBe(false); - expect(send).not.toHaveProperty("url"); - expect(send).not.toHaveProperty("title"); - expect(send).not.toHaveProperty("pageContent"); - expect(send.toolBindings).toEqual({ - browser: expect.objectContaining({ - kind: "tab", - profile: "chrome", - tabId: expect.any(Number), - target: "host", - targetId: expect.any(String), - }), - }); - } - expect(gateway.histories.get(textValue(alphaSend.sessionKey))).not.toEqual( - gateway.histories.get(textValue(betaSend.sessionKey)), - ); - expect(gateway.connectParams[0]).toEqual( - expect.objectContaining({ - client: expect.objectContaining({ id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }), - caps: expect.arrayContaining([ - GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, - GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS, - ]), - device: expect.objectContaining({ - id: expect.any(String), - publicKey: expect.any(String), - signature: expect.any(String), - }), - }), - ); - - await alphaTab.close(); - await expect - .poll(() => gateway.archived.has(textValue(alphaSend.sessionKey)), { timeout: 15_000 }) - .toBe(true); - const alphaLifecycle = gateway.requests - .filter((request) => textValue(request.params?.key) === alphaSend.sessionKey) - .map((request) => request.method); - expect(alphaLifecycle).toEqual( - expect.arrayContaining(["sessions.messages.unsubscribe", "sessions.abort", "sessions.patch"]), - ); - expect(gateway.histories.get(textValue(alphaSend.sessionKey))).toHaveLength(2); - const subscriptionsBeforeRace = gateway.requests.filter( - (request) => request.method === "sessions.messages.subscribe", - ).length; - const releaseSubscription = gateway.holdNextSubscription(); - const connectionsBeforeSetupRace = gateway.connectParams.length; - gateway.disconnectClients(); - await expect - .poll(() => gateway.connectParams.length, { timeout: 15_000 }) - .toBe(connectionsBeforeSetupRace + 1); - await expect - .poll( - () => - gateway.requests.filter((request) => request.method === "sessions.messages.subscribe") - .length, - { timeout: 10_000 }, - ) - .toBe(subscriptionsBeforeRace + 1); - expect(await betaPanel.disabled("#message-input")).toBe(true); - expect(await betaPanel.text("#gate-title")).toBe("Preparing this tab"); - await disableTabPanel(worker, betaTabId); - releaseSubscription(); - await new Promise((resolve) => { - setTimeout(resolve, 250); - }); - expect(gateway.chatSends).toHaveLength(2); - - let reopenedBetaPanel = await openTabPanel({ - browserCdp, - expect, - extensionId, - page: betaTab, - }); - await betaTab.goto(`${fixture.baseUrl}/beta`); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - - const subscriptionsBeforeConsentRace = gateway.requests.filter( - (request) => request.method === "sessions.messages.subscribe", - ).length; - const releaseConsentSubscription = gateway.holdNextSubscription(); - const connectionsBeforeConsentRace = gateway.connectParams.length; - gateway.disconnectClients(); - await expect - .poll(() => gateway.connectParams.length, { timeout: 15_000 }) - .toBe(connectionsBeforeConsentRace + 1); - await expect - .poll( - () => - gateway.requests.filter((request) => request.method === "sessions.messages.subscribe") - .length, - { timeout: 10_000 }, - ) - .toBe(subscriptionsBeforeConsentRace + 1); - expect(await reopenedBetaPanel.disabled("#message-input")).toBe(true); - await unshareTab(worker, betaTabId); - releaseConsentSubscription(); - await expect - .poll(async () => await reopenedBetaPanel.text("#gate-title"), { timeout: 10_000 }) - .toBe("Allow this tab"); - expect(gateway.chatSends).toHaveLength(2); - await reopenedBetaPanel.click("#gate-action"); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - - await reopenedBetaPanel.fill("#message-input", "ambiguous linger marker"); - await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); - const connectionsBeforeAmbiguousSend = gateway.connectParams.length; - await reopenedBetaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(3); - const networkRunId = textValue(gateway.chatSends[2]?.idempotencyKey); - await expect - .poll(() => gateway.connectParams.length, { timeout: 15_000 }) - .toBe(connectionsBeforeAmbiguousSend + 1); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - expect(gateway.connectParams.at(-1)?.auth).toEqual( - expect.objectContaining({ token: expect.any(String) }), - ); - expect( - gateway.requests.some( - (request) => request.method === "sessions.abort" && request.params?.runId === networkRunId, - ), - ).toBe(true); - await reopenedBetaPanel.fill("#message-input", "after reconnect marker"); - await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); - const historiesBeforeReconnectTurn = countCopilotHistoryRequests(gateway); - await reopenedBetaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(4); - await expect - .poll(async () => await reopenedBetaPanel.allText(".message.assistant"), { - timeout: 10_000, - }) - .toContain("Isolated reply: after reconnect marker"); - await expect - .poll(() => countCopilotHistoryRequests(gateway), { timeout: 10_000 }) - .toBeGreaterThan(historiesBeforeReconnectTurn); - - await reopenedBetaPanel.fill("#message-input", "panel linger marker"); - await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); - await reopenedBetaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(5); - const panelRunId = textValue(gateway.chatSends[4]?.idempotencyKey); - const historiesBeforeNavigation = countCopilotHistoryRequests(gateway); - await betaTab.goto(`${fixture.baseUrl}/beta?during-run=1`); - await expect - .poll( - async () => ({ - gateHidden: await reopenedBetaPanel.hidden("#gate"), - messagesHidden: await reopenedBetaPanel.hidden("#messages"), - }), - { timeout: 10_000 }, - ) - .toEqual({ gateHidden: true, messagesHidden: false }); - await new Promise((resolve) => { - setTimeout(resolve, 250); - }); - expect(countCopilotHistoryRequests(gateway)).toBe(historiesBeforeNavigation); - gateway.failNextAbort(); - await disableTabPanel(worker, betaTabId); - await expect - .poll( - () => ({ - aborts: gateway.requests.filter( - (request) => - request.method === "sessions.abort" && request.params?.runId === panelRunId, - ).length, - unsubscribed: gateway.requests.some( - (request) => - request.method === "sessions.messages.unsubscribe" && - request.params?.key === betaSend.sessionKey, - ), - }), - { timeout: 10_000 }, - ) - .toEqual({ aborts: 2, unsubscribed: true }); - - reopenedBetaPanel = await openTabPanel({ - browserCdp, - expect, - extensionId, - page: betaTab, - }); - await betaTab.goto(`${fixture.baseUrl}/beta`); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - await reopenedBetaPanel.fill("#message-input", "reopened marker"); - await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); - await reopenedBetaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(6); - await expect - .poll(async () => await reopenedBetaPanel.allText(".message.assistant"), { - timeout: 10_000, - }) - .toContain("Isolated reply: reopened marker"); - - await reopenedBetaPanel.fill("#message-input", "relay disconnect linger marker"); - await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); - await reopenedBetaPanel.click("#send-button"); - await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(7); - const relayRunId = textValue(gateway.chatSends[6]?.idempotencyKey); - const relayConnectionsBeforeDrop = relay.connectionCount; - relay.setAvailable(false); - await expect - .poll( - async () => ({ - detail: await reopenedBetaPanel.text("#gate-detail"), - disabled: await reopenedBetaPanel.disabled("#message-input"), - title: await reopenedBetaPanel.text("#gate-title"), - }), - { timeout: 10_000 }, - ) - .toEqual({ - detail: "Browser relay reconnecting", - disabled: true, - title: "Preparing this tab", - }); - await expect - .poll( - () => - gateway.requests.some( - (request) => - request.method === "sessions.abort" && request.params?.runId === relayRunId, - ), - { timeout: 10_000 }, - ) - .toBe(true); - relay.setAvailable(true); - await expect - .poll(() => relay.connectionCount, { timeout: 15_000 }) - .toBeGreaterThan(relayConnectionsBeforeDrop); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - - const connectionsBeforeWorkerRestart = gateway.connectParams.length; - await restartServiceWorker(browserCdp, worker, reopenedBetaPanel); - await expect - .poll(() => gateway.connectParams.length, { timeout: 15_000 }) - .toBe(connectionsBeforeWorkerRestart + 1); - await expect - .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { - timeout: 15_000, - }) - .toBe(true); - - await assertCopilotStaleRunIsolation({ expect, gateway, panel: reopenedBetaPanel }); - }, 120_000); -}); diff --git a/extensions/browser/chrome-extension/sidepanel.html b/extensions/browser/chrome-extension/sidepanel.html deleted file mode 100644 index ce4193070e67..000000000000 --- a/extensions/browser/chrome-extension/sidepanel.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - OpenClaw Copilot - - - -
- -
-
TAB COPILOT
-
Resolving tab…
-
-
-
- -
- - This panel is bound to one Chrome tab -
- -
-
-
SECURE BINDING
-

Preparing this tab

-

Chrome is proving which tab owns this panel.

- - -
- -
- -
-
- No session until OpenClaw can access this tab. -
-
- - -
-
- Page text stays out of prompts. Browser actions stay on this tab. -
-
- - - - diff --git a/extensions/browser/chrome-extension/sidepanel.js b/extensions/browser/chrome-extension/sidepanel.js deleted file mode 100644 index 2bc8832b3735..000000000000 --- a/extensions/browser/chrome-extension/sidepanel.js +++ /dev/null @@ -1,257 +0,0 @@ -import { - applyChatDelta, - createChatStream, - readMessageText, - renderMarkdownLite, - resetChatStream, -} from "./modules/panel-core.js"; - -const tabTitle = document.getElementById("tab-title"); -const tabOrigin = document.getElementById("tab-origin"); -const statusDot = document.getElementById("status-dot"); -const gate = document.getElementById("gate"); -const gateTitle = document.getElementById("gate-title"); -const gateDetail = document.getElementById("gate-detail"); -const gateAction = document.getElementById("gate-action"); -const requestId = document.getElementById("request-id"); -const messages = document.getElementById("messages"); -const sessionNote = document.getElementById("session-note"); -const input = document.getElementById("message-input"); -const sendButton = document.getElementById("send-button"); - -const stream = createChatStream(); -let streamingBubble = null; -let panelReady = false; -let sending = false; -let panelState = "connecting"; -let port = null; -let reconnectTimer = null; -let reconnectDelayMs = 250; - -function setComposerEnabled(enabled) { - panelReady = enabled; - input.disabled = !enabled || sending; - sendButton.disabled = !enabled || sending || !input.value.trim(); -} - -function setGate({ action = null, detail, title }) { - gate.classList.remove("hidden"); - messages.classList.add("hidden"); - gateTitle.textContent = title; - gateDetail.textContent = detail; - gateAction.classList.toggle("hidden", action !== "share"); - setComposerEnabled(false); -} - -function addBubble(role, text, streaming = false) { - const bubble = document.createElement("div"); - bubble.className = `message ${role}${streaming ? " streaming" : ""}`; - if (role === "system") { - bubble.textContent = text; - } else { - bubble.innerHTML = renderMarkdownLite(text); - } - messages.appendChild(bubble); - messages.parentElement.scrollTop = messages.parentElement.scrollHeight; - return bubble; -} - -function renderHistory(history) { - messages.replaceChildren(); - for (const message of history) { - if (message?.role !== "user" && message?.role !== "assistant") { - continue; - } - const text = readMessageText(message); - if (text) { - addBubble(message.role, text); - } - } - if (messages.childElementCount === 0) { - addBubble("system", "New tab conversation · page content is not added automatically"); - } -} - -function finalizeStream() { - streamingBubble?.classList.remove("streaming"); - streamingBubble = null; - resetChatStream(stream); - sending = false; - setComposerEnabled(panelReady); -} - -function handleChatEvent(payload) { - if (payload.state === "delta") { - const update = applyChatDelta(stream, payload); - if (!update) { - return; - } - if (!streamingBubble || update.newBubble) { - streamingBubble?.classList.remove("streaming"); - streamingBubble = addBubble("assistant", update.text, true); - } else { - streamingBubble.innerHTML = renderMarkdownLite(update.text); - streamingBubble.classList.add("streaming"); - } - return; - } - if (payload.state === "error") { - addBubble("system", payload.errorMessage || "The run failed."); - } - if (payload.state === "aborted") { - addBubble("system", "Run stopped because this tab was closed or access was revoked."); - } - if (payload.state === "final" || payload.state === "error" || payload.state === "aborted") { - finalizeStream(); - } -} - -function updateState(state) { - panelState = state.state; - statusDot.className = `status-dot ${state.state}`; - statusDot.title = state.label || state.state; - if (state.tab) { - tabTitle.textContent = state.tab.title || state.tab.label || "Untitled tab"; - tabOrigin.textContent = state.tab.label - ? `${state.tab.label} · Chrome-bound tab` - : "Chrome-bound tab"; - } - requestId.classList.toggle("hidden", !state.requestId); - requestId.textContent = state.requestId ? `request ${state.requestId}` : ""; - switch (state.state) { - case "ready": - gate.classList.add("hidden"); - messages.classList.remove("hidden"); - sessionNote.textContent = "Live only for this tab · transcript retained after archive"; - setComposerEnabled(true); - break; - case "needs-sharing": - sessionNote.textContent = "No session until OpenClaw can access this tab."; - setGate({ - action: "share", - title: "Allow this tab", - detail: - "Use the current access mode to allow OpenClaw here. Restricted and incognito tabs remain unavailable.", - }); - break; - case "needs-pairing": - setGate({ - title: "Pair the extension first", - detail: - "Open the OpenClaw toolbar popup and paste the output of openclaw browser extension pair.", - }); - break; - case "approval": - setGate({ - title: "Approve this copilot device", - detail: - "On the Gateway, run openclaw devices list, inspect this dedicated browser identity, then approve its current request.", - }); - break; - case "denied": - setGate({ title: "This panel was denied", detail: state.label }); - break; - case "error": - setGate({ title: "Gateway unavailable", detail: state.label }); - break; - default: - setGate({ title: "Preparing this tab", detail: state.label || "Connecting securely…" }); - } -} - -function handlePortMessage(message) { - reconnectDelayMs = 250; - if (message?.type === "panel.state") { - updateState(message); - } else if (message?.type === "panel.history") { - if (!sending) { - renderHistory(message.messages); - } - } else if (message?.type === "panel.event" && message.event?.event === "chat") { - handleChatEvent(message.event.payload ?? {}); - } else if (message?.type === "panel.turn-reset") { - if (sending) { - addBubble("system", "Previous run stopped after the Gateway reconnected."); - } - finalizeStream(); - } else if (message?.type === "panel.error") { - addBubble("system", message.message || "Request failed."); - sending = false; - setComposerEnabled(panelReady); - } -} - -function schedulePortReconnect() { - if (reconnectTimer || panelState === "denied") { - return; - } - const delayMs = reconnectDelayMs; - reconnectDelayMs = Math.min(reconnectDelayMs * 2, 5_000); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - updateState({ state: "connecting", label: "Reconnecting to the extension background" }); - connectPanelPort(); - }, delayMs); -} - -function connectPanelPort() { - if (port) { - return; - } - let nextPort; - try { - nextPort = chrome.runtime.connect({ name: "openclaw-copilot-panel" }); - } catch { - schedulePortReconnect(); - return; - } - port = nextPort; - nextPort.onMessage.addListener((message) => { - if (port === nextPort) { - handlePortMessage(message); - } - }); - nextPort.onDisconnect.addListener(() => { - if (port !== nextPort) { - return; - } - port = null; - finalizeStream(); - if (panelState !== "denied") { - updateState({ state: "error", label: "Extension background disconnected." }); - schedulePortReconnect(); - } - }); - port?.postMessage({ type: "panel.refresh" }); -} - -async function send() { - const message = input.value.trim(); - if (!message || !panelReady || sending) { - return; - } - addBubble("user", message); - input.value = ""; - input.style.height = "auto"; - sending = true; - setComposerEnabled(true); - port?.postMessage({ type: "panel.send", message }); -} - -input.addEventListener("input", () => { - input.style.height = "auto"; - input.style.height = `${Math.min(input.scrollHeight, 130)}px`; - setComposerEnabled(panelReady); -}); -input.addEventListener("keydown", (event) => { - if (event.isComposing) { - return; - } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - void send(); - } -}); -sendButton.addEventListener("click", () => void send()); -gateAction.addEventListener("click", () => port?.postMessage({ type: "panel.share" })); -connectPanelPort(); diff --git a/extensions/browser/cli-output-mode.ts b/extensions/browser/cli-output-mode.ts index cc9b08160bf4..1330337a9851 100644 --- a/extensions/browser/cli-output-mode.ts +++ b/extensions/browser/cli-output-mode.ts @@ -86,6 +86,10 @@ export function isBrowserMachineOutput(params: { argv: readonly string[] }): boo path[0] === "evaluate" || path[0] === "console" || (path[0] === "cookies" && path.length === 1) || - (path[0] === "storage" && ["local", "session"].includes(path[1] ?? "") && path[2] === "get") + (path[0] === "storage" && ["local", "session"].includes(path[1] ?? "") && path[2] === "get") || + (path[0] === "extension" && path[1] === "native-host") || + (path[0] === "extension" && + ["install", "status", "uninstall-host"].includes(path[1] ?? "") && + params.argv.includes("--json")) ); } diff --git a/extensions/browser/native-host-entry.ts b/extensions/browser/native-host-entry.ts new file mode 100644 index 000000000000..214440da9a22 --- /dev/null +++ b/extensions/browser/native-host-entry.ts @@ -0,0 +1,42 @@ +import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { + parseBrowserNativeHostOrigins, + runBrowserNativeHost, +} from "./src/browser/extension-native-host.js"; +import { buildBrowserExtensionPairing } from "./src/browser/extension-pairing.js"; + +function requiredArgument(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value) { + throw new Error(`Missing ${name}`); + } + return value; +} + +async function main(): Promise { + const { callerOrigin, expectedOrigins } = parseBrowserNativeHostOrigins(process.argv.slice(2)); + let responseFrame: Buffer | undefined; + await runBrowserNativeHost({ + manifestPath: requiredArgument("--manifest"), + launcherPath: requiredArgument("--launcher"), + callerOrigin, + expectedOrigins, + input: process.stdin, + write: (frame) => { + responseFrame = frame; + }, + buildPairing: async () => await buildBrowserExtensionPairing({ cfg: getRuntimeConfig() }), + }); + const response = responseFrame; + if (!response) { + throw new Error("Native host produced no response frame"); + } + await new Promise((resolve) => { + process.stdout.write(response, () => resolve()); + }); +} + +void main().catch(() => { + process.exitCode = 1; +}); diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 7de59713c1d9..7783af8b3660 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -6,15 +6,12 @@ "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", - "@noble/ed25519": "3.1.0", - "esbuild": "0.28.1", "express": "5.2.1", "playwright-core": "1.62.0", "typebox": "1.3.6", "ws": "8.21.1" }, "devDependencies": { - "@openclaw/gateway-client": "workspace:*", "@openclaw/plugin-sdk": "workspace:*", "undici": "8.9.0" }, @@ -23,10 +20,6 @@ "./index.ts" ], "assetScripts": { - "build": "node scripts/build-copilot-runtime.mjs", - "buildOutputs": [ - "chrome-extension/modules/copilot-runtime.js" - ], "copy": "node scripts/copy-chrome-extension.mjs" } } diff --git a/extensions/browser/scripts/build-copilot-runtime.d.mts b/extensions/browser/scripts/build-copilot-runtime.d.mts deleted file mode 100644 index 14f541a2dbf4..000000000000 --- a/extensions/browser/scripts/build-copilot-runtime.d.mts +++ /dev/null @@ -1,20 +0,0 @@ -type CopilotRuntimeBuild = (options: { - bundle: boolean; - entryPoints: string[]; - format: string; - legalComments: string; - minify: boolean; - outfile: string; - platform: string; - target: string; - tsconfig: string; - write: false; -}) => Promise<{ - outputFiles?: Array<{ text: string }>; -}>; - -/** Builds the Browser copilot runtime and returns whether the generated asset changed. */ -export function buildCopilotRuntime(params?: { - build?: CopilotRuntimeBuild; - outputPath?: string; -}): Promise; diff --git a/extensions/browser/scripts/build-copilot-runtime.mjs b/extensions/browser/scripts/build-copilot-runtime.mjs deleted file mode 100644 index fd78a465e506..000000000000 --- a/extensions/browser/scripts/build-copilot-runtime.mjs +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { build } from "esbuild"; - -const modulePath = fileURLToPath(import.meta.url); -const pluginDir = path.resolve(path.dirname(modulePath), ".."); -const repoRoot = path.resolve(pluginDir, "../.."); -const outfile = path.join(pluginDir, "chrome-extension", "modules", "copilot-runtime.js"); - -async function writeCopilotRuntimeIfChanged(filePath, contents) { - try { - if ((await fs.readFile(filePath, "utf8")) === contents) { - return false; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, contents, "utf8"); - return true; -} - -/** Builds the copilot runtime without rewriting an identical generated asset. */ -export async function buildCopilotRuntime(params = {}) { - const buildImpl = params.build ?? build; - const outputPath = params.outputPath ?? outfile; - const result = await buildImpl({ - entryPoints: [path.join(pluginDir, "scripts", "copilot-runtime-entry.ts")], - outfile: outputPath, - bundle: true, - format: "esm", - legalComments: "inline", - minifyIdentifiers: false, - minifySyntax: true, - minifyWhitespace: true, - platform: "browser", - target: "chrome125", - tsconfig: path.join(repoRoot, "tsconfig.json"), - write: false, - }); - - const outputFile = result.outputFiles?.[0]; - if (!outputFile) { - throw new Error("esbuild did not produce the Browser copilot runtime bundle"); - } - - return writeCopilotRuntimeIfChanged(outputPath, outputFile.text); -} - -if (process.argv[1] === modulePath) { - await buildCopilotRuntime(); -} diff --git a/extensions/browser/scripts/build-copilot-runtime.test.ts b/extensions/browser/scripts/build-copilot-runtime.test.ts deleted file mode 100644 index fffa5d419921..000000000000 --- a/extensions/browser/scripts/build-copilot-runtime.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../test-support.js"; -import { buildCopilotRuntime } from "./build-copilot-runtime.mjs"; - -const tempDirs = useAutoCleanupTempDirTracker(afterEach); - -describe("scripts/build-copilot-runtime.mjs", () => { - it("creates a missing bundle without rewriting it when unchanged", async () => { - const rootDir = tempDirs.make("openclaw-browser-copilot-runtime-"); - const outputPath = path.join(rootDir, "copilot-runtime.js"); - const build = vi.fn(async () => ({ - outputFiles: [{ text: "export const copilotRuntime = true;\n" }], - })); - - await expect(buildCopilotRuntime({ build, outputPath })).resolves.toBe(true); - expect(fs.readFileSync(outputPath, "utf8")).toBe("export const copilotRuntime = true;\n"); - - const initialTime = new Date("2026-07-18T04:00:00.000Z"); - fs.utimesSync(outputPath, initialTime, initialTime); - - await expect(buildCopilotRuntime({ build, outputPath })).resolves.toBe(false); - expect(fs.statSync(outputPath).mtimeMs).toBe(initialTime.getTime()); - expect(build).toHaveBeenCalledWith( - expect.objectContaining({ - outfile: outputPath, - minifyIdentifiers: false, - write: false, - }), - ); - }); - - it("matches the checked-in Chrome extension runtime", async () => { - const rootDir = tempDirs.make("openclaw-browser-copilot-runtime-"); - const outputPath = path.join(rootDir, "copilot-runtime.js"); - const checkedInPath = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", - "chrome-extension", - "modules", - "copilot-runtime.js", - ); - - await expect(buildCopilotRuntime({ outputPath })).resolves.toBe(true); - expect(fs.readFileSync(outputPath, "utf8")).toBe(fs.readFileSync(checkedInPath, "utf8")); - }); -}); diff --git a/extensions/browser/scripts/copilot-runtime-entry.ts b/extensions/browser/scripts/copilot-runtime-entry.ts deleted file mode 100644 index 11fa825c459e..000000000000 --- a/extensions/browser/scripts/copilot-runtime-entry.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Browser copilot runtime bundle entry. Keep this list narrow: the extension -// consumes the canonical Gateway auth/wire engines plus the Ed25519 primitive -// needed below Chrome's native WebCrypto support floor. -export { - GATEWAY_CLIENT_CAPS, - GATEWAY_CLIENT_IDS, - GATEWAY_CLIENT_MODES, - GatewayBrowserDeviceAuthLifecycle, - GatewayProtocolClient, - GatewayProtocolRequestError, - MIN_CLIENT_PROTOCOL_VERSION, - PROTOCOL_VERSION, -} from "@openclaw/gateway-client/browser"; -export { getPublicKeyAsync, signAsync, utils as ed25519Utils } from "@noble/ed25519"; diff --git a/extensions/browser/scripts/copy-chrome-extension.mjs b/extensions/browser/scripts/copy-chrome-extension.mjs index 43ef042351bd..c81565343f30 100644 --- a/extensions/browser/scripts/copy-chrome-extension.mjs +++ b/extensions/browser/scripts/copy-chrome-extension.mjs @@ -40,11 +40,16 @@ async function main() { // Ship only the runtime extension; colocated *.test.ts and *.d.ts stay out. await fs.cp(srcDir, outDir, { recursive: true, - filter: (source) => - !source.endsWith(".test.ts") && - !source.endsWith(".test-support.ts") && - !source.endsWith(".test-harness.ts") && - !source.endsWith(".d.ts"), + filter: (source) => { + const basename = path.basename(source); + return ( + !/(?:sidepanel|copilot|page-share)/iu.test(basename) && + !source.endsWith(".test.ts") && + !source.endsWith(".test-support.ts") && + !source.endsWith(".test-harness.ts") && + !source.endsWith(".d.ts") + ); + }, }); } diff --git a/extensions/browser/src/browser/extension-install-layout.ts b/extensions/browser/src/browser/extension-install-layout.ts new file mode 100644 index 000000000000..b039938cc7b8 --- /dev/null +++ b/extensions/browser/src/browser/extension-install-layout.ts @@ -0,0 +1,427 @@ +import crypto from "node:crypto"; +import { constants as fsConstants, type Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; + +const EXTENSION_ID_PATTERN = /^[a-p]{32}$/; +const UNPACKED_MANIFEST_LOCATION = 4; +const OWNED_COPY_MARKER = ".openclaw-owned.json"; +const SECURE_PREFERENCES_MAX_BYTES = 32 * 1024 * 1024; + +export type ChromeProduct = "chrome" | "chrome-for-testing" | "chromium"; +export type ChromeProductRoot = { + product: ChromeProduct; + label: string; + userDataDir: string; + nativeManifestDir: string; +}; +export type DiscoveredChromeExtension = { + product: ChromeProduct; + browser: string; + userDataDir: string; + profile: string; + securePreferencesPath: string; + extensionId: string; + extensionPath: string; +}; +export type ExtensionInstallDeps = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + stateDir?: string; + homeDir?: string; + nodePath?: string; + nativeHostPath?: string; + now?: () => number; + sleep?: (ms: number) => Promise; +}; + +/** Chromium crx_file::id_util::GenerateIdForPath for a canonical absolute path. */ +export function generateChromeExtensionIdForPath( + canonicalPath: string, + platform: NodeJS.Platform = process.platform, +): string { + const isAbsolute = + platform === "win32" ? path.win32.isAbsolute(canonicalPath) : path.isAbsolute(canonicalPath); + if (!isAbsolute) { + throw new Error("Chrome extension ID paths must be canonical absolute paths"); + } + let nativePath = canonicalPath; + if (platform === "win32" && /^[a-z]:/u.test(nativePath)) { + nativePath = `${nativePath[0]?.toUpperCase()}${nativePath.slice(1)}`; + } + const bytes = Buffer.from(nativePath, platform === "win32" ? "utf16le" : "utf8"); + const hexadecimal = crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 32); + return hexadecimal.replace(/[0-9a-f]/gu, (nibble) => + String.fromCharCode("a".charCodeAt(0) + Number.parseInt(nibble, 16)), + ); +} + +function homeDirectory(deps: ExtensionInstallDeps): string { + const value = deps.homeDir ?? deps.env?.HOME ?? deps.env?.USERPROFILE ?? os.homedir(); + if (!value.trim()) { + throw new Error("Could not resolve the user home directory."); + } + return path.resolve(value); +} + +/** Chromium-derived default user-data and user native-host roots. */ +export function chromeProductRoots(deps: ExtensionInstallDeps = {}): ChromeProductRoot[] { + const platform = deps.platform ?? process.platform; + const env = deps.env ?? process.env; + const home = homeDirectory({ ...deps, env }); + if (platform === "darwin") { + const appSupport = path.join(home, "Library", "Application Support"); + const testingData = path.join(appSupport, "Google", "Chrome for Testing"); + return [ + { + product: "chrome", + label: "Google Chrome", + userDataDir: path.join(appSupport, "Google", "Chrome"), + nativeManifestDir: path.join(appSupport, "Google", "Chrome", "NativeMessagingHosts"), + }, + { + product: "chrome-for-testing", + label: "Google Chrome for Testing", + userDataDir: testingData, + nativeManifestDir: path.join(testingData, "NativeMessagingHosts"), + }, + // Chromium derives this root from user data; Chrome's public table + // currently documents the no-space spelling. Cover both until aligned. + { + product: "chrome-for-testing", + label: "Google Chrome for Testing (documented host root)", + userDataDir: testingData, + nativeManifestDir: path.join( + appSupport, + "Google", + "ChromeForTesting", + "NativeMessagingHosts", + ), + }, + { + product: "chromium", + label: "Chromium", + userDataDir: path.join(appSupport, "Chromium"), + nativeManifestDir: path.join(appSupport, "Chromium", "NativeMessagingHosts"), + }, + ]; + } + if (platform === "linux") { + const configHome = path.resolve( + env.CHROME_CONFIG_HOME?.trim() || env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"), + ); + const roots: Array<[ChromeProduct, string, string]> = [ + ["chrome", "Google Chrome", "google-chrome"], + ["chrome-for-testing", "Google Chrome for Testing", "google-chrome-for-testing"], + ["chromium", "Chromium", "chromium"], + ]; + return roots.map(([product, label, basename]) => ({ + product: product as ChromeProduct, + label, + userDataDir: path.join(configHome, basename), + nativeManifestDir: path.join(configHome, basename, "NativeMessagingHosts"), + })); + } + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (!localAppData) { + return []; + } + const roots: Array<[ChromeProduct, string, string]> = [ + ["chrome", "Google Chrome", path.join("Google", "Chrome", "User Data")], + [ + "chrome-for-testing", + "Google Chrome for Testing", + path.join("Google", "Chrome for Testing", "User Data"), + ], + ["chromium", "Chromium", path.join("Chromium", "User Data")], + ]; + return roots.map(([product, label, suffix]) => ({ + product: product as ChromeProduct, + label, + userDataDir: path.join(localAppData, suffix), + nativeManifestDir: "", + })); + } + return []; +} + +export function stableChromeExtensionDir(deps: ExtensionInstallDeps = {}): string { + return path.join( + path.resolve(deps.stateDir ?? resolveStateDir(deps.env)), + "browser", + "chrome-extension", + ); +} + +export async function pathInfo( + target: string, +): Promise> | null> { + try { + return await fs.lstat(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function assertOwnedPath( + target: string, + kind: "file" | "directory", + policy: { allowRootOwner?: boolean } = {}, +): Promise { + const info = await fs.lstat(target); + if (info.isSymbolicLink() || (kind === "file" ? !info.isFile() : !info.isDirectory())) { + throw new Error(`Unsafe ${kind} at ${target}`); + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + const ownerAllowed = + uid === undefined || info.uid === uid || (policy.allowRootOwner === true && info.uid === 0); + if (!ownerAllowed) { + throw new Error(`Refusing foreign owner at ${target}`); + } + if ((info.mode & 0o022) !== 0) { + throw new Error(`Refusing group/world-writable path at ${target}`); + } + } + if ((await fs.realpath(target)) !== path.resolve(target)) { + throw new Error(`Refusing non-canonical path at ${target}`); + } +} + +export async function ensurePrivateDirectory(target: string): Promise { + await fs.mkdir(target, { recursive: true, mode: 0o700 }); + await assertOwnedPath(target, "directory"); + if (process.platform !== "win32") { + await fs.chmod(target, 0o700); + } +} + +export async function inspectInstalledCopy( + target: string, +): Promise<{ present: boolean; owned: boolean }> { + if (!(await pathInfo(target))) { + return { present: false, owned: false }; + } + await assertOwnedPath(target, "directory"); + const markerPath = path.join(target, OWNED_COPY_MARKER); + try { + await assertOwnedPath(markerPath, "file"); + const marker: unknown = JSON.parse(await fs.readFile(markerPath, "utf8")); + return { + present: true, + owned: + Boolean(marker) && + typeof marker === "object" && + !Array.isArray(marker) && + (marker as { v?: unknown }).v === 1, + }; + } catch { + return { present: true, owned: false }; + } +} + +async function copyRuntimeTree(source: string, target: string): Promise { + await ensurePrivateDirectory(target); + for (const entry of await fs.readdir(source, { withFileTypes: true })) { + const skipped = + /(?:sidepanel|copilot|page-share)/iu.test(entry.name) || + entry.name.endsWith(".test.ts") || + entry.name.endsWith(".test-support.ts") || + entry.name.endsWith(".test-harness.ts") || + entry.name.endsWith(".d.ts"); + if (skipped) { + continue; + } + const sourcePath = path.join(source, entry.name); + const targetPath = path.join(target, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Refusing symlink in bundled Chrome extension: ${sourcePath}`); + } + if (entry.isDirectory()) { + await copyRuntimeTree(sourcePath, targetPath); + } else if (entry.isFile()) { + await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL); + if (process.platform !== "win32") { + await fs.chmod(targetPath, 0o600); + } + } + } +} + +/** Copy/update the bundled extension with rollback-safe same-directory renames. */ +export async function installStableChromeExtension( + bundledDir: string, + deps: ExtensionInstallDeps = {}, +): Promise { + const source = await fs.realpath(path.resolve(bundledDir)); + await assertOwnedPath(source, "directory", { allowRootOwner: true }); + const target = stableChromeExtensionDir(deps); + await ensurePrivateDirectory(path.resolve(deps.stateDir ?? resolveStateDir(deps.env))); + await ensurePrivateDirectory(path.dirname(target)); + const existing = await inspectInstalledCopy(target); + if (existing.present && !existing.owned) { + throw new Error(`Refusing to overwrite foreign Chrome extension directory: ${target}`); + } + const suffix = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`; + const temporary = `${target}.tmp-${suffix}`; + const previous = `${target}.previous-${suffix}`; + try { + await copyRuntimeTree(source, temporary); + await fs.writeFile( + path.join(temporary, OWNED_COPY_MARKER), + `${JSON.stringify({ v: 1, owner: "openclaw" })}\n`, + { mode: 0o600, flag: "wx" }, + ); + if (existing.present) { + await fs.rename(target, previous); + } + try { + await fs.rename(temporary, target); + } catch (error) { + if (existing.present) { + await fs.rename(previous, target).catch(() => undefined); + } + throw error; + } + if (existing.present) { + await fs.rm(previous, { recursive: true, force: true }); + } + return await fs.realpath(target); + } finally { + await fs.rm(temporary, { recursive: true, force: true }).catch(() => undefined); + } +} + +function comparablePath(value: string, platform: NodeJS.Platform): string { + const resolved = path.resolve(value); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +async function approvedRealpaths(paths: readonly string[]): Promise { + const resolved = await Promise.all( + paths.map(async (candidate) => await fs.realpath(candidate).catch(() => null)), + ); + return [...new Set(resolved.filter((value): value is string => value !== null))]; +} + +/** Discover unpacked OpenClaw IDs from exact Secure Preferences path records. */ +export async function discoverChromeExtensionIds(params: { + approvedDirs: readonly string[]; + deps?: ExtensionInstallDeps; +}): Promise<{ + discovered: DiscoveredChromeExtension[]; + issues: string[]; + identityMismatches: string[]; +}> { + const deps = params.deps ?? {}; + const platform = deps.platform ?? process.platform; + const approved = new Set( + (await approvedRealpaths(params.approvedDirs)).map((value) => comparablePath(value, platform)), + ); + const discovered: DiscoveredChromeExtension[] = []; + const issues: string[] = []; + const identityMismatches: string[] = []; + for (const root of chromeProductRoots(deps)) { + if (!(await pathInfo(root.userDataDir))) { + continue; + } + try { + await assertOwnedPath(root.userDataDir, "directory"); + } catch (error) { + issues.push(`${root.label}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + let profiles: Dirent[]; + try { + profiles = await fs.readdir(root.userDataDir, { withFileTypes: true }); + } catch (error) { + issues.push(`${root.label}: could not list profiles (${String(error)})`); + continue; + } + for (const profileEntry of profiles) { + if (!profileEntry.isDirectory() || profileEntry.isSymbolicLink()) { + continue; + } + const profileDir = path.join(root.userDataDir, profileEntry.name); + const securePreferencesPath = path.join(profileDir, "Secure Preferences"); + const secureInfo = await pathInfo(securePreferencesPath); + if (!secureInfo) { + continue; + } + try { + await assertOwnedPath(profileDir, "directory"); + await assertOwnedPath(securePreferencesPath, "file"); + if (secureInfo.size > SECURE_PREFERENCES_MAX_BYTES) { + throw new Error("Secure Preferences exceeds the 32 MiB inspection limit"); + } + const preferences: unknown = JSON.parse(await fs.readFile(securePreferencesPath, "utf8")); + const settings = (preferences as { extensions?: { settings?: unknown } })?.extensions + ?.settings; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + continue; + } + for (const [extensionId, rawEntry] of Object.entries(settings)) { + if ( + !EXTENSION_ID_PATTERN.test(extensionId) || + !rawEntry || + typeof rawEntry !== "object" + ) { + continue; + } + const entry = rawEntry as { location?: unknown; path?: unknown }; + if (entry.location !== UNPACKED_MANIFEST_LOCATION || typeof entry.path !== "string") { + continue; + } + const recordedPath = path.isAbsolute(entry.path) + ? entry.path + : path.join(profileDir, "Extensions", entry.path); + const canonicalPath = await fs.realpath(recordedPath).catch(() => null); + if (!canonicalPath || !approved.has(comparablePath(canonicalPath, platform))) { + continue; + } + const predictedId = generateChromeExtensionIdForPath(canonicalPath, platform); + if (extensionId !== predictedId) { + const issue = `${root.label} profile ${profileEntry.name}: unpacked extension ID ${extensionId} does not match predicted ID ${predictedId} for ${canonicalPath}`; + issues.push(issue); + identityMismatches.push(issue); + continue; + } + discovered.push({ + product: root.product, + browser: root.label, + userDataDir: root.userDataDir, + profile: profileEntry.name, + securePreferencesPath, + extensionId, + extensionPath: canonicalPath, + }); + } + } catch (error) { + issues.push( + `${root.label} profile ${profileEntry.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + const unique = new Map( + discovered.map((entry) => [ + `${entry.product}\0${entry.profile}\0${entry.extensionId}\0${entry.extensionPath}`, + entry, + ]), + ); + return { + discovered: [...unique.values()].toSorted((a, b) => + `${a.product}/${a.profile}/${a.extensionId}`.localeCompare( + `${b.product}/${b.profile}/${b.extensionId}`, + ), + ), + issues, + identityMismatches, + }; +} diff --git a/extensions/browser/src/browser/extension-install.test.ts b/extensions/browser/src/browser/extension-install.test.ts new file mode 100644 index 000000000000..d7db2f9c8f5c --- /dev/null +++ b/extensions/browser/src/browser/extension-install.test.ts @@ -0,0 +1,834 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js"; +import { + assertOwnedPath, + chromeProductRoots, + discoverChromeExtensionIds, + generateChromeExtensionIdForPath, + installStableChromeExtension, + stableChromeExtensionDir, +} from "./extension-install-layout.js"; +import { + browserExtensionStatus, + installChromeExtensionBootstrap, + normalizeExtensionInstallWaitMs, + repairOwnedChromeExtensionNativeHosts, + resolveChromeExtensionLoadPath, + uninstallChromeExtensionNativeHosts, +} from "./extension-install.js"; + +const ID_A = "abcdefghijklmnopabcdefghijklmnop"; +const tempRoots: string[] = []; + +async function predictedId(candidate: string, platform: NodeJS.Platform = process.platform) { + return generateChromeExtensionIdForPath(await fs.realpath(candidate), platform); +} + +async function fixture(platform: NodeJS.Platform = "linux") { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extension-install-")), + ); + tempRoots.push(root); + const homeDir = path.join(root, "home"); + const stateDir = path.join(homeDir, ".openclaw"); + const bundledDir = path.join(root, "package", "extensions", "browser", "chrome-extension"); + const pluginRoot = path.dirname(bundledDir); + const nativeHostPath = path.join(root, "package", "native-host-entry.js"); + await fs.mkdir(path.join(bundledDir, "modules"), { recursive: true, mode: 0o700 }); + await fs.mkdir(homeDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(path.join(bundledDir, "manifest.json"), '{"manifest_version":3}\n'); + await fs.writeFile(path.join(bundledDir, "background.js"), "export {};\n"); + await fs.writeFile(path.join(bundledDir, "modules", "runtime.js"), "export {};\n"); + await fs.writeFile(path.join(bundledDir, "modules", "runtime.test.ts"), "throw new Error();\n"); + await fs.writeFile(path.join(bundledDir, "sidepanel.html"), "must not ship\n"); + await fs.writeFile(nativeHostPath, "export {};\n", { mode: 0o600 }); + const deps = { + platform, + homeDir, + stateDir, + env: { + HOME: homeDir, + LOCALAPPDATA: path.join(homeDir, "AppData", "Local"), + }, + nativeHostPath, + nodePath: process.execPath, + }; + return { root, homeDir, stateDir, bundledDir, pluginRoot, nativeHostPath, deps }; +} + +async function writeSecurePreferences(params: { + userDataDir: string; + profile: string; + entries: Record; +}) { + const profileDir = path.join(params.userDataDir, params.profile); + await fs.mkdir(profileDir, { recursive: true, mode: 0o700 }); + const file = path.join(profileDir, "Secure Preferences"); + await fs.writeFile(file, JSON.stringify({ extensions: { settings: params.entries } }), { + mode: 0o600, + }); + return file; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +function statsWithUid>>(info: T, uid: number): T { + return new Proxy(info, { + get(target, property) { + if (property === "uid") { + return uid; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +describe.runIf(process.platform !== "win32")("extension install ownership policy", () => { + it("allows only explicit read-only root-owned inputs", async () => { + const target = "/opt/openclaw/native-host-entry.js"; + vi.spyOn(process, "getuid").mockReturnValue(1000); + vi.spyOn(fs, "lstat").mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100644, + uid: 0, + } as Awaited>); + vi.spyOn(fs, "realpath").mockResolvedValue(target); + + await expect( + assertOwnedPath(target, "file", { allowRootOwner: true }), + ).resolves.toBeUndefined(); + await expect(assertOwnedPath(target, "file")).rejects.toThrow("foreign owner"); + }); + + it.each([ + { label: "root-owned state", uid: 0, mode: 0o100600, allowRootOwner: false }, + { label: "foreign-owned input", uid: 2000, mode: 0o100600, allowRootOwner: true }, + { label: "root-owned group-writable input", uid: 0, mode: 0o100660, allowRootOwner: true }, + { label: "user-owned world-writable input", uid: 1000, mode: 0o100602, allowRootOwner: false }, + ])("rejects $label", async ({ uid, mode, allowRootOwner }) => { + const target = "/opt/openclaw/unsafe"; + vi.spyOn(process, "getuid").mockReturnValue(1000); + vi.spyOn(fs, "lstat").mockResolvedValue({ + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + mode, + uid, + } as Awaited>); + vi.spyOn(fs, "realpath").mockResolvedValue(target); + + await expect(assertOwnedPath(target, "file", { allowRootOwner })).rejects.toThrow( + uid !== 1000 && !(allowRootOwner && uid === 0) ? "foreign owner" : "group/world-writable", + ); + }); + + it("installs from a package-shaped root-owned tree into user-owned state", async () => { + const value = await fixture(); + const chromium = chromeProductRoots(value.deps).find((root) => root.product === "chromium"); + if (!chromium) { + throw new Error("missing Chromium fixture root"); + } + await fs.mkdir(chromium.userDataDir, { recursive: true, mode: 0o700 }); + const userUid = 1000; + const packageRoot = path.join(value.root, "package"); + const canonicalNodePath = await fs.realpath(value.deps.nodePath); + const realLstat = fs.lstat.bind(fs); + vi.spyOn(process, "getuid").mockReturnValue(userUid); + vi.spyOn(fs, "lstat").mockImplementation(async (target) => { + const info = await realLstat(target); + const resolved = path.resolve(String(target)); + const rootOwned = + resolved.startsWith(`${packageRoot}${path.sep}`) || resolved === canonicalNodePath; + return statsWithUid(info, rootOwned ? 0 : userUid); + }); + let now = 0; + + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: { + ...value.deps, + now: () => now, + sleep: async (ms) => { + now += ms; + }, + }, + }); + + expect(status.installedCopy).toMatchObject({ present: true, owned: true }); + expect(status.registrations.find((entry) => entry.product === "chromium")?.state).toBe("owned"); + }); +}); + +describe("stable extension copy", () => { + it("atomically replaces only its owned runtime copy with private modes", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + await fs.writeFile( + path.join(value.bundledDir, "background.js"), + "export const updated = true;\n", + ); + await installStableChromeExtension(value.bundledDir, value.deps); + + expect(await fs.readFile(path.join(installed, "background.js"), "utf8")).toContain("updated"); + expect(await fs.readFile(path.join(installed, ".openclaw-owned.json"), "utf8")).toContain( + '"owner":"openclaw"', + ); + expect(await fs.readdir(path.join(installed, "modules"))).toEqual(["runtime.js"]); + expect(await fs.readdir(installed)).not.toContain("sidepanel.html"); + if (process.platform !== "win32") { + expect((await fs.stat(installed)).mode & 0o777).toBe(0o700); + expect((await fs.stat(path.join(installed, "background.js"))).mode & 0o777).toBe(0o600); + } + }); + + it("refuses a foreign target and symlinked source content", async () => { + const value = await fixture(); + const target = stableChromeExtensionDir(value.deps); + await fs.mkdir(target, { recursive: true }); + await expect(installStableChromeExtension(value.bundledDir, value.deps)).rejects.toThrow( + "foreign Chrome extension directory", + ); + + await fs.rm(target, { recursive: true, force: true }); + await fs.symlink( + path.join(value.bundledDir, "background.js"), + path.join(value.bundledDir, "link.js"), + ); + await expect(installStableChromeExtension(value.bundledDir, value.deps)).rejects.toThrow( + "Refusing symlink", + ); + }); + + it("keeps path read-only and prefers the installed copy", async () => { + const value = await fixture(); + await expect(resolveChromeExtensionLoadPath(value.bundledDir, value.deps)).resolves.toBe( + await fs.realpath(value.bundledDir), + ); + expect(await fs.stat(value.stateDir).catch(() => null)).toBeNull(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + await expect(resolveChromeExtensionLoadPath(value.bundledDir, value.deps)).resolves.toBe( + installed, + ); + }); +}); + +describe("deterministic unpacked extension ID", () => { + it("matches Chromium's published POSIX and Windows path vectors", () => { + expect(generateChromeExtensionIdForPath("/path/to/file.ext", "linux")).toBe( + "lnkgfdknojmdambfcanadbhmfjfljobb", + ); + expect(generateChromeExtensionIdForPath("/path/to/file.ext", "win32")).toBe( + "jjlkojfgbeklddcpckipekckcmgcbfjn", + ); + }); + + it("normalizes only a lowercase Windows drive letter", () => { + expect(generateChromeExtensionIdForPath("c:\\OpenClaw\\extension", "win32")).toBe( + generateChromeExtensionIdForPath("C:\\OpenClaw\\extension", "win32"), + ); + }); +}); + +describe("Secure Preferences discovery", () => { + it("discovers multiple exact unpacked IDs and ignores name, location, and path lookalikes", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + const installedId = await predictedId(installed, value.deps.platform); + const bundledId = await predictedId(value.bundledDir, value.deps.platform); + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { + [installedId]: { location: 4, path: installed, manifest: { name: "Not OpenClaw" } }, + ["p".repeat(32)]: { location: 1, path: installed, manifest: { name: "OpenClaw" } }, + }, + }); + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Profile 1", + entries: { + [bundledId]: { location: 4, path: value.bundledDir }, + ["o".repeat(32)]: { location: 4, path: path.join(value.root, "lookalike") }, + }, + }); + + const result = await discoverChromeExtensionIds({ + approvedDirs: [installed, value.bundledDir], + deps: value.deps, + }); + + expect(result.discovered.map((entry) => [entry.profile, entry.extensionId])).toEqual([ + ["Default", installedId], + ["Profile 1", bundledId], + ]); + for (const entry of result.discovered) { + expect(entry.extensionId).toBe( + generateChromeExtensionIdForPath(entry.extensionPath, value.deps.platform), + ); + } + }); + + it("rejects a recorded ID that does not match the canonical approved path", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + const expected = await predictedId(installed, value.deps.platform); + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [ID_A]: { location: 4, path: installed } }, + }); + + const result = await discoverChromeExtensionIds({ + approvedDirs: [installed], + deps: value.deps, + }); + + expect(result.discovered).toEqual([]); + expect(result.identityMismatches).toHaveLength(1); + expect(result.issues[0]).toContain(`does not match predicted ID ${expected}`); + }); + + it("fails closed on malformed, oversized, locked, and symlinked profile metadata", async () => { + const value = await fixture(); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + const malformed = await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: {}, + }); + await fs.writeFile(malformed, "{partial", { mode: 0o600 }); + const profileLink = path.join(chrome.userDataDir, "Profile 2"); + await fs.symlink(path.join(chrome.userDataDir, "Default"), profileLink); + const oversized = await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Profile 3", + entries: {}, + }); + await fs.truncate(oversized, 32 * 1024 * 1024 + 1); + const canLockFile = process.platform !== "win32" && process.getuid?.() !== 0; + if (canLockFile) { + const locked = await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Profile 4", + entries: {}, + }); + await fs.chmod(locked, 0o000); + } + + const result = await discoverChromeExtensionIds({ + approvedDirs: [value.bundledDir], + deps: value.deps, + }); + expect(result.discovered).toEqual([]); + expect(result.issues.join("\n")).toContain("Default"); + expect(result.issues.join("\n")).toContain("Profile 3"); + if (canLockFile) { + expect(result.issues.join("\n")).toContain("Profile 4"); + } + }); + + it("does not approve a foreign stable copy in status discovery", async () => { + const value = await fixture(); + const target = stableChromeExtensionDir(value.deps); + await fs.mkdir(target, { recursive: true, mode: 0o700 }); + await fs.writeFile(path.join(target, "manifest.json"), "{}\n", { mode: 0o600 }); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [await predictedId(target, value.deps.platform)]: { location: 4, path: target } }, + }); + + const status = await browserExtensionStatus({ + bundledDir: value.bundledDir, + deps: value.deps, + }); + + expect(status.discovered).toEqual([]); + expect(status.manualSetupRequired).toBe(true); + expect(status.issues.join("\n")).toContain("not OpenClaw-owned"); + }); +}); + +describe("native host registration", () => { + it("launches with the exact custom installation context when Chrome has no selectors", async () => { + const value = await fixture(); + const stateDir = path.join(value.root, "custom state's dir"); + const configPath = path.join(value.root, "custom config's dir", "openclaw.json"); + const relayPort = 19_031; + const token = relayTestKey(4); + const deps = { + ...value.deps, + stateDir, + nativeHostPath: path.resolve("dist/extensions/browser/native-host-entry.js"), + env: { + ...value.deps.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + }, + }; + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true, mode: 0o700 }); + await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 }); + await fs.writeFile( + path.join(stateDir, "credentials", "browser-extension-relay.secret"), + `${token}\n`, + { mode: 0o600 }, + ); + await fs.writeFile( + configPath, + `${JSON.stringify({ browser: { profiles: { e2e: { driver: "extension", cdpPort: relayPort } } } })}\n`, + { mode: 0o600 }, + ); + const installed = await installStableChromeExtension(value.bundledDir, deps); + const chromium = chromeProductRoots(deps).find((root) => root.product === "chromium"); + if (!chromium) { + throw new Error("missing Chromium fixture root"); + } + const extensionId = await predictedId(installed, deps.platform); + await writeSecurePreferences({ + userDataDir: chromium.userDataDir, + profile: "Default", + entries: { [extensionId]: { location: 4, path: installed } }, + }); + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps, + }); + const registration = status.registrations.find((entry) => entry.product === "chromium"); + const manifest = JSON.parse(await fs.readFile(registration?.manifestPath ?? "", "utf8")) as { + path: string; + }; + + const nonce = Buffer.alloc(16, 7).toString("base64url"); + const requestBody = Buffer.from(JSON.stringify({ v: 1, op: "bootstrap", nonce })); + const requestFrame = Buffer.alloc(requestBody.length + 4); + if (os.endianness() === "LE") { + requestFrame.writeUInt32LE(requestBody.length); + } else { + requestFrame.writeUInt32BE(requestBody.length); + } + requestBody.copy(requestFrame, 4); + const host = spawnSync(manifest.path, [`chrome-extension://${extensionId}/`], { + input: requestFrame, + env: { HOME: value.homeDir }, + timeout: 10_000, + }); + expect(host.status, host.stderr.toString("utf8")).toBe(0); + const frameLength = + os.endianness() === "LE" ? host.stdout.readUInt32LE() : host.stdout.readUInt32BE(); + expect(host.stdout).toHaveLength(frameLength + 4); + expect(JSON.parse(host.stdout.subarray(4).toString("utf8"))).toEqual({ + v: 1, + ok: true, + nonce, + pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#${token}`, + }); + }); + + it("pre-registers predicted IDs before waiting, then verifies Chrome's recorded ID", async () => { + const value = await fixture(); + const installed = stableChromeExtensionDir(value.deps); + const chromium = chromeProductRoots(value.deps).find((root) => root.product === "chromium"); + if (!chromium) { + throw new Error("missing Chromium fixture root"); + } + await fs.mkdir(chromium.userDataDir, { recursive: true, mode: 0o700 }); + const installedId = generateChromeExtensionIdForPath(installed, value.deps.platform); + const bundledId = await predictedId(value.bundledDir, value.deps.platform); + let now = 0; + let wroteProfile = false; + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: { + ...value.deps, + now: () => now, + sleep: async (ms) => { + now += ms; + if (!wroteProfile) { + const manifestPath = path.join( + chromium.nativeManifestDir, + "ai.openclaw.browser_bootstrap.json", + ); + const preRegistration = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + allowed_origins: string[]; + }; + expect(preRegistration.allowed_origins).toEqual( + [installedId, bundledId].toSorted().map((id) => `chrome-extension://${id}/`), + ); + wroteProfile = true; + await writeSecurePreferences({ + userDataDir: chromium.userDataDir, + profile: "Default", + entries: { + [installedId]: { location: 4, path: installed }, + }, + }); + } + }, + }, + }); + + expect(status.manualSetupRequired).toBe(false); + const registration = status.registrations.find((entry) => entry.product === "chromium"); + expect(registration).toMatchObject({ + state: "owned", + extensionIds: [installedId, bundledId].toSorted(), + }); + const manifest = await fs.readFile(registration?.manifestPath ?? "", "utf8"); + expect(manifest).toContain(`chrome-extension://${installedId}/`); + expect(manifest).not.toMatch(/[0-9a-f]{64}/u); + expect(JSON.stringify(status)).not.toMatch(/pairingString|token|Bearer/u); + if (process.platform !== "win32") { + expect((await fs.stat(registration?.manifestPath ?? "")).mode & 0o777).toBe(0o600); + const launcherPath = (JSON.parse(manifest) as { path: string }).path; + expect((await fs.stat(launcherPath)).mode & 0o777).toBe(0o700); + const launcher = await fs.readFile(launcherPath, "utf8"); + const expectedOrigins = [installedId, bundledId] + .toSorted() + .map((id) => `chrome-extension://${id}/`); + expect(launcher.match(/chrome-extension:\/\/[a-p]{32}\//gu)?.toSorted()).toEqual( + expectedOrigins, + ); + expect(launcher).not.toMatch(/pairingString|Bearer|#[A-Za-z0-9_-]{20}/u); + } + }); + + it("refuses to overwrite or remove a foreign manifest with the same host name", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const extensionId = await predictedId(installed, value.deps.platform); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [extensionId]: { location: 4, path: installed } }, + }); + await fs.mkdir(chrome.nativeManifestDir, { recursive: true, mode: 0o700 }); + const manifestPath = path.join(chrome.nativeManifestDir, "ai.openclaw.browser_bootstrap.json"); + await fs.writeFile( + manifestPath, + JSON.stringify({ + name: "ai.openclaw.browser_bootstrap", + path: "/foreign/host", + allowed_origins: [`chrome-extension://${extensionId}/`], + }), + { mode: 0o600 }, + ); + + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + expect(status.manualSetupRequired).toBe(true); + expect(status.issues.join("\n")).toContain("pre-registration refused"); + const repair = await repairOwnedChromeExtensionNativeHosts({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + deps: value.deps, + }); + expect(repair.changes).toEqual([]); + expect(repair.warnings.join("\n")).toContain("native host repair refused"); + const removal = await uninstallChromeExtensionNativeHosts({ deps: value.deps }); + expect(removal.refused).toContain(manifestPath); + await expect(fs.readFile(manifestPath, "utf8")).resolves.toContain("/foreign/host"); + }); + + it("warns about an unused product's foreign manifest without blocking the discovered product", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const extensionId = await predictedId(installed, value.deps.platform); + const roots = chromeProductRoots(value.deps); + const chrome = roots.find((root) => root.product === "chrome"); + const chromium = roots.find((root) => root.product === "chromium"); + if (!chrome || !chromium) { + throw new Error("missing browser fixture roots"); + } + await fs.mkdir(chrome.userDataDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(chrome.nativeManifestDir, { recursive: true, mode: 0o700 }); + await fs.writeFile( + path.join(chrome.nativeManifestDir, "ai.openclaw.browser_bootstrap.json"), + JSON.stringify({ name: "foreign", path: "/foreign/host", allowed_origins: [] }), + { mode: 0o600 }, + ); + await writeSecurePreferences({ + userDataDir: chromium.userDataDir, + profile: "Default", + entries: { [extensionId]: { location: 4, path: installed } }, + }); + + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + + expect(status.manualSetupRequired).toBe(false); + expect(status.issues.join("\n")).toContain("Google Chrome"); + expect(status.registrations.find((entry) => entry.product === "chromium")?.state).toBe("owned"); + }); + + it("rejects and removes an owned-path manifest with an extra valid origin", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const installedId = await predictedId(installed, value.deps.platform); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [installedId]: { location: 4, path: installed } }, + }); + let status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + const registration = status.registrations.find((entry) => entry.product === "chrome"); + const manifestPath = registration?.manifestPath ?? ""; + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + allowed_origins: string[]; + }; + const extraOrigin = `chrome-extension://${"p".repeat(32)}/`; + await fs.writeFile( + manifestPath, + `${JSON.stringify({ + ...manifest, + allowed_origins: [...manifest.allowed_origins, extraOrigin].toSorted(), + })}\n`, + { mode: 0o600 }, + ); + + status = await browserExtensionStatus({ bundledDir: value.bundledDir, deps: value.deps }); + expect(status.manualSetupRequired).toBe(true); + expect(status.registrations.find((entry) => entry.product === "chrome")?.state).toBe("invalid"); + const install = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + expect(install.issues.join("\n")).toContain("pre-registration refused"); + await expect(fs.readFile(manifestPath, "utf8")).resolves.toContain(extraOrigin); + const repair = await repairOwnedChromeExtensionNativeHosts({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + deps: value.deps, + }); + expect(repair.changes).toEqual([]); + expect(repair.warnings.join("\n")).toContain("native host repair refused"); + const removal = await uninstallChromeExtensionNativeHosts({ deps: value.deps }); + expect(removal.refused).toEqual([]); + expect(removal.removed).toHaveLength(2); + }); + + it("uninstalls owned registrations and reports Windows as manual_required", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const extensionId = await predictedId(installed, value.deps.platform); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [extensionId]: { location: 4, path: installed } }, + }); + await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + const result = await uninstallChromeExtensionNativeHosts({ deps: value.deps }); + expect(result.refused).toEqual([]); + expect(result.removed).toHaveLength(2); + + const windows = await fixture("win32"); + await installStableChromeExtension(windows.bundledDir, windows.deps); + const status = await browserExtensionStatus({ + bundledDir: windows.bundledDir, + deps: windows.deps, + }); + expect(status.platformSupport).toBe("manual_required"); + await expect(uninstallChromeExtensionNativeHosts({ deps: windows.deps })).resolves.toEqual({ + removed: [], + refused: [], + manualRequired: true, + }); + }); + + it("refuses owned-path ID drift even when the native host moved", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const installedId = await predictedId(installed, value.deps.platform); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [installedId]: { location: 4, path: installed } }, + }); + let status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + const registration = status.registrations.find((entry) => entry.product === "chrome"); + const manifestPath = registration?.manifestPath ?? ""; + const firstManifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + path: string; + allowed_origins: string[]; + }; + await fs.writeFile( + manifestPath, + `${JSON.stringify({ + ...firstManifest, + allowed_origins: [`chrome-extension://${installedId}/`], + })}\n`, + { mode: 0o600 }, + ); + const movedNativeHost = path.join(value.root, "moved", "native-host-entry.js"); + await fs.mkdir(path.dirname(movedNativeHost), { recursive: true }); + await fs.writeFile(movedNativeHost, "export {};\n", { mode: 0o600 }); + const repair = await repairOwnedChromeExtensionNativeHosts({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + deps: { ...value.deps, nativeHostPath: movedNativeHost }, + }); + expect(repair.changes).toEqual([]); + expect(repair.warnings.join("\n")).toContain("native host repair refused"); + status = await browserExtensionStatus({ + bundledDir: value.bundledDir, + deps: { ...value.deps, nativeHostPath: movedNativeHost }, + }); + expect(status.manualSetupRequired).toBe(true); + expect(status.registrations.find((entry) => entry.product === "chrome")?.state).toBe("invalid"); + await expect(fs.readFile(firstManifest.path, "utf8")).resolves.not.toContain(movedNativeHost); + }); + + it("repairs a stale owned launcher when the registered IDs are already exact", async () => { + const value = await fixture(); + const installed = await installStableChromeExtension(value.bundledDir, value.deps); + const installedId = await predictedId(installed, value.deps.platform); + const chrome = chromeProductRoots(value.deps).find((root) => root.product === "chrome"); + if (!chrome) { + throw new Error("missing Chrome fixture root"); + } + await writeSecurePreferences({ + userDataDir: chrome.userDataDir, + profile: "Default", + entries: { [installedId]: { location: 4, path: installed } }, + }); + const status = await installChromeExtensionBootstrap({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + waitMs: 1_000, + deps: value.deps, + }); + const registration = status.registrations.find((entry) => entry.product === "chrome"); + const manifest = JSON.parse(await fs.readFile(registration?.manifestPath ?? "", "utf8")) as { + path: string; + }; + + await expect( + repairOwnedChromeExtensionNativeHosts({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + deps: value.deps, + }), + ).resolves.toEqual({ changes: [], warnings: [] }); + + const movedNativeHost = path.join(value.root, "moved", "native-host-entry.js"); + await fs.mkdir(path.dirname(movedNativeHost), { recursive: true }); + await fs.writeFile(movedNativeHost, "export {};\n", { mode: 0o600 }); + const repair = await repairOwnedChromeExtensionNativeHosts({ + bundledDir: value.bundledDir, + pluginRoot: value.pluginRoot, + deps: { ...value.deps, nativeHostPath: movedNativeHost }, + }); + + expect(repair).toEqual({ + changes: ["Repaired Google Chrome OpenClaw native messaging registration."], + warnings: [], + }); + await expect(fs.readFile(manifest.path, "utf8")).resolves.toContain(movedNativeHost); + }); +}); + +describe("platform roots", () => { + it("maps Chrome, Chrome for Testing, and Chromium profile roots on every supported OS", async () => { + const linux = await fixture("linux"); + expect(chromeProductRoots(linux.deps).map((entry) => entry.product)).toEqual([ + "chrome", + "chrome-for-testing", + "chromium", + ]); + const mac = await fixture("darwin"); + expect(chromeProductRoots(mac.deps).map((entry) => entry.product)).toEqual([ + "chrome", + "chrome-for-testing", + "chrome-for-testing", + "chromium", + ]); + const windows = await fixture("win32"); + expect(chromeProductRoots(windows.deps).map((entry) => entry.userDataDir)).toEqual([ + path.join(windows.deps.env.LOCALAPPDATA, "Google", "Chrome", "User Data"), + path.join(windows.deps.env.LOCALAPPDATA, "Google", "Chrome for Testing", "User Data"), + path.join(windows.deps.env.LOCALAPPDATA, "Chromium", "User Data"), + ]); + }); +}); + +describe("installer option bounds", () => { + it("accepts bounded waits and rejects unbounded waits", () => { + expect(normalizeExtensionInstallWaitMs(undefined)).toBe(30_000); + expect(normalizeExtensionInstallWaitMs("1000")).toBe(1_000); + expect(() => normalizeExtensionInstallWaitMs(999)).toThrow("--wait-ms"); + expect(() => normalizeExtensionInstallWaitMs(120_001)).toThrow("--wait-ms"); + }); +}); diff --git a/extensions/browser/src/browser/extension-install.ts b/extensions/browser/src/browser/extension-install.ts new file mode 100644 index 000000000000..9ed8511d69ea --- /dev/null +++ b/extensions/browser/src/browser/extension-install.ts @@ -0,0 +1,570 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { + assertOwnedPath, + chromeProductRoots, + type ChromeProduct, + type ChromeProductRoot, + discoverChromeExtensionIds, + type DiscoveredChromeExtension, + ensurePrivateDirectory, + type ExtensionInstallDeps, + generateChromeExtensionIdForPath, + inspectInstalledCopy, + installStableChromeExtension, + pathInfo, + stableChromeExtensionDir, +} from "./extension-install-layout.js"; +import { BROWSER_NATIVE_HOST_NAME } from "./extension-native-host.js"; + +const OWNED_LAUNCHER_MARKER = "# OpenClaw native messaging bootstrap v1"; +const BROWSER_EXTENSION_INSTALL_WAIT_DEFAULT_MS = 30_000; +const BROWSER_EXTENSION_INSTALL_WAIT_MIN_MS = 1_000; +const BROWSER_EXTENSION_INSTALL_WAIT_MAX_MS = 120_000; +const NATIVE_HOST_DESCRIPTION = "OpenClaw browser extension bootstrap"; + +type NativeHostRegistrationStatus = { + product: ChromeProduct; + browser: string; + manifestPath: string; + extensionIds: string[]; + state: "missing" | "owned" | "foreign" | "invalid"; + issue?: string; +}; + +type BrowserExtensionStatus = { + platform: NodeJS.Platform; + platformSupport: "automatic" | "manual_required"; + installedCopy: { path: string; present: boolean; owned: boolean }; + bundledPath: string; + approvedPaths: string[]; + discovered: DiscoveredChromeExtension[]; + registrations: NativeHostRegistrationStatus[]; + manualSetupRequired: boolean; + issues: string[]; +}; + +function nativeMessagingRoot(deps: ExtensionInstallDeps = {}): string { + return path.join(resolveInstallStateDir(deps), "browser", "native-messaging"); +} + +function resolveInstallStateDir(deps: ExtensionInstallDeps): string { + return path.resolve(deps.stateDir ?? resolveStateDir(deps.env)); +} + +function resolveInstallConfigPath(deps: ExtensionInstallDeps): string | undefined { + const env = deps.env ?? process.env; + const explicit = env.OPENCLAW_CONFIG_PATH?.trim(); + return explicit ? resolveStateDir({ ...env, OPENCLAW_STATE_DIR: explicit }) : undefined; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +async function resolveNativeHostPath(pluginRoot: string, explicit?: string): Promise { + if (explicit) { + return await fs.realpath(explicit); + } + const resolvedPluginRoot = path.resolve(pluginRoot); + const candidates = [path.join(resolvedPluginRoot, "native-host-entry.js")]; + let cursor = resolvedPluginRoot; + for (;;) { + candidates.push(path.join(cursor, "dist", "extensions", "browser", "native-host-entry.js")); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + for (const candidate of candidates) { + if (await pathInfo(candidate)) { + return await fs.realpath(candidate); + } + } + throw new Error("Could not resolve the built browser native-host entrypoint; run pnpm build."); +} + +function launcherPathForManifest(manifestPath: string, deps: ExtensionInstallDeps): string { + const suffix = crypto.createHash("sha256").update(manifestPath).digest("hex").slice(0, 16); + return path.join(nativeMessagingRoot(deps), `${BROWSER_NATIVE_HOST_NAME}.${suffix}.sh`); +} + +function expectedOriginsForExtensionIds(extensionIds: string[]): string[] { + return [...new Set(extensionIds)] + .toSorted() + .map((extensionId) => `chrome-extension://${extensionId}/`); +} + +async function resolveLauncherInstall(params: { + manifestPath: string; + pluginRoot: string; + extensionIds: string[]; + deps: ExtensionInstallDeps; +}): Promise<{ path: string; content: string }> { + const launcherPath = launcherPathForManifest(params.manifestPath, params.deps); + const nodePath = await fs.realpath(params.deps.nodePath ?? process.execPath); + const nativeHostPath = await resolveNativeHostPath(params.pluginRoot, params.deps.nativeHostPath); + await assertOwnedPath(nodePath, "file", { allowRootOwner: true }); + await assertOwnedPath(nativeHostPath, "file", { allowRootOwner: true }); + const command = [ + nodePath, + nativeHostPath, + "--manifest", + params.manifestPath, + "--launcher", + launcherPath, + ...expectedOriginsForExtensionIds(params.extensionIds).flatMap((origin) => [ + "--expected-origin", + origin, + ]), + ]; + const configPath = resolveInstallConfigPath(params.deps); + return { + path: launcherPath, + content: [ + "#!/bin/sh", + OWNED_LAUNCHER_MARKER, + `export OPENCLAW_STATE_DIR=${shellQuote(resolveInstallStateDir(params.deps))}`, + ...(configPath ? [`export OPENCLAW_CONFIG_PATH=${shellQuote(configPath)}`] : []), + `exec ${command.map(shellQuote).join(" ")} "$@"`, + "", + ].join("\n"), + }; +} + +async function inspectRegistration( + root: ChromeProductRoot, + deps: ExtensionInstallDeps, + expectedExtensionIds?: string[], +): Promise { + const manifestPath = path.join(root.nativeManifestDir, `${BROWSER_NATIVE_HOST_NAME}.json`); + if (!(await pathInfo(manifestPath))) { + return { + product: root.product, + browser: root.label, + manifestPath, + extensionIds: [], + state: "missing", + }; + } + try { + await assertOwnedPath(manifestPath, "file"); + const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("manifest is not an object"); + } + const manifest = parsed as { name?: unknown; path?: unknown; allowed_origins?: unknown }; + const expectedLauncher = launcherPathForManifest(manifestPath, deps); + const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : []; + const ids = origins.flatMap((origin) => { + const match = /^chrome-extension:\/\/([a-p]{32})\/$/.exec(String(origin)); + return match?.[1] ? [match[1]] : []; + }); + if (manifest.name !== BROWSER_NATIVE_HOST_NAME || manifest.path !== expectedLauncher) { + return { + product: root.product, + browser: root.label, + manifestPath, + extensionIds: ids, + state: "foreign", + issue: "same host name is registered to a foreign manifest or launcher", + }; + } + const exactKeys = ["name", "description", "path", "type", "allowed_origins"]; + const validOrigins = + origins.length > 0 && + origins.every( + (origin) => + typeof origin === "string" && /^chrome-extension:\/\/[a-p]{32}\/$/u.test(origin), + ) && + new Set(origins).size === origins.length; + const expectedOrigins = expectedExtensionIds + ? expectedOriginsForExtensionIds(expectedExtensionIds) + : null; + if ( + Object.keys(manifest).length !== exactKeys.length || + !exactKeys.every((key) => Object.hasOwn(manifest, key)) || + (manifest as { description?: unknown }).description !== NATIVE_HOST_DESCRIPTION || + (manifest as { type?: unknown }).type !== "stdio" || + !validOrigins || + (expectedOrigins !== null && JSON.stringify(origins) !== JSON.stringify(expectedOrigins)) + ) { + throw new Error("native host manifest does not contain exact allowed origins"); + } + await assertOwnedPath(expectedLauncher, "file"); + if (!(await fs.readFile(expectedLauncher, "utf8")).includes(OWNED_LAUNCHER_MARKER)) { + throw new Error("launcher ownership marker is missing"); + } + return { + product: root.product, + browser: root.label, + manifestPath, + extensionIds: ids.toSorted(), + state: "owned", + }; + } catch (error) { + return { + product: root.product, + browser: root.label, + manifestPath, + extensionIds: [], + state: "invalid", + issue: error instanceof Error ? error.message : String(error), + }; + } +} + +async function installRegistration(params: { + root: ChromeProductRoot; + extensionIds: string[]; + pluginRoot: string; + deps: ExtensionInstallDeps; +}): Promise { + const { root, extensionIds, deps } = params; + const manifestPath = path.join(root.nativeManifestDir, `${BROWSER_NATIVE_HOST_NAME}.json`); + const existing = await inspectRegistration(root, deps, extensionIds); + if (existing.state === "foreign" || existing.state === "invalid") { + throw new Error(`Refusing to overwrite ${existing.state} native host: ${manifestPath}`); + } + await ensurePrivateDirectory(nativeMessagingRoot(deps)); + await ensurePrivateDirectory(root.nativeManifestDir); + const launcher = await resolveLauncherInstall({ + manifestPath, + pluginRoot: params.pluginRoot, + extensionIds, + deps, + }); + const launcherPath = launcher.path; + if (await pathInfo(launcherPath)) { + await assertOwnedPath(launcherPath, "file"); + const existingLauncher = await fs.readFile(launcherPath, "utf8"); + if (!existingLauncher.includes(OWNED_LAUNCHER_MARKER)) { + throw new Error(`Refusing to overwrite foreign native host launcher: ${launcherPath}`); + } + if (existingLauncher !== launcher.content) { + const replacement = `${launcherPath}.tmp-${process.pid}`; + await fs.writeFile(replacement, launcher.content, { mode: 0o700, flag: "wx" }); + await fs.rename(replacement, launcherPath); + } + } else { + await fs.writeFile(launcherPath, launcher.content, { mode: 0o700, flag: "wx" }); + } + if (process.platform !== "win32") { + await fs.chmod(launcherPath, 0o700); + } + const manifest = { + name: BROWSER_NATIVE_HOST_NAME, + description: NATIVE_HOST_DESCRIPTION, + path: launcherPath, + type: "stdio", + allowed_origins: expectedOriginsForExtensionIds(extensionIds), + }; + const temporary = `${manifestPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`; + await fs.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { + mode: 0o600, + flag: "wx", + }); + await fs.rename(temporary, manifestPath); + if (process.platform !== "win32") { + await fs.chmod(manifestPath, 0o600); + } + return await inspectRegistration(root, deps, extensionIds); +} + +async function approvedInstallRealpaths(installed: string, bundled: string): Promise { + const installedPath = await fs.realpath(installed); + const bundledPath = await fs.realpath(bundled); + await assertOwnedPath(installedPath, "directory"); + await assertOwnedPath(bundledPath, "directory", { allowRootOwner: true }); + return [...new Set([installedPath, bundledPath])]; +} + +export function normalizeExtensionInstallWaitMs(value: unknown): number { + if (value === undefined) { + return BROWSER_EXTENSION_INSTALL_WAIT_DEFAULT_MS; + } + const parsed = typeof value === "number" ? value : Number(value); + if ( + !Number.isInteger(parsed) || + parsed < BROWSER_EXTENSION_INSTALL_WAIT_MIN_MS || + parsed > BROWSER_EXTENSION_INSTALL_WAIT_MAX_MS + ) { + throw new Error( + `--wait-ms must be an integer from ${BROWSER_EXTENSION_INSTALL_WAIT_MIN_MS} to ${BROWSER_EXTENSION_INSTALL_WAIT_MAX_MS}`, + ); + } + return parsed; +} + +/** Copy, pre-register deterministic IDs, then verify Chrome's recorded identity. */ +export async function installChromeExtensionBootstrap(params: { + bundledDir: string; + pluginRoot: string; + waitMs?: number; + deps?: ExtensionInstallDeps; + onProgress?: (message: string) => void; +}): Promise { + const deps = params.deps ?? {}; + const platform = deps.platform ?? process.platform; + const installed = await installStableChromeExtension(params.bundledDir, deps); + if (platform === "win32") { + return await browserExtensionStatus({ bundledDir: params.bundledDir, deps }); + } + const approvedPaths = await approvedInstallRealpaths(installed, params.bundledDir); + const predictedIds = [ + ...new Set( + approvedPaths.map((candidate) => generateChromeExtensionIdForPath(candidate, platform)), + ), + ].toSorted(); + const preRegistrationIssues: string[] = []; + let preRegisteredRoots = 0; + for (const root of chromeProductRoots(deps)) { + if (!(await pathInfo(root.userDataDir))) { + continue; + } + try { + await assertOwnedPath(root.userDataDir, "directory"); + await installRegistration({ + root, + extensionIds: predictedIds, + pluginRoot: params.pluginRoot, + deps, + }); + preRegisteredRoots += 1; + params.onProgress?.(`Pre-registered the native host for ${root.label}.`); + } catch (error) { + preRegistrationIssues.push( + `${root.label}: native host pre-registration refused (${error instanceof Error ? error.message : String(error)})`, + ); + } + } + if (preRegisteredRoots > 0) { + params.onProgress?.( + `Native bootstrap is ready. In Chrome, use chrome://extensions → Developer mode → Load unpacked → ${installed}`, + ); + } else { + preRegistrationIssues.push( + "No existing Chrome-family user-data directory was available for native host pre-registration. Launch Chrome, then run install again before loading the extension.", + ); + } + const waitMs = normalizeExtensionInstallWaitMs(params.waitMs); + const now = deps.now ?? Date.now; + const sleep = + deps.sleep ?? + ((ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })); + const deadline = now() + waitMs; + let discovery = await discoverChromeExtensionIds({ + approvedDirs: approvedPaths, + deps, + }); + let announcedWait = false; + while (discovery.discovered.length === 0 && now() < deadline) { + if (!announcedWait) { + params.onProgress?.("Waiting for Chrome to verify the unpacked OpenClaw extension…"); + announcedWait = true; + } + await sleep(Math.min(500, Math.max(1, deadline - now()))); + discovery = await discoverChromeExtensionIds({ + approvedDirs: approvedPaths, + deps, + }); + } + const status = await browserExtensionStatus({ bundledDir: params.bundledDir, deps }); + return { + ...status, + issues: [...new Set([...preRegistrationIssues, ...status.issues])], + }; +} + +/** Read-only extension copy, profile discovery, and native registration report. */ +export async function browserExtensionStatus(params: { + bundledDir: string; + deps?: ExtensionInstallDeps; +}): Promise { + const deps = params.deps ?? {}; + const platform = deps.platform ?? process.platform; + const installedPath = stableChromeExtensionDir(deps); + const installedCopy = await inspectInstalledCopy(installedPath); + const bundledPath = await fs.realpath(params.bundledDir); + await assertOwnedPath(bundledPath, "directory", { allowRootOwner: true }); + const approvedPaths = installedCopy.owned + ? await approvedInstallRealpaths(installedPath, bundledPath) + : [bundledPath]; + const discovery = await discoverChromeExtensionIds({ approvedDirs: approvedPaths, deps }); + const predictedIds = [ + ...new Set( + approvedPaths.map((candidate) => generateChromeExtensionIdForPath(candidate, platform)), + ), + ].toSorted(); + const registrations = + platform === "win32" + ? [] + : await Promise.all( + chromeProductRoots(deps).map((root) => inspectRegistration(root, deps, predictedIds)), + ); + const missingRegistration = chromeProductRoots(deps).some((root) => { + const productWasDiscovered = discovery.discovered.some( + (entry) => entry.product === root.product, + ); + if (!productWasDiscovered) { + return false; + } + const manifestPath = path.join(root.nativeManifestDir, `${BROWSER_NATIVE_HOST_NAME}.json`); + const registration = registrations.find((entry) => entry.manifestPath === manifestPath); + return ( + registration?.state !== "owned" || + JSON.stringify(registration.extensionIds) !== JSON.stringify(predictedIds) + ); + }); + return { + platform, + platformSupport: platform === "win32" ? "manual_required" : "automatic", + installedCopy: { path: installedPath, ...installedCopy }, + bundledPath: path.resolve(params.bundledDir), + approvedPaths, + discovered: discovery.discovered, + registrations, + manualSetupRequired: + platform === "win32" || + (installedCopy.present && !installedCopy.owned) || + discovery.discovered.length === 0 || + discovery.identityMismatches.length > 0 || + missingRegistration, + issues: [ + ...(installedCopy.present && !installedCopy.owned + ? [`Chrome extension copy is not OpenClaw-owned: ${installedPath}`] + : []), + ...discovery.issues, + ...registrations.flatMap((entry) => + entry.issue ? [`${entry.browser}: ${entry.issue}`] : [], + ), + ], + }; +} + +/** Remove only registrations and launchers that carry OpenClaw ownership. */ +export async function uninstallChromeExtensionNativeHosts( + params: { deps?: ExtensionInstallDeps } = {}, +): Promise<{ removed: string[]; refused: string[]; manualRequired: boolean }> { + const deps = params.deps ?? {}; + if ((deps.platform ?? process.platform) === "win32") { + return { removed: [], refused: [], manualRequired: true }; + } + const removed: string[] = []; + const refused: string[] = []; + for (const root of chromeProductRoots(deps)) { + const status = await inspectRegistration(root, deps); + if (status.state === "missing") { + continue; + } + if (status.state !== "owned") { + refused.push(status.manifestPath); + continue; + } + const launcherPath = launcherPathForManifest(status.manifestPath, deps); + const launcher = await pathInfo(launcherPath); + if (launcher) { + await assertOwnedPath(launcherPath, "file"); + if (!(await fs.readFile(launcherPath, "utf8")).includes(OWNED_LAUNCHER_MARKER)) { + refused.push(launcherPath); + continue; + } + } + await fs.unlink(status.manifestPath); + removed.push(status.manifestPath); + if (launcher) { + await fs.unlink(launcherPath); + removed.push(launcherPath); + } + } + return { removed, refused, manualRequired: false }; +} + +/** Resolve the installed stable copy when present, bundled source otherwise. */ +export async function resolveChromeExtensionLoadPath( + bundledDir: string, + deps: ExtensionInstallDeps = {}, +): Promise { + const installedPath = stableChromeExtensionDir(deps); + const installed = await inspectInstalledCopy(installedPath); + if (installed.present) { + if (!installed.owned) { + throw new Error(`Refusing foreign Chrome extension directory: ${installedPath}`); + } + return await fs.realpath(installedPath); + } + const bundledPath = await fs.realpath(path.resolve(bundledDir)); + await assertOwnedPath(bundledPath, "directory", { allowRootOwner: true }); + return bundledPath; +} + +/** Repair drift only when both the copy and existing registration are already owned. */ +export async function repairOwnedChromeExtensionNativeHosts(params: { + bundledDir: string; + pluginRoot: string; + deps?: ExtensionInstallDeps; +}): Promise<{ changes: string[]; warnings: string[] }> { + const deps = params.deps ?? {}; + if ((deps.platform ?? process.platform) === "win32") { + return { changes: [], warnings: [] }; + } + const before = await browserExtensionStatus({ bundledDir: params.bundledDir, deps }); + if (!before.installedCopy.owned || before.discovered.length === 0) { + return { changes: [], warnings: [] }; + } + const changes: string[] = []; + const warnings: string[] = []; + const predictedIds = before.approvedPaths + .map((candidate) => + generateChromeExtensionIdForPath(candidate, deps.platform ?? process.platform), + ) + .toSorted(); + for (const root of chromeProductRoots(deps)) { + const manifestPath = path.join(root.nativeManifestDir, `${BROWSER_NATIVE_HOST_NAME}.json`); + const registration = before.registrations.find((entry) => entry.manifestPath === manifestPath); + const productWasDiscovered = before.discovered.some((entry) => entry.product === root.product); + if (!productWasDiscovered) { + continue; + } + if (registration?.state === "foreign" || registration?.state === "invalid") { + warnings.push( + `${root.label} native host repair refused: ${registration.issue ?? registration.state}`, + ); + continue; + } + if (registration?.state !== "owned") { + continue; + } + try { + const launcher = await resolveLauncherInstall({ + manifestPath, + pluginRoot: params.pluginRoot, + extensionIds: predictedIds, + deps, + }); + await assertOwnedPath(launcher.path, "file"); + const idsAreCurrent = + JSON.stringify(registration.extensionIds) === JSON.stringify(predictedIds); + const launcherIsCurrent = (await fs.readFile(launcher.path, "utf8")) === launcher.content; + if (idsAreCurrent && launcherIsCurrent) { + continue; + } + await installRegistration({ + root, + extensionIds: predictedIds, + pluginRoot: params.pluginRoot, + deps, + }); + changes.push(`Repaired ${root.label} OpenClaw native messaging registration.`); + } catch (error) { + warnings.push(`${root.label} native host repair failed: ${String(error)}`); + } + } + return { changes, warnings }; +} diff --git a/extensions/browser/src/browser/extension-native-host.test.ts b/extensions/browser/src/browser/extension-native-host.test.ts new file mode 100644 index 000000000000..5ad442a6be89 --- /dev/null +++ b/extensions/browser/src/browser/extension-native-host.test.ts @@ -0,0 +1,311 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js"; +import { parseBrowserNativeHostOrigins, runBrowserNativeHost } from "./extension-native-host.js"; +import { + decodeBrowserNativeFrame, + encodeBrowserNativeResponse, + readBrowserNativeFrame, +} from "./extension-native-protocol.js"; + +const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop"; +const ORIGIN = `chrome-extension://${EXTENSION_ID}/`; +const OTHER_ORIGIN = `chrome-extension://${"p".repeat(32)}/`; +const NONCE = Buffer.alloc(16, 7).toString("base64url"); +const PAIRING = `ws://127.0.0.1:18799/extension#${relayTestKey(1)}`; +const REQUEST_MAX_BYTES = 4 * 1024; +const tempRoots: string[] = []; + +function frame(payload: Buffer | string): Buffer { + const body = typeof payload === "string" ? Buffer.from(payload) : payload; + const result = Buffer.alloc(body.length + 4); + if (os.endianness() === "LE") { + result.writeUInt32LE(body.length); + } else { + result.writeUInt32BE(body.length); + } + body.copy(result, 4); + return result; +} + +function requestJson(overrides: Record = {}): string { + return JSON.stringify({ v: 1, op: "bootstrap", nonce: NONCE, ...overrides }); +} + +async function* chunks(...values: Buffer[]) { + for (const value of values) { + yield value; + } +} + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe("native messaging framing", () => { + it("reads a fragmented native-endian frame exactly", async () => { + const expected = frame(requestJson()); + const actual = await readBrowserNativeFrame( + chunks( + expected.subarray(0, 1), + expected.subarray(1, 4), + expected.subarray(4, 9), + expected.subarray(9), + ), + ); + + expect(actual).toEqual(expected); + expect(decodeBrowserNativeFrame(actual)).toEqual({ + ok: true, + request: { v: 1, op: "bootstrap", nonce: NONCE }, + }); + }); + + it.each([ + ["truncated header", Buffer.from([1, 0, 0])], + ["truncated body", frame(requestJson()).subarray(0, 8)], + ["zero length", Buffer.alloc(4)], + ["multiple messages", Buffer.concat([frame(requestJson()), frame(requestJson())])], + ])("rejects %s", async (_label, input) => { + await expect(readBrowserNativeFrame(chunks(input))).rejects.toThrow("invalid_frame"); + }); + + it("returns a complete request without waiting for Chrome to close stdin", async () => { + const expected = frame(requestJson()); + let first = true; + const openPipe = { + [Symbol.asyncIterator]() { + return { + next: async () => { + if (first) { + first = false; + return { done: false as const, value: expected }; + } + return await new Promise>(() => {}); + }, + }; + }, + }; + const result = await Promise.race([ + readBrowserNativeFrame(openPipe), + new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), 100); + }), + ]); + + expect(result).toEqual(expected); + }); + + it("rejects an oversized length before allocating its payload", async () => { + const header = Buffer.alloc(4); + if (os.endianness() === "LE") { + header.writeUInt32LE(REQUEST_MAX_BYTES + 1); + } else { + header.writeUInt32BE(REQUEST_MAX_BYTES + 1); + } + + await expect(readBrowserNativeFrame(chunks(header))).rejects.toThrow("invalid_frame"); + }); + + it("rejects fatal UTF-8", () => { + expect(decodeBrowserNativeFrame(frame(Buffer.from([0xc3, 0x28])))).toEqual({ + ok: false, + code: "invalid_utf8", + }); + }); + + it("uses one bounded stdout frame", () => { + const output = encodeBrowserNativeResponse({ + v: 1, + ok: true, + nonce: NONCE, + pairingString: PAIRING, + }); + const length = os.endianness() === "LE" ? output.readUInt32LE() : output.readUInt32BE(); + expect(output).toHaveLength(length + 4); + expect(length).toBeLessThan(1024 * 1024); + expect(JSON.parse(output.subarray(4).toString("utf8"))).toEqual({ + v: 1, + ok: true, + nonce: NONCE, + pairingString: PAIRING, + }); + }); +}); + +describe("native bootstrap request schema", () => { + it("accepts only the exact flat request", () => { + expect(decodeBrowserNativeFrame(frame(requestJson()))).toEqual({ + ok: true, + request: { v: 1, op: "bootstrap", nonce: NONCE }, + }); + }); + + it.each([ + ["array", JSON.stringify([{ v: 1, op: "bootstrap", nonce: NONCE }])], + ["prototype-shaped", `{"v":1,"op":"bootstrap","nonce":"${NONCE}","__proto__":{}}`], + [ + "constructor field", + JSON.stringify({ v: 1, op: "bootstrap", nonce: NONCE, constructor: "x" }), + ], + ["duplicate field", `{"v":1,"op":"bootstrap","nonce":"${NONCE}","nonce":"${NONCE}"}`], + ["unknown field", JSON.stringify({ v: 1, op: "bootstrap", nonce: NONCE, extra: true })], + ["padded nonce", JSON.stringify({ v: 1, op: "bootstrap", nonce: `${NONCE}=` })], + ["short nonce", JSON.stringify({ v: 1, op: "bootstrap", nonce: "AA" })], + ])("rejects $0", (_label, raw) => { + expect(decodeBrowserNativeFrame(frame(raw))).toEqual({ + ok: false, + code: "invalid_request", + }); + }); +}); + +async function nativeFixture() { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-host-"))); + tempRoots.push(root); + const stateDir = path.join(root, "state"); + const managedDir = path.join(stateDir, "browser", "native-messaging"); + const manifestDir = path.join(root, "chrome", "NativeMessagingHosts"); + await fs.mkdir(managedDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(manifestDir, { recursive: true, mode: 0o700 }); + const launcherPath = path.join(managedDir, "bootstrap.sh"); + const manifestPath = path.join(manifestDir, "ai.openclaw.browser_bootstrap.json"); + await fs.writeFile(launcherPath, "#!/bin/sh\n", { mode: 0o700 }); + await fs.writeFile( + manifestPath, + `${JSON.stringify({ + name: "ai.openclaw.browser_bootstrap", + description: "OpenClaw browser extension bootstrap", + path: launcherPath, + type: "stdio", + allowed_origins: [ORIGIN], + })}\n`, + { mode: 0o600 }, + ); + return { stateDir, launcherPath, manifestPath }; +} + +async function invokeHost(overrides: Partial[0]> = {}) { + const fixture = await nativeFixture(); + const writes: Buffer[] = []; + const response = await runBrowserNativeHost({ + ...fixture, + callerOrigin: ORIGIN, + expectedOrigins: [ORIGIN], + input: chunks(frame(requestJson())), + write: (value) => writes.push(value), + buildPairing: async () => ({ pairingString: PAIRING, topology: "local" }), + ...overrides, + }); + return { response, writes, fixture }; +} + +describe("native host origin and topology boundary", () => { + it("parses a nonempty sorted unique expected-origin list before the caller origin", () => { + expect( + parseBrowserNativeHostOrigins([ + "--manifest", + "manifest.json", + "--expected-origin", + ORIGIN, + "--expected-origin", + OTHER_ORIGIN, + OTHER_ORIGIN, + ]), + ).toEqual({ expectedOrigins: [ORIGIN, OTHER_ORIGIN], callerOrigin: OTHER_ORIGIN }); + }); + + it.each([ + ["missing list", [ORIGIN]], + ["missing value", ["--expected-origin"]], + ["duplicate", ["--expected-origin", ORIGIN, "--expected-origin", ORIGIN, ORIGIN]], + ["unsorted", ["--expected-origin", OTHER_ORIGIN, "--expected-origin", ORIGIN, ORIGIN]], + ["malformed", ["--expected-origin", "chrome-extension://*/", ORIGIN]], + ["multiple callers", ["--expected-origin", ORIGIN, ORIGIN, OTHER_ORIGIN]], + ])("rejects %s expected-origin arguments", (_label, argv) => { + expect(() => parseBrowserNativeHostOrigins(argv)).toThrow(); + }); + + it("echoes the nonce and returns only the canonical pairing", async () => { + const result = await invokeHost(); + expect(result.response).toEqual({ v: 1, ok: true, nonce: NONCE, pairingString: PAIRING }); + expect(result.writes).toHaveLength(1); + }); + + it("rejects a wrong extension origin", async () => { + const result = await invokeHost({ callerOrigin: OTHER_ORIGIN }); + expect(result.response).toEqual({ v: 1, ok: false, code: "origin_forbidden" }); + }); + + it("rejects a manifest with an extra valid origin before building pairing", async () => { + const fixture = await nativeFixture(); + await fs.writeFile( + fixture.manifestPath, + `${JSON.stringify({ + name: "ai.openclaw.browser_bootstrap", + description: "OpenClaw browser extension bootstrap", + path: fixture.launcherPath, + type: "stdio", + allowed_origins: [ORIGIN, OTHER_ORIGIN], + })}\n`, + { mode: 0o600 }, + ); + const buildPairing = vi.fn(async () => ({ pairingString: PAIRING, topology: "local" })); + + const response = await runBrowserNativeHost({ + ...fixture, + callerOrigin: ORIGIN, + expectedOrigins: [ORIGIN], + input: chunks(frame(requestJson())), + write: vi.fn(), + buildPairing, + }); + + expect(response).toEqual({ v: 1, ok: false, code: "manifest_invalid" }); + expect(buildPairing).not.toHaveBeenCalled(); + }); + + it("rejects a wildcard manifest", async () => { + const fixture = await nativeFixture(); + await fs.writeFile( + fixture.manifestPath, + JSON.stringify({ + name: "ai.openclaw.browser_bootstrap", + description: "OpenClaw browser extension bootstrap", + path: fixture.launcherPath, + type: "stdio", + allowed_origins: ["chrome-extension://*/"], + }), + { mode: 0o600 }, + ); + const writes: Buffer[] = []; + const response = await runBrowserNativeHost({ + ...fixture, + callerOrigin: ORIGIN, + expectedOrigins: [ORIGIN], + input: chunks(frame(requestJson())), + write: (value) => writes.push(value), + buildPairing: async () => ({ pairingString: PAIRING, topology: "local" }), + }); + expect(response).toEqual({ v: 1, ok: false, code: "manifest_invalid" }); + }); + + it("returns manual_required for direct remote topology and Windows", async () => { + await expect( + invokeHost({ + buildPairing: async () => ({ pairingString: PAIRING, topology: "direct-remote" }), + }).then((result) => result.response), + ).resolves.toEqual({ v: 1, ok: false, code: "manual_required" }); + await expect( + invokeHost({ platform: "win32" }).then((result) => result.response), + ).resolves.toEqual({ + v: 1, + ok: false, + code: "manual_required", + }); + }); +}); diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts new file mode 100644 index 000000000000..4bea06f7134e --- /dev/null +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -0,0 +1,189 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { asRecord } from "../record-shared.js"; +import { + type BrowserNativeBootstrapResponse, + decodeBrowserNativeFrame, + encodeBrowserNativeResponse, + readBrowserNativeFrame, +} from "./extension-native-protocol.js"; + +export const BROWSER_NATIVE_HOST_NAME = "ai.openclaw.browser_bootstrap"; +const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}\/$/; + +type NativeHostManifest = { + name: string; + description: string; + path: string; + type: string; + allowed_origins: string[]; +}; + +function validateExpectedOrigins(origins: string[]): string[] { + const canonical = [...new Set(origins)].toSorted(); + if ( + origins.length === 0 || + origins.length !== canonical.length || + origins.some((origin, index) => origin !== canonical[index]) || + origins.some((origin) => !EXTENSION_ORIGIN_PATTERN.test(origin)) + ) { + throw new Error("invalid expected origins"); + } + return canonical; +} + +export function parseBrowserNativeHostOrigins(argv: string[]): { + expectedOrigins: string[]; + callerOrigin: string; +} { + const expectedOrigins: string[] = []; + let callerOrigin = ""; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--expected-origin") { + const value = argv[index + 1]; + if (!value || callerOrigin) { + throw new Error("invalid expected-origin arguments"); + } + expectedOrigins.push(value); + index += 1; + } else if (argument?.startsWith("chrome-extension://")) { + if (callerOrigin) { + throw new Error("multiple Chrome extension origins"); + } + callerOrigin = argument; + } + } + validateExpectedOrigins(expectedOrigins); + if (!EXTENSION_ORIGIN_PATTERN.test(callerOrigin)) { + throw new Error("missing Chrome extension origin"); + } + return { expectedOrigins, callerOrigin }; +} + +async function validateOwnedFile(filePath: string, executable: boolean): Promise { + const resolved = path.resolve(filePath); + const info = await fs.lstat(resolved); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("unsafe file type"); + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid !== undefined && info.uid !== uid) { + throw new Error("foreign file owner"); + } + const mode = info.mode & 0o777; + if ((mode & 0o077) !== 0 || (executable && (mode & 0o100) === 0)) { + throw new Error("unsafe file mode"); + } + } + const canonical = await fs.realpath(resolved); + if (canonical !== resolved) { + throw new Error("non-canonical file path"); + } + return canonical; +} + +async function validateNativeManifest(params: { + manifestPath: string; + launcherPath: string; + callerOrigin: string; + expectedOrigins: string[]; + stateDir?: string; +}): Promise { + const manifestPath = await validateOwnedFile(params.manifestPath, false); + const launcherPath = await validateOwnedFile(params.launcherPath, true); + const managedRoot = path.resolve( + params.stateDir ?? resolveStateDir(), + "browser", + "native-messaging", + ); + if (launcherPath !== managedRoot && !launcherPath.startsWith(`${managedRoot}${path.sep}`)) { + throw new Error("launcher is outside the managed root"); + } + const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8")); + if (!asRecord(parsed)) { + throw new Error("invalid manifest"); + } + const manifest = parsed as NativeHostManifest; + const expectedOrigins = validateExpectedOrigins(params.expectedOrigins); + const keys = ["name", "description", "path", "type", "allowed_origins"]; + if ( + Object.keys(manifest).length !== keys.length || + !keys.every((key) => Object.hasOwn(manifest, key)) || + manifest.name !== BROWSER_NATIVE_HOST_NAME || + manifest.type !== "stdio" || + manifest.path !== launcherPath || + !Array.isArray(manifest.allowed_origins) || + JSON.stringify(manifest.allowed_origins) !== JSON.stringify(expectedOrigins) + ) { + throw new Error("invalid manifest"); + } + if (!expectedOrigins.includes(params.callerOrigin)) { + throw new Error("origin forbidden"); + } +} + +/** Run one request/response native host process. */ +export async function runBrowserNativeHost(params: { + manifestPath: string; + launcherPath: string; + callerOrigin: string; + expectedOrigins: string[]; + input: AsyncIterable; + write: (frame: Buffer) => void; + buildPairing: () => Promise<{ pairingString: string; topology: string }>; + stateDir?: string; + platform?: NodeJS.Platform; +}): Promise { + let response: BrowserNativeBootstrapResponse; + try { + const decoded = decodeBrowserNativeFrame(await readBrowserNativeFrame(params.input)); + if (!decoded.ok) { + response = { v: 1, ok: false, code: decoded.code }; + } else if ((params.platform ?? process.platform) === "win32") { + response = { v: 1, ok: false, code: "manual_required" }; + } else { + try { + await validateNativeManifest(params); + } catch (error) { + response = { + v: 1, + ok: false, + code: + error instanceof Error && error.message === "origin forbidden" + ? "origin_forbidden" + : "manifest_invalid", + }; + params.write(encodeBrowserNativeResponse(response)); + return response; + } + try { + const pairing = await params.buildPairing(); + response = + pairing.topology === "direct-remote" + ? { v: 1, ok: false, code: "manual_required" } + : { + v: 1, + ok: true, + nonce: decoded.request.nonce, + pairingString: pairing.pairingString, + }; + } catch (error) { + response = { + v: 1, + ok: false, + code: + error instanceof Error && error.message.includes("--gateway-url") + ? "manual_required" + : "pairing_unavailable", + }; + } + } + } catch { + response = { v: 1, ok: false, code: "invalid_frame" }; + } + params.write(encodeBrowserNativeResponse(response)); + return response; +} diff --git a/extensions/browser/src/browser/extension-native-protocol.ts b/extensions/browser/src/browser/extension-native-protocol.ts new file mode 100644 index 000000000000..c350f5e597c6 --- /dev/null +++ b/extensions/browser/src/browser/extension-native-protocol.ts @@ -0,0 +1,215 @@ +import os from "node:os"; +import { asRecord } from "../record-shared.js"; + +const BROWSER_NATIVE_REQUEST_MAX_BYTES = 4 * 1024; +const BROWSER_NATIVE_RESPONSE_MAX_BYTES = 1024 * 1024; +const NONCE_PATTERN = /^[A-Za-z0-9_-]+$/; + +type BrowserNativeFailureCode = + | "invalid_frame" + | "invalid_utf8" + | "invalid_request" + | "origin_forbidden" + | "manifest_invalid" + | "manual_required" + | "pairing_unavailable"; +type BrowserNativeBootstrapRequest = { v: 1; op: "bootstrap"; nonce: string }; +export type BrowserNativeBootstrapResponse = + | { v: 1; ok: true; nonce: string; pairingString: string } + | { v: 1; ok: false; code: BrowserNativeFailureCode }; + +function readNativeUint32(buffer: Buffer, offset = 0): number { + return os.endianness() === "LE" ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); +} + +function writeNativeUint32(buffer: Buffer, value: number, offset = 0): void { + if (os.endianness() === "LE") { + buffer.writeUInt32LE(value, offset); + } else { + buffer.writeUInt32BE(value, offset); + } +} + +function rootJsonKeys(raw: string): string[] | null { + const keys: string[] = []; + let index = 0; + const skipWhitespace = () => { + while (/\s/u.test(raw[index] ?? "")) { + index += 1; + } + }; + const readString = (): string | null => { + if (raw[index] !== '"') { + return null; + } + const start = index++; + while (index < raw.length) { + const char = raw[index++]; + if (char === "\\") { + index += 1; + } else if (char === '"') { + try { + return JSON.parse(raw.slice(start, index)) as string; + } catch { + return null; + } + } + } + return null; + }; + const skipValue = (): boolean => { + let depth = 0; + let inString = false; + let escaped = false; + while (index < raw.length) { + const char = raw[index]; + if (inString) { + index += 1; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + index += 1; + continue; + } + if (char === "{" || char === "[") { + depth += 1; + } else if (char === "}" || char === "]") { + if (depth === 0) { + return true; + } + depth -= 1; + } else if (char === "," && depth === 0) { + return true; + } + index += 1; + } + return true; + }; + + skipWhitespace(); + if (raw[index++] !== "{") { + return null; + } + for (;;) { + skipWhitespace(); + if (raw[index] === "}") { + return keys; + } + const key = readString(); + if (key === null) { + return null; + } + keys.push(key); + skipWhitespace(); + if (raw[index++] !== ":") { + return null; + } + skipWhitespace(); + if (!skipValue()) { + return null; + } + skipWhitespace(); + if (raw[index] === ",") { + index += 1; + continue; + } + return raw[index] === "}" ? keys : null; + } +} + +function isCanonicalNonce(value: unknown): value is string { + if (typeof value !== "string" || !NONCE_PATTERN.test(value)) { + return false; + } + const bytes = Buffer.from(value, "base64url"); + return bytes.length >= 16 && bytes.length <= 32 && bytes.toString("base64url") === value; +} + +/** Strictly validate one decoded native bootstrap request. */ +function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest | null { + const keys = rootJsonKeys(raw); + if (!keys || new Set(keys).size !== keys.length) { + return null; + } + const expected = ["v", "op", "nonce"]; + if (keys.length !== expected.length || !expected.every((key) => keys.includes(key))) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const record = asRecord(parsed); + return record?.v === 1 && record.op === "bootstrap" && isCanonicalNonce(record.nonce) + ? { v: 1, op: "bootstrap", nonce: record.nonce } + : null; +} + +export function decodeBrowserNativeFrame( + frame: Buffer, +): + | { ok: true; request: BrowserNativeBootstrapRequest } + | { ok: false; code: BrowserNativeFailureCode } { + if (frame.length < 4) { + return { ok: false, code: "invalid_frame" }; + } + const length = readNativeUint32(frame); + if (length === 0 || length > BROWSER_NATIVE_REQUEST_MAX_BYTES || frame.length !== length + 4) { + return { ok: false, code: "invalid_frame" }; + } + let raw: string; + try { + raw = new TextDecoder("utf-8", { fatal: true }).decode(frame.subarray(4)); + } catch { + return { ok: false, code: "invalid_utf8" }; + } + const request = parseBrowserNativeRequest(raw); + return request ? { ok: true, request } : { ok: false, code: "invalid_request" }; +} + +/** Read exactly one small frame without allocating from an untrusted length. */ +export async function readBrowserNativeFrame(input: AsyncIterable): Promise { + let buffered = Buffer.alloc(0); + let expected = 4; + for await (const chunk of input) { + if (buffered.length + chunk.length > BROWSER_NATIVE_REQUEST_MAX_BYTES + 4) { + throw new Error("invalid_frame"); + } + buffered = Buffer.concat([buffered, chunk], buffered.length + chunk.length); + if (expected === 4 && buffered.length >= 4) { + const length = readNativeUint32(buffered); + if (length === 0 || length > BROWSER_NATIVE_REQUEST_MAX_BYTES) { + throw new Error("invalid_frame"); + } + expected = length + 4; + } + if (buffered.length >= expected) { + if (buffered.length !== expected) { + throw new Error("invalid_frame"); + } + return buffered; + } + } + throw new Error("invalid_frame"); +} + +export function encodeBrowserNativeResponse(response: BrowserNativeBootstrapResponse): Buffer { + const payload = Buffer.from(JSON.stringify(response), "utf8"); + if (payload.length >= BROWSER_NATIVE_RESPONSE_MAX_BYTES) { + throw new Error("native response exceeds Chrome's 1 MiB limit"); + } + const frame = Buffer.allocUnsafe(payload.length + 4); + writeNativeUint32(frame, payload.length); + payload.copy(frame, 4); + return frame; +} diff --git a/extensions/browser/src/browser/extension-pairing.ts b/extensions/browser/src/browser/extension-pairing.ts new file mode 100644 index 000000000000..8496ba0a4c78 --- /dev/null +++ b/extensions/browser/src/browser/extension-pairing.ts @@ -0,0 +1,90 @@ +import { isLoopbackHost } from "../gateway/net.js"; +import { type BrowserConfig, type OpenClawConfig, resolveGatewayPort } from "../sdk-config.js"; +import { resolveBrowserConfig } from "./config.js"; +import { ensureExtensionRelayToken } from "./extension-relay/relay-auth.js"; + +/** Gateway route for direct extension-only remote pairing. */ +const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension"; + +type BrowserExtensionPairing = { + pairingString: string; + relayPort: number; + topology: "local" | "browser-node" | "direct-remote"; +}; + +type PairingConfig = OpenClawConfig & { browser?: BrowserConfig }; + +function firstExtensionRelayPort(cfg: PairingConfig): number { + const resolved = resolveBrowserConfig(cfg.browser, cfg); + for (const [name, profile] of Object.entries(resolved.profiles)) { + if (profile.driver === "extension") { + return ( + profile.cdpPort ?? resolved.extensionRelayPorts[name] ?? resolved.extensionRelayDefaultPort + ); + } + } + return resolved.extensionRelayDefaultPort; +} + +/** Resolve a safe direct-Gateway relay URL with the v2-bound route path. */ +function buildDirectGatewayRelayUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw new Error("--gateway-url must be a valid ws:// or wss:// URL"); + } + const secure = url.protocol === "wss:"; + const localPlaintext = url.protocol === "ws:" && isLoopbackHost(url.hostname); + if (!secure && !localPlaintext) { + throw new Error("--gateway-url must use wss:// (ws:// is allowed only for loopback)"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("--gateway-url must not include credentials, a query, or a fragment"); + } + if (url.pathname !== "/") { + throw new Error( + "--gateway-url must not include a path prefix; Browser Relay Authentication v2 binds the exact /browser/extension path", + ); + } + url.pathname = GATEWAY_EXTENSION_RELAY_PATH; + return url.toString(); +} + +/** + * Build the canonical host-owned extension pairing used by both the CLI and + * native bootstrap. A direct remote pairing is opt-in because its key belongs + * to the remote Gateway rather than the browser host. + */ +export async function buildBrowserExtensionPairing(params: { + cfg: PairingConfig; + gatewayUrl?: string; + ensureToken?: typeof ensureExtensionRelayToken; +}): Promise { + const relayPort = firstExtensionRelayPort(params.cfg); + const token = await (params.ensureToken ?? ensureExtensionRelayToken)(); + const gateway = params.gatewayUrl?.trim(); + if (gateway) { + const relayUrl = new URL(buildDirectGatewayRelayUrl(gateway)); + relayUrl.searchParams.set("gateway", gateway); + return { + pairingString: `${relayUrl.toString()}#${token}`, + relayPort, + topology: "direct-remote", + }; + } + + const configuredRemote = + params.cfg.gateway?.mode === "remote" ? params.cfg.gateway.remote?.url?.trim() : ""; + if (!configuredRemote && params.cfg.gateway?.tls?.enabled === true) { + throw new Error("Gateway TLS pairing requires --gateway-url wss://[:port]"); + } + const gatewayHint = configuredRemote || `ws://127.0.0.1:${resolveGatewayPort(params.cfg)}`; + const relayUrl = new URL(`ws://127.0.0.1:${relayPort}/extension`); + relayUrl.searchParams.set("gateway", gatewayHint); + return { + pairingString: `${relayUrl.toString()}#${token}`, + relayPort, + topology: configuredRemote ? "browser-node" : "local", + }; +} diff --git a/extensions/browser/src/browser/extension-relay/page-share.test.ts b/extensions/browser/src/browser/extension-relay/page-share.test.ts deleted file mode 100644 index b2c3b3cb389b..000000000000 --- a/extensions/browser/src/browser/extension-relay/page-share.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - PAGE_SHARE_GATEWAY_REQUIRED_ERROR, - deliverPageShare, - setPageShareSink, -} from "./page-share.js"; - -function createSink(sessionKey = "agent:main:main") { - const enqueueSystemEvent = vi.fn(); - const requestHeartbeat = vi.fn(); - const resolveDefaultAgentId = vi.fn(() => "main"); - const resolveMainSessionKey = vi.fn(() => sessionKey); - const sink = { - enqueueSystemEvent, - requestHeartbeat, - resolveDefaultAgentId, - resolveMainSessionKey, - }; - return { - enqueueSystemEvent, - requestHeartbeat, - resolveDefaultAgentId, - resolveMainSessionKey, - sink, - }; -} - -afterEach(() => { - setPageShareSink(null); -}); - -describe("page share delivery", () => { - it("formats metadata, keeps the note trusted, and wakes a scoped session", async () => { - const { - enqueueSystemEvent, - requestHeartbeat, - resolveDefaultAgentId, - resolveMainSessionKey, - sink, - } = createSink("agent:ops:main"); - setPageShareSink(sink); - - await deliverPageShare({ - url: "https://example.com/article", - title: "Example article", - content: "ignored content", - selection: " selected page text ", - note: " Summarize for me ", - }); - - expect(enqueueSystemEvent).toHaveBeenCalledOnce(); - expect(resolveDefaultAgentId).not.toHaveBeenCalled(); - expect(resolveMainSessionKey).toHaveBeenCalledOnce(); - const [text, options] = enqueueSystemEvent.mock.calls[0] as [string, { sessionKey: string }]; - expect(options).toEqual({ sessionKey: "agent:ops:main" }); - expect(text).toContain( - "Page shared from the OpenClaw Chrome extension.\nNote: Summarize for me", - ); - expect(text).toContain('<< { - const { requestHeartbeat, resolveDefaultAgentId, sink } = createSink("global"); - setPageShareSink(sink); - - await deliverPageShare({ - url: "https://example.com", - title: "Example", - content: "full page content", - }); - - expect(resolveDefaultAgentId).toHaveBeenCalledOnce(); - expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ - source: "notifications-event", - intent: "immediate", - reason: "wake", - agentId: "main", - sessionKey: "global", - heartbeat: { target: "last" }, - }); - }); - - it("omits an empty note and falls back to page content", async () => { - const { enqueueSystemEvent, sink } = createSink(); - setPageShareSink(sink); - - await deliverPageShare({ - url: "https://example.com", - title: "Example", - content: "full page content", - note: " ", - }); - - const text = enqueueSystemEvent.mock.calls[0]?.[0] as string; - expect(text).not.toContain("Note:"); - expect(text).toContain("full page content"); - }); - - it("rejects delivery outside the gateway process", async () => { - await expect( - deliverPageShare({ - url: "https://example.com", - title: "Example", - content: "body", - }), - ).rejects.toThrow(PAGE_SHARE_GATEWAY_REQUIRED_ERROR); - }); -}); diff --git a/extensions/browser/src/browser/extension-relay/page-share.ts b/extensions/browser/src/browser/extension-relay/page-share.ts deleted file mode 100644 index 4338602421e0..000000000000 --- a/extensions/browser/src/browser/extension-relay/page-share.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; -import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; -import { wrapExternalContent } from "openclaw/plugin-sdk/security-runtime"; -import { - enqueueSystemEvent, - resolveMainSessionKeyFromConfig, -} from "openclaw/plugin-sdk/system-event-runtime"; -import { getRuntimeConfig } from "../../sdk-config.js"; -import type { PageSharePayload } from "./relay-protocol.js"; - -export const PAGE_SHARE_GATEWAY_REQUIRED_ERROR = - "Send to OpenClaw needs the extension relay hosted by the Gateway (pair on the Gateway host or use direct Gateway pairing). Node-hosted relays are not supported yet."; - -type PageShareSink = { - enqueueSystemEvent(text: string, opts: { sessionKey: string }): unknown; - requestHeartbeat(opts: { - source: "notifications-event"; - intent: "immediate"; - reason: "wake"; - agentId?: string; - sessionKey: string; - heartbeat: { target: "last" }; - }): unknown; - resolveDefaultAgentId(): string; - resolveMainSessionKey(): string; -}; - -let pageShareSink: PageShareSink | null = null; - -export function setPageShareSink(sink: PageShareSink | null): void { - // Sink presence marks a Gateway process with the main agent loop. Node-hosted - // relays never set it, preventing page shares from black-holing there. - pageShareSink = sink; -} - -export function createGatewayPageShareSink(): PageShareSink { - return { - enqueueSystemEvent, - requestHeartbeat, - resolveDefaultAgentId: () => resolveDefaultAgentId(getRuntimeConfig()), - resolveMainSessionKey: resolveMainSessionKeyFromConfig, - }; -} - -export async function deliverPageShare(payload: PageSharePayload): Promise { - const sink = pageShareSink; - if (!sink) { - throw new Error(PAGE_SHARE_GATEWAY_REQUIRED_ERROR); - } - - const note = payload.note?.trim(); - // Title and URL are page-controlled; they must stay inside the untrusted - // boundary or a hostile becomes trusted header text. Only the static - // framing and the user's own note may sit outside the wrapper. - const body = payload.selection?.trim() || payload.content; - const wrapped = wrapExternalContent(`Title: ${payload.title}\nURL: ${payload.url}\n\n${body}`, { - source: "browser", - }); - const header = [ - "Page shared from the OpenClaw Chrome extension.", - ...(note ? [`Note: ${note}`] : []), - ].join("\n"); - const text = `${header}\n\n${wrapped}`; - - const sessionKey = sink.resolveMainSessionKey(); - await sink.enqueueSystemEvent(text, { sessionKey }); - await sink.requestHeartbeat({ - source: "notifications-event", - intent: "immediate", - reason: "wake", - ...(sessionKey === "global" ? { agentId: sink.resolveDefaultAgentId() } : {}), - sessionKey, - heartbeat: { target: "last" }, - }); -} diff --git a/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts b/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts index 8333bbd2d191..584051b21a52 100644 --- a/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts +++ b/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts @@ -575,123 +575,6 @@ describe("ExtensionRelayBridge", () => { expect(response?.error).toBeTruthy(); }); - it("delivers a valid page share and acknowledges success", async () => { - const onPageShare = vi.fn(async () => undefined); - const bridge = new ExtensionRelayBridge({ onPageShare }); - const { socket, handlers } = wireExtension(bridge); - sendHello(handlers); - const payload = { - url: "https://example.com/article", - title: "Example", - content: "Article body", - }; - - handlers.onMessage(JSON.stringify({ type: "pageShare", requestId: 41, payload })); - await flush(); - - expect(onPageShare).toHaveBeenCalledWith(payload); - expect(socket.frames()).toContainEqual({ - type: "pageShareResult", - requestId: 41, - ok: true, - }); - }); - - it("returns the delivery error when the page-share handler rejects", async () => { - const bridge = new ExtensionRelayBridge({ - onPageShare: async () => { - throw new Error("queue unavailable"); - }, - }); - const { socket, handlers } = wireExtension(bridge); - sendHello(handlers); - - handlers.onMessage( - JSON.stringify({ - type: "pageShare", - requestId: 42, - payload: { url: "https://example.com", title: "Example", content: "Body" }, - }), - ); - await flush(); - - expect(socket.frames()).toContainEqual({ - type: "pageShareResult", - requestId: 42, - ok: false, - error: "queue unavailable", - }); - }); - - it("explains that page shares require a gateway-hosted relay", async () => { - const bridge = new ExtensionRelayBridge(); - const { socket, handlers } = wireExtension(bridge); - sendHello(handlers); - - handlers.onMessage( - JSON.stringify({ - type: "pageShare", - requestId: 43, - payload: { url: "https://example.com", title: "Example", content: "Body" }, - }), - ); - await flush(); - - expect(socket.frames()).toContainEqual({ - type: "pageShareResult", - requestId: 43, - ok: false, - error: - "Send to OpenClaw needs the extension relay hosted by the Gateway (pair on the Gateway host or use direct Gateway pairing). Node-hosted relays are not supported yet.", - }); - }); - - it("rejects invalid and oversized page-share payloads before delivery", async () => { - const onPageShare = vi.fn(async () => undefined); - const bridge = new ExtensionRelayBridge({ onPageShare }); - const { socket, handlers } = wireExtension(bridge); - sendHello(handlers); - - handlers.onMessage( - JSON.stringify({ - type: "pageShare", - requestId: 44, - payload: { url: "https://example.com", title: 7, content: "Body" }, - }), - ); - handlers.onMessage( - JSON.stringify({ - type: "pageShare", - requestId: 45, - payload: { - url: "https://example.com", - title: "Example", - content: "c".repeat(200_000), - selection: "s".repeat(100_001), - }, - }), - ); - await flush(); - - expect(onPageShare).not.toHaveBeenCalled(); - expect(socket.frames()).toEqual( - expect.arrayContaining([ - { - type: "pageShareResult", - requestId: 44, - ok: false, - error: "Invalid page-share payload.", - }, - { - type: "pageShareResult", - requestId: 45, - ok: false, - error: "Invalid page-share payload.", - }, - ]), - ); - }); - it("requires a hello frame before other extension messages", () => { const bridge = new ExtensionRelayBridge(); const socket = new FakeSocket(); diff --git a/extensions/browser/src/browser/extension-relay/relay-bridge.ts b/extensions/browser/src/browser/extension-relay/relay-bridge.ts index c91afd8474d4..96432593d7d5 100644 --- a/extensions/browser/src/browser/extension-relay/relay-bridge.ts +++ b/extensions/browser/src/browser/extension-relay/relay-bridge.ts @@ -9,16 +9,10 @@ */ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveCreateTargetParams } from "./create-target-params.js"; -import { PAGE_SHARE_GATEWAY_REQUIRED_ERROR } from "./page-share.js"; import { type ExtensionToRelayMessage, - PAGE_SHARE_MAX_NOTE_CHARS, - PAGE_SHARE_MAX_TITLE_CHARS, - PAGE_SHARE_MAX_URL_CHARS, - type PageSharePayload, parseExtensionMessage, type RelayCommandBody, - type RelayPageShareResultMessage, type RelayTabInfo, type RelayToExtensionMessage, } from "./relay-protocol.js"; @@ -29,7 +23,6 @@ const log = createSubsystemLogger("browser").child("extension-relay"); const EXTENSION_COMMAND_TIMEOUT_MS = 15_000; /** App-level keepalive interval; message traffic keeps the MV3 worker alive. */ const EXTENSION_PING_INTERVAL_MS = 20_000; -const PAGE_SHARE_MAX_BODY_CHARS = 300_000; /** Synthetic targetId for the emulated browser target. */ const BROWSER_TARGET_ID = "openclaw-extension-relay"; @@ -115,16 +108,13 @@ export class ExtensionRelayBridge { private pingTimer: NodeJS.Timeout | null = null; private missedPongs = 0; private readonly onStateChange?: () => void; - private readonly onPageShare?: (payload: PageSharePayload) => Promise<void>; constructor( opts: { onStateChange?: () => void; - onPageShare?: (payload: PageSharePayload) => Promise<void>; } = {}, ) { this.onStateChange = opts.onStateChange; - this.onPageShare = opts.onPageShare; } /** True once an extension socket completed its hello handshake. */ @@ -271,10 +261,6 @@ export class ExtensionRelayBridge { this.syncTabs(msg.tabs); return; } - case "pageShare": { - void this.handlePageShare(msg.requestId, msg.payload); - return; - } case "detached": { const tab = this.tabs.get(msg.tabId); if (tab?.attached) { @@ -291,54 +277,6 @@ export class ExtensionRelayBridge { } } - private async handlePageShare(requestId: number, payload: PageSharePayload): Promise<void> { - const validRequestId = Number.isSafeInteger(requestId) && requestId >= 0; - const validPayload = - payload !== null && - typeof payload === "object" && - typeof payload.url === "string" && - payload.url.length <= PAGE_SHARE_MAX_URL_CHARS && - typeof payload.title === "string" && - payload.title.length <= PAGE_SHARE_MAX_TITLE_CHARS && - typeof payload.content === "string" && - (payload.selection === undefined || typeof payload.selection === "string") && - (payload.note === undefined || - (typeof payload.note === "string" && payload.note.length <= PAGE_SHARE_MAX_NOTE_CHARS)) && - payload.content.length + (payload.selection?.length ?? 0) <= PAGE_SHARE_MAX_BODY_CHARS; - - if (!validRequestId || !validPayload) { - this.sendPageShareResult({ - requestId: validRequestId ? requestId : 0, - ok: false, - error: "Invalid page-share payload.", - }); - return; - } - if (!this.onPageShare) { - this.sendPageShareResult({ requestId, ok: false, error: PAGE_SHARE_GATEWAY_REQUIRED_ERROR }); - return; - } - - try { - await this.onPageShare(payload); - this.sendPageShareResult({ requestId, ok: true }); - } catch (err) { - this.sendPageShareResult({ - requestId, - ok: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - private sendPageShareResult(result: Omit<RelayPageShareResultMessage, "type">): void { - try { - this.sendToExtension({ type: "pageShareResult", ...result }); - } catch (err) { - log.warn(`failed to send page-share result: ${String(err)}`); - } - } - private handleExtensionGone(): void { this.extension = null; this.stopPing(); diff --git a/extensions/browser/src/browser/extension-relay/relay-lifecycle.test.ts b/extensions/browser/src/browser/extension-relay/relay-lifecycle.test.ts index 54dc35ee62f0..0b305074244f 100644 --- a/extensions/browser/src/browser/extension-relay/relay-lifecycle.test.ts +++ b/extensions/browser/src/browser/extension-relay/relay-lifecycle.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { relayTestKey } from "../../../chrome-extension/relay-key.test-support.js"; import { resolveProfile, type ResolvedBrowserConfig } from "../config.js"; import { getProfileLifecycle } from "../server-context.lifecycle.js"; import type { BrowserServerState } from "../server-context.types.js"; @@ -19,8 +20,8 @@ vi.mock("./relay-server.js", () => ({ import { ensureExtensionRelayForProfile } from "./relay-lifecycle.js"; -const OLD_TOKEN = "a".repeat(64); -const ROTATED_TOKEN = "b".repeat(64); +const OLD_TOKEN = relayTestKey(1); +const ROTATED_TOKEN = relayTestKey(2); const PROFILE_NAME = "chrome"; const RELAY_PORT = 18_123; @@ -101,7 +102,6 @@ describe("extension relay lifecycle", () => { port: RELAY_PORT, token: ROTATED_TOKEN, allowLegacyAuth: true, - onPageShare: expect.any(Function), }); expect(handle.token).toBe(ROTATED_TOKEN); expect(state.resolved.extensionRelayToken).toBe(ROTATED_TOKEN); diff --git a/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts b/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts index c54f22d1494d..bc0425161638 100644 --- a/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts +++ b/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts @@ -11,7 +11,6 @@ import { withProfileOperationLease, } from "../server-context.lifecycle.js"; import type { BrowserServerState, ProfileRuntimeState } from "../server-context.types.js"; -import { deliverPageShare } from "./page-share.js"; import { type ExtensionRelayHandle, startExtensionRelayServer } from "./relay-server.js"; const log = createSubsystemLogger("browser").child("extension-relay"); @@ -27,7 +26,7 @@ const pendingRelayEnsures = new WeakMap<ProfileRuntimeState, PendingRelayEnsure> /** Human guidance for a relay without a paired/connected extension. */ export const EXTENSION_PAIRING_HINT = - "Install the OpenClaw Chrome extension, then run `openclaw browser extension pair` and paste the pairing string into the extension popup."; + "Run `openclaw browser extension install`, load the printed unpacked directory once, and wait for automatic setup."; function relays(state: BrowserServerState): Map<string, ExtensionRelayHandle> { if (!state.extensionRelays) { @@ -187,7 +186,6 @@ async function ensureDesiredRelay(params: { port: profile.cdpPort, token, allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth, - onPageShare: (payload) => deliverPageShare(payload), }); actor.cleanupRelays.add(handle); signal.throwIfAborted(); diff --git a/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts b/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts index 6826c203c8b6..0a5b270e8b66 100644 --- a/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts +++ b/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts @@ -17,20 +17,6 @@ describe("parseExtensionMessage", () => { expect( parseExtensionMessage(JSON.stringify({ type: "result", seq: 3, result: { ok: true } })), ).toMatchObject({ type: "result", seq: 3 }); - expect( - parseExtensionMessage( - JSON.stringify({ - type: "pageShare", - requestId: 7, - payload: { url: "https://example.com", title: "Example", content: "Body" }, - }), - ), - ).toMatchObject({ type: "pageShare", requestId: 7 }); - // Frame parsing intentionally recognizes only the discriminator. The bridge - // owns payload validation and returns a correlated error. - expect(parseExtensionMessage(JSON.stringify({ type: "pageShare" }))).toEqual({ - type: "pageShare", - }); }); it.each([ diff --git a/extensions/browser/src/browser/extension-relay/relay-protocol.ts b/extensions/browser/src/browser/extension-relay/relay-protocol.ts index 9b9ff77694d3..2688cb085054 100644 --- a/extensions/browser/src/browser/extension-relay/relay-protocol.ts +++ b/extensions/browser/src/browser/extension-relay/relay-protocol.ts @@ -13,22 +13,6 @@ export type RelayTabInfo = { active: boolean; }; -export const PAGE_SHARE_MAX_NOTE_CHARS = 2_000; -export const PAGE_SHARE_MAX_TITLE_CHARS = 500; -export const PAGE_SHARE_MAX_URL_CHARS = 2_000; - -/** Page-share payload captured by the extension on explicit user action. */ -export type PageSharePayload = { - url: string; - title: string; - /** Extracted readable page text (already truncated extension-side). */ - content: string; - /** User-highlighted selection; preferred over content when present. */ - selection?: string; - /** Short user-typed note; trusted (typed by the user in the popup). */ - note?: string; -}; - /** First message the extension sends after the WebSocket opens. */ type ExtensionHelloMessage = { type: "hello"; @@ -80,12 +64,6 @@ type ExtensionPongMessage = { type: "pong"; }; -type ExtensionPageShareMessage = { - type: "pageShare"; - requestId: number; - payload: PageSharePayload; -}; - export type ExtensionToRelayMessage = | ExtensionHelloMessage | ExtensionTabsMessage @@ -93,8 +71,7 @@ export type ExtensionToRelayMessage = | ExtensionResultMessage | ExtensionErrorMessage | ExtensionDetachedMessage - | ExtensionPongMessage - | ExtensionPageShareMessage; + | ExtensionPongMessage; /** * Command bodies sent to the extension. The bridge assigns the `seq` used to @@ -119,17 +96,7 @@ type RelayPingMessage = { type: "ping"; }; -export type RelayPageShareResultMessage = { - type: "pageShareResult"; - requestId: number; - ok: boolean; - error?: string; -}; - -export type RelayToExtensionMessage = - | (RelayCommandBody & { seq: number }) - | RelayPingMessage - | RelayPageShareResultMessage; +export type RelayToExtensionMessage = (RelayCommandBody & { seq: number }) | RelayPingMessage; function hasExactOwnKeys(value: object, keys: readonly string[]): boolean { const actual = Object.keys(value); @@ -207,7 +174,6 @@ export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | nu case "error": case "detached": case "pong": - case "pageShare": return parsed as ExtensionToRelayMessage; default: return null; diff --git a/extensions/browser/src/browser/extension-relay/relay-server.ts b/extensions/browser/src/browser/extension-relay/relay-server.ts index 651d4f067182..d2c51e4a7114 100644 --- a/extensions/browser/src/browser/extension-relay/relay-server.ts +++ b/extensions/browser/src/browser/extension-relay/relay-server.ts @@ -29,7 +29,7 @@ import { } from "./preauth-websocket-guard.js"; import { readExtensionRelayToken } from "./relay-auth.js"; import { ExtensionRelayBridge } from "./relay-bridge.js"; -import { parseExtensionMessage, type PageSharePayload } from "./relay-protocol.js"; +import { parseExtensionMessage } from "./relay-protocol.js"; import { firstHeader, isAllowedExtensionOrigin, @@ -344,17 +344,13 @@ export async function startExtensionRelayServer(params: { token: string; allowLegacyAuth?: boolean; onStateChange?: () => void; - onPageShare?: (payload: PageSharePayload) => Promise<void>; }): Promise<ExtensionRelayHandle> { const allowLegacyAuth = params.allowLegacyAuth ?? true; const internalToken = crypto.randomBytes(32).toString("base64url"); if (readExtensionRelayToken() === params.token) { getBrowserRelayAuthV2Authority(params.token); } - const bridge = new ExtensionRelayBridge({ - onStateChange: params.onStateChange, - onPageShare: params.onPageShare, - }); + const bridge = new ExtensionRelayBridge({ onStateChange: params.onStateChange }); const wss = new WebSocketServer({ noServer: true, maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES, diff --git a/extensions/browser/src/cli/browser-cli-extension-pairing.ts b/extensions/browser/src/cli/browser-cli-extension-pairing.ts deleted file mode 100644 index 326e44077516..000000000000 --- a/extensions/browser/src/cli/browser-cli-extension-pairing.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function resolveLocalPairingGatewayUrl(params: { - configuredRemote?: string; - gatewayPort: number; - tlsEnabled: boolean; -}): string { - if (params.configuredRemote) { - return params.configuredRemote; - } - if (params.tlsEnabled) { - throw new Error("Gateway TLS pairing requires --gateway-url wss://<certificate-host>[:port]"); - } - return `ws://127.0.0.1:${params.gatewayPort}`; -} diff --git a/extensions/browser/src/cli/browser-cli-extension.test.ts b/extensions/browser/src/cli/browser-cli-extension.test.ts index 6b0c3b35f342..e2ddd4a8dc41 100644 --- a/extensions/browser/src/cli/browser-cli-extension.test.ts +++ b/extensions/browser/src/cli/browser-cli-extension.test.ts @@ -1,42 +1,111 @@ import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "../../test-support.js"; +import type { installChromeExtensionBootstrap } from "../browser/extension-install.js"; +import { buildBrowserExtensionPairing } from "../browser/extension-pairing.js"; import { relayKeyIdFromHex } from "../browser/extension-relay/auth-v2-crypto.js"; -import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js"; import * as cliCoreApiModule from "./core-api.js"; -const relayMocks = vi.hoisted(() => ({ ensureExtensionRelayToken: vi.fn(() => "a".repeat(64)) })); +const relayMocks = vi.hoisted(() => { + let relayKey = ""; + for (let byteIndex = 0; byteIndex < 32; byteIndex += 1) { + relayKey += ((1 + byteIndex * 17) & 0xff).toString(16).padStart(2, "0"); + } + return { relayKey, ensureExtensionRelayToken: vi.fn(() => relayKey) }; +}); +const installMocks = vi.hoisted(() => ({ installChromeExtensionBootstrap: vi.fn() })); vi.mock("../browser/extension-relay/relay-auth.js", async (importOriginal) => ({ ...(await importOriginal<typeof import("../browser/extension-relay/relay-auth.js")>()), ensureExtensionRelayToken: relayMocks.ensureExtensionRelayToken, })); +vi.mock("../browser/extension-install.js", async (importOriginal) => ({ + ...(await importOriginal<typeof import("../browser/extension-install.js")>()), + installChromeExtensionBootstrap: installMocks.installChromeExtensionBootstrap, +})); + const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); describe("browser extension pairing Gateway URL", () => { afterEach(() => { vi.restoreAllMocks(); + installMocks.installChromeExtensionBootstrap.mockReset(); resetRuntimeCapture(); }); - it("uses loopback only for a plaintext local Gateway", () => { - expect(resolveLocalPairingGatewayUrl({ gatewayPort: 18789, tlsEnabled: false })).toBe( - "ws://127.0.0.1:18789", + it("prints Load unpacked only after native pre-registration is ready", async () => { + installMocks.installChromeExtensionBootstrap.mockImplementation( + async (params: Parameters<typeof installChromeExtensionBootstrap>[0]) => { + params.onProgress?.("Pre-registered the native host for Chromium."); + params.onProgress?.( + "Native bootstrap is ready. In Chrome, use chrome://extensions → Developer mode → Load unpacked → /stable/openclaw-extension", + ); + return { + platform: "linux", + platformSupport: "automatic", + installedCopy: { path: "/stable/openclaw-extension", present: true, owned: true }, + bundledPath: "/bundled/openclaw-extension", + approvedPaths: ["/stable/openclaw-extension"], + discovered: [ + { + product: "chromium", + browser: "Chromium", + userDataDir: "/chrome", + profile: "Default", + securePreferencesPath: "/chrome/Default/Secure Preferences", + extensionId: "abcdefghijklmnopabcdefghijklmnop", + extensionPath: "/stable/openclaw-extension", + }, + ], + registrations: [], + manualSetupRequired: false, + issues: [], + }; + }, ); + const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log); + const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js"); + const program = new Command(); + registerBrowserExtensionCommands(program.command("browser"), () => ({})); + + await program.parseAsync(["browser", "extension", "install", "--wait-ms", "1000"], { + from: "user", + }); + + const output = logSpy.mock.calls.map(([message]) => String(message)); + expect(output[0]).toContain("Preparing"); + expect(output.findIndex((message) => message.includes("Pre-registered"))).toBeLessThan( + output.findIndex((message) => message.includes("Load unpacked")), + ); + expect(output.at(-1)).toContain("deterministic extension identity verified"); }); - it("requires the certificate hostname for a TLS Gateway", () => { - expect(() => resolveLocalPairingGatewayUrl({ gatewayPort: 18789, tlsEnabled: true })).toThrow( - "--gateway-url wss://<certificate-host>", - ); - expect( - resolveLocalPairingGatewayUrl({ - configuredRemote: "wss://gateway.example", - gatewayPort: 18789, - tlsEnabled: true, + it("uses loopback only for a plaintext local Gateway", async () => { + await expect( + buildBrowserExtensionPairing({ cfg: {}, ensureToken: async () => relayMocks.relayKey }), + ).resolves.toMatchObject({ + pairingString: expect.stringContaining("gateway=ws%3A%2F%2F127.0.0.1%3A18789"), + topology: "local", + }); + }); + + it("requires the certificate hostname for a TLS Gateway", async () => { + await expect( + buildBrowserExtensionPairing({ + cfg: { gateway: { tls: { enabled: true } } }, + ensureToken: async () => relayMocks.relayKey, }), - ).toBe("wss://gateway.example"); + ).rejects.toThrow("--gateway-url wss://<certificate-host>"); + await expect( + buildBrowserExtensionPairing({ + cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example" } } }, + ensureToken: async () => relayMocks.relayKey, + }), + ).resolves.toMatchObject({ + pairingString: expect.stringContaining("gateway=wss%3A%2F%2Fgateway.example"), + topology: "browser-node", + }); }); it("rejects path-rewriting proxy prefixes for strict v2 resource binding", async () => { @@ -74,7 +143,7 @@ describe("browser extension pairing Gateway URL", () => { await program.parseAsync(["browser", "extension", "pair", "--json"], { from: "user" }); expect(writeJsonSpy).toHaveBeenCalledWith({ - pairingString: expect.stringContaining(`#${"a".repeat(64)}`), + pairingString: expect.stringContaining(`#${relayMocks.relayKey}`), relayPort: 18799, remote: false, }); @@ -125,7 +194,7 @@ describe("browser extension pairing Gateway URL", () => { auth: { label: "openclaw.browser-relay.auth", version: 2, - keyId: relayKeyIdFromHex("a".repeat(64)), + keyId: relayKeyIdFromHex(relayMocks.relayKey), challengeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/challenge", completeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/complete", role: "cdp", @@ -136,7 +205,7 @@ describe("browser extension pairing Gateway URL", () => { }, }); expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("Bearer"); - expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("a".repeat(64)); + expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain(relayMocks.relayKey); expect(logSpy).not.toHaveBeenCalled(); }); @@ -159,7 +228,7 @@ describe("browser extension pairing Gateway URL", () => { expect(writeJsonSpy).toHaveBeenCalledWith( expect.objectContaining({ - headers: { Authorization: `Bearer ${"a".repeat(64)}` }, + headers: { Authorization: `Bearer ${relayMocks.relayKey}` }, }), ); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("reveals the relay key")); @@ -191,6 +260,6 @@ describe("browser extension pairing Gateway URL", () => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Legacy browser relay auth is disabled"), ); - expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("a".repeat(64)); + expect(errorSpy.mock.calls.flat().join("\n")).not.toContain(relayMocks.relayKey); }); }); diff --git a/extensions/browser/src/cli/browser-cli-extension.ts b/extensions/browser/src/cli/browser-cli-extension.ts index b12f83788a3f..292ec900a023 100644 --- a/extensions/browser/src/cli/browser-cli-extension.ts +++ b/extensions/browser/src/cli/browser-cli-extension.ts @@ -1,10 +1,18 @@ /** - * `openclaw browser extension` CLI: locate the unpacked Chrome extension and - * print the pairing string that connects it to this install's relay. + * `openclaw browser extension` CLI: install the unpacked Chrome extension, + * register its native bootstrap host, and retain advanced manual pairing. */ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Command } from "commander"; +import { + browserExtensionStatus, + installChromeExtensionBootstrap, + normalizeExtensionInstallWaitMs, + resolveChromeExtensionLoadPath, + uninstallChromeExtensionNativeHosts, +} from "../browser/extension-install.js"; +import { buildBrowserExtensionPairing } from "../browser/extension-pairing.js"; import { BROWSER_RELAY_AUTH_LABEL, BROWSER_RELAY_AUTH_VERSION, @@ -15,9 +23,6 @@ import { BROWSER_RELAY_AUTH_COMPLETE_PATH, } from "../browser/extension-relay/auth-v2.js"; import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js"; -import { isLoopbackHost } from "../gateway/net.js"; -import { resolveGatewayPort } from "../sdk-config.js"; -import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; import { danger, @@ -39,6 +44,10 @@ function resolveChromeExtensionDir(pluginRoot?: string): string { return path.resolve(here, "..", "..", "chrome-extension"); } +function resolveBrowserPluginRoot(pluginRoot?: string): string { + return pluginRoot ?? path.resolve(resolveChromeExtensionDir(), ".."); +} + function firstExtensionProfile( resolved: ReturnType<typeof resolveBrowserConfig>, ): { name: string; relayPort: number } | null { @@ -56,73 +65,17 @@ function firstExtensionProfile( return null; } -/** Gateway route path for the remote extension relay (see gateway-relay-route.ts). */ -const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension"; - -/** Resolve a safe direct-Gateway relay URL with an exact v2-bound route path. */ -function buildRemoteGatewayRelayUrl(raw: string): string { - let url: URL; - try { - url = new URL(raw.trim()); - } catch { - throw new Error("--gateway-url must be a valid ws:// or wss:// URL"); - } - const secure = url.protocol === "wss:"; - const localPlaintext = url.protocol === "ws:" && isLoopbackHost(url.hostname); - if (!secure && !localPlaintext) { - throw new Error("--gateway-url must use wss:// (ws:// is allowed only for loopback)"); - } - if (url.username || url.password || url.search || url.hash) { - throw new Error("--gateway-url must not include credentials, a query, or a fragment"); - } - if (url.pathname !== "/") { - throw new Error( - "--gateway-url must not include a path prefix; Browser Relay Authentication v2 binds the exact /browser/extension path", - ); - } - url.pathname = GATEWAY_EXTENSION_RELAY_PATH; - return url.toString(); -} - async function buildPairingString(gatewayUrl?: string): Promise<{ pairing: string; relayPort: number; remote: boolean; }> { const cfg = getRuntimeConfig(); - const resolved = resolveBrowserConfig(cfg.browser, cfg); - // Create the host-local relay secret if this host has not used the extension - // driver yet, so pairing works on a fresh gateway or node host before the - // relay has started. Pairing must run on the machine that hosts the browser. - const token = await ensureExtensionRelayToken(); - const profile = firstExtensionProfile(resolved); - const relayPort = profile?.relayPort ?? resolved.extensionRelayDefaultPort; - - const gateway = gatewayUrl?.trim(); - if (gateway) { - // Remote: the extension connects straight to this gateway over wss:// — no - // node host on the browser machine. The gateway route self-validates the - // same host-local secret. - const relayUrl = new URL(buildRemoteGatewayRelayUrl(gateway)); - relayUrl.searchParams.set("gateway", gateway); - return { - pairing: `${relayUrl.toString()}#${token}`, - relayPort, - remote: true, - }; - } - const configuredRemote = cfg.gateway?.mode === "remote" ? cfg.gateway.remote?.url?.trim() : ""; - const directGatewayUrl = resolveLocalPairingGatewayUrl({ - configuredRemote, - gatewayPort: resolveGatewayPort(cfg), - tlsEnabled: cfg.gateway?.tls?.enabled === true, - }); - const relayUrl = new URL(`ws://127.0.0.1:${relayPort}/extension`); - relayUrl.searchParams.set("gateway", directGatewayUrl); + const result = await buildBrowserExtensionPairing({ cfg, gatewayUrl }); return { - pairing: `${relayUrl.toString()}#${token}`, - relayPort, - remote: false, + pairing: result.pairingString, + relayPort: result.relayPort, + remote: result.topology === "direct-remote", }; } @@ -184,7 +137,7 @@ async function buildCdpEndpoint(options: { }; } -/** Register `openclaw browser extension {path,pair,cdp}`. */ +/** Register `openclaw browser extension` lifecycle and compatibility commands. */ export function registerBrowserExtensionCommands( browser: Command, _parentOpts: (cmd: Command) => BrowserParentOpts, @@ -192,18 +145,125 @@ export function registerBrowserExtensionCommands( ) { const extension = browser .command("extension") - .description("Chrome extension: print the load path and pairing string"); + .description("Install and inspect the OpenClaw Chrome extension bootstrap"); extension .command("path") .description("Print the unpacked Chrome extension directory (Load unpacked)") - .action(() => { - defaultRuntime.log(resolveChromeExtensionDir(pluginRoot)); + .action(async () => { + await runCommandWithRuntime(defaultRuntime, async () => { + defaultRuntime.log( + await resolveChromeExtensionLoadPath(resolveChromeExtensionDir(pluginRoot)), + ); + }); + }); + + extension + .command("install") + .description("Install the stable extension copy and register its native bootstrap host") + .option("--json", "Print a machine-readable status report") + .option( + "--wait-ms <ms>", + "How long to wait after pre-registration for Chrome to verify the unpacked extension", + String(30_000), + ) + .action(async (opts) => { + await runCommandWithRuntime( + defaultRuntime, + async () => { + const waitMs = normalizeExtensionInstallWaitMs(opts.waitMs); + const bundledDir = resolveChromeExtensionDir(pluginRoot); + if (opts.json !== true) { + defaultRuntime.log( + info("Preparing the OpenClaw Chrome extension. Keep Chrome running…"), + ); + } + const status = await installChromeExtensionBootstrap({ + bundledDir, + pluginRoot: resolveBrowserPluginRoot(pluginRoot), + waitMs, + onProgress: + opts.json === true ? undefined : (message) => defaultRuntime.log(info(message)), + }); + if (opts.json === true) { + defaultRuntime.writeJson(status); + } else { + for (const issue of status.issues) { + defaultRuntime.error(theme.warn(issue)); + } + defaultRuntime.log( + status.manualSetupRequired + ? theme.warn( + status.platformSupport === "manual_required" + ? "Automatic native bootstrap is not supported on this platform; use Settings for manual pairing." + : "Automatic setup was not verified. Keep Chrome running, rerun install, and use Load unpacked only after the command says native bootstrap is ready. If this extension already attempted setup before the host existed, restart Chrome once before retrying.", + ) + : info( + `Native host and deterministic extension identity verified for ${status.discovered.length} profile registration(s). The extension connects automatically.`, + ), + ); + } + if (status.manualSetupRequired) { + defaultRuntime.exit(1); + } + }, + (err: unknown) => { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + }, + ); + }); + + extension + .command("status") + .description("Inspect extension copies, Chrome IDs, and native-host registrations") + .option("--json", "Print a machine-readable status report") + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + const status = await browserExtensionStatus({ + bundledDir: resolveChromeExtensionDir(pluginRoot), + }); + if (opts.json === true) { + defaultRuntime.writeJson(status); + return; + } + defaultRuntime.log( + [ + `Extension copy: ${status.installedCopy.owned ? "installed" : "bundled fallback"}`, + `Load unpacked: ${status.installedCopy.owned ? status.installedCopy.path : status.bundledPath}`, + `Chrome IDs: ${status.discovered.length > 0 ? status.discovered.map((entry) => `${entry.extensionId} (${entry.browser}/${entry.profile})`).join(", ") : "none detected"}`, + `Native hosts: ${status.registrations.filter((entry) => entry.state === "owned").length} owned`, + `Setup: ${status.manualSetupRequired ? "manual action required" : "automatic bootstrap ready"}`, + ].join("\n"), + ); + }); + }); + + extension + .command("uninstall-host") + .description("Remove only OpenClaw-owned Chrome native-host registrations") + .option("--json", "Print a machine-readable removal report") + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + const result = await uninstallChromeExtensionNativeHosts(); + if (opts.json === true) { + defaultRuntime.writeJson(result); + return; + } + defaultRuntime.log( + result.manualRequired + ? theme.warn("Windows native-host removal is manual; no registry key was changed.") + : info(`Removed ${result.removed.length} owned native-host artifact(s).`), + ); + for (const refused of result.refused) { + defaultRuntime.error(theme.warn(`Refused foreign registration: ${refused}`)); + } + }); }); extension .command("pair") - .description("Print the pairing string to paste into the OpenClaw extension popup") + .description("Print an advanced manual pairing string") .option("--json", "Print the pairing string as JSON") .option( "--gateway-url <url>", diff --git a/extensions/browser/src/cli/browser-cli.lazy.test.ts b/extensions/browser/src/cli/browser-cli.lazy.test.ts index ca1cfa960a4a..244ec1bf4ba6 100644 --- a/extensions/browser/src/cli/browser-cli.lazy.test.ts +++ b/extensions/browser/src/cli/browser-cli.lazy.test.ts @@ -88,6 +88,7 @@ describe("registerBrowserCli lazy browser subcommands", () => { ["cookies", ["browser", "cookies"]], ["local storage", ["browser", "storage", "local", "get"]], ["session storage", ["browser", "storage", "session", "get", "key"]], + ["native host", ["browser", "extension", "native-host"]], ])("declares default JSON output for %s", (_name, args) => { expect(isBrowserMachineOutput({ argv: ["node", "openclaw", ...args] })).toBe(true); }); diff --git a/extensions/browser/src/cli/browser-cli.ts b/extensions/browser/src/cli/browser-cli.ts index e13e3ba97dec..3cf040ab10b4 100644 --- a/extensions/browser/src/cli/browser-cli.ts +++ b/extensions/browser/src/cli/browser-cli.ts @@ -139,7 +139,7 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = }, }, { - placeholders: [command("extension", "Chrome extension load path and pairing")], + placeholders: [command("extension", "Chrome extension install, status, and pairing")], register: async (args) => { const module = await import("./browser-cli-extension.js"); module.registerBrowserExtensionCommands(args.browser, args.parentOpts, args.pluginRoot); diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts index ce046dd4df3f..423fea348336 100644 --- a/extensions/browser/src/doctor-browser.ts +++ b/extensions/browser/src/doctor-browser.ts @@ -4,6 +4,7 @@ */ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseBrowserMajorVersion, @@ -12,6 +13,10 @@ import { resolveGoogleChromeExecutableForPlatform, } from "./browser/chrome.executables.js"; import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, resolveBrowserConfig } from "./browser/config.js"; +import { + browserExtensionStatus, + repairOwnedChromeExtensionNativeHosts, +} from "./browser/extension-install.js"; import { listSystemProfiles } from "./browser/system-profiles.js"; import { movePathToTrash } from "./browser/trash.js"; import type { OpenClawConfig } from "./config/config.js"; @@ -26,6 +31,8 @@ const REMOTE_DEBUGGING_PAGES = [ "brave://inspect/#remote-debugging", "edge://inspect/#remote-debugging", ].join(", "); +const BROWSER_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const BUNDLED_CHROME_EXTENSION_DIR = path.join(BROWSER_PLUGIN_ROOT, "chrome-extension"); type ExistingSessionProfile = { name: string; @@ -243,6 +250,38 @@ export async function noteChromeMcpBrowserReadiness( "Browser relay authentication", ); } + const extensionStateDir = deps?.configDir ?? CONFIG_DIR; + const extensionCopyPath = path.join(extensionStateDir, "browser", "chrome-extension"); + try { + const extension = fs.existsSync(extensionCopyPath) + ? await browserExtensionStatus({ + bundledDir: BUNDLED_CHROME_EXTENSION_DIR, + deps: { + stateDir: extensionStateDir, + platform, + env, + homeDir: deps?.homeDir, + }, + }) + : null; + if (extension && (extension.installedCopy.present || extension.discovered.length > 0)) { + if (extension.manualSetupRequired) { + noteFn( + [ + "- The Chrome extension native bootstrap is not fully registered.", + `- Run ${formatCliCommand("openclaw browser extension status --json")} for the redacted registration report.`, + `- Run ${formatCliCommand("openclaw browser extension install")} after loading the printed unpacked directory.`, + ].join("\n"), + "Browser extension bootstrap", + ); + } + } + } catch (error) { + noteFn( + `- Chrome extension bootstrap status could not be inspected: ${error instanceof Error ? error.message : String(error)}`, + "Browser extension bootstrap", + ); + } const legacyClawdResidue = detectLegacyClawdBrowserProfileResidue(cfg, { configDir: deps?.configDir, pathExists: deps?.pathExists, @@ -386,6 +425,17 @@ export async function noteChromeMcpBrowserReadiness( noteFn(lines.join("\n"), "Browser"); } +/** Repair only an already-owned native-host registration during doctor --fix. */ +export async function maybeRepairOwnedChromeExtensionNativeHosts(): Promise<{ + changes: string[]; + warnings: string[]; +}> { + return await repairOwnedChromeExtensionNativeHosts({ + bundledDir: BUNDLED_CHROME_EXTENSION_DIR, + pluginRoot: BROWSER_PLUGIN_ROOT, + }); +} + /** Archives legacy clawd browser profile residue when doctor --fix is requested. */ export async function maybeArchiveLegacyClawdBrowserProfileResidue( cfg: OpenClawConfig, diff --git a/extensions/browser/src/plugin-service.test.ts b/extensions/browser/src/plugin-service.test.ts index 16dc8b2e238f..063d91fda595 100644 --- a/extensions/browser/src/plugin-service.test.ts +++ b/extensions/browser/src/plugin-service.test.ts @@ -20,8 +20,6 @@ type StartLazyPluginServiceModuleParamsWithValidator = { const runtimeMocks = vi.hoisted(() => ({ startLazyPluginServiceModule: vi.fn(async (_params: StartLazyPluginServiceModuleParams) => null), stopBrowserControlService: vi.fn(async () => undefined), - createGatewayPageShareSink: vi.fn(() => ({ id: "gateway-page-share-sink" })), - setPageShareSink: vi.fn(), })); vi.mock("./sdk-node-runtime.js", () => ({ @@ -32,19 +30,10 @@ vi.mock("./control-service.js", () => ({ stopBrowserControlService: runtimeMocks.stopBrowserControlService, })); -vi.mock("./browser/extension-relay/page-share.js", () => ({ - createGatewayPageShareSink: runtimeMocks.createGatewayPageShareSink, - setPageShareSink: runtimeMocks.setPageShareSink, -})); - describe("createBrowserPluginService", () => { beforeEach(() => { runtimeMocks.startLazyPluginServiceModule.mockReset().mockResolvedValue(null); runtimeMocks.stopBrowserControlService.mockReset().mockResolvedValue(undefined); - runtimeMocks.createGatewayPageShareSink - .mockReset() - .mockReturnValue({ id: "gateway-page-share-sink" }); - runtimeMocks.setPageShareSink.mockReset(); }); afterEach(() => { @@ -71,18 +60,6 @@ describe("createBrowserPluginService", () => { expect(runtimeMocks.startLazyPluginServiceModule).not.toHaveBeenCalled(); }); - it("marks page-share delivery available for the full service lifecycle", async () => { - const service = createBrowserPluginService(); - - await service.start(SERVICE_CONTEXT); - expect(runtimeMocks.setPageShareSink).toHaveBeenCalledWith({ - id: "gateway-page-share-sink", - }); - - await service.stop?.(SERVICE_CONTEXT); - expect(runtimeMocks.setPageShareSink).toHaveBeenLastCalledWith(null); - }); - for (const value of ["0", "", "disabled"]) { it(`does not start the control server for eager env value ${JSON.stringify(value)}`, async () => { vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", value); diff --git a/extensions/browser/src/plugin-service.ts b/extensions/browser/src/plugin-service.ts index dd359ea4b965..1df200c67048 100644 --- a/extensions/browser/src/plugin-service.ts +++ b/extensions/browser/src/plugin-service.ts @@ -27,10 +27,6 @@ export function createBrowserPluginService(): OpenClawPluginService { return { id: "browser-control", start: async () => { - const pageShare = await import("./browser/extension-relay/page-share.js"); - // Plugin services start only in the Gateway process. The sink marks this - // process as able to deliver page shares to the main session. - pageShare.setPageShareSink(pageShare.createGatewayPageShareSink()); if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) { return; } @@ -51,8 +47,6 @@ export function createBrowserPluginService(): OpenClawPluginService { }); }, stop: async () => { - const { setPageShareSink } = await import("./browser/extension-relay/page-share.js"); - setPageShareSink(null); const current = handle; if (current) { await current.stop(); diff --git a/package.json b/package.json index 6d74ba904f36..8e19ce01b45b 100644 --- a/package.json +++ b/package.json @@ -1889,7 +1889,7 @@ "test:type-suppression-inventory:report": "node --import tsx scripts/type-suppression-inventory.ts", "test:e2e": "pnpm test:e2e:gateway && pnpm test:e2e:agent-plugin-gateway && pnpm test:ui:e2e", "test:e2e:agent-plugin-gateway": "node --import tsx scripts/agent-plugin-gateway-e2e.ts", - "test:e2e:browser-copilot": "node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node --import tsx scripts/ensure-playwright-chromium.mts --require-playwright-chromium && node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_COPILOT_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/browser/chrome-extension/page-share.e2e.test.ts extensions/browser/chrome-extension/sidepanel.e2e.test.ts", + "test:e2e:browser-extension": "node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node --import tsx scripts/ensure-playwright-chromium.mts --require-playwright-chromium && node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_EXTENSION_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs extensions/browser/chrome-extension/bootstrap.chromium.test.ts", "test:e2e:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", "test:e2e:openshell": "node --import tsx scripts/run-with-env.mts OPENCLAW_E2E_OPENSHELL=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts", "test:e2e:status-corrupt-plugin-deps": "bash scripts/e2e/status-corrupt-plugin-deps.sh", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ecc21d29fe5..11b5a8da69f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -527,12 +527,6 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.30.0 version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@noble/ed25519': - specifier: 3.1.0 - version: 3.1.0 - esbuild: - specifier: 0.28.1 - version: 0.28.1 express: specifier: 5.2.1 version: 5.2.1(supports-color@10.2.2) @@ -546,9 +540,6 @@ importers: specifier: 8.21.1 version: 8.21.1 devDependencies: - '@openclaw/gateway-client': - specifier: workspace:* - version: link:../../packages/gateway-client '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 52696209c959..7bf7e7d1cd2b 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -2490,11 +2490,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [ ], [ /^scripts\/lib\/generated-text-asset\.mts$/u, - [ - "extensions/browser/scripts/build-copilot-runtime.test.ts", - "build-diffs-viewer-runtime", - "bundled-plugin-assets", - ], + ["build-diffs-viewer-runtime", "bundled-plugin-assets"], ], [/^scripts\/check-plugin-npm-runtime-builds\.mts$/u, ["plugin-npm-runtime-build-args"]], [ diff --git a/scripts/update-gateway.sh b/scripts/update-gateway.sh index 0ec2493075d0..6030b57385c2 100755 --- a/scripts/update-gateway.sh +++ b/scripts/update-gateway.sh @@ -38,11 +38,6 @@ if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] || \ exit 1 fi -# `pnpm build` rewrites this tracked bundle, which would make the tree look -# dirty and block the rebase below. Restoring it loses nothing: the build -# regenerates it from source every run. -git checkout -- extensions/browser/chrome-extension/modules/copilot-runtime.js 2>/dev/null || true - # Fail closed on any other local changes: an agent or operator may have # uncommitted work in this checkout, and an update must never eat it. if ! git diff --quiet || ! git diff --cached --quiet; then diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index bdf7f21223eb..cb24c5ab42fe 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -451,6 +451,11 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ exact: true, policy: { ownsProtocolStdout: true }, }, + { + commandPath: ["browser", "extension", "native-host"], + exact: true, + policy: { hideBanner: true, ownsProtocolStdout: true, networkProxy: "bypass" }, + }, { commandPath: ["node"], policy: { networkProxy: "bypass" }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 4cca68edad5d..7e3bacfd6fc3 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -270,6 +270,11 @@ describe("command-path-policy", () => { ownsProtocolStdout: true, networkProxy: "bypass", }); + expectResolvedPolicy(["browser", "extension", "native-host"], { + hideBanner: true, + ownsProtocolStdout: true, + networkProxy: "bypass", + }); expectResolvedPolicy(["configure"], { configGuard: "skip", loadPlugins: "never", diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 153bddc2489f..df949dcc0732 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -385,6 +385,13 @@ describe("command-startup-policy", () => { expect(resolvePolicy({ commandPath: ["mcp", "serve"] }).suppressDoctorStdout).toBe(true); }); + it("reserves stdout for the browser native-host protocol", () => { + const policy = resolvePolicy({ commandPath: ["browser", "extension", "native-host"] }); + + expect(policy.hideBanner).toBe(true); + expect(policy.suppressDoctorStdout).toBe(true); + }); + it("reserves stdout for the node worker protocol", () => { const policy = resolvePolicy({ commandPath: ["node", "worker"] }); diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 0c5fb296bcde..28223a1d6779 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -17,6 +17,7 @@ const RESERVED_CATALOG_ROOTS = { } as const; const PLUGIN_CATALOG_PATHS = { + "browser extension native-host": "registered and covered by the browser plugin", memory: "registered and covered by the memory-core plugin", "memory search": "registered and covered by the memory-core plugin", "memory status": "registered and covered by the memory-core plugin", diff --git a/src/commands/doctor-browser.facade.test.ts b/src/commands/doctor-browser.facade.test.ts index ebf3bc98735d..b842fc837fa6 100644 --- a/src/commands/doctor-browser.facade.test.ts +++ b/src/commands/doctor-browser.facade.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue, + maybeRepairOwnedChromeExtensionNativeHosts, noteChromeMcpBrowserReadiness, } from "./doctor-browser.js"; @@ -110,6 +111,20 @@ describe("doctor browser facade", () => { expect(cleanup).toHaveBeenCalledWith(cfg, deps); }); + it("delegates owned Chrome native-host repair to the browser facade surface", async () => { + const repair = vi.fn().mockResolvedValue({ changes: ["repaired"], warnings: [] }); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + noteChromeMcpBrowserReadiness: vi.fn(), + maybeRepairOwnedChromeExtensionNativeHosts: repair, + }); + + await expect(maybeRepairOwnedChromeExtensionNativeHosts()).resolves.toEqual({ + changes: ["repaired"], + warnings: [], + }); + expect(repair).toHaveBeenCalledOnce(); + }); + it("warns when browser profile cleanup surface is unavailable", async () => { loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { throw new Error("missing browser doctor facade"); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index d98b37c35464..36f544693cf7 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -45,6 +45,10 @@ type BrowserDoctorSurface = { cfg: OpenClawConfig, deps?: BrowserDoctorRepairDeps, ) => Promise<{ changes: string[]; warnings: string[] }>; + maybeRepairOwnedChromeExtensionNativeHosts?: () => Promise<{ + changes: string[]; + warnings: string[]; + }>; }; function loadBrowserDoctorSurface(): BrowserDoctorSurface { @@ -54,6 +58,24 @@ function loadBrowserDoctorSurface(): BrowserDoctorSurface { }); } +/** Repairs only already-owned Chrome native-host registration drift. */ +export async function maybeRepairOwnedChromeExtensionNativeHosts(): Promise<{ + changes: string[]; + warnings: string[]; +}> { + try { + const repair = loadBrowserDoctorSurface().maybeRepairOwnedChromeExtensionNativeHosts; + return repair ? await repair() : { changes: [], warnings: [] }; + } catch (error) { + return { + changes: [], + warnings: [ + `Browser extension native-host repair is unavailable: ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } +} + function mayHaveLegacyClawdBrowserProfileResidue(deps?: BrowserDoctorRepairDeps): boolean { const configDir = deps?.configDir ?? resolveConfigDir(deps?.env ?? process.env); const legacyProfileDir = path.join(configDir, "browser", "clawd"); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 12dab75253f1..1aa555b393ed 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -505,6 +505,10 @@ vi.mock("./doctor-browser.js", () => ({ changes: [], warnings: [], }), + maybeRepairOwnedChromeExtensionNativeHosts: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 9e40f163d4a4..acdd57ea1bb2 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -31,6 +31,10 @@ vi.mock("./doctor-browser.js", () => ({ changes: [], warnings: [], }), + maybeRepairOwnedChromeExtensionNativeHosts: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index c3fec3b4f524..b0bbb319bd7a 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -110,6 +110,10 @@ function mockDoctorBrowserFastPath(): void { changes: [], warnings: [], }), + maybeRepairOwnedChromeExtensionNativeHosts: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined), })); } diff --git a/src/flows/doctor-core-browser-residue-check.test.ts b/src/flows/doctor-core-browser-residue-check.test.ts index 70f85915c073..840107d42bd7 100644 --- a/src/flows/doctor-core-browser-residue-check.test.ts +++ b/src/flows/doctor-core-browser-residue-check.test.ts @@ -7,6 +7,10 @@ import type { HealthRepairContext } from "./health-checks.js"; const browserMocks = vi.hoisted(() => ({ detectLegacyClawdBrowserProfileResidue: vi.fn(), maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(), + maybeRepairOwnedChromeExtensionNativeHosts: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), noteChromeMcpBrowserReadiness: vi.fn(), })); @@ -14,6 +18,8 @@ vi.mock("../commands/doctor-browser.js", () => ({ detectLegacyClawdBrowserProfileResidue: browserMocks.detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue: browserMocks.maybeArchiveLegacyClawdBrowserProfileResidue, + maybeRepairOwnedChromeExtensionNativeHosts: + browserMocks.maybeRepairOwnedChromeExtensionNativeHosts, noteChromeMcpBrowserReadiness: browserMocks.noteChromeMcpBrowserReadiness, })); diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index fd448af4cf52..ba6e8218cb7b 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -5,6 +5,7 @@ import { isExperimentalClawsEnabled } from "../claws/experimental.js"; import { detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue, + maybeRepairOwnedChromeExtensionNativeHosts, noteChromeMcpBrowserReadiness, type LegacyClawdBrowserProfileResidue, } from "../commands/doctor-browser.js"; @@ -1006,6 +1007,23 @@ const browserCheck: HealthCheck = { await noteChromeMcpBrowserReadiness(ctx.cfg, { noteFn: collector.noteFn }); return collector.findings; }, + async repair(ctx) { + if (ctx.dryRun === true) { + return { + status: "skipped", + reason: "native-host repair requires filesystem writes", + changes: [], + }; + } + const result = await maybeRepairOwnedChromeExtensionNativeHosts(); + return { + ...(result.changes.length === 0 && result.warnings.length > 0 + ? { status: "failed" as const, reason: result.warnings.join("; ") } + : {}), + changes: result.changes, + warnings: result.warnings, + }; + }, }; function createSkillsReadinessCheck( diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f535f6aa1994..825e08693a87 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -98,6 +98,10 @@ const mocks = vi.hoisted(() => ({ maybeRepairLegacyPluginManifestContracts: vi.fn().mockResolvedValue(undefined), detectLegacyClawdBrowserProfileResidue: vi.fn(), maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(), + maybeRepairOwnedChromeExtensionNativeHosts: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), listAgentIds: vi.fn<(_cfg: OpenClawConfig) => string[]>(() => ["default"]), resolveAgentWorkspaceDir: vi.fn<(_cfg: OpenClawConfig, agentId: string) => string>( () => "/tmp/openclaw-workspace", @@ -377,6 +381,7 @@ vi.mock("../commands/doctor-browser.js", () => ({ noteChromeMcpBrowserReadiness: mocks.noteChromeMcpBrowserReadiness, detectLegacyClawdBrowserProfileResidue: mocks.detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue: mocks.maybeArchiveLegacyClawdBrowserProfileResidue, + maybeRepairOwnedChromeExtensionNativeHosts: mocks.maybeRepairOwnedChromeExtensionNativeHosts, })); vi.mock("../agents/agent-scope.js", () => ({ diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 2b102dd9341c..adab7a1955ac 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -140,9 +140,9 @@ describe("package scripts", () => { ); }); - it("runs browser copilot E2E against real Chromium", () => { - expect(readPackageJson().scripts["test:e2e:browser-copilot"]).toBe( - "node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node --import tsx scripts/ensure-playwright-chromium.mts --require-playwright-chromium && node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_COPILOT_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/browser/chrome-extension/page-share.e2e.test.ts extensions/browser/chrome-extension/sidepanel.e2e.test.ts", + it("runs browser extension bootstrap E2E against real Chromium", () => { + expect(readPackageJson().scripts["test:e2e:browser-extension"]).toBe( + "node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node --import tsx scripts/ensure-playwright-chromium.mts --require-playwright-chromium && node --import tsx scripts/run-with-env.mts PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_EXTENSION_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs extensions/browser/chrome-extension/bootstrap.chromium.test.ts", ); }); diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts index 3213409b67fd..2de3382835cb 100644 --- a/test/scripts/bundled-plugin-assets.test.ts +++ b/test/scripts/bundled-plugin-assets.test.ts @@ -103,9 +103,6 @@ describe("bundled plugin assets", () => { ).toBe(true); } - expect(generatedAssetSources).toContain( - "extensions/browser/chrome-extension/modules/copilot-runtime.js", - ); expect(generatedAssetSources).toContain("extensions/canvas/src/host/a2ui/.bundle.hash"); expect(generatedAssetSources).toContain("extensions/canvas/src/host/a2ui/a2ui.bundle.js"); expect(generatedAssetSources).toContain("extensions/discord/assets/embedded-app-sdk.mjs"); @@ -113,9 +110,6 @@ describe("bundled plugin assets", () => { expect(isBuildRelevantRunNodePath(source), source).toBe(false); expect(isRestartRelevantRunNodePath(source), source).toBe(false); } - expect( - isRestartRelevantRunNodePath("extensions/browser/scripts/copilot-runtime-entry.ts"), - ).toBe(true); expect(isRestartRelevantRunNodePath("extensions/discord/src/activities/http.ts")).toBe(true); }); diff --git a/test/scripts/changed-lanes-generated-extension-lint.test.ts b/test/scripts/changed-lanes-generated-extension-lint.test.ts index b8172e125b6e..1aac2eb52008 100644 --- a/test/scripts/changed-lanes-generated-extension-lint.test.ts +++ b/test/scripts/changed-lanes-generated-extension-lint.test.ts @@ -4,8 +4,8 @@ import { createChangedCheckPlan } from "../../scripts/check-changed.mts"; describe("generated extension asset lint planning", () => { it("still lints extension tests alongside a generated browser asset", () => { - const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js"; - const extensionTest = "extensions/browser/chrome-extension/modules/copilot-gateway.test.ts"; + const generatedAsset = "extensions/canvas/src/host/a2ui/a2ui.bundle.js"; + const extensionTest = "extensions/canvas/scripts/bundle-a2ui.test.ts"; const result = detectChangedLanes([generatedAsset, extensionTest]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); @@ -29,8 +29,8 @@ describe("generated extension asset lint planning", () => { }); it("keeps fallback extension lint for a manifest beside a generated browser asset", () => { - const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js"; - const manifest = "extensions/browser/openclaw.plugin.json"; + const generatedAsset = "extensions/canvas/src/host/a2ui/a2ui.bundle.js"; + const manifest = "extensions/canvas/openclaw.plugin.json"; const result = detectChangedLanes([generatedAsset, manifest]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index d572b0e9224e..35a4f6359a15 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -808,48 +808,6 @@ describe("scripts/changed-lanes", () => { } }); - it("keeps manifest-declared generated browser assets out of targeted extension lint", () => { - const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js"; - const result = detectChangedLanes([ - generatedAsset, - "packages/gateway-client/src/protocol-client.ts", - ]); - const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); - - expect(result.lanes.extensions).toBe(true); - expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions"); - expect(plan.commands.map((command) => command.args[0])).not.toContain("lint:extensions"); - expect( - plan.commands - .filter((command) => command.args[0] === "scripts/run-oxlint.mjs") - .flatMap((command) => command.args), - ).not.toContain(generatedAsset); - }); - - it("still lints extension source alongside its generated browser asset", () => { - const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js"; - const source = "extensions/browser/scripts/copilot-runtime-entry.ts"; - const result = detectChangedLanes([generatedAsset, source]); - const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); - - expect(plan.commands).toContainEqual( - expect.objectContaining({ - name: "lint extension changed file", - args: [ - "scripts/run-oxlint.mjs", - "--tsconfig", - "config/tsconfig/oxlint.extensions.json", - source, - ], - }), - ); - expect( - plan.commands - .filter((command) => command.args[0] === "scripts/run-oxlint.mjs") - .flatMap((command) => command.args), - ).not.toContain(generatedAsset); - }); - it.each([ { owner: "core", diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 285e52538621..424a80d07a44 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -5627,12 +5627,14 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(scenario.run).toBe( "node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner --shard ${{ matrix.shard }}/4", ); - const browserCopilot = expectDefined( - uiE2e.steps.find((step: WorkflowStep) => step.name === "Test browser copilot end-to-end"), - "browser copilot E2E suite", + const browserExtension = expectDefined( + uiE2e.steps.find( + (step: WorkflowStep) => step.name === "Test browser extension bootstrap end-to-end", + ), + "browser extension bootstrap E2E suite", ); - expect(browserCopilot.if).toBe("matrix.shard == 1"); - expect(browserCopilot.run).toBe("pnpm test:e2e:browser-copilot"); + expect(browserExtension.if).toBe("matrix.shard == 1"); + expect(browserExtension.run).toBe("pnpm test:e2e:browser-extension"); for (const { job } of routedUiE2eJobs) { const jobContract = JSON.stringify(job); expect(jobContract).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM"); diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index ef275334d871..6b37f5e5fad2 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -148,7 +148,6 @@ describe("oxlint config", () => { ".agents/skills/autoreview/tests/fixtures/**", "test/fixtures/oxlint-boundary-guards/**", "**/a2ui.bundle.js", - "extensions/browser/chrome-extension/modules/copilot-runtime.js", "extensions/diffs/assets/viewer-runtime.js", "extensions/diffs-language-pack/assets/viewer-runtime.js", "node_modules/", diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 10038751df5d..17c59f5fbd38 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1923,7 +1923,6 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/plugin-npm-runtime-build-args.test.ts", ], "scripts/lib/generated-text-asset.mts": [ - "extensions/browser/scripts/build-copilot-runtime.test.ts", "test/scripts/build-diffs-viewer-runtime.test.ts", "test/scripts/bundled-plugin-assets.test.ts", ], diff --git a/tsdown.config.ts b/tsdown.config.ts index 7993591fec71..8e03a849e518 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -528,6 +528,7 @@ function buildUnifiedDistEntries(): Record<string, string> { "memory-core-local-embedding-worker": "packages/memory-host-sdk/src/host/embeddings-worker-child.ts", ...listBundledPluginEntrySources(rootBundledPluginBuildEntries), + "extensions/browser/native-host-entry": "extensions/browser/native-host-entry.ts", ...bundledHookEntries, }; }