diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac79b8f7..ab08e8383 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,11 @@ jobs: needs: changes if: needs.changes.outputs.docs_only == 'false' name: verify (${{ matrix.os }}, ${{ matrix.node }}) - timeout-minutes: 15 + # A ceiling, not a cost: a job that finishes in eleven minutes still takes + # eleven minutes. The Linux profile drives the real-browser suite, which + # launches a browser per case because each case needs a clean extension + # state, and that suite does not fit in fifteen minutes any more. + timeout-minutes: 25 strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 3fe5f1ad5..0a1380c27 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ dist-webui/ .idea/ .codegraph/ .plan +dist-*.zip +dist-store-*/ diff --git a/.gitleaks.toml b/.gitleaks.toml index d749e8f03..b222e6bb7 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -12,6 +12,13 @@ paths = ['''(^|/)packages/agent-modules/permission/src/tools/bash-checker\.ts$'' regexTarget = "match" regexes = ['''^labelKey: 'permission\.scope\.byArgvPrefix2'$'''] +[[rules.allowlists]] +description = "Published pairing transfer vector, not a credential" +condition = "AND" +paths = ['''(^|/)packages/(browser-extension/test/protocol\.test\.ts|webui/test/unit/browser-bridge-pairing\.test\.ts)$'''] +regexTarget = "secret" +regexes = ['''^00112233445566778899aabbccddeeff$'''] + [[rules.allowlists]] description = "Documented keyboard shortcut" condition = "AND" diff --git a/packages/browser-extension/README.md b/packages/browser-extension/README.md new file mode 100644 index 000000000..2eeaede65 --- /dev/null +++ b/packages/browser-extension/README.md @@ -0,0 +1,76 @@ +# @mavis/browser-extension + +The Edge/Chrome extension that bridges a person's own browser to a local WebUI +host, so the person and the Agent operate **one** browser session rather than two. +It is the extension half of ADR 0019. + +## What it does + +- Pairs with the local WebUI over a loopback WebSocket at `/browser-bridge`, + using challenge-response rather than a bearer token. +- Reports the tabs it can reach, each with a generation that rises whenever the + page behind the tab is replaced. +- Streams live images of the visible tab for the panel. +- Delivers pointer, keyboard and text input into the tab the Agent addressed. + +## Layout + +| File | Purpose | +| --- | --- | +| `manifest.json` | The MV3 manifest: permissions, loopback host permissions, no remote code. | +| `src/chrome.d.ts` | The subset of the Chrome API this extension uses; `@types/chrome` is unavailable to an extension. | +| `src/protocol.ts` | The extension's copy of the bridge wire protocol, pinned to the host's signature test vector. | +| `src/pairing.ts` | The pairing credential in `chrome.storage.local` and the challenge answer. | +| `src/bridge-client.ts` | The loopback WebSocket client, the reconnect schedule and the operation deadline. | +| `src/targets.ts` | Tab discovery and the generation registry. | +| `src/images.ts` | The visible-tab image stream. | +| `src/input.ts` | Pointer / keyboard / text delivery, bound to target and generation. | +| `src/background/service-worker.ts` | The MV3 entry point that wires the above together. | + +## Running it + +Load `packages/browser-extension/` as an unpacked extension +(`chrome://extensions` → Developer mode → Load unpacked), then set the bridge +origin in extension storage: + +```json +{ "webui.browserBridge.origin": "http://127.0.0.1:" } +``` + +The extension cannot learn the host's address by itself, so an absent origin is +reported rather than replaced with a guessed default port. + +The bridge path, the frame shapes and the signature construction are duplicated +from `packages/webui/src/shared/contracts/browser-bridge.ts`: an extension has no +node_modules and no bundler, so it cannot import a workspace package. Both sides +are pinned to the same test vector +(`secret = "webui-browser-bridge-test-secret"`, `nonce = "nonce-abc"`, +`keyId = "key-1"` → `xb7/GT5s+ScVsaiLkh+eRRndKi5oPQwqQMqU3aX8Znc=`), so a change +to either side breaks one suite rather than silently failing to pair. + +## Two limitations that shape the product + +- **Only the visible tab can be captured.** `chrome.debugger` does not expose + `Page.captureScreenshot`, so `chrome.tabs.captureVisibleTab` is the only image + path, and it returns nothing for a background tab. The extension reports "no + frame" rather than substituting another tab's page. +- **No remote code and no ``.** The content security policy allows only + `'self'` scripts, and host permissions cover the two loopback origins the + bridge needs. Page access comes from `tabs`/`debugger`/`activeTab`, not from a + blanket host grant. + +## Verification + +``` +pnpm --filter @mavis/browser-extension typecheck +pnpm --filter @mavis/browser-extension test +``` + +## Store distribution + +Chrome Web Store and Edge Add-ons artifacts are a separate release action. Both +builds come from this one source package; a store build needs a compiled service +worker (`dist/background/service-worker.js`) with `manifest.json` pointing at the +built path, since a browser cannot execute the TypeScript sources directly. This +package deliberately declares no build step, so that compilation belongs to the +packaging change rather than to the bridge implementation. \ No newline at end of file diff --git a/packages/browser-extension/STORE-DISCLOSURES.md b/packages/browser-extension/STORE-DISCLOSURES.md new file mode 100644 index 000000000..71c14eb76 --- /dev/null +++ b/packages/browser-extension/STORE-DISCLOSURES.md @@ -0,0 +1,127 @@ +# Store disclosures — Chrome Web Store and Edge Add-ons + +Both stores ask the same questions, and the answers are the same because the +extension is one package. This document is the source the two listings are +written from; the per-store wording differs only in the store's own terms. + +## Single purpose + +**Pair this browser with a MiniMax Code WebUI running on the same machine, so +that the person and the Agent can work in the same browser page.** + +The extension connects to a WebUI host on the loopback interface, pairs with +it once, and then relays three things between them: + +- which tabs are open, so the host can show them; +- the picture of the tab the person is looking at, so the host can display it; +- the person's pointer, keyboard and text input, so the person can act on the + page the Agent is working in. + +It does not browse on its own, it does not collect anything for anyone else, +and it does not work unless a WebUI is running on this machine and the person +has turned the browser's Browser Use switch on. + +## What the extension sends, and to whom + +Every byte the extension sends goes to **one place**: the WebUI host on +`http://127.0.0.1` or `http://localhost`, over a WebSocket, and only after +the person has paired it. The extension has no other destination, no +analytics, no telemetry and no remote code. + +| What | Where it goes | Why | +| --- | --- | --- | +| The tabs' titles and URLs | The loopback WebUI host | So the host can show which page is open and let the Agent select one. | +| A picture of the **visible** tab | The loopback WebUI host | So the host can show the person the page the Agent is driving. | +| Pointer, keyboard and text input | The loopback WebUI host | So the person's input reaches the page the Agent is working in. | +| The result of a DevTools command | The loopback WebUI host | So the host can read the page the Agent asked about. | +| A pairing credential, encrypted | The loopback WebUI host | So the host can prove the peer is this extension and not something else. | + +**The extension can read the contents of a page.** It attaches the browser's own +DevTools debugger to the tab the person has paired, and the allowed commands +include reading the page: `DOM.getDocument`, `DOM.querySelector`, +`DOM.getOuterHTML`, `DOM.describeNode`, `Runtime.evaluate`, +`Runtime.callFunctionOn`, `Runtime.getProperties` and +`Accessibility.getFullAXTree` read the page's DOM, the values of its +JavaScript objects and its accessibility tree, and `Network.getCookies` and +`Network.getResponseBody` read the page's cookies and the bodies of the +responses it received. + +Whatever such a command returns is sent back to the loopback WebUI host, +because that is how the Agent learns what the page says. **The host is the +only destination**, and the person is the one who paired the tab, but the +extension is not limited to the page's title and its picture. + +The full list of allowed commands is +`packages/browser-extension/src/cdp.ts`; a command outside it is refused +rather than attempted, and the refusal is reported to the host as +`unsupported`. + +## What the extension stores, and where + +All of it is in the browser's own extension storage, on this machine, and +none of it leaves the machine. + +| What | Where | Lifetime | +| --- | --- | --- | +| The WebUI host's address | `chrome.storage.local` | Until the person clears it. | +| The installation key the person pasted | `chrome.storage.local` | Until the person clears it. | +| The pairing credential the host issued | `chrome.storage.local` | Replaced on every pairing; removed when the browser's switch is turned off. | +| A random identifier for this browser | `chrome.storage.local` | Until the person clears it. | +| The last error, for the options page | `chrome.storage.session` | Until the browser closes. | + +The extension writes **nothing** to disk outside the browser's extension +storage, and it keeps **no log**: there is no `console` call anywhere in the +extension, because an extension that logs what it is carrying leaks pairing +material and page content into whatever collects extension logs. + +## The permissions requested, and why each is needed + +| Permission | Why it is needed | +| --- | --- | +| `debugger` | The only way an extension can dispatch input to a page the way the browser does, and the only way to run a DevTools command against a tab. Chrome's `debugger` API exposes only a **subset** of the DevTools domains, and the extension restricts itself further to the commands in `cdp.ts`. | +| `tabs` | Knowing which tabs exist, so the host can show them and the Agent can select one. | +| `activeTab` | Working with the tab the person has made active. | +| `scripting` | Injecting the small amount of scripting the input path needs. | +| `storage` | Holding the host address, the installation key and the pairing credential, as described above. | +| `webNavigation` | Knowing when a tab navigated, so the host can tell a page that was replaced from the page it replaced. | +| `` (host permission) | `chrome.tabs.captureVisibleTab` — the only way to deliver the picture of the visible tab — requires either this permission or an `activeTab` permission granted by a user gesture on that page. There is no gesture here: the **host** is asking for the picture, not the person clicking the extension's icon. Without it the browser refuses the capture with *"Either the '' or 'activeTab' permission is required."* The extension captures the visible tab and does nothing else with the permission itself: it injects nothing into the page, and the picture is the only thing the permission is used for. The permission is what lets the extension attach the debugger to a tab, and the DevTools subset it may run there — which can read the page, as described above — is what that attachment allows. | + +The host permissions also include `http://127.0.0.1/*` and +`http://localhost/*`, which is where the WebUI host is reached. + +## Privacy policy + +The extension collects **no personal data** of its own and transmits +**no personal data** to any party other than the WebUI host running on the +same machine, which the person has installed and started themselves. + +The data that passes between the extension and that host — tab titles, tab +URLs, the picture of the visible tab, the person's input, and the result of a +DevTools command, which **can** be the content of the page — is the data of +the pages the person is already looking at. It moves only between two programs +on the same machine, over the loopback interface, and only while the person has +the browser's Browser Use switch on. It is not stored by the extension beyond +the credentials described above, and it is not sent anywhere else. + +The host stores what it is given in its own data directory, and the Agent may +act on it. That is the point of the feature, and it is why the switch is off +until the person turns it on. + +No data is sold, shared, or used for advertising, and there is no analytics or +telemetry of any kind. + +## The packaged build differs from the development build in exactly these ways + +| | Development build | Packaged build | +| --- | --- | --- | +| Extension identity | The id Chrome derives from the unpacked directory — different for every path, and therefore **not** a stable identity | The id the store issues, fixed and recorded | +| How the identity is fixed | Nothing in the build pins it | The manifest's `key` field carries the store's public key, and the id follows from it: the first sixteen bytes of its SHA-256, each nibble mapped to a letter from `a` | +| Where the identity is recorded | Nowhere; it is derived from the path | In the build manifest, and the host validates against it | +| Everything else | Identical | Identical — the packaged manifest differs from the development manifest in the `key` field and in nothing else | +| Accepted by a packaged host | **No.** A packaged host ignores the development allowlist | Only the recorded store identity | +| Accepted by a development host | Only the ids in the development allowlist | The store identity, when it is listed | + +A packaged host therefore never accepts a development build, and a +development build is never mistaken for a store build. The identity the bridge +accepts is the one the store issues, because that is the only identity the +person can be sure is the extension they installed from the store. diff --git a/packages/browser-extension/manifest.json b/packages/browser-extension/manifest.json new file mode 100644 index 000000000..fd033230f --- /dev/null +++ b/packages/browser-extension/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "MiniMax Code Browser Bridge", + "version": "0.1.0", + "description": "Pairs this browser with a local MiniMax Code WebUI host so the person and the Agent can operate the same browser session.", + "minimum_chrome_version": "116", + "background": { + "service_worker": "src/background/service-worker.ts", + "type": "module" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "permissions": [ + "activeTab", + "debugger", + "scripting", + "storage", + "tabs", + "webNavigation" + ], + "host_permissions": [ + "", + "http://127.0.0.1/*", + "http://localhost/*" + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + } +} diff --git a/packages/browser-extension/options.html b/packages/browser-extension/options.html new file mode 100644 index 000000000..62c4c6b08 --- /dev/null +++ b/packages/browser-extension/options.html @@ -0,0 +1,51 @@ + + + + + MiniMax Code Browser Bridge — options + + + +

Browser Bridge

+

+ Paste the installation key shown in the WebUI Settings for this browser. + It is stored in this browser only, and is never shown again in full. +

+ + +
+ + +
+

+

+ + + \ No newline at end of file diff --git a/packages/browser-extension/package.json b/packages/browser-extension/package.json new file mode 100644 index 000000000..d5379754e --- /dev/null +++ b/packages/browser-extension/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mavis/browser-extension", + "version": "0.1.0", + "description": "Edge/Chrome extension that bridges a person's own browser to the local WebUI browser bridge.", + "license": "MIT", + "type": "module", + "private": true, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "node scripts/build.mjs", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5", + "vitest": "^4" + } +} diff --git a/packages/browser-extension/scripts/build.mjs b/packages/browser-extension/scripts/build.mjs new file mode 100644 index 000000000..8b679b938 --- /dev/null +++ b/packages/browser-extension/scripts/build.mjs @@ -0,0 +1,135 @@ +// Builds the extension into something a browser can actually load. +// +// A browser extension runtime executes **JavaScript only**. The extension's +// source is TypeScript, so loading the source directory registers a service +// worker that then cannot run — the extension looks installed and does +// nothing. A probe against a real Chrome showed exactly that: +// `.plan/board/validation/extension-load-evidence.json`. +// +// So the source directory is not the loadable artefact. This script produces +// `dist/`, which is: +// +// dist/manifest.json the manifest, with the built paths +// dist/background/service-worker.js the bundled service worker +// dist/options.html, dist/options.js the bundled options page +// +// Load `dist/` as an unpacked extension. The source `manifest.json` stays the +// single declaration of the extension's metadata; this script only rewrites +// the two paths that point at source, and it says so where it rewrites them. + +import { build } from "esbuild"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * Where this build goes. + * + * `--out `, relative to the package root. The default is `dist`. A store + * build wants its own directory: a packaged build and a development build + * cannot share one directory, because the packaged build's manifest carries the + * store's public key and the development build's does not, and a build that + * overwrote the other would make the identity of the loaded extension depend + * on which suite ran last. + */ +const outIndex = process.argv.indexOf("--out"); +const outDir = outIndex >= 0 ? join(packageRoot, process.argv[outIndex + 1]) : join(packageRoot, "dist"); + +/** + * The store this build is for, when it is a packaged build. + * + * `--store chrome` or `--store edge`. A packaged build embeds the store's + * public key, which fixes the extension's identity: an unpacked build derives + * its id from the directory it is loaded from, and that id is different for + * every path and therefore not an identity anyone can rely on. + */ +const store = process.argv.includes("--store") + ? process.argv[process.argv.indexOf("--store") + 1] + : undefined; + +/** The browser this targets. The manifest's `minimum_chrome_version` says the same. */ +const BROWSER_TARGET = ["chrome116", "edge116"]; + +await rm(outDir, { recursive: true, force: true }); +await mkdir(join(outDir, "background"), { recursive: true }); + +const common = { + bundle: true, + format: "esm", + platform: "browser", + target: BROWSER_TARGET, + // The service worker runs as a module, so the bundle must be an ES module + // and the manifest's `background.type` must stay "module". + sourcemap: false, + minify: false, + legalComments: "none", +}; + +await build({ + ...common, + entryPoints: [join(packageRoot, "src/background/service-worker.ts")], + outfile: join(outDir, "background/service-worker.js"), +}); + +await build({ + ...common, + entryPoints: [join(packageRoot, "src/options.ts")], + outfile: join(outDir, "options.js"), +}); + +// The options page is HTML; its script reference has to point at the bundle. +const optionsHtml = await readFile(join(packageRoot, "options.html"), "utf8"); +const rewrittenOptions = optionsHtml.replace( + /(src\s*=\s*)["'][^"']*options[^"']*["']/u, + '$1"options.js"', +); +await writeFile(join(outDir, "options.html"), rewrittenOptions, "utf8"); + +// The manifest is copied with the two source paths rewritten to the built +// ones. Nothing else about it changes: it is the single declaration of the +// extension's metadata, and the build only relocates it. +const manifest = JSON.parse(await readFile(join(packageRoot, "manifest.json"), "utf8")); +if (manifest.background?.service_worker) { + manifest.background.service_worker = "background/service-worker.js"; +} +if (manifest.options_ui?.page) { + manifest.options_ui.page = "options.html"; +} +if (store) { + // The store's public key fixes the identity. It is the same key the store + // holds, so the id the extension gets is the id the store issued. + const keyEnv = `MAVIS_BROWSER_STORE_${store.toUpperCase()}_PUBLIC_KEY`; + const publicKey = process.env[keyEnv]; + if (!publicKey) { + throw new Error( + `a packaged build needs the store's public key in ${keyEnv}; ` + + "a packaged build without a fixed identity is not a packaged build.", + ); + } + manifest.key = publicKey; +} +await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + +// The manifest's icon, if the extension ever has one, is copied verbatim. +if (manifest.icons) { + for (const icon of Object.values(manifest.icons)) { + await cp(join(packageRoot, icon), join(outDir, icon)); + } +} + +process.stdout.write(`Built the extension into ${outDir}\n`); + +if (store) { + // The store takes a zip of the unpacked directory. The name carries the + // store, so the two artifacts cannot be confused for one another. + const archive = join(packageRoot, `dist-${store}.zip`); + await rm(archive, { force: true }); + await run("zip", ["-r", "-q", archive, "."], { cwd: outDir }); + process.stdout.write(`Packaged the ${store} build as ${archive}\n`); +} diff --git a/packages/browser-extension/src/background/service-worker.ts b/packages/browser-extension/src/background/service-worker.ts new file mode 100644 index 000000000..a63e823a5 --- /dev/null +++ b/packages/browser-extension/src/background/service-worker.ts @@ -0,0 +1,387 @@ +// The MV3 service worker: the extension's only long-lived entry point. +// +// An MV3 worker is terminated after a few seconds of inactivity and restarted +// when something needs it, so nothing here keeps state in a module variable. The +// pairing credential is read from `chrome.storage.local` and the target +// generations from `chrome.storage.session` on every start, which is what lets a +// restarted worker answer a fresh challenge and carry on without the host +// noticing anything beyond a new WebSocket. The module holds only a promise for +// the in-flight start, so the worker's repeated top-level evaluation cannot +// register a second set of tab listeners or a second client. + +import { + WebuiBrowserBridgeClient, +} from "../bridge-client.js"; +import { createWebuiBrowserBridgeImageStream } from "../images.js"; +import { + WEBUI_BROWSER_BRIDGE_DEBUGGER_VERSION, + createWebuiBrowserBridgeInputDispatcher, +} from "../input.js"; +import { createWebuiBrowserBridgeCdpDispatcher } from "../cdp.js"; +import { + answerWebuiBrowserBridgeChallengeWithStoredSecrets, + applyWebuiBrowserBridgePairingCredential, + loadWebuiBrowserBridgeInstallationKey, + loadWebuiBrowserBridgePairingCredential, + webuiBrowserBridgeInstallationKeyStore, + webuiBrowserBridgePairingStore, + type WebuiBrowserBridgeBrowserName, + type WebuiBrowserBridgeInstallationKey, + type WebuiBrowserBridgePairingCredential, +} from "../pairing.js"; +import { WEBUI_BROWSER_BRIDGE_RECONNECT_MESSAGE } from "../protocol.js"; +import type { + WebuiBrowserBridgeClientFrame, + WebuiBrowserBridgeError, + WebuiBrowserBridgeHostFrame, +} from "../protocol.js"; +import { + createWebuiBrowserBridgeTargetRegistry, + webuiBrowserBridgeTargetGenerationStore, +} from "../targets.js"; + +/** The `chrome.storage.local` key the bridge origin lives under. */ +const ORIGIN_STORAGE_KEY = "webui.browserBridge.origin"; + +/** The `chrome.storage.local` key this browser's stable identity lives under. */ +const BROWSER_ID_STORAGE_KEY = "webui.browserBridge.browserId"; + +/** Where the last failure is parked for a diagnostics surface to read. */ +const LAST_ERROR_STORAGE_KEY = "webui.browserBridge.lastError"; + +/** + * How often the panel's image stream captures. + * + * The frame rate is deliberately modest: the tab has to be in front for + * `captureVisibleTab` to return anything, so a faster loop mostly spends the + * person's CPU on frames nobody can receive. + */ +const IMAGE_FRAME_INTERVAL_MS = 500; + +const pairingStore = webuiBrowserBridgePairingStore(chrome.storage.local); +const installationKeyStore = webuiBrowserBridgeInstallationKeyStore(chrome.storage.local); +const generations = webuiBrowserBridgeTargetGenerationStore(chrome.storage.session); + +let starting: Promise | undefined; +/** The live client, so a reconnect can stop it rather than leave it running. */ +let currentBridge: WebuiBrowserBridgeClient | undefined; + +function start(): Promise { + starting ??= startBridge(); + return starting; +} + +/** + * Reconnects the bridge. + * + * A Manifest V3 service worker is **lazy**: it starts on an event and is torn + * down when it goes idle. After the person pastes the installation key + * nothing wakes it, so the extension would sit there configured but + * unconnected until some unrelated browser event happened to start it again. + * The options page asks for a reconnect instead, and this is what answers. + */ +async function reconnect(): Promise { + currentBridge?.stop(); + currentBridge = undefined; + starting = undefined; + await start(); +} + +/** The options page's request to reconnect. */ +const RECONNECT_MESSAGE = WEBUI_BROWSER_BRIDGE_RECONNECT_MESSAGE; + +async function startBridge(): Promise { + const browser = detectBrowser(); + const origin = await resolveOrigin(); + if (!origin) { + // The extension cannot learn the host's address by itself — a person has to + // enter it — so an absent value is reported rather than replaced by a + // default port that would quietly connect to something else. + await report({ + code: "browser_provider_unavailable", + message: "no WebUI bridge origin is configured for this extension", + retryable: false, + }); + return; + } + const browserId = await resolveBrowserId(); + // Read any pre-existing credential so a reconnect of an already-paired + // browser doesn't wait for a `pair` round-trip it has nothing to learn + // from. The host still sends a fresh pair on each `hello`, but the + // extension only overwrites storage when that pair arrives — see + // `applyPairCredential` below. + let credential: WebuiBrowserBridgePairingCredential | undefined = + await loadWebuiBrowserBridgePairingCredential(pairingStore); + // The installation key the person pasted into the options page. Absent on a + // browser that has never been provisioned, which is a state the handshake + // reports rather than works around. + let installationKeyForAnswer: WebuiBrowserBridgeInstallationKey | undefined = + await loadWebuiBrowserBridgeInstallationKey(installationKeyStore); + const targets = createWebuiBrowserBridgeTargetRegistry({ + browserId, + tabs: chrome.tabs, + generations, + }); + const input = createWebuiBrowserBridgeInputDispatcher({ targets, debugger: chrome.debugger }); + // The DevTools path shares the input dispatcher's debugger, so the extension + // has one attach/send path rather than two that could disagree about what is + // attached to which tab. + const cdp = createWebuiBrowserBridgeCdpDispatcher({ + debugger: chrome.debugger, + version: WEBUI_BROWSER_BRIDGE_DEBUGGER_VERSION, + }); + const images = createWebuiBrowserBridgeImageStream({ + browserId, + capture: chrome.tabs, + resolveGeneration: async (targetId) => (await targets.lookup(targetId))?.generation, + }); + + // A restarted worker only knows about the committed navigations the registry + // persisted, which is why the generations live in storage rather than in a map. + chrome.webNavigation.onCommitted.addListener((details) => { + if (details.frameId !== 0) return; + void targets.noteCommitted(details.tabId).then(publishTargets, report); + }); + chrome.tabs.onRemoved.addListener((tabId) => { + void targets.noteRemoved(tabId).then(publishTargets, report); + }); + + let streaming = false; + + async function publishTargets(): Promise { + // Publishing waits for a session rather than dropping the list: the target + // set is what the host needs in order to show the panel at all. + const list = await targets.list(); + await bridge.publish({ kind: "targets", targets: list }); + } + + async function streamImages(): Promise { + if (streaming) return; + streaming = true; + try { + while (bridge.connected) { + try { + const frame = await images.captureVisibleFrame(); + // The visible tab is the person's tab, which need not be the one the + // Agent is driving. The frame names its own target so the host can + // decide what to show, rather than being handed a silent substitution. + await bridge.publish({ kind: "frame", frame }); + } catch (cause) { + await report({ + code: "browser_provider_unavailable", + message: cause instanceof Error ? cause.message : String(cause), + retryable: true, + }); + } + await sleep(IMAGE_FRAME_INTERVAL_MS); + } + } finally { + streaming = false; + } + } + + function handleHostFrame(frame: WebuiBrowserBridgeHostFrame): void { + switch (frame.kind) { + case "input": { + // One operation. The delivery runs exactly once and the client reports its + // outcome under the operation deadline; a reconnect in between + // re-establishes the session but never re-delivers the command. + void bridge + .runOperation(async () => resultFrame(frame.commandId, await input.dispatch(frame.command))) + .catch(report); + return; + } + case "cdp": { + // A raw DevTools command, sent only when the host's Full CDP switch is + // on. The extension still decides whether it will run it: a method + // outside its supported subset is refused with a reason, and the + // refusal is reported as *unsupported* so the host can tell it apart + // from a command that merely failed. + void bridge + .runOperation(async () => { + const target = await targets.lookup(frame.targetId); + if (!target) { + return { + kind: "cdp-result", + commandId: frame.commandId, + accepted: false, + error: `target ${frame.targetId} is not open`, + } as const; + } + if (target.generation !== frame.generation) { + return { + kind: "cdp-result", + commandId: frame.commandId, + accepted: false, + error: `target ${frame.targetId} was replaced before the command arrived`, + } as const; + } + const outcome = await cdp.dispatch( + { tabId: target.tabId }, + frame.method, + frame.params, + ); + return outcome.accepted + ? ({ + kind: "cdp-result", + commandId: frame.commandId, + accepted: true, + result: outcome.result, + } as const) + : ({ + kind: "cdp-result", + commandId: frame.commandId, + accepted: false, + error: outcome.error, + ...(outcome.unsupported ? { unsupported: true } : {}), + } as const); + }) + .catch(report); + return; + } + case "closed": { + void report({ code: "browser_pairing_rejected", message: frame.reason, retryable: false }); + bridge.stop(); + return; + } + default: { + // `challenge` and `ready` are the client's handshake, and `ping` is + // answered there too. + } + } + } + + const bridge = new WebuiBrowserBridgeClient({ + browser, + browserId, + extensionVersion: chrome.runtime.getManifest().version, + origin, + /** + * The credential is held in a worker-local slot and replaced by every + * `pair` frame. The host is authoritative: each connection it sends the + * encrypted credential afresh, and the extension stores it. A surface + * that was off and on again therefore lands at this side without any + * operator action. + */ + applyPairCredential: async (cipher, ownPrivateKey) => { + // The ECDH private key is per-connection and lives only for this call; + // after the secret has been written to storage nothing else needs it. + credential = await applyWebuiBrowserBridgePairingCredential( + pairingStore, + cipher, + ownPrivateKey, + ); + }, + answerChallenge: async (challenge) => { + // Storage is the source of truth on every challenge, and the local slot + // is only a fast path: a worker restart reloads from storage and a + // `pair` overwrites both. + const current = credential ?? (await loadWebuiBrowserBridgePairingCredential(pairingStore)); + const installationKey = + installationKeyForAnswer ?? (await loadWebuiBrowserBridgeInstallationKey(installationKeyStore)); + // The installation key is preferred: it is the anchor the host recognises + // on every connection and the one that survives a credential rotation, so + // preferring it is what lets a host that re-provisioned re-pair this + // browser without the person touching anything. + return answerWebuiBrowserBridgeChallengeWithStoredSecrets( + challenge, + installationKey, + current, + ); + }, + onStatus: (status) => { + if (status !== "connected") return; + void publishTargets().catch(report); + void streamImages(); + }, + onHostFrame: handleHostFrame, + onError: report, + }); + + bridge.start(); + currentBridge = bridge; + void publishTargets().catch(report); +} + +function resultFrame( + commandId: string, + result: + | { readonly accepted: true } + | { readonly accepted: false; readonly error: WebuiBrowserBridgeError }, +): WebuiBrowserBridgeClientFrame { + return result.accepted + ? { kind: "input-result", commandId, accepted: true } + : { kind: "input-result", commandId, accepted: false, error: result.error.message }; +} + +/** + * Failures are parked in session storage for a diagnostics surface to read. + * + * There is no console logging anywhere in this extension: an extension that logs + * what it is carrying leaks pairing material and page content into whatever + * collects extension logs, and neither belongs there. + */ +async function report(error: WebuiBrowserBridgeError): Promise { + await chrome.storage.session.set({ [LAST_ERROR_STORAGE_KEY]: { ...error, at: Date.now() } }); +} + +async function resolveBrowserId(): Promise { + const stored = (await chrome.storage.local.get(BROWSER_ID_STORAGE_KEY))[BROWSER_ID_STORAGE_KEY]; + if (typeof stored === "string" && stored.length > 0) return stored; + const created = `browser-${crypto.randomUUID()}`; + await chrome.storage.local.set({ [BROWSER_ID_STORAGE_KEY]: created }); + return created; +} + +async function resolveOrigin(): Promise { + const stored = (await chrome.storage.local.get(ORIGIN_STORAGE_KEY))[ORIGIN_STORAGE_KEY]; + return typeof stored === "string" && stored.length > 0 ? stored : undefined; +} + +/** + * Which browser this is. + * + * Both Edge and Chrome advertise themselves in the user agent, and the host + * authorizes each surface separately, so the two are never treated as one. + */ +function detectBrowser(): WebuiBrowserBridgeBrowserName { + return navigator.userAgent.includes("Edg/") ? "edge" : "chrome"; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +chrome.runtime.onStartup.addListener(() => { + void start(); +}); + +/** + * The options page asks for a reconnect once the person has pasted the key. + * + * The reply tells the page whether the reconnect happened, so the page can say + * something true rather than "saved" and leave the person wondering. + */ +chrome.runtime.onMessage.addListener( + (message: unknown, _sender: unknown, sendResponse: (response: unknown) => void) => { + if ( + typeof message !== "object" || + message === null || + (message as { type?: unknown }).type !== RECONNECT_MESSAGE + ) { + return undefined; + } + void reconnect().then( + () => sendResponse({ ok: true }), + (error: unknown) => + sendResponse({ + ok: false, + message: error instanceof Error ? error.message : String(error), + }), + ); + // The reply is asynchronous, so the channel has to stay open. + return true; + }, +); + +void start().catch(report); \ No newline at end of file diff --git a/packages/browser-extension/src/bridge-client.ts b/packages/browser-extension/src/bridge-client.ts new file mode 100644 index 000000000..37ef1c325 --- /dev/null +++ b/packages/browser-extension/src/bridge-client.ts @@ -0,0 +1,566 @@ +// The loopback WebSocket client, its reconnect policy and its deadlines. +// +// Two retry regimes run here, and they differ only in what they are waiting for. +// +// * While an operation is in flight the client retries on the schedule the ADR +// fixes — 250 ms, 500 ms, 1 s, 2 s, 4 s, capped at 5 s — for at most 15 s. +// When that deadline passes, the operation fails explicitly with +// `browser_operation_timeout` and is **not** replayed: a command that reached +// the page may already have acted, and sending it twice is not a retry. +// * In the background, while the person still has that browser switched on, the +// client keeps reconnecting with the same doubling capped at 30 s, so an +// extension that was asleep for an hour rejoins without help. +// +// Nothing here picks a different tab, browser or surface when a connection fails. +// A dropped socket is reported; substituting something else would leave the panel +// and the Agent looking at different pages, which is the failure this whole +// protocol exists to make impossible. + +import { + exportWebuiBrowserBridgeEcdhPublicKey, + generateWebuiBrowserBridgeEcdhKeyPair, + parseWebuiBrowserBridgeHostFrame, + webuiBrowserBridgeError, + webuiBrowserBridgeUrl, + type WebuiBrowserBridgeChallenge, + type WebuiBrowserBridgeClientFrame, + type WebuiBrowserBridgeEcdhPublicKey, + type WebuiBrowserBridgeError, + type WebuiBrowserBridgeHostFrame, + type WebuiBrowserBridgePair, + type WebuiBrowserBridgeProof, +} from "./protocol.js"; + +/** How long one operation may spend waiting for a session. */ +export const WEBUI_BROWSER_BRIDGE_OPERATION_DEADLINE_MS = 15_000; +/** The first retry delay; every later one doubles from it. */ +export const WEBUI_BROWSER_BRIDGE_RETRY_BASE_MS = 250; +/** The cap while an operation is in flight. */ +export const WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS = 5_000; +/** The cap while merely staying connected. */ +export const WEBUI_BROWSER_BRIDGE_BACKGROUND_RETRY_CAP_MS = 30_000; + +/** + * The retry delay for the nth failed attempt. + * + * Exported on its own so the schedule can be asserted exactly without running a + * socket: 250, 500, 1000, 2000, 4000, then 5000 for every attempt after that. + */ +export function webuiBrowserBridgeRetryDelayMs(attempt: number, capMs: number): number { + return Math.min(WEBUI_BROWSER_BRIDGE_RETRY_BASE_MS * 2 ** attempt, capMs); +} + +/** + * The retry delay with jitter. + * + * The jitter only ever delays, by up to a tenth of the delay, so the schedule the + * ADR pins stays inspectable in a log while a room full of extensions does not + * reconnect in lockstep. + */ +export function webuiBrowserBridgeJitteredDelayMs( + attempt: number, + capMs: number, + random: () => number, +): number { + const delay = webuiBrowserBridgeRetryDelayMs(attempt, capMs); + return delay + Math.round(delay * 0.1 * random()); +} + +export interface WebuiBrowserBridgeClock { + now(): number; + /** Returns a canceller for the callback. */ + schedule(callback: () => void, delayMs: number): () => void; +} + +export const webuiBrowserBridgeSystemClock: WebuiBrowserBridgeClock = { + now: () => Date.now(), + schedule: (callback, ms) => { + const handle = setTimeout(callback, ms); + return () => clearTimeout(handle); + }, +}; + +export interface WebuiBrowserBridgeSocketHandlers { + onOpen(): void; + onMessage(data: string): void; + onClose(): void; +} + +export interface WebuiBrowserBridgeSocket { + send(data: string): void; + close(): void; + setHandlers(handlers: WebuiBrowserBridgeSocketHandlers): void; +} + +export type WebuiBrowserBridgeSocketFactory = (url: string) => WebuiBrowserBridgeSocket; + +/** Adapts the platform `WebSocket` to the socket shape the client uses. */ +export function webuiBrowserBridgeWebSocketFactory(url: string): WebuiBrowserBridgeSocket { + const socket = new WebSocket(url); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + setHandlers(handlers) { + socket.addEventListener("open", () => handlers.onOpen()); + socket.addEventListener("message", (event: MessageEvent) => handlers.onMessage(String(event.data))); + socket.addEventListener("close", () => handlers.onClose()); + }, + }; +} + +export type WebuiBrowserBridgeStatus = "idle" | "connecting" | "connected" | "stopped"; + +export interface WebuiBrowserBridgeClientOptions { + readonly browser: "edge" | "chrome"; + readonly browserId: string; + readonly extensionVersion: string; + /** The loopback origin the bridge is served from, without the bridge path. */ + readonly origin: string; + readonly answerChallenge: (challenge: WebuiBrowserBridgeChallenge) => Promise; + /** + * Decrypts and stores the host's credential. + * + * Called once per connection, on the worker's end, when the host sends a + * `pair` frame. The client never looks at the secret itself — keeping the + * raw bytes out of the bridge state is the point — so this is the whole of + * the extension's side of the transfer. + * + * The host sends a `pair` on every `hello`, so a credential that was + * rotated by a switch off/on reaches here without anyone doing anything. + * When the host sends none (its own surface is off, or it does not + * recognise the id) this is never called and `answerChallenge` has to fall + * back to whatever storage holds — or report that it holds nothing, rather + * than hanging. + */ + readonly applyPairCredential: ( + cipher: WebuiBrowserBridgePair, + ownPrivateKey: CryptoKey, + ) => Promise; + readonly createSocket?: WebuiBrowserBridgeSocketFactory; + readonly clock?: WebuiBrowserBridgeClock; + readonly random?: () => number; + readonly operationDeadlineMs?: number; + /** Frames the extension sends on its own initiative. */ + readonly onFrame?: (frame: WebuiBrowserBridgeClientFrame) => void; + /** Frames the host sends that the client does not handle itself. */ + readonly onHostFrame?: (frame: WebuiBrowserBridgeHostFrame) => void; + readonly onStatus?: (status: WebuiBrowserBridgeStatus) => void; + readonly onError?: (error: WebuiBrowserBridgeError) => void; +} + +/** One operation whose outcome the host is waiting to hear. */ +interface InFlightOperation { + deadlineAt: number; + cancelDeadline: () => void; + resolve: () => void; + reject: (error: WebuiBrowserBridgeError) => void; +} + +/** A caller blocked on the next session, bounded by a deadline. */ +interface SessionWaiter { + cancelDeadline: () => void; + resolve: () => void; + reject: (error: WebuiBrowserBridgeError) => void; +} + +export class WebuiBrowserBridgeClient { + private readonly clock: WebuiBrowserBridgeClock; + private readonly random: () => number; + private readonly createSocket: WebuiBrowserBridgeSocketFactory; + private readonly deadlineMs: number; + private status: WebuiBrowserBridgeStatus = "idle"; + private socket: WebuiBrowserBridgeSocket | undefined; + private attempt = 0; + private cancelReconnect: (() => void) | undefined; + private readonly operations = new Set(); + private readonly sessionWaiters = new Set(); + + constructor(private readonly options: WebuiBrowserBridgeClientOptions) { + this.clock = options.clock ?? webuiBrowserBridgeSystemClock; + this.random = options.random ?? Math.random; + this.createSocket = options.createSocket ?? webuiBrowserBridgeWebSocketFactory; + this.deadlineMs = options.operationDeadlineMs ?? WEBUI_BROWSER_BRIDGE_OPERATION_DEADLINE_MS; + } + + /** Whether a session is up. */ + get connected(): boolean { + return this.status === "connected"; + } + + /** Opens the connection and keeps it open until `stop()`. */ + start(): void { + if (this.status === "stopped") return; + this.openSocket(); + } + + /** + * Closes the session and fails everything still waiting on one. + * + * The person turning a browser switch off is the ordinary reason. Any other + * reason — a revoked pairing, a host that sent `closed` — is reported to + * `onHostFrame` first, so the service worker can decide whether to `start()` + * again rather than the client reconnecting behind the person's back. + */ + stop(): void { + if (this.status === "stopped") return; + this.cancelReconnect?.(); + this.cancelReconnect = undefined; + const socket = this.socket; + this.socket = undefined; + this.setStatus("stopped"); + socket?.close(); + this.failPending( + webuiBrowserBridgeError("browser_operation_cancelled", "browser bridge client stopped", false), + ); + } + + /** Sends a frame on the current session. No waiting: the caller decided. */ + send(frame: WebuiBrowserBridgeClientFrame): void { + if (!this.socket || this.status !== "connected") { + throw webuiBrowserBridgeError( + "browser_provider_unavailable", + "browser bridge has no live session", + true, + ); + } + this.write(this.socket, frame); + } + + /** + * Publishes a frame, waiting for a session first. + * + * Used for frames the extension originates — the target list, an image frame, + * a pong — where nothing has been delivered yet, so writing the frame exactly + * once as soon as a session exists is the whole semantics. + */ + async publish(frame: WebuiBrowserBridgeClientFrame, deadlineMs = this.deadlineMs): Promise { + await this.waitForSession(deadlineMs, () => + webuiBrowserBridgeError( + "browser_provider_unavailable", + `browser bridge did not reconnect within ${deadlineMs} ms`, + true, + ), + ); + this.send(frame); + } + + /** + * Runs one operation under the ADR's deadline. + * + * `body` runs exactly once. Its result frame is written once, when a session + * exists; if the deadline passes first the operation is rejected with + * `browser_operation_timeout` and nothing is written. `body` is never called + * again — whether the page acted on the command is unknowable from here, so a + * second attempt would be a duplicate action rather than a retry. + */ + async runOperation(body: () => Promise): Promise { + const operation = this.registerOperation(); + const settled = new Promise((resolve, reject) => { + operation.resolve = resolve; + operation.reject = reject; + }); + // A deadline can fire while the caller is still awaiting the body, so the + // rejection is reported through `onError` as well rather than being left as + // an unhandled rejection until the caller gets there. + settled.catch((error: WebuiBrowserBridgeError) => this.options.onError?.(error)); + try { + const frame = await body(); + await this.waitForSession(operation.deadlineAt - this.clock.now(), () => this.timeoutError()); + // The operation's own deadline can fire while the body was still running. + // Reporting a result then would be reporting on an operation the host has + // already given up on. + if (!this.operations.has(operation)) return; + this.send(frame); + operation.resolve(); + } catch (cause) { + operation.reject(this.asError(cause)); + } finally { + operation.cancelDeadline(); + this.operations.delete(operation); + } + await settled; + } + + private registerOperation(): InFlightOperation { + const operation: InFlightOperation = { + deadlineAt: this.clock.now() + this.deadlineMs, + cancelDeadline: () => {}, + resolve: () => {}, + reject: () => {}, + }; + operation.cancelDeadline = this.clock.schedule(() => { + if (!this.operations.delete(operation)) return; + operation.reject(this.timeoutError()); + }, this.deadlineMs); + this.operations.add(operation); + return operation; + } + + private timeoutError(): WebuiBrowserBridgeError { + return webuiBrowserBridgeError( + "browser_operation_timeout", + `browser bridge operation exceeded its ${this.deadlineMs} ms deadline`, + true, + ); + } + + private async waitForSession( + budgetMs: number, + error: () => WebuiBrowserBridgeError, + ): Promise { + if (this.status === "connected") return; + if (this.status === "stopped") { + // The person turned this browser off. That is a cancellation, not an + // unreachable provider, and a caller retrying the other way would be + // chasing the wrong cause. + throw webuiBrowserBridgeError( + "browser_operation_cancelled", + "browser bridge is stopped", + false, + ); + } + if (budgetMs <= 0) throw error(); + await new Promise((resolve, reject) => { + const waiter: SessionWaiter = { cancelDeadline: () => {}, resolve, reject }; + waiter.cancelDeadline = this.clock.schedule(() => { + this.sessionWaiters.delete(waiter); + reject(error()); + }, budgetMs); + this.sessionWaiters.add(waiter); + }); + } + + private openSocket(): void { + if (this.status === "stopped" || this.status === "connecting" || this.status === "connected") return; + // An ephemeral key pair per connection. The private half never leaves this + // closure, so a captured `pair` from one connection has no value on the + // next — and a worker that is terminated leaves no key behind to leak. + // It is minted before `onOpen` can send `hello`, which is what carries + // the public key the host needs to build the cipher. + let handshake: + | { readonly privateKey: CryptoKey; readonly publicKey: WebuiBrowserBridgeEcdhPublicKey } + | undefined; + const ready: Promise = (async () => { + const { privateKey, publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + handshake = { privateKey, publicKey: await exportWebuiBrowserBridgeEcdhPublicKey(publicKey) }; + })(); + let socket: WebuiBrowserBridgeSocket; + try { + socket = this.createSocket(webuiBrowserBridgeUrl(this.options.origin)); + } catch (cause) { + this.options.onError?.(this.asError(cause)); + this.handleDropped(); + return; + } + this.socket = socket; + this.setStatus("connecting"); + socket.setHandlers({ + onOpen: () => { + if (this.socket !== socket) return; + // The key pair is what `hello` carries, so the greeting waits for it + // rather than going out without one. + void ready.then( + () => { + if (this.socket !== socket || !handshake) return; + socket.send( + JSON.stringify({ + kind: "hello", + browser: this.options.browser, + browserId: this.options.browserId, + extensionVersion: this.options.extensionVersion, + publicKey: handshake.publicKey, + }), + ); + }, + (cause: unknown) => { + this.options.onError?.(this.asError(cause)); + socket.close(); + if (this.socket === socket) this.socket = undefined; + this.handleDropped(); + }, + ); + }, + onMessage: (data) => { + const keyPair = handshake; + if (!keyPair) { + // A frame before the handshake is ready is a protocol violation: the + // host cannot answer a hello that has not gone out. + this.options.onError?.( + webuiBrowserBridgeError( + "browser_pairing_rejected", + "browser bridge: host spoke before the extension's hello", + false, + ), + ); + socket.close(); + if (this.socket === socket) this.socket = undefined; + this.handleDropped(); + return; + } + this.handleMessage(socket, data, keyPair); + }, + onClose: () => { + if (this.socket !== socket) return; + this.socket = undefined; + this.handleDropped(); + }, + }); + } + + private handleMessage( + socket: WebuiBrowserBridgeSocket, + data: string, + handshake: { readonly privateKey: CryptoKey; readonly publicKey: WebuiBrowserBridgeEcdhPublicKey }, + ): void { + if (this.socket !== socket) return; + const parsed = parseWebuiBrowserBridgeHostFrame(data); + if (!parsed.ok) { + // The stream can no longer be trusted, so the session ends rather than + // continuing to read frames from a framing this side does not understand. + this.options.onError?.( + webuiBrowserBridgeError( + "browser_pairing_rejected", + `protocol violation: ${parsed.reason}`, + false, + ), + ); + socket.close(); + if (this.socket === socket) this.socket = undefined; + this.handleDropped(); + return; + } + const frame = parsed.frame; + switch (frame.kind) { + case "pair": { + void this.applyPair(frame, socket, handshake); + return; + } + case "challenge": { + void this.answerChallenge(frame, socket); + return; + } + case "ready": { + this.attempt = 0; + this.setStatus("connected"); + this.releaseSessionWaiters(); + return; + } + case "ping": { + this.write(socket, { kind: "pong", nonce: frame.nonce }); + return; + } + default: { + void this.options.onHostFrame?.(frame); + } + } + } + + /** + * Decrypts and stores the host's `pair` frame. + * + * Order on the wire is `challenge`, then `proof`, then `pair` — and the + * `pair` is sent only to a peer that proved the installation key. This + * method therefore runs *after* the challenge has been answered, not before + * it. If the decryption fails — a tampered payload, a cipher that came from a + * different key — we report it and close the socket rather than carry on + * against a credential we cannot verify. + */ + private async applyPair( + frame: WebuiBrowserBridgePair, + socket: WebuiBrowserBridgeSocket, + handshake: { readonly privateKey: CryptoKey; readonly publicKey: WebuiBrowserBridgeEcdhPublicKey }, + ): Promise { + try { + await this.options.applyPairCredential(frame, handshake.privateKey); + } catch (cause) { + this.options.onError?.(this.asError(cause)); + socket.close(); + if (this.socket === socket) this.socket = undefined; + this.handleDropped(); + } + } + + private async answerChallenge( + challenge: WebuiBrowserBridgeChallenge, + socket: WebuiBrowserBridgeSocket, + ): Promise { + try { + this.write(socket, await this.options.answerChallenge(challenge)); + } catch (cause) { + this.options.onError?.(this.asError(cause)); + socket.close(); + } + } + + private handleDropped(): void { + if (this.status === "stopped") return; + this.setStatus("idle"); + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.status === "stopped") return; + this.cancelReconnect?.(); + // An operation in flight gets the tight cap: it is waiting on a result. With + // nothing in flight the goal is only to come back eventually. + const cap = this.operations.size > 0 + ? WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS + : WEBUI_BROWSER_BRIDGE_BACKGROUND_RETRY_CAP_MS; + const delay = webuiBrowserBridgeJitteredDelayMs(this.attempt, cap, this.random); + this.attempt += 1; + this.cancelReconnect = this.clock.schedule(() => { + this.cancelReconnect = undefined; + this.openSocket(); + }, delay); + } + + private releaseSessionWaiters(): void { + for (const waiter of [...this.sessionWaiters]) { + waiter.cancelDeadline(); + this.sessionWaiters.delete(waiter); + waiter.resolve(); + } + } + + private failPending(error: WebuiBrowserBridgeError): void { + for (const waiter of [...this.sessionWaiters]) { + waiter.cancelDeadline(); + this.sessionWaiters.delete(waiter); + waiter.reject(error); + } + for (const operation of [...this.operations]) { + this.operations.delete(operation); + operation.cancelDeadline(); + operation.reject(error); + } + } + + private write(socket: WebuiBrowserBridgeSocket, frame: WebuiBrowserBridgeClientFrame): void { + if (this.socket !== socket) return; + socket.send(JSON.stringify(frame)); + this.options.onFrame?.(frame); + } + + private setStatus(status: WebuiBrowserBridgeStatus): void { + if (this.status === status) return; + this.status = status; + this.options.onStatus?.(status); + } + + private asError(cause: unknown): WebuiBrowserBridgeError { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + "message" in cause && + "retryable" in cause + ) { + return cause as WebuiBrowserBridgeError; + } + return webuiBrowserBridgeError( + "browser_provider_failed", + cause instanceof Error ? cause.message : String(cause), + false, + ); + } +} \ No newline at end of file diff --git a/packages/browser-extension/src/cdp.ts b/packages/browser-extension/src/cdp.ts new file mode 100644 index 000000000..d80a70c9b --- /dev/null +++ b/packages/browser-extension/src/cdp.ts @@ -0,0 +1,162 @@ +// DevTools commands the extension is willing to send on the bridge's behalf. +// +// `chrome.debugger` exposes only a **subset** of the DevTools domains, and the +// subset differs from what a raw CDP client can do. The host is therefore not +// allowed to name an arbitrary method and have the extension try it: the +// extension sends only the commands listed here, and refuses anything else +// with a reason. +// +// Two things make this list trustworthy rather than hopeful: +// +// * it is an **explicit** constant, so a method that is not listed is +// refused before any browser call is made; and +// * Chrome's own answer is still the authority — a command that is listed but +// that this browser's build refuses comes back as a refusal with Chrome's +// own message, not as a success. +// +// The list covers what Browser Use actually needs: reading a page, evaluating +// against it, and driving it. + +/** + * The DevTools commands the extension will send. + * + * Chrome documents the `chrome.debugger` API's supported domains at + * . The + * commands below are drawn from the domains it lists — Accessibility, DOM, + * DOMDebugger, Emulation, Fetch, Input, Log, Network, Page, Runtime — and each + * is one that the browser's own documentation lists as supported. + */ +export const WEBUI_BROWSER_BRIDGE_CDP_COMMANDS: ReadonlySet = new Set([ + // Reading the page. + "DOM.getDocument", + "DOM.querySelector", + "DOM.getOuterHTML", + "DOM.describeNode", + "DOMDebugger.getEventListeners", + "Runtime.evaluate", + "Runtime.callFunctionOn", + "Runtime.getProperties", + "Runtime.releaseObject", + "Accessibility.getFullAXTree", + // Driving the page. + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Input.dispatchTouchEvent", + // Navigating it. + "Page.navigate", + "Page.reload", + "Page.getNavigationHistory", + "Page.navigateToHistoryEntry", + // The network, read-only. + "Network.getCookies", + "Network.getResponseBody", + // The surface the page is shown on. + "Emulation.setDeviceMetricsOverride", + "Emulation.setScrollbarsHidden", + // Diagnostics. + "Log.enable", + "Console.enable", + "Fetch.enable", + "Fetch.disable", +]); + +/** The outcome of one dispatched DevTools command. */ +export type WebuiBrowserBridgeCdpOutcome = + | { readonly accepted: true; readonly result: unknown } + | { + readonly accepted: false; + readonly error: string; + /** + * Whether the method is outside the extension's supported subset. + * + * It is reported separately from a transport failure because the two + * mean different things to the host: an unsupported method will never + * work, while a failure may. + */ + readonly unsupported?: boolean; + }; + +/** The tab the extension is talking to. */ +export interface WebuiBrowserBridgeCdpTarget { + readonly tabId: number; +} + +export interface WebuiBrowserBridgeCdpDispatcherOptions { + /** + * The debugger API. + * + * It is the same interface the input dispatcher uses, so the extension has + * one attach/send path rather than two that could disagree. + */ + readonly debugger: WebuiBrowserBridgeDebuggerApi; + /** The protocol version the extension attaches with. */ + readonly version: string; +} + +/** + * The dispatcher the bridge's DevTools commands go through. + * + * It attaches once per tab and keeps the attachment, because attaching and + * detaching around every command would make a sequence of commands racy + * against a page that is moving. + */ +export interface WebuiBrowserBridgeCdpDispatcher { + dispatch( + target: WebuiBrowserBridgeCdpTarget, + method: string, + params: Record, + ): Promise; +} + +import type { WebuiBrowserBridgeDebuggerApi } from "./input.js"; + +export function createWebuiBrowserBridgeCdpDispatcher( + options: WebuiBrowserBridgeCdpDispatcherOptions, +): WebuiBrowserBridgeCdpDispatcher { + const attached = new Map>(); + + function attachOnce(tabId: number): Promise { + const existing = attached.get(tabId); + if (existing) return existing; + const attaching = options.debugger.attach({ tabId }, options.version); + attached.set(tabId, attaching); + return attaching; + } + + return { + async dispatch(target, method, params) { + if (!WEBUI_BROWSER_BRIDGE_CDP_COMMANDS.has(method)) { + // Refused before any browser call: the point of the list is that the + // host cannot ask for something the extension will not do. + return { + accepted: false, + error: `the extension does not support the DevTools command "${method}"`, + unsupported: true, + }; + } + try { + await attachOnce(target.tabId); + } catch (error) { + attached.delete(target.tabId); + return { + accepted: false, + error: `the extension could not attach to the tab: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + try { + const result = await options.debugger.sendCommand({ tabId: target.tabId }, method, params); + return { accepted: true, result }; + } catch (error) { + return { + accepted: false, + error: `the browser refused the DevTools command "${method}": ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + }, + }; +} diff --git a/packages/browser-extension/src/chrome.d.ts b/packages/browser-extension/src/chrome.d.ts new file mode 100644 index 000000000..b29f715b8 --- /dev/null +++ b/packages/browser-extension/src/chrome.d.ts @@ -0,0 +1,96 @@ +// The subset of the Chrome extension API this extension uses. +// +// The extension has no build step and no node_modules, so `@types/chrome` is +// not available to it. Declaring only the members actually called keeps the +// ambient surface honest: anything the extension starts using has to be added +// here deliberately, which is also what a store review sees. + +interface WebuiBrowserExtensionTab { + readonly id?: number; + readonly windowId?: number; + readonly active?: boolean; + readonly title?: string; + readonly url?: string; + readonly status?: string; +} + +interface WebuiBrowserExtensionStorageArea { + get(keys: string | string[] | null): Promise>; + set(items: Record): Promise; + remove(keys: string | string[]): Promise; +} + +interface WebuiBrowserExtensionEvent { + addListener(listener: Listener): void; + removeListener(listener: Listener): void; +} + +interface WebuiBrowserExtensionTabs { + query(queryInfo: Record): Promise; + get(tabId: number): Promise; + captureVisibleTab( + windowId?: number, + options?: { format?: "png" | "jpeg"; quality?: number }, + ): Promise; + readonly onUpdated: WebuiBrowserExtensionEvent< + (tabId: number, changeInfo: Record, tab: WebuiBrowserExtensionTab) => void + >; + readonly onRemoved: WebuiBrowserExtensionEvent<(tabId: number) => void>; +} + +interface WebuiBrowserExtensionWebNavigation { + readonly onCommitted: WebuiBrowserExtensionEvent< + (details: { tabId: number; frameId: number }) => void + >; +} + +interface WebuiBrowserExtensionDebuggerTarget { + tabId?: number; +} + +interface WebuiBrowserExtensionDebuggerProtocolMessage { + method: string; + params?: Record; +} + +interface WebuiBrowserExtensionDebugger { + attach(target: WebuiBrowserExtensionDebuggerTarget, requiredVersion: string): Promise; + detach(target: WebuiBrowserExtensionDebuggerTarget): Promise; + sendCommand( + target: WebuiBrowserExtensionDebuggerTarget, + method: string, + parameters?: Record, + ): Promise; +} + +declare const chrome: { + readonly runtime: { + readonly id: string; + readonly onStartup: WebuiBrowserExtensionEvent<() => void>; + readonly onInstalled: WebuiBrowserExtensionEvent< + (details: { reason: string; previousVersion?: string }) => void + >; + /** + * The options page asking the worker to reconnect. + * + * A Manifest V3 service worker is lazy, so the page has to wake it + * explicitly once the person has pasted the installation key. + */ + readonly onMessage: WebuiBrowserExtensionEvent< + ( + message: unknown, + sender: unknown, + sendResponse: (response: unknown) => void, + ) => boolean | undefined + >; + sendMessage(message: unknown): Promise; + getManifest(): { version: string }; + }; + readonly storage: { + readonly local: WebuiBrowserExtensionStorageArea; + readonly session: WebuiBrowserExtensionStorageArea; + }; + readonly tabs: WebuiBrowserExtensionTabs; + readonly webNavigation: WebuiBrowserExtensionWebNavigation; + readonly debugger: WebuiBrowserExtensionDebugger; +}; \ No newline at end of file diff --git a/packages/browser-extension/src/images.ts b/packages/browser-extension/src/images.ts new file mode 100644 index 000000000..821eacb12 --- /dev/null +++ b/packages/browser-extension/src/images.ts @@ -0,0 +1,91 @@ +// The live image stream for the panel. +// +// `chrome.debugger` is not a screenshot path. The Debugger API exposes a +// restricted subset of CDP that does **not** include `Page.captureScreenshot`, so +// an attached debugger cannot produce an image frame at all. The only API that +// can is `chrome.tabs.captureVisibleTab`. +// +// That has a consequence worth stating plainly rather than hiding behind an +// error path: **only the tab the person is currently looking at, in a window +// that is in front, can be captured.** The stream is therefore the extension's +// answer to "show the tab they are on", not a way to observe any tab on demand. +// A background tab yields no frame, and the extension reports that rather than +// substituting another tab's page — a stream of the wrong page is exactly the +// failure the programme exists to prevent. + +import type { WebuiBrowserBridgeImageFrame } from "./protocol.js"; + +export type WebuiBrowserBridgeImageFormat = "png" | "jpeg"; + +export interface WebuiBrowserBridgeCaptureApi { + query(queryInfo: Record): Promise<{ readonly id?: number; readonly windowId?: number }[]>; + captureVisibleTab( + windowId?: number, + options?: { format?: WebuiBrowserBridgeImageFormat; quality?: number }, + ): Promise; +} + +export interface WebuiBrowserBridgeImageStreamOptions { + readonly browserId: string; + readonly capture: WebuiBrowserBridgeCaptureApi; + readonly format?: WebuiBrowserBridgeImageFormat; + /** + * The registry's view of a target. + * + * Capture and target lookup are separate facts, so the frame is stamped with + * the generation the registry reports *now*: a frame carrying the generation + * the tab had when the capture started would let the panel display a page that + * has already moved on. + */ + readonly resolveGeneration: (targetId: string) => Promise; +} + +export interface WebuiBrowserBridgeImageStream { + /** + * Captures the visible tab once. + * + * Rejects when there is no visible tab to capture — the one limitation above — + * so the caller surfaces "no frame" instead of showing a stale one. + */ + captureVisibleFrame(): Promise; + /** Frames delivered on this stream so far. Monotonic within one stream. */ + readonly sequence: number; +} + +/** The data-URL prefix the capture API wraps base64 bytes in. */ +const DATA_URL_PREFIX = /^data:image\/(?:png|jpeg);base64,/u; + +export function createWebuiBrowserBridgeImageStream( + options: WebuiBrowserBridgeImageStreamOptions, +): WebuiBrowserBridgeImageStream { + const format = options.format ?? "png"; + let sequence = 0; + return { + get sequence() { + return sequence; + }, + async captureVisibleFrame() { + const tabs = await options.capture.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + if (!tab || tab.id === undefined) { + throw new Error("browser bridge: no visible tab to capture"); + } + const targetId = `tab-${tab.id}`; + const generation = await options.resolveGeneration(targetId); + if (generation === undefined) { + throw new Error(`browser bridge: visible tab ${targetId} is not a known target`); + } + const captured = await options.capture.captureVisibleTab(tab.windowId, { format }); + const prefix = DATA_URL_PREFIX.exec(captured); + sequence += 1; + return { + browserId: options.browserId, + targetId, + generation, + data: prefix ? captured.slice(prefix[0].length) : captured, + format, + sequence, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/browser-extension/src/input.ts b/packages/browser-extension/src/input.ts new file mode 100644 index 000000000..79919dc27 --- /dev/null +++ b/packages/browser-extension/src/input.ts @@ -0,0 +1,174 @@ +// Pointer, keyboard and text forwarding into the extension's own browser. +// +// Every command is bound to a target identity *and* a generation. A tab that +// navigated or was replaced is a different page even when the browser hands back +// the same tab id, so a command whose generation is no longer current is refused +// outright rather than delivered to whatever is in the tab now. The refusal is +// reported as a refusal — an `input-result` with `accepted: false` and the stable +// error code — because a caller that cannot tell a refusal from a delivery will +// read a lost click as a landed one. +// +// Input reaches the page through the Debugger API's `Input` domain rather than +// synthesized DOM events: only the debugger can produce events the page treats as +// trusted, and a page that ignores untrusted events would show the panel working +// while the Agent saw nothing change. + +import { + webuiBrowserBridgeError, + type WebuiBrowserBridgeError, + type WebuiBrowserBridgeInputCommand, +} from "./protocol.js"; +import type { WebuiBrowserBridgeTargetRegistry } from "./targets.js"; + +/** The CDP version this extension attaches with. */ +export const WEBUI_BROWSER_BRIDGE_DEBUGGER_VERSION = "1.3"; + +export interface WebuiBrowserBridgeDebuggerTarget { + tabId?: number; +} + +export interface WebuiBrowserBridgeDebuggerApi { + attach(target: WebuiBrowserBridgeDebuggerTarget, requiredVersion: string): Promise; + detach(target: WebuiBrowserBridgeDebuggerTarget): Promise; + sendCommand( + target: WebuiBrowserBridgeDebuggerTarget, + method: string, + parameters?: Record, + ): Promise; +} + +export type WebuiBrowserBridgeInputResult = + | { readonly accepted: true } + | { readonly accepted: false; readonly error: WebuiBrowserBridgeError }; + +export interface WebuiBrowserBridgeInputDispatcherOptions { + readonly targets: WebuiBrowserBridgeTargetRegistry; + readonly debugger: WebuiBrowserBridgeDebuggerApi; +} + +export interface WebuiBrowserBridgeInputDispatcher { + dispatch(command: WebuiBrowserBridgeInputCommand): Promise; + /** Resolves once nothing is still queued for the target. */ + settled(targetId: string): Promise; +} + +const MODIFIER_BITS: Record = { alt: 1, ctrl: 2, meta: 4, shift: 8 }; + +/** + * Creates the dispatcher. + * + * Commands are serialized per target. Two operations against one tab interleaved + * would let a pointer event land between two halves of a keystroke, which is + * indistinguishable from a page that ate the input; different tabs stay + * independent. + */ +export function createWebuiBrowserBridgeInputDispatcher( + options: WebuiBrowserBridgeInputDispatcherOptions, +): WebuiBrowserBridgeInputDispatcher { + const queues = new Map>(); + const attached = new Map>(); + + function attachOnce(tabId: number): Promise { + const existing = attached.get(tabId); + if (existing) return existing; + const attaching = options.debugger.attach({ tabId }, WEBUI_BROWSER_BRIDGE_DEBUGGER_VERSION); + attached.set(tabId, attaching); + return attaching; + } + + async function send(tabId: number, method: string, params: Record): Promise { + await options.debugger.sendCommand({ tabId }, method, params); + } + + async function run(command: WebuiBrowserBridgeInputCommand): Promise { + const target = await options.targets.lookup(command.targetId); + if (!target) { + return { + accepted: false, + error: webuiBrowserBridgeError( + "browser_target_unknown", + `target ${command.targetId} is not open`, + false, + ), + }; + } + if (target.generation !== command.generation) { + return { + accepted: false, + error: webuiBrowserBridgeError( + "browser_target_stale", + `target ${command.targetId} is at generation ${target.generation}, command aimed at ${command.generation}`, + false, + ), + }; + } + try { + await attachOnce(target.tabId); + await applyCommand(command, target.tabId, send); + return { accepted: true }; + } catch (cause) { + return { + accepted: false, + error: webuiBrowserBridgeError( + "browser_provider_failed", + `input delivery failed: ${cause instanceof Error ? cause.message : String(cause)}`, + true, + ), + }; + } + } + + return { + dispatch(command) { + const previous = queues.get(command.targetId) ?? Promise.resolve(); + const next = previous.then(() => run(command)); + // The queue must not reject: a refusal is a result, and letting it escape + // here would poison every later command queued behind it. + queues.set(command.targetId, next.then(noop, noop)); + return next; + }, + async settled(targetId) { + await (queues.get(targetId) ?? Promise.resolve()); + }, + }; +} + +async function applyCommand( + command: WebuiBrowserBridgeInputCommand, + tabId: number, + send: (tabId: number, method: string, params: Record) => Promise, +): Promise { + switch (command.kind) { + case "pointer": { + const base = { x: command.x, y: command.y, button: command.button, clickCount: command.clickCount }; + await send(tabId, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" }); + await send(tabId, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" }); + return; + } + case "key": { + const modifiers = (command.modifiers ?? []).reduce((bits, name) => bits | (MODIFIER_BITS[name] ?? 0), 0); + const base = { key: command.key, code: command.code, modifiers }; + await send(tabId, "Input.dispatchKeyEvent", { ...base, type: command.text ? "keyDown" : "rawKeyDown" }); + if (command.text) { + await send(tabId, "Input.dispatchKeyEvent", { ...base, type: "char", text: command.text }); + } + await send(tabId, "Input.dispatchKeyEvent", { ...base, type: "keyUp" }); + return; + } + case "text": { + // `Input.insertText` is the one path that delivers a whole string as the + // page typed it, so an IME composition in the panel does not arrive as a + // burst of untrusted key events. + await send(tabId, "Input.insertText", { text: command.text }); + return; + } + default: { + const exhaustive: never = command; + throw new Error(`browser bridge: unsupported input command ${JSON.stringify(exhaustive)}`); + } + } +} + +function noop(): void { + // Queue bookkeeping only; deliberately silent. +} \ No newline at end of file diff --git a/packages/browser-extension/src/options.ts b/packages/browser-extension/src/options.ts new file mode 100644 index 000000000..efa5103d6 --- /dev/null +++ b/packages/browser-extension/src/options.ts @@ -0,0 +1,104 @@ +// The extension's options page: where the person pastes the installation key. +// +// The installation key is the one secret in this protocol that a local process +// cannot reach, because it only ever exists in this browser's +// `chrome.storage.local` and in the host's OS secure store. The person moves it +// across by hand, once per browser. +// +// Two rules this page holds to: +// +// * The key is **write-only** after the first save. The page reads the stored +// key through `webuiBrowserBridgeInstallationKeyStatus`, whose return type +// carries a fingerprint and no key, so there is no code path here that +// could render it — the field is cleared on save as well, so it is not +// sitting in the DOM either. +// * Nothing is written until the paste validates as exactly 32 bytes. A +// truncated paste would otherwise be stored and then fail at handshake +// time as an opaque "installation key does not match". + +import { WEBUI_BROWSER_BRIDGE_RECONNECT_MESSAGE } from "./protocol.js"; +import { + clearWebuiBrowserBridgeInstallationKey, + saveWebuiBrowserBridgeInstallationKey, + webuiBrowserBridgeInstallationKeyStore, + webuiBrowserBridgeInstallationKeyStatus, +} from "./pairing.js"; + +function element(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`options page is missing #${id}`); + return found as T; +} + +const input = element("installation-key"); +const save = element("save"); +const clear = element("clear"); +const status = element("status"); +const stored = element("stored"); + +function say(message: string): void { + status.textContent = message; +} + +/** Render what is stored — a fingerprint, never the key. */ +async function renderStored(): Promise { + const state = await webuiBrowserBridgeInstallationKeyStatus( + webuiBrowserBridgeInstallationKeyStore(chrome.storage.local), + ); + clear.disabled = !state.stored; + stored.textContent = state.stored + ? `An installation key is stored (fingerprint ${state.fingerprint}).` + : "No installation key is stored in this browser yet."; +} + +async function onSave(): Promise { + try { + await saveWebuiBrowserBridgeInstallationKey( + webuiBrowserBridgeInstallationKeyStore(chrome.storage.local), + input.value, + ); + } catch (error) { + say(error instanceof Error ? error.message : "the installation key was not saved"); + return; + } + // Clear the field immediately: the key is in storage, and leaving it in the + // DOM is the one place this page could leak it. + input.value = ""; + await renderStored(); + + // Wake the worker so the extension pairs now. + // + // A Manifest V3 service worker is lazy: it starts on an event and is torn + // down when it goes idle. Saving the key does not wake it, so without this + // the extension would sit there configured but unconnected — and the person + // would have to reload the extension by hand to find out. + try { + const reply = await chrome.runtime.sendMessage({ type: WEBUI_BROWSER_BRIDGE_RECONNECT_MESSAGE }); + if (reply && typeof reply === "object" && (reply as { ok?: unknown }).ok === true) { + say("Saved. Connecting to the WebUI…"); + return; + } + say( + "Saved. The extension could not be woken to connect — reload the extension and try again.", + ); + } catch { + say("Saved. The extension could not be woken to connect — reload the extension and try again."); + } +} + +async function onClear(): Promise { + await clearWebuiBrowserBridgeInstallationKey( + webuiBrowserBridgeInstallationKeyStore(chrome.storage.local), + ); + input.value = ""; + say("Cleared. Paste the key again to pair this browser."); + await renderStored(); +} + +save.addEventListener("click", () => { + void onSave(); +}); +clear.addEventListener("click", () => { + void onClear(); +}); +void renderStored(); \ No newline at end of file diff --git a/packages/browser-extension/src/pairing.ts b/packages/browser-extension/src/pairing.ts new file mode 100644 index 000000000..574cdba73 --- /dev/null +++ b/packages/browser-extension/src/pairing.ts @@ -0,0 +1,410 @@ +// Challenge-response pairing against the WebUI bridge. +// +// Pairing is not a bearer token. The host issues a fresh nonce and expects an +// HMAC over that nonce, so a proof lifted off the loopback socket is useless +// against the next connection, and the secret itself never travels at all. +// +// The credential is **host-authored**. The browser-extension does not mint +// one of its own, because two independently minted credentials never match: +// the host would never accept a proof against the wrong secret. Instead, the +// host delivers its credential, encrypted under an ECDH key the extension +// mints per connection, in a `pair` frame — and **that** is the credential +// the extension stores in `chrome.storage.local`. A re-enable on the host +// side means a fresh credential that arrives on the next connect and +// replaces the stored one. +// +// The secret lives in `chrome.storage.local` — not `storage.sync`, which would +// replicate it to a signed-in Google account outside this browser. It is read +// from storage on every connection rather than cached in a module variable, +// because an MV3 service worker is terminated at will and must still be able +// to answer a challenge after it restarts. +// +// The WebUI per-start connection token is never read here. That credential is +// the WebUI's own, and borrowing it would make the bridge's admission +// indistinguishable from the WebUI's. + +import { + decodeWebuiBrowserBridgeBase64, + encodeWebuiBrowserBridgeBase64, + openWebuiBrowserBridgePairingCredential, + signWebuiBrowserBridgeProof, + WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM, + type WebuiBrowserBridgeChallenge, + type WebuiBrowserBridgePair, + type WebuiBrowserBridgeProof, +} from "./protocol.js"; + +/** The `chrome.storage.local` key the credential is stored under. */ +export const WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY = "webui.browserBridge.pairing.v1"; + +/** + * The `chrome.storage.local` key the installation key is stored under. + * + * Deliberately a different entry from the pairing credential: the two have + * different lifetimes. The installation key outlives a revoked credential (the + * person must not have to re-paste it after toggling the switch), so the two + * can never be cleared together by accident. + */ +export const WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_STORAGE_KEY = + "webui.browserBridge.installationKey.v1"; + +/** + * 32 bytes of entropy for the HMAC key. + * + * 256 bits is the hash width, so the key is not the weak link; below that it + * would be. + */ +export const WEBUI_BROWSER_BRIDGE_SECRET_BYTES = 32; + +export interface WebuiBrowserBridgePairingCredential { + readonly keyId: string; + readonly secret: Uint8Array; +} + +/** The storage surface pairing needs. Narrow on purpose so tests can fake it. */ +export interface WebuiBrowserBridgePairingStore { + read(): Promise; + write(value: unknown): Promise; + clear(): Promise; +} + +export interface WebuiBrowserBridgeStorageArea { + get(keys: string | string[] | null): Promise>; + set(items: Record): Promise; + remove(keys: string | string[]): Promise; +} + +/** + * Adapts a `chrome.storage` area to the pairing store. + * + * The area is passed in rather than reached for globally so that the choice + * between `local` and `sync` is made by the caller, in one place, where it can + * be reviewed. + */ +export function webuiBrowserBridgePairingStore( + area: WebuiBrowserBridgeStorageArea, + key: string = WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY, +): WebuiBrowserBridgePairingStore { + return { + async read() { + return (await area.get(key))[key]; + }, + async write(value) { + await area.set({ [key]: value }); + }, + async clear() { + await area.remove(key); + }, + }; +} + +/** Fills a buffer with `crypto.getRandomValues`. */ +export function webuiBrowserBridgeRandomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return bytes; +} + +/** + * The credential for this browser, **as stored**. + * + * Returns `undefined` when storage has no credential or holds one whose + * bytes are missing or too short. The credential is host-authored: this + * function never mints one, because two independently-minted credentials + * never match and the host would never accept a proof against the wrong + * secret. The `pair` frame is the only path that puts credentials here. + * + * A stored credential whose bytes are missing or too short returns + * `undefined` rather than being repaired: half a credential is not a + * credential, and the host would reject it with a pairing error the person + * cannot act on. + */ +export async function loadWebuiBrowserBridgePairingCredential( + store: WebuiBrowserBridgePairingStore, +): Promise { + const stored = await store.read(); + return readStoredCredential(stored); +} + +/** + * Decrypts and stores a host-authored credential. + * + * Called by the client when a `pair` frame arrives. The host's credential + * is authoritative: any prior stored value is replaced. The decryption is + * the symmetric counterpart of the host's seal, with the same `info`, + * salt, and AES-GCM IV handling; any tamper fails the AES-GCM auth and + * rejects the frame, so a captured or rogue `pair` cannot read as a valid + * credential. + */ +export async function applyWebuiBrowserBridgePairingCredential( + store: WebuiBrowserBridgePairingStore, + cipher: WebuiBrowserBridgePair, + ownPrivateKey: CryptoKey, +): Promise { + const secret = await openWebuiBrowserBridgePairingCredential(cipher, ownPrivateKey); + const credential: WebuiBrowserBridgePairingCredential = { + keyId: cipher.keyId, + secret, + }; + await store.write({ + keyId: credential.keyId, + secret: encodeWebuiBrowserBridgeBase64(credential.secret), + }); + return credential; +} + +/** + * Answers a challenge with this browser's credential. + * + * The key id is echoed rather than chosen: answering a challenge for a different + * credential would produce a proof the host cannot attribute, and answering one + * that asks for an unknown algorithm would be a downgrade. + */ +export async function answerWebuiBrowserBridgeChallenge( + challenge: WebuiBrowserBridgeChallenge, + credential: WebuiBrowserBridgePairingCredential, +): Promise { + if (challenge.algorithm !== WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM) { + throw new Error(`browser bridge: unsupported challenge algorithm ${challenge.algorithm}`); + } + if (challenge.keyId !== credential.keyId) { + throw new Error("browser bridge: challenge names a credential this browser does not hold"); + } + return { + kind: "proof", + keyId: credential.keyId, + nonce: challenge.nonce, + algorithm: challenge.algorithm, + signature: await signWebuiBrowserBridgeProof(credential.secret, challenge.nonce, credential.keyId), + }; +} + +/* -------------------------------------------------------------------------- */ +/* The installation key */ +/* -------------------------------------------------------------------------- */ + +/** + * 32 bytes of entropy for the installation key. Matches the pairing + * credential's own width, so neither secret is the weak link. + */ +export const WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES = 32; + +/** + * An installation key this browser holds, **as stored**. + * + * The `keyId` is not stored: the host names it on every challenge, so the + * extension stores only the secret and takes the id from the challenge. That + * way a host that re-provisions the id cannot leave a stale id behind here. + */ +export interface WebuiBrowserBridgeInstallationKey { + readonly secret: Uint8Array; +} + +/** The store the options page writes the pasted key through. */ +export function webuiBrowserBridgeInstallationKeyStore( + area: WebuiBrowserBridgeStorageArea, + key: string = WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_STORAGE_KEY, +): WebuiBrowserBridgePairingStore { + return { + async read() { + return (await area.get(key))[key]; + }, + async write(value) { + await area.set({ [key]: value }); + }, + async clear() { + await area.remove(key); + }, + }; +} + +/** + * Stores the key the person pasted from the WebUI Settings surface. + * + * This is the only writer. The extension cannot mint an installation key, + * because the host would then verify against a different secret than the one + * the person pasted — the same rule that makes the pairing credential + * host-authored. + * + * The value is validated to exactly 32 bytes before it is stored: a truncated + * or padded paste would otherwise be accepted and then fail as an opaque + * "installation key does not match" at handshake time. + */ +export async function saveWebuiBrowserBridgeInstallationKey( + store: WebuiBrowserBridgePairingStore, + key: string, +): Promise { + const secret = decodeWebuiBrowserBridgeBase64(key.trim()); + if (secret.length !== WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES) { + throw new Error( + `browser bridge: an installation key is ${WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES} bytes; got ${secret.length}`, + ); + } + await store.write({ secret: encodeWebuiBrowserBridgeBase64(secret) }); + return { secret }; +} + +/** + * The installation key this browser holds, or `undefined` if none was pasted. + * + * Read from storage on every connection rather than cached in a module + * variable: an MV3 service worker is terminated at will and must still be able + * to answer a challenge after it restarts. + */ +export async function loadWebuiBrowserBridgeInstallationKey( + store: WebuiBrowserBridgePairingStore, +): Promise { + const stored = await store.read(); + if (typeof stored !== "object" || stored === null) return undefined; + const record = stored as Record; + if (typeof record.secret !== "string" || record.secret.length === 0) return undefined; + let secret: Uint8Array; + try { + secret = decodeWebuiBrowserBridgeBase64(record.secret); + } catch { + return undefined; + } + if (secret.length !== WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES) return undefined; + return { secret }; +} + +/** Forgets the installation key. Only the options page's "clear" does this. */ +export async function clearWebuiBrowserBridgeInstallationKey( + store: WebuiBrowserBridgePairingStore, +): Promise { + await store.clear(); +} + +/** + * What the options page is allowed to show about the stored key. + * + * A fingerprint and nothing else. The type carries no `key` field on purpose: + * the page has no way to display the key even if a future change wanted to, + * so "the key is never rendered again" is a property of this type rather than + * of the markup. + */ +export type WebuiBrowserBridgeInstallationKeyStatus = + | { readonly stored: false } + | { readonly stored: true; readonly fingerprint: string }; + +/** + * The stored key, reduced to what the options page may display. + * + * This is the only reader the options page uses, so the page never receives + * the key's bytes — not on first load, not after a save, and not after a + * service-worker restart. + */ +export async function webuiBrowserBridgeInstallationKeyStatus( + store: WebuiBrowserBridgePairingStore, +): Promise { + const key = await loadWebuiBrowserBridgeInstallationKey(store); + if (!key) return { stored: false }; + return { stored: true, fingerprint: await installationKeyFingerprint(key.secret) }; +} + +/** + * A short, non-secret handle for a stored installation key. + * + * Computed with `crypto.subtle` rather than `node:crypto` because this runs in + * an extension page. It reveals nothing about the key: it is a digest, not a + * reversible encoding. + */ +export async function installationKeyFingerprint(secret: Uint8Array): Promise { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new Uint8Array(secret)), + ); + let hex = ""; + for (const byte of digest.subarray(0, 8)) hex += byte.toString(16).padStart(2, "0"); + return hex; +} + +/** + * Answers a challenge with the installation key. + * + * This is the answer a browser gives on a *first* connection, where it has no + * pairing credential yet. The key id is the one the host named on the + * challenge, which is what binds the proof to this host's key and stops a + * proof made for one host from being replayed against another. + */ +export async function answerWebuiBrowserBridgeInstallationChallenge( + challenge: WebuiBrowserBridgeChallenge, + installationKey: WebuiBrowserBridgeInstallationKey, +): Promise { + if (challenge.algorithm !== WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM) { + throw new Error(`browser bridge: unsupported challenge algorithm ${challenge.algorithm}`); + } + return { + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + algorithm: challenge.algorithm, + signature: await signWebuiBrowserBridgeProof( + installationKey.secret, + challenge.nonce, + challenge.installationKeyId, + ), + }; +} + +/** + * Answers whichever challenge this browser can actually answer. + * + * The installation key wins when this browser holds one, because it is the + * anchor the host recognises on every connection and the one that survives a + * credential rotation. The pairing credential is the fallback for a browser + * that was paired before the installation key existed, or whose options page + * was cleared after the credential was already issued. + * + * When neither is held the answer is a refusal, not a guess: signing with a + * key the host does not expect would produce a proof that fails anyway, and + * the person needs a message that says what to paste. + */ +export async function answerWebuiBrowserBridgeChallengeWithStoredSecrets( + challenge: WebuiBrowserBridgeChallenge, + installationKey: WebuiBrowserBridgeInstallationKey | undefined, + credential: WebuiBrowserBridgePairingCredential | undefined, +): Promise { + if (installationKey) { + return answerWebuiBrowserBridgeInstallationChallenge(challenge, installationKey); + } + if (credential) { + return answerWebuiBrowserBridgeChallenge(challenge, credential); + } + throw new Error( + "browser bridge: this browser holds no installation key and no pairing credential — open the extension's options page and paste the installation key from the WebUI Settings", + ); +} + +/** + * Forgets the credential. + * + * Revocation is the host's decision — it tells the extension to drop the pairing + * — so this is only ever called on that instruction, never on a failed + * verification, which is what keeps a replayed proof from destroying a pairing + * the person did not ask to give up. + */ +export async function clearWebuiBrowserBridgePairingCredential( + store: WebuiBrowserBridgePairingStore, +): Promise { + await store.clear(); +} + +/** The browser names the key id is prefixed with. */ +export type WebuiBrowserBridgeBrowserName = "edge" | "chrome"; + +function readStoredCredential( + stored: unknown, +): WebuiBrowserBridgePairingCredential | undefined { + if (typeof stored !== "object" || stored === null) return undefined; + const record = stored as Record; + if (typeof record.keyId !== "string" || record.keyId.length === 0) return undefined; + if (typeof record.secret !== "string" || record.secret.length === 0) return undefined; + let secret: Uint8Array; + try { + secret = decodeWebuiBrowserBridgeBase64(record.secret); + } catch { + return undefined; + } + if (secret.length < WEBUI_BROWSER_BRIDGE_SECRET_BYTES) return undefined; + return { keyId: record.keyId, secret }; +} \ No newline at end of file diff --git a/packages/browser-extension/src/protocol.ts b/packages/browser-extension/src/protocol.ts new file mode 100644 index 000000000..7756dcb48 --- /dev/null +++ b/packages/browser-extension/src/protocol.ts @@ -0,0 +1,627 @@ +// The extension's copy of the WebUI browser-bridge wire protocol. +// +// It duplicates `packages/webui/src/shared/contracts/browser-bridge.ts` on +// purpose. An extension is loaded by the browser as plain ES modules with no +// node_modules and no bundler, so it cannot resolve a workspace package, and the +// ADR forbids importing WebUI implementation modules across the bridge boundary +// anyway. The duplication is kept safe by two things: the names here match the +// host's names one for one, so a diff of the two files is meaningful, and both +// sides are pinned to the same signature test vector — a change to the +// construction on either side breaks exactly one suite. + +/** The dedicated path the bridge listens on. It is not the WebUI's own path. */ +export const WEBUI_BROWSER_BRIDGE_PATH = "/browser-bridge"; + +/** + * The options page's request to reconnect. + * + * A Manifest V3 service worker is lazy, so saving the installation key does + * not by itself start it. The page sends this and the worker reconnects. + */ +export const WEBUI_BROWSER_BRIDGE_RECONNECT_MESSAGE = "webui-bridge-reconnect"; + +/** The only signature scheme the bridge admits. */ +export const WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM = "HMAC-SHA256" as const; + +/** The HKDF `info` literal the pair cipher is keyed with. */ +export const WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO = "webui-browser-bridge-pair-v1" as const; + +/** The HKDF salt and AES-GCM IV lengths, in bytes. */ +export const WEBUI_BROWSER_BRIDGE_PAIR_HKDF_SALT_BYTES = 16; +export const WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES = 12; + +/** A P-256 ECDH public key, as JWK, for the pairing transfer. */ +export interface WebuiBrowserBridgeEcdhPublicKey { + readonly kty: "EC"; + readonly crv: "P-256"; + /** Base64url (no padding) of the raw x coordinate. */ + readonly x: string; + /** Base64url (no padding) of the raw y coordinate. */ + readonly y: string; +} + +/** The external browsers this extension bridges. */ +export type WebuiBrowserBridgeBrowser = "edge" | "chrome"; + +export interface WebuiBrowserBridgeChallenge { + readonly kind: "challenge"; + /** A fresh, single-use nonce. */ + readonly nonce: string; + readonly algorithm: typeof WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM; + /** Which stored credential the host expects the answer to use. */ + readonly keyId: string; + /** + * Which installation key the host will accept a proof with. + * + * The installation key is what a browser proves *before* it is handed a + * pairing credential, so it — not `keyId` — is what the extension signs + * with on a first connection. The id is public: the host names it here on + * every challenge. + */ + readonly installationKeyId: string; +} + +export interface WebuiBrowserBridgeProof { + readonly kind: "proof"; + readonly keyId: string; + /** The nonce this proof answers, echoed so the host cannot mix two challenges. */ + readonly nonce: string; + /** Base64 HMAC-SHA256 over the proof material. */ + readonly signature: string; + /** + * The algorithm the proof was made with, echoed from the challenge. + * + * The host verifies against exactly one algorithm, so an absent value is + * readable as an older extension. Echoing it anyway keeps the two sides of + * the wire saying the same thing. + */ + readonly algorithm?: typeof WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM; +} + +/** + * The credential-transfer frame the host sends before the challenge. + * + * The host's authoritative credential arrives here on every connect, so a + * switch that went off and on again — the only way the credential could + * change — reaches the extension without anyone doing anything. + */ +export interface WebuiBrowserBridgePair { + readonly kind: "pair"; + readonly keyId: string; + /** + * The host's ephemeral P-256 ECDH public key. Required because both + * halves of the ECDH shared secret depend on it; without it the extension + * cannot derive the same AES key the host used. + */ + readonly publicKey: WebuiBrowserBridgeEcdhPublicKey; + /** Base64 of the 16-byte HKDF salt the host generated for this connection. */ + readonly salt: string; + /** `base64( iv || aes-gcm-ciphertext )` — the ciphertext already carries its tag. */ + readonly payload: string; +} + +export interface WebuiBrowserBridgeTarget { + readonly browserId: string; + readonly targetId: string; + readonly title: string; + readonly url: string; + readonly generation: number; +} + +export interface WebuiBrowserBridgeImageFrame { + readonly browserId: string; + readonly targetId: string; + readonly generation: number; + /** Base64 image bytes. */ + readonly data: string; + readonly format: "png" | "jpeg"; + readonly sequence: number; +} + +export type WebuiBrowserBridgeInputCommand = + | { + readonly kind: "pointer"; + readonly targetId: string; + readonly generation: number; + readonly x: number; + readonly y: number; + readonly button: "left" | "middle" | "right"; + readonly clickCount: number; + } + | { + readonly kind: "key"; + readonly targetId: string; + readonly generation: number; + readonly key: string; + readonly code?: string; + readonly text?: string; + readonly modifiers: readonly ("alt" | "ctrl" | "meta" | "shift")[]; + } + | { + readonly kind: "text"; + readonly targetId: string; + readonly generation: number; + readonly text: string; + }; + +export type WebuiBrowserBridgeClientFrame = + | { + readonly kind: "hello"; + readonly browser: WebuiBrowserBridgeBrowser; + readonly browserId: string; + readonly extensionVersion: string; + /** + * The extension's ephemeral P-256 ECDH public key for this connection. + * Always set by the current extension build; the host refuses to pair + * without it. + */ + readonly publicKey: WebuiBrowserBridgeEcdhPublicKey; + } + | WebuiBrowserBridgeProof + | { readonly kind: "targets"; readonly targets: readonly WebuiBrowserBridgeTarget[] } + | { readonly kind: "frame"; readonly frame: WebuiBrowserBridgeImageFrame } + | { + readonly kind: "input-result"; + readonly commandId: string; + readonly accepted: boolean; + readonly error?: string; + } + | { + readonly kind: "cdp-result"; + readonly commandId: string; + readonly accepted: boolean; + readonly result?: unknown; + readonly error?: string; + /** + * Whether the method is outside the extension's supported subset. + * + * It is reported separately because it means the command will never work, + * which is a different thing from a failure that might. + */ + readonly unsupported?: boolean; + } + | { readonly kind: "pong"; readonly nonce: string }; + +export type WebuiBrowserBridgeHostFrame = + | WebuiBrowserBridgePair + | WebuiBrowserBridgeChallenge + | { readonly kind: "ready"; readonly sessionId: string } + | { + readonly kind: "input"; + readonly commandId: string; + readonly command: WebuiBrowserBridgeInputCommand; + } + | { readonly kind: "ping"; readonly nonce: string } + | { + /** + * A raw DevTools command for a tab the extension drives. + * + * It is sent only when the Full CDP switch is on; the runtime decides + * that, and the extension refuses a method outside its supported subset. + */ + readonly kind: "cdp"; + readonly commandId: string; + readonly targetId: string; + readonly generation: number; + readonly method: string; + readonly params: Record; + } + | { readonly kind: "closed"; readonly reason: string }; + +/** + * The codes the extension itself can report. + * + * The full vocabulary belongs to the host contract; this is the subset that a + * failure inside the extension can honestly claim, kept with the same strings so + * the panel and the Agent's tools branch on them without the extension importing + * the host module. + */ +export const WEBUI_BROWSER_BRIDGE_ERROR_CODES = [ + "browser_target_stale", + "browser_target_unknown", + "browser_command_invalid", + "browser_provider_failed", + "browser_provider_unavailable", + "browser_operation_timeout", + "browser_operation_cancelled", + "browser_pairing_rejected", +] as const; + +export type WebuiBrowserBridgeErrorCode = (typeof WEBUI_BROWSER_BRIDGE_ERROR_CODES)[number]; + +export interface WebuiBrowserBridgeError { + readonly code: WebuiBrowserBridgeErrorCode; + readonly message: string; + /** Whether waiting for the same operation to become possible again could help. */ + readonly retryable: boolean; +} + +export function webuiBrowserBridgeError( + code: WebuiBrowserBridgeErrorCode, + message: string, + retryable: boolean, +): WebuiBrowserBridgeError { + return { code, message, retryable }; +} + +/** + * The bytes a proof is computed over. + * + * The key id is inside the signed material so a proof captured for one + * credential cannot be presented against another. + */ +export function webuiBrowserBridgeProofMaterial(nonce: string, keyId: string): string { + return `${nonce} ${keyId}`; +} + +/** + * Computes the proof for a challenge. + * + * Web Crypto rather than `node:crypto`, because this runs in a browser service + * worker where `node:crypto` does not exist, and base64 is built from `btoa` + * rather than `Buffer` for the same reason. + */ +export async function signWebuiBrowserBridgeProof( + secret: Uint8Array, + nonce: string, + keyId: string, +): Promise { + // Copied into a fresh buffer first: `crypto.subtle` only accepts a buffer that + // is known not to be shared, and a caller's `Uint8Array` may have come from a + // storage decode with a wider backing store. + const key = await crypto.subtle.importKey( + "raw", + new Uint8Array(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(webuiBrowserBridgeProofMaterial(nonce, keyId)), + ); + return encodeWebuiBrowserBridgeBase64(new Uint8Array(signature)); +} + +/** + * Checks a proof against a stored secret. + * + * The host verifies every proof it admits; the extension keeps the same check so + * its own round trip is verifiable in tests without a host process, and so a + * wiring mistake on this side fails as a wrong proof rather than as a silent + * connection. The comparison is length-then-byte-constant-time, which is as + * strong as it gets over two base64 strings of public data. + */ +export async function verifyWebuiBrowserBridgeProof( + secret: Uint8Array, + proof: WebuiBrowserBridgeProof, + expectedNonce: string, + expectedKeyId: string, +): Promise { + if (proof.keyId !== expectedKeyId) return false; + if (proof.nonce !== expectedNonce) return false; + const expected = await signWebuiBrowserBridgeProof(secret, proof.nonce, proof.keyId); + return constantTimeEqual(expected, proof.signature); +} + +/** Base64 over bytes, without `Buffer`. */ +export function encodeWebuiBrowserBridgeBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** Bytes from base64, without `Buffer`. */ +export function decodeWebuiBrowserBridgeBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** + * Copies bytes into a plain `ArrayBuffer`. + * + * Web Crypto's `BufferSource` is declared over `ArrayBuffer`, but a + * `Uint8Array` is generic over its buffer and a view's buffer might be a + * `SharedArrayBuffer`. Copying into a buffer this function owns removes the + * question rather than casting it away. + */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +/* -------------------------------------------------------------------------- */ +/* Pairing transfer: ECDH P-256 + HKDF-SHA256 + AES-256-GCM (extension side) */ +/* -------------------------------------------------------------------------- */ + +/** + * Generates an ephemeral P-256 ECDH key pair for one connection. + * + * The private half leaves scope at end of call: there is no longer-lived key + * to store or leak, and a fresh handshake produces a fresh shared secret that + * cannot be replayed from a previously captured `pair` frame. + */ +export function generateWebuiBrowserBridgeEcdhKeyPair(): Promise<{ + readonly privateKey: CryptoKey; + readonly publicKey: CryptoKey; +}> { + // `ECDH` over `P-256` with `deriveBits`. The 256-bit output is exactly the + // width the HKDF then expands into an AES-256 key — anything else would + // either widen the KDF or shrink the cipher, and both are gratuitous. + return crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); +} + +/** Imports a peer ECDH public key from a wire-format JWK. */ +export async function importWebuiBrowserBridgeEcdhPublicKey( + jwk: WebuiBrowserBridgeEcdhPublicKey, +): Promise { + return crypto.subtle.importKey( + "jwk", + { ...jwk, ext: true, key_ops: [] }, + { name: "ECDH", namedCurve: "P-256" }, + true, + [], + ); +} + +/** Exports the wire-format P-256 ECDH public key (no `d`, no `key_ops`). */ +export async function exportWebuiBrowserBridgeEcdhPublicKey( + publicKey: CryptoKey, +): Promise { + const jwk = await crypto.subtle.exportKey("jwk", publicKey); + if (jwk.kty !== "EC" || jwk.crv !== "P-256") { + throw new Error(`browser bridge: unexpected ECDH JWK shape (kty=${jwk.kty}, crv=${jwk.crv})`); + } + if (typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new Error("browser bridge: ECDH JWK is missing x/y"); + } + return { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }; +} + +/** + * Derives an AES-GCM key from the ECDH shared secret. + * + * Same construction as the host: `HKDF-SHA256` over the shared bits, with + * the salt the host generated and the agreed `info`. A divergence between + * the two sides is what the cross-implementation test vector catches. + */ +async function deriveWebuiBrowserBridgePairAesKey( + sharedBits: ArrayBuffer, + salt: Uint8Array, + subtle: SubtleCrypto, +): Promise { + const baseKey = await subtle.importKey("raw", sharedBits, { name: "HKDF" }, false, ["deriveKey"]); + return subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: toArrayBuffer(salt), + info: new TextEncoder().encode(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO), + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +/** + * Opens a `pair` frame. + * + * Returns the secret **bytes** — the `chrome.storage.local` write stores the + * bytes themselves, not their base64 form, so a re-load on the next connect + * produces the exact value the host expects the proof against. + * + * A one-byte change in the ciphertext or IV is a GCM auth failure and this + * throws — which is what we want: a tampered frame cannot read as a valid + * credential. + */ +export async function openWebuiBrowserBridgePairingCredential( + cipher: Pick, + ownPrivateKey: CryptoKey, + subtle: SubtleCrypto = crypto.subtle, +): Promise { + const salt = decodeWebuiBrowserBridgeBase64(cipher.salt); + const payloadBytes = decodeWebuiBrowserBridgeBase64(cipher.payload); + if (payloadBytes.length < WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES + 16) { + throw new Error("browser bridge: pair payload is too short to be a valid AES-GCM frame"); + } + const iv = payloadBytes.subarray(0, WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES); + const ciphertext = payloadBytes.subarray(WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES); + const peerPublic = await importWebuiBrowserBridgeEcdhPublicKey(cipher.publicKey); + const sharedBits = await subtle.deriveBits( + { name: "ECDH", public: peerPublic }, + ownPrivateKey, + 256, + ); + const aesKey = await deriveWebuiBrowserBridgePairAesKey(sharedBits, salt, subtle); + const plain = new Uint8Array( + await subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + aesKey, + toArrayBuffer(ciphertext), + ), + ); + // Plaintext is `base64(secretBytes)`; decode so we store the raw secret. + const decoded = decodeWebuiBrowserBridgeBase64(new TextDecoder().decode(plain)); + if (decoded.length === 0) { + throw new Error("browser bridge: pair plaintext is empty"); + } + return decoded; +} + +function constantTimeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +/** + * The loopback WebSocket URL for a configured host origin. + * + * Only loopback origins are accepted: the bridge is a local-only surface, and an + * extension that can reach any origin could be pointed at a remote listener by + * anything that can write its storage. + */ +export function webuiBrowserBridgeUrl(origin: string): string { + const parsed = new URL(origin); + if (parsed.protocol !== "http:") { + throw new Error(`browser bridge: unsupported origin protocol ${parsed.protocol}`); + } + if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { + throw new Error(`browser bridge: origin host must be loopback, got ${parsed.hostname}`); + } + return `${parsed.origin}${WEBUI_BROWSER_BRIDGE_PATH}`; +} + +export type WebuiBrowserBridgeFrameParse = + | { readonly ok: true; readonly frame: WebuiBrowserBridgeHostFrame } + | { readonly ok: false; readonly reason: string }; + +/** + * Parses one frame from the host. + * + * A frame that is not one this protocol declares is a protocol violation, not + * something to ignore: the caller closes the socket with the returned reason + * rather than carrying on against a stream it is no longer parsing correctly. + */ +export function parseWebuiBrowserBridgeHostFrame(raw: string): WebuiBrowserBridgeFrameParse { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return { ok: false, reason: "frame is not valid JSON" }; + } + if (typeof value !== "object" || value === null) return { ok: false, reason: "frame is not an object" }; + const candidate = value as Record; + switch (candidate.kind) { + case "challenge": { + if (typeof candidate.nonce !== "string" || candidate.nonce.length === 0) { + return { ok: false, reason: "challenge has no nonce" }; + } + if (typeof candidate.keyId !== "string" || candidate.keyId.length === 0) { + return { ok: false, reason: "challenge has no key id" }; + } + // Required, not optional: without it the extension cannot tell which + // key the host will accept a proof with, and answering with the pairing + // credential on a first connection would fail against a host that has + // none. A host that omits it is refused rather than guessed at. + if ( + typeof candidate.installationKeyId !== "string" || + candidate.installationKeyId.length === 0 + ) { + return { ok: false, reason: "challenge has no installation key id" }; + } + if (candidate.algorithm !== WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM) { + return { ok: false, reason: `challenge algorithm ${String(candidate.algorithm)} is not supported` }; + } + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "pair": { + if (typeof candidate.keyId !== "string" || candidate.keyId.length === 0) { + return { ok: false, reason: "pair has no key id" }; + } + if (!isWebuiBrowserBridgeEcdhPublicKey(candidate.publicKey)) { + return { ok: false, reason: "pair public key is missing or not a P-256 JWK" }; + } + if (typeof candidate.salt !== "string" || candidate.salt.length === 0) { + return { ok: false, reason: "pair has no salt" }; + } + if (typeof candidate.payload !== "string" || candidate.payload.length === 0) { + return { ok: false, reason: "pair has no payload" }; + } + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "ready": { + if (typeof candidate.sessionId !== "string") return { ok: false, reason: "ready has no session id" }; + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "input": { + if (typeof candidate.commandId !== "string") return { ok: false, reason: "input has no command id" }; + if (!isWebuiBrowserBridgeInputCommand(candidate.command)) { + return { ok: false, reason: "input carries an invalid command" }; + } + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "cdp": { + // A raw DevTools command. The extension decides for itself whether it + // will run the method — this parser only establishes that the frame is + // well formed, so a malformed one is refused rather than dispatched. + if (typeof candidate.commandId !== "string") { + return { ok: false, reason: "cdp has no command id" }; + } + if (typeof candidate.targetId !== "string" || candidate.targetId.length === 0) { + return { ok: false, reason: "cdp has no target id" }; + } + if (typeof candidate.generation !== "number" || !Number.isInteger(candidate.generation)) { + return { ok: false, reason: "cdp has no generation" }; + } + if (typeof candidate.method !== "string" || candidate.method.length === 0) { + return { ok: false, reason: "cdp has no method" }; + } + if (!isWebuiBrowserBridgeCdpParams(candidate.params)) { + return { ok: false, reason: "cdp carries invalid params" }; + } + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "ping": { + if (typeof candidate.nonce !== "string") return { ok: false, reason: "ping has no nonce" }; + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + case "closed": { + if (typeof candidate.reason !== "string") return { ok: false, reason: "closed has no reason" }; + return { ok: true, frame: candidate as unknown as WebuiBrowserBridgeHostFrame }; + } + default: + return { ok: false, reason: `unknown frame kind ${String(candidate.kind)}` }; + } +} + +/** Whether a value is a plain object of DevTools parameters. */ +function isWebuiBrowserBridgeCdpParams(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Whether a value is a structurally valid P-256 ECDH public-key JWK. */ +function isWebuiBrowserBridgeEcdhPublicKey(value: unknown): value is WebuiBrowserBridgeEcdhPublicKey { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + candidate.kty === "EC" && + candidate.crv === "P-256" && + typeof candidate.x === "string" && + candidate.x.length > 0 && + typeof candidate.y === "string" && + candidate.y.length > 0 + ); +} + +/** Whether an input command has the shape the wire protocol declares. */ +export function isWebuiBrowserBridgeInputCommand(value: unknown): value is WebuiBrowserBridgeInputCommand { + if (typeof value !== "object" || value === null) return false; + const command = value as Record; + if (typeof command.targetId !== "string" || command.targetId.length === 0) return false; + if (typeof command.generation !== "number") return false; + switch (command.kind) { + case "pointer": + return ( + typeof command.x === "number" && + typeof command.y === "number" && + typeof command.button === "string" && + typeof command.clickCount === "number" + ); + case "key": + return typeof command.key === "string" && Array.isArray(command.modifiers); + case "text": + return typeof command.text === "string"; + default: + return false; + } +} \ No newline at end of file diff --git a/packages/browser-extension/src/targets.ts b/packages/browser-extension/src/targets.ts new file mode 100644 index 000000000..5ea3299ad --- /dev/null +++ b/packages/browser-extension/src/targets.ts @@ -0,0 +1,198 @@ +// Tab discovery and the generation registry. +// +// A tab id is not a page. The same tab id comes back after a reload, a navigation +// or a closed-and-reopened tab, and any element reference the Agent recorded +// against the previous page would then point at nothing. Every target therefore +// carries a generation that rises on each committed top-level navigation, and +// input is refused when the generation it was aimed at is no longer current +// (see `input.ts`). +// +// The generation map is persisted in `chrome.storage.session`, not held only in +// memory: an MV3 service worker is terminated and restarted at the browser's +// discretion, and a registry that forgot its generations on restart would accept +// input aimed at a page that no longer exists. + +import { + type WebuiBrowserBridgeStorageArea, +} from "./pairing.js"; +import type { WebuiBrowserBridgeTarget } from "./protocol.js"; + +/** The `chrome.storage.session` key the generation map is stored under. */ +export const WEBUI_BROWSER_BRIDGE_TARGETS_STORAGE_KEY = "webui.browserBridge.targets.v1"; + +/** The generation a tab has before any navigation has been observed. */ +const FIRST_GENERATION = 1; + +export interface WebuiBrowserExtensionTab { + readonly id?: number; + readonly windowId?: number; + readonly active?: boolean; + readonly title?: string; + readonly url?: string; +} + +export interface WebuiBrowserBridgeTabsApi { + query(queryInfo: Record): Promise; + get(tabId: number): Promise; +} + +/** The generation map, as one storage value. */ +export interface WebuiBrowserBridgeTargetGenerationStore { + read(): Promise>; + write(generations: Record): Promise; +} + +/** + * Adapts a `chrome.storage` area to the generation store. + * + * `storage.session` is the right area for this: the generations describe one + * browser session and should not outlive it. + */ +export function webuiBrowserBridgeTargetGenerationStore( + area: WebuiBrowserBridgeStorageArea, + key: string = WEBUI_BROWSER_BRIDGE_TARGETS_STORAGE_KEY, +): WebuiBrowserBridgeTargetGenerationStore { + return { + async read() { + const stored = (await area.get(key))[key]; + if (typeof stored !== "object" || stored === null) return {}; + const generations = (stored as Record).generations; + if (typeof generations !== "object" || generations === null) return {}; + return generations as Record; + }, + async write(generations) { + await area.set({ [key]: { generations } }); + }, + }; +} + +/** The wire identity of one tab. */ +export function webuiBrowserBridgeTargetId(tabId: number): string { + return `tab-${tabId}`; +} + +/** The tab id a wire identity names, or `undefined` when it is not one of ours. */ +export function webuiBrowserBridgeTabId(targetId: string): number | undefined { + if (!targetId.startsWith("tab-")) return undefined; + const tabId = Number(targetId.slice("tab-".length)); + return Number.isSafeInteger(tabId) && tabId >= 0 ? tabId : undefined; +} + +/** + * Whether a URL is one an extension cannot drive or capture. + * + * The browser's own pages and the stores' own restriction pages are refused by + * the platform, so they are dropped from the target list rather than offered and + * then failing — the ADR asks for these to be reported as unavailable, not to + * prompt per tab. + */ +export function isWebuiBrowserBridgePolicyBlockedUrl(url: string): boolean { + const schemeEnd = url.indexOf(":"); + if (schemeEnd < 0) return true; + const scheme = url.slice(0, schemeEnd).toLowerCase(); + if (scheme !== "http" && scheme !== "https" && scheme !== "file") return true; + const host = url.slice(schemeEnd + 1).toLowerCase(); + return ( + host.startsWith("chromewebstore.google.com/") || + host.startsWith("chrome.google.com/webstore") || + host.startsWith("microsoftedge.microsoft.com/addons") || + host.startsWith("chrome-extension://") || + host.startsWith("edge-extension://") + ); +} + +export interface WebuiBrowserBridgeResolvedTarget { + readonly targetId: string; + readonly tabId: number; + readonly generation: number; +} + +export interface WebuiBrowserBridgeTargetRegistry { + /** Every target the extension can currently reach. */ + list(): Promise; + /** One target's current generation, or `undefined` when it is gone. */ + lookup(targetId: string): Promise; + /** Raises the generation of a tab whose top-level document was replaced. */ + noteCommitted(tabId: number): Promise; + /** Drops a closed tab, so a reused tab id starts from a fresh generation. */ + noteRemoved(tabId: number): Promise; +} + +export interface WebuiBrowserBridgeTargetRegistryOptions { + readonly browserId: string; + readonly tabs: WebuiBrowserBridgeTabsApi; + readonly generations: WebuiBrowserBridgeTargetGenerationStore; +} + +/** + * Creates the registry. + * + * Generations are loaded lazily and every write goes through one queue: the tab + * events that raise them arrive faster than storage settles, and an interleaved + * write could persist an older map over a newer one. + */ +export function createWebuiBrowserBridgeTargetRegistry( + options: WebuiBrowserBridgeTargetRegistryOptions, +): WebuiBrowserBridgeTargetRegistry { + let cache: Record | undefined; + let writes: Promise = Promise.resolve(); + + async function load(): Promise> { + if (!cache) cache = await options.generations.read(); + return cache; + } + + async function set(generations: Record): Promise { + cache = generations; + writes = writes.then(() => options.generations.write(generations)); + await writes; + } + + return { + async list() { + const tabs = await options.tabs.query({}); + const generations = await load(); + const targets: WebuiBrowserBridgeTarget[] = []; + for (const tab of tabs) { + if (tab.id === undefined) continue; + const url = tab.url ?? ""; + if (isWebuiBrowserBridgePolicyBlockedUrl(url)) continue; + const targetId = webuiBrowserBridgeTargetId(tab.id); + targets.push({ + browserId: options.browserId, + targetId, + title: tab.title ?? "", + url, + generation: generations[targetId] ?? FIRST_GENERATION, + }); + } + return targets; + }, + async lookup(targetId) { + const tabId = webuiBrowserBridgeTabId(targetId); + if (tabId === undefined) return undefined; + // Existence comes from the browser, not from the generation map: a tab + // that has never navigated has no entry there and is still very much open. + const tab = await options.tabs.get(tabId).catch(() => undefined); + if (!tab) return undefined; + if (isWebuiBrowserBridgePolicyBlockedUrl(tab.url ?? "")) return undefined; + const generations = await load(); + return { targetId, tabId, generation: generations[targetId] ?? FIRST_GENERATION }; + }, + async noteCommitted(tabId) { + const generations = { ...(await load()) }; + const targetId = webuiBrowserBridgeTargetId(tabId); + // A tab the registry has not seen before is already at the first + // generation, so its first committed navigation takes it to the second. + generations[targetId] = (generations[targetId] ?? FIRST_GENERATION) + 1; + await set(generations); + }, + async noteRemoved(tabId) { + const generations = { ...(await load()) }; + const targetId = webuiBrowserBridgeTargetId(tabId); + if (generations[targetId] === undefined) return; + delete generations[targetId]; + await set(generations); + }, + }; +} \ No newline at end of file diff --git a/packages/browser-extension/test-results/.last-run.json b/packages/browser-extension/test-results/.last-run.json new file mode 100644 index 000000000..5fca3f84b --- /dev/null +++ b/packages/browser-extension/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/packages/browser-extension/test/bridge-client.test.ts b/packages/browser-extension/test/bridge-client.test.ts new file mode 100644 index 000000000..78934fbf7 --- /dev/null +++ b/packages/browser-extension/test/bridge-client.test.ts @@ -0,0 +1,442 @@ +// No browser and no socket: the client is driven through an injected clock and a +// fake socket that records what it was sent. The clock makes the reconnect +// schedule and the operation deadline exact rather than approximate, and the +// fakes cover everything the client depends on below the `WebSocket` boundary. + +import { describe, expect, it } from "vitest"; + +import { + WEBUI_BROWSER_BRIDGE_BACKGROUND_RETRY_CAP_MS, + WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS, + WebuiBrowserBridgeClient, + webuiBrowserBridgeJitteredDelayMs, + webuiBrowserBridgeRetryDelayMs, + type WebuiBrowserBridgeClock, + type WebuiBrowserBridgeSocket, + type WebuiBrowserBridgeSocketHandlers, +} from "../src/bridge-client.js"; +import { + signWebuiBrowserBridgeProof, + verifyWebuiBrowserBridgeProof, + WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM, + type WebuiBrowserBridgeChallenge, + type WebuiBrowserBridgeClientFrame, + type WebuiBrowserBridgeError, + type WebuiBrowserBridgeHostFrame, +} from "../src/protocol.js"; + +const SECRET = new TextEncoder().encode("webui-browser-bridge-test-secret"); +const KEY_ID = "key-1"; +const INSTALLATION_KEY_ID = "webui-browser-bridge-installation-edge-v1"; +const ORIGIN = "http://127.0.0.1:7777"; + +/** A virtual clock: nothing waits in real time, so the schedule is assertable. */ +class FakeClock implements WebuiBrowserBridgeClock { + time = 0; + private readonly timers: { at: number; run: () => void }[] = []; + + now(): number { + return this.time; + } + + schedule(callback: () => void, delayMs: number): () => void { + const timer = { at: this.time + delayMs, run: callback }; + this.timers.push(timer); + return () => { + const index = this.timers.indexOf(timer); + if (index >= 0) this.timers.splice(index, 1); + }; + } + + /** Runs every timer due at or before `target`, in order. */ + async advanceTo(target: number): Promise { + for (;;) { + const due = this.timers + .filter((timer) => timer.at <= target) + .sort((left, right) => left.at - right.at)[0]; + if (!due) break; + this.timers.splice(this.timers.indexOf(due), 1); + this.time = Math.max(this.time, due.at); + due.run(); + await settle(); + } + this.time = Math.max(this.time, target); + await settle(); + } +} + +class FakeSocket implements WebuiBrowserBridgeSocket { + readonly sent: string[] = []; + closed = false; + /** The clock time the client created this socket, for schedule assertions. */ + readonly createdAt: number; + private handlers: WebuiBrowserBridgeSocketHandlers | undefined; + + constructor(createdAt: number) { + this.createdAt = createdAt; + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.handlers?.onClose(); + } + + setHandlers(handlers: WebuiBrowserBridgeSocketHandlers): void { + this.handlers = handlers; + } + + open(): void { + this.handlers?.onOpen(); + } + + receive(frame: WebuiBrowserBridgeHostFrame): void { + this.handlers?.onMessage(JSON.stringify(frame)); + } + + /** Frames of one kind this socket was sent, in order. */ + frames(kind: WebuiBrowserBridgeClientFrame["kind"]): WebuiBrowserBridgeClientFrame[] { + return this.sent + .map((raw) => JSON.parse(raw) as WebuiBrowserBridgeClientFrame) + .filter((frame) => frame.kind === kind); + } +} + +/** + * Yields to the event loop. + * + * More than one turn, because signing runs on Node's Web Crypto — its results + * come back off the threadpool, so a single microtask turn is not enough to + * observe the proof the client writes after answering a challenge. + */ +async function settle(turns = 4): Promise { + for (let turn = 0; turn < turns; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +/** Waits until `predicate` holds, so no assertion races an async continuation. */ +async function waitFor(predicate: () => boolean, turns = 50): Promise { + for (let turn = 0; turn < turns; turn += 1) { + if (predicate()) return; + await settle(1); + } + throw new Error("condition was never met"); +} + +interface Harness { + readonly clock: FakeClock; + readonly sockets: FakeSocket[]; + readonly client: WebuiBrowserBridgeClient; + readonly errors: WebuiBrowserBridgeError[]; + readonly hostFrames: WebuiBrowserBridgeHostFrame[]; +} + +/** + * A bridge whose sockets can be told whether they ever come up. + * + * `respondWith` plays the host: a challenge after the hello, and a ready once the + * proof arrives. Leaving it false models a bridge that is not coming back inside + * the operation's deadline. + */ +function createHarness(options: { respondWith?: boolean; respondEverySocket?: boolean } = {}): Harness { + const clock = new FakeClock(); + const sockets: FakeSocket[] = []; + const errors: WebuiBrowserBridgeError[] = []; + const hostFrames: WebuiBrowserBridgeHostFrame[] = []; + const client = new WebuiBrowserBridgeClient({ + browser: "edge", + browserId: "browser-1", + extensionVersion: "0.1.0", + origin: ORIGIN, + clock, + random: () => 0, + createSocket: () => { + const socket = new FakeSocket(clock.time); + sockets.push(socket); + if (options.respondWith === false || (sockets.length > 1 && options.respondEverySocket !== true)) { + // Stays `connecting` and then drops, which is what a bridge that is not + // listening looks like from this side. + queueMicrotask(() => socket.close()); + } + return socket; + }, + // These tests never exercise the pair path: they drive the client straight + // at a challenge. The credential store is a no-op that records the + // `pair` frames it is handed so a test can assert one arrived without + // this file taking on the ECDH round trip (the extension pairing suite + // covers that, on both sides of the cipher). + applyPairCredential: async () => {}, + answerChallenge: async (challenge: WebuiBrowserBridgeChallenge) => ({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: await signWebuiBrowserBridgeProof(SECRET, challenge.nonce, challenge.keyId), + }), + onHostFrame: (frame) => { + hostFrames.push(frame); + }, + onError: (error) => { + errors.push(error); + }, + }); + return { clock, sockets, client, errors, hostFrames }; +} + +function challenge(nonce: string): WebuiBrowserBridgeHostFrame { + return { + kind: "challenge", + nonce, + algorithm: WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM, + keyId: KEY_ID, + installationKeyId: INSTALLATION_KEY_ID, + }; +} + +async function connect(harness: Harness, nonce = "nonce-1"): Promise { + harness.client.start(); + const socket = harness.sockets[harness.sockets.length - 1]!; + socket.open(); + // The client's greeting waits on the ephemeral key pair, which is a Web + // Crypto call; the host cannot speak before it, so the fake host waits too. + await waitFor(() => socket.frames("hello").length > 0); + socket.receive(challenge(nonce)); + await waitFor(() => socket.frames("proof").length > 0); + socket.receive({ kind: "ready", sessionId: "session-1" }); + await settle(); + return socket; +} + +describe("reconnect schedule", () => { + it("follows the ADR's steps and caps at five seconds while an operation is in flight", () => { + const steps = [0, 1, 2, 3, 4, 5, 6].map((attempt) => + webuiBrowserBridgeRetryDelayMs(attempt, WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS), + ); + expect(steps).toEqual([250, 500, 1000, 2000, 4000, 5000, 5000]); + }); + + it("caps background reconnection at thirty seconds", () => { + const steps = [0, 1, 2, 3, 4, 5, 6, 7].map((attempt) => + webuiBrowserBridgeRetryDelayMs(attempt, WEBUI_BROWSER_BRIDGE_BACKGROUND_RETRY_CAP_MS), + ); + expect(steps).toEqual([250, 500, 1000, 2000, 4000, 8000, 16000, 30000]); + }); + + it("adds jitter only as extra delay", () => { + expect(webuiBrowserBridgeJitteredDelayMs(0, WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS, () => 0)).toBe(250); + expect(webuiBrowserBridgeJitteredDelayMs(0, WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS, () => 1)).toBe(275); + expect(webuiBrowserBridgeJitteredDelayMs(1, WEBUI_BROWSER_BRIDGE_OPERATION_RETRY_CAP_MS, () => 0.5)).toBe(525); + }); +}); + +describe("pairing over the bridge", () => { + it("greets the host with an ephemeral key and answers its challenge before it reports ready", async () => { + const harness = createHarness(); + const socket = await connect(harness); + + expect(harness.client.connected).toBe(true); + const hello = socket.frames("hello"); + // Exactly one greeting, and it carries the P-256 key the host needs to + // deliver the credential. The coordinates are per-connection, so they are + // asserted for shape rather than for value. + expect(hello).toHaveLength(1); + expect(hello[0]).toMatchObject({ + kind: "hello", + browser: "edge", + browserId: "browser-1", + extensionVersion: "0.1.0", + publicKey: { kty: "EC", crv: "P-256" }, + }); + const publicKey = (hello[0] as { publicKey: { x: string; y: string } }).publicKey; + expect(publicKey.x.length).toBeGreaterThan(0); + expect(publicKey.y.length).toBeGreaterThan(0); + const proof = socket.frames("proof")[0]; + if (proof?.kind !== "proof") throw new Error("expected a proof frame"); + await expect(verifyWebuiBrowserBridgeProof(SECRET, proof, "nonce-1", KEY_ID)).resolves.toBe(true); + // `ready` is what makes the session usable, not the socket being open. + expect(harness.client.connected).toBe(true); + }); + + it("publishes an originated frame once a session exists", async () => { + const harness = createHarness(); + const socket = await connect(harness); + + await harness.client.publish({ kind: "targets", targets: [] }); + + expect(socket.frames("targets")).toHaveLength(1); + }); + + it("answers a ping with the same nonce", async () => { + const harness = createHarness(); + const socket = await connect(harness); + + socket.receive({ kind: "ping", nonce: "p-1" }); + await settle(); + + expect(socket.frames("pong")).toEqual([{ kind: "pong", nonce: "p-1" }]); + }); + + it("passes input commands to the caller and reports a refusal as a refusal", async () => { + const harness = createHarness(); + const socket = await connect(harness); + const command = { + kind: "input", + commandId: "c-1", + command: { kind: "text", targetId: "tab-1", generation: 3, text: "hi" }, + } as const; + + socket.receive(command); + await settle(); + + expect(harness.hostFrames).toEqual([command]); + }); +}); + +describe("operation deadline", () => { + it("fails explicitly at the deadline and never replays the operation", async () => { + const harness = createHarness(); + const socket = await connect(harness); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const delivered: string[] = []; + + const operation = harness.client.runOperation(async () => { + await gate; + delivered.push("delivered-once"); + return { kind: "input-result", commandId: "c-1", accepted: true }; + }); + const settled = operation.catch((error: WebuiBrowserBridgeError) => error); + await settle(); + + // The session drops while the command is still being delivered, and the + // bridge never comes back. + socket.close(); + release(); + + await harness.clock.advanceTo(15_000); + const outcome = await settled; + + expect(delivered).toEqual(["delivered-once"]); + expect((outcome as WebuiBrowserBridgeError).code).toBe("browser_operation_timeout"); + for (const attempt of harness.sockets) { + expect(attempt.frames("input-result")).toEqual([]); + } + }); + + it("retries on the fixed schedule while the operation is waiting", async () => { + const harness = createHarness(); + const socket = await connect(harness); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const operation = harness.client.runOperation(async () => { + await gate; + return { kind: "input-result", commandId: "c-1", accepted: true }; + }); + const settled = operation.catch((error: WebuiBrowserBridgeError) => error); + await settle(); + socket.close(); + release(); + await settle(); + + await harness.clock.advanceTo(7_750); + + const attemptTimes = harness.sockets.slice(1).map((attempt, index, all) => { + const previous = index === 0 ? 0 : all[index - 1].createdAt; + return attempt.createdAt - previous; + }); + expect(attemptTimes).toEqual([250, 500, 1000, 2000, 4000]); + + await harness.clock.advanceTo(15_000); + expect((await settled as WebuiBrowserBridgeError).code).toBe("browser_operation_timeout"); + }); + + it("keeps the target when the bridge returns, and still writes the result once", async () => { + const harness = createHarness({ respondWith: true, respondEverySocket: true }); + const socket = await connect(harness); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const delivered: string[] = []; + + const operation = harness.client.runOperation(async () => { + await gate; + delivered.push("delivered-once"); + return { kind: "input-result", commandId: "c-1", accepted: false, error: "browser_target_stale" }; + }); + const settled = operation.catch((error: WebuiBrowserBridgeError) => error); + await settle(); + socket.close(); + release(); + + await harness.clock.advanceTo(1_000); + const replacement = harness.sockets[harness.sockets.length - 1]!; + replacement.open(); + await waitFor(() => replacement.frames("hello").length > 0); + replacement.receive(challenge("nonce-2")); + await waitFor(() => replacement.frames("proof").length > 0); + replacement.receive({ kind: "ready", sessionId: "session-2" }); + await settle(); + + await expect(operation).resolves.toBeUndefined(); + expect(delivered).toEqual(["delivered-once"]); + // The refusal is reported as a refusal, on the new session, exactly once. + expect(replacement.frames("input-result")).toEqual([ + { kind: "input-result", commandId: "c-1", accepted: false, error: "browser_target_stale" }, + ]); + expect(socket.frames("input-result")).toEqual([]); + }); + + it("reports an unreachable bridge when an originated frame cannot be sent", async () => { + const harness = createHarness(); + const socket = await connect(harness); + socket.close(); + const pending = harness.client + .publish({ kind: "targets", targets: [] }) + .catch((error: WebuiBrowserBridgeError) => error); + + await harness.clock.advanceTo(15_000); + + expect((await pending as WebuiBrowserBridgeError).code).toBe("browser_provider_unavailable"); + }); + + it("keeps reconnecting in the background on the wider cap", async () => { + const harness = createHarness(); + const socket = await connect(harness); + socket.close(); + + await harness.clock.advanceTo(93_000); + + // Nothing is in flight, so the backoff grows to the background cap. + const attemptTimes = harness.sockets.slice(1).map((attempt, index, all) => { + const previous = index === 0 ? 0 : all[index - 1].createdAt; + return attempt.createdAt - previous; + }); + expect(attemptTimes.slice(0, 7)).toEqual([250, 500, 1000, 2000, 4000, 8000, 16000]); + expect(attemptTimes.slice(7, 9)).toEqual([30000, 30000]); + }); + + it("fails everything still waiting when the person switches the browser off", async () => { + const harness = createHarness(); + await connect(harness); + const waiting = harness.client + .publish({ kind: "targets", targets: [] }) + .catch((error: WebuiBrowserBridgeError) => error); + harness.client.stop(); + const error = harness.client + .publish({ kind: "targets", targets: [] }) + .catch((cause: WebuiBrowserBridgeError) => cause); + + await expect(waiting).resolves.toMatchObject({ code: "browser_provider_unavailable" }); + await expect(error).resolves.toMatchObject({ code: "browser_operation_cancelled" }); + }); +}); \ No newline at end of file diff --git a/packages/browser-extension/test/cdp.test.ts b/packages/browser-extension/test/cdp.test.ts new file mode 100644 index 000000000..e6b016d65 --- /dev/null +++ b/packages/browser-extension/test/cdp.test.ts @@ -0,0 +1,127 @@ +// The bridge's DevTools command path. +// +// `chrome.debugger` exposes only a subset of the DevTools domains, so the +// extension decides for itself which commands it will run. These tests pin +// that decision: a command in the subset is sent, a command outside it is +// refused **before** any browser call is made, and a browser that refuses a +// command comes back as a refusal rather than a success. + +import { describe, expect, it } from "vitest"; + +import { + WEBUI_BROWSER_BRIDGE_CDP_COMMANDS, + createWebuiBrowserBridgeCdpDispatcher, +} from "../src/cdp.js"; +import type { WebuiBrowserBridgeDebuggerApi } from "../src/input.js"; + +/** A debugger that records what it was asked to do. */ +function recordingDebugger( + behaviour: (method: string) => "ok" | "refuse" = () => "ok", +): WebuiBrowserBridgeDebuggerApi & { readonly sent: string[]; readonly attached: number[] } { + const sent: string[] = []; + const attached: number[] = []; + return { + sent, + attached, + async attach(target: { readonly tabId?: number }) { + attached.push(target.tabId ?? -1); + }, + async detach() { + // The dispatcher keeps its attachment; detaching is the browser's call. + }, + async sendCommand( + _target: { readonly tabId?: number }, + method: string, + ) { + sent.push(method); + if (behaviour(method) === "refuse") { + throw new Error(`the browser does not support ${method}`); + } + return { echoed: method }; + }, + }; +} + +describe("the bridge's DevTools commands", () => { + it("sends a command that is in the supported subset", async () => { + const debuggerApi = recordingDebugger(); + const dispatcher = createWebuiBrowserBridgeCdpDispatcher({ + debugger: debuggerApi, + version: "1.3", + }); + const outcome = await dispatcher.dispatch({ tabId: 7 }, "Runtime.evaluate", { + expression: "1 + 1", + }); + expect(outcome).toEqual({ accepted: true, result: { echoed: "Runtime.evaluate" } }); + expect(debuggerApi.sent).toEqual(["Runtime.evaluate"]); + expect(debuggerApi.attached).toEqual([7]); + }); + + it("refuses a command outside the subset without asking the browser", async () => { + const debuggerApi = recordingDebugger(); + const dispatcher = createWebuiBrowserBridgeCdpDispatcher({ + debugger: debuggerApi, + version: "1.3", + }); + const outcome = await dispatcher.dispatch({ tabId: 7 }, "Browser.setPermission", {}); + expect(outcome.accepted).toBe(false); + // The refusal says the command will never work, which is a different + // thing from a failure that might. + expect(outcome).toMatchObject({ unsupported: true }); + expect(debuggerApi.sent).toEqual([]); + }); + + it("reports a browser refusal as a failure, not as an unsupported command", async () => { + const debuggerApi = recordingDebugger((method) => + method === "Runtime.evaluate" ? "refuse" : "ok", + ); + const dispatcher = createWebuiBrowserBridgeCdpDispatcher({ + debugger: debuggerApi, + version: "1.3", + }); + const outcome = await dispatcher.dispatch({ tabId: 7 }, "Runtime.evaluate", {}); + expect(outcome.accepted).toBe(false); + // The browser refused it, so the command may work elsewhere; it is not the + // same as "the extension will never run this". + expect(outcome).not.toMatchObject({ unsupported: true }); + }); + + it("reports a tab it cannot attach to as a refusal", async () => { + const debuggerApi: WebuiBrowserBridgeDebuggerApi = { + async attach() { + throw new Error("the tab is not debuggable"); + }, + async detach() {}, + async sendCommand() { + return undefined; + }, + }; + const dispatcher = createWebuiBrowserBridgeCdpDispatcher({ + debugger: debuggerApi, + version: "1.3", + }); + const outcome = await dispatcher.dispatch({ tabId: 7 }, "Runtime.evaluate", {}); + expect(outcome.accepted).toBe(false); + expect(outcome).not.toMatchObject({ unsupported: true }); + }); + + it("attaches once per tab rather than once per command", async () => { + const debuggerApi = recordingDebugger(); + const dispatcher = createWebuiBrowserBridgeCdpDispatcher({ + debugger: debuggerApi, + version: "1.3", + }); + await dispatcher.dispatch({ tabId: 7 }, "Runtime.evaluate", {}); + await dispatcher.dispatch({ tabId: 7 }, "DOM.getDocument", {}); + await dispatcher.dispatch({ tabId: 8 }, "DOM.getDocument", {}); + // One attachment per tab, not per command. + expect(debuggerApi.attached).toEqual([7, 8]); + }); + + it("keeps the supported subset explicit", () => { + // A subset that is not explicit is not a restriction. + expect(WEBUI_BROWSER_BRIDGE_CDP_COMMANDS.has("Runtime.evaluate")).toBe(true); + expect(WEBUI_BROWSER_BRIDGE_CDP_COMMANDS.has("Page.captureScreenshot")).toBe(false); + expect(WEBUI_BROWSER_BRIDGE_CDP_COMMANDS.has("Browser.setPermission")).toBe(false); + }); +}); diff --git a/packages/browser-extension/test/input.test.ts b/packages/browser-extension/test/input.test.ts new file mode 100644 index 000000000..14a6c3c8f --- /dev/null +++ b/packages/browser-extension/test/input.test.ts @@ -0,0 +1,207 @@ +// No browser is involved here: the tab registry and the Debugger API are small +// fakes. They cover what the dispatcher decides — whether a command is allowed to +// touch a tab, and which DevTools commands that turns into — not Chrome's +// behaviour on the other end. + +import { describe, expect, it } from "vitest"; + +import { + createWebuiBrowserBridgeInputDispatcher, + type WebuiBrowserBridgeDebuggerApi, +} from "../src/input.js"; +import { + createWebuiBrowserBridgeTargetRegistry, + webuiBrowserBridgeTargetGenerationStore, + type WebuiBrowserBridgeTabsApi, +} from "../src/targets.js"; +import type { WebuiBrowserBridgeInputCommand } from "../src/protocol.js"; + +interface SentCommand { + readonly method: string; + readonly params: Record | undefined; +} + +function fakeTabs(tabs: { id: number; url?: string; title?: string }[]): WebuiBrowserBridgeTabsApi { + return { + async query() { + return tabs; + }, + async get(tabId) { + return tabs.find((tab) => tab.id === tabId); + }, + }; +} + +function fakeDebugger(): { sent: SentCommand[]; attached: number[] } & WebuiBrowserBridgeDebuggerApi { + const sent: SentCommand[] = []; + const attached: number[] = []; + return { + sent, + attached, + attach: async (target) => { + if (target.tabId === undefined) throw new Error("attach without a tab"); + attached.push(target.tabId); + }, + detach: async () => {}, + sendCommand: async (_target, method, params) => { + sent.push({ method, params }); + return {}; + }, + }; +} + +async function registryWith(generation: number) { + const generations = webuiBrowserBridgeTargetGenerationStore({ + async get() { + return {}; + }, + async set() {}, + async remove() {}, + }); + const registry = createWebuiBrowserBridgeTargetRegistry({ + browserId: "browser-1", + tabs: fakeTabs([{ id: 7, url: "https://example.com/", title: "Example" }]), + generations, + }); + for (let index = 1; index < generation; index += 1) await registry.noteCommitted(7); + return registry; +} + +describe("input forwarding", () => { + it("refuses a command aimed at a generation the tab has moved past", async () => { + const targets = await registryWith(3); + const browserDebugger = fakeDebugger(); + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + + const result = await dispatcher.dispatch({ + kind: "pointer", + targetId: "tab-7", + generation: 2, + x: 10, + y: 20, + button: "left", + clickCount: 1, + }); + + // A refusal is reported as a refusal, never as a delivery. + expect(result).toEqual({ + accepted: false, + error: { + code: "browser_target_stale", + message: "target tab-7 is at generation 3, command aimed at 2", + retryable: false, + }, + }); + expect(browserDebugger.sent).toEqual([]); + expect(browserDebugger.attached).toEqual([]); + }); + + it("refuses a command for a target that is not open", async () => { + const targets = await registryWith(1); + const browserDebugger = fakeDebugger(); + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + + const result = await dispatcher.dispatch({ kind: "text", targetId: "tab-404", generation: 1, text: "hi" }); + + expect(result).toMatchObject({ accepted: false, error: { code: "browser_target_unknown", retryable: false } }); + expect(browserDebugger.sent).toEqual([]); + }); + + it("delivers a current-generation pointer as press and release", async () => { + const targets = await registryWith(1); + const browserDebugger = fakeDebugger(); + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + + const result = await dispatcher.dispatch({ + kind: "pointer", + targetId: "tab-7", + generation: 1, + x: 10, + y: 20, + button: "left", + clickCount: 1, + }); + + expect(result).toEqual({ accepted: true }); + expect(browserDebugger.attached).toEqual([7]); + expect(browserDebugger.sent.map((entry) => entry.method)).toEqual([ + "Input.dispatchMouseEvent", + "Input.dispatchMouseEvent", + ]); + expect(browserDebugger.sent[0]?.params).toMatchObject({ type: "mousePressed", x: 10, y: 20, button: "left" }); + }); + + it("delivers text through insertText rather than synthesized key events", async () => { + const targets = await registryWith(1); + const browserDebugger = fakeDebugger(); + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + + const result = await dispatcher.dispatch({ kind: "text", targetId: "tab-7", generation: 1, text: "héllo" }); + + expect(result).toEqual({ accepted: true }); + expect(browserDebugger.sent).toEqual([{ method: "Input.insertText", params: { text: "héllo" } }]); + }); + + it("carries modifiers on a key command", async () => { + const targets = await registryWith(1); + const browserDebugger = fakeDebugger(); + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + + await dispatcher.dispatch({ + kind: "key", + targetId: "tab-7", + generation: 1, + key: "a", + code: "KeyA", + text: "a", + modifiers: ["ctrl", "shift"], + }); + + // alt 1, ctrl 2, meta 4, shift 8 — so ctrl+shift is 10. + expect(browserDebugger.sent.map((entry) => entry.params?.modifiers)).toEqual([10, 10, 10]); + }); + + it("serializes commands aimed at one target", async () => { + const targets = await registryWith(1); + const order: string[] = []; + const browserDebugger: WebuiBrowserBridgeDebuggerApi = { + attach: async () => {}, + detach: async () => {}, + sendCommand: async () => { + order.push("sent"); + return {}; + }, + }; + const dispatcher = createWebuiBrowserBridgeInputDispatcher({ + targets, + debugger: browserDebugger, + }); + const commands: WebuiBrowserBridgeInputCommand[] = [ + { kind: "text", targetId: "tab-7", generation: 1, text: "one" }, + { kind: "text", targetId: "tab-7", generation: 1, text: "two" }, + ]; + + const first = dispatcher.dispatch(commands[0]!); + const second = dispatcher.dispatch(commands[1]!); + + expect(order).toEqual([]); + await Promise.all([first, second]); + await dispatcher.settled("tab-7"); + expect(order).toEqual(["sent", "sent"]); + }); +}); \ No newline at end of file diff --git a/packages/browser-extension/test/pairing.test.ts b/packages/browser-extension/test/pairing.test.ts new file mode 100644 index 000000000..d634bfea2 --- /dev/null +++ b/packages/browser-extension/test/pairing.test.ts @@ -0,0 +1,527 @@ +// No browser is involved here: `chrome.storage` is replaced by the small fakes +// below, and `crypto.subtle` is Node's own Web Crypto, which is the same +// implementation the service worker gets. Nothing here talks to a real Chrome. +// +// The point of this file is that the credential is **host-authored**. The +// extension cannot mint one: two independently minted credentials never +// match, so the host would never accept a proof against the wrong secret. +// Every credential below arrives through a `pair` frame, decrypted with an +// ECDH key this side minted for that one connection. + +import { describe, expect, it } from "vitest"; + +import { + answerWebuiBrowserBridgeChallenge, + answerWebuiBrowserBridgeChallengeWithStoredSecrets, + answerWebuiBrowserBridgeInstallationChallenge, + applyWebuiBrowserBridgePairingCredential, + clearWebuiBrowserBridgeInstallationKey, + clearWebuiBrowserBridgePairingCredential, + loadWebuiBrowserBridgeInstallationKey, + loadWebuiBrowserBridgePairingCredential, + saveWebuiBrowserBridgeInstallationKey, + webuiBrowserBridgeInstallationKeyStore, + webuiBrowserBridgePairingStore, + webuiBrowserBridgeInstallationKeyStatus, + WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES, + WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_STORAGE_KEY, + WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY, + WEBUI_BROWSER_BRIDGE_SECRET_BYTES, + type WebuiBrowserBridgeStorageArea, +} from "../src/pairing.js"; +import { + encodeWebuiBrowserBridgeBase64, + exportWebuiBrowserBridgeEcdhPublicKey, + generateWebuiBrowserBridgeEcdhKeyPair, + importWebuiBrowserBridgeEcdhPublicKey, + openWebuiBrowserBridgePairingCredential, + verifyWebuiBrowserBridgeProof, + WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES, + WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO, + type WebuiBrowserBridgeChallenge, + type WebuiBrowserBridgeEcdhPublicKey, + type WebuiBrowserBridgePair, +} from "../src/protocol.js"; + +function fakeArea(): WebuiBrowserBridgeStorageArea & { readonly values: Record } { + const values: Record = {}; + return { + values, + async get(key) { + const keys = key === null ? Object.keys(values) : Array.isArray(key) ? key : [key]; + const read: Record = {}; + for (const name of keys) if (name in values) read[name] = values[name]; + return read; + }, + async set(items) { + Object.assign(values, items); + }, + async remove(key) { + for (const name of Array.isArray(key) ? key : [key]) delete values[name]; + }, + }; +} + +/** + * Bytes 1..255, 0..255, 1..255, ... — non-zero, so a zero byte in the secret + * cannot be mistaken for a padding artefact when it comes back. + */ +function countingSecret(length: number): Uint8Array { + return Uint8Array.from({ length }, (_value, index) => ((index + 1) % 256) || 1); +} + +/** The credential body a host would keep in its OS secure store. */ +function makeHostSecret(seed: number): Uint8Array { + const bytes = new Uint8Array(WEBUI_BROWSER_BRIDGE_SECRET_BYTES); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = ((seed * (index + 1)) % 255) || 1; + } + return bytes; +} + +/** An ECDH key pair, as the two shapes the wire needs. */ +async function mintEcdhPair(): Promise<{ + readonly privateKey: CryptoKey; + readonly publicJwk: WebuiBrowserBridgeEcdhPublicKey; +}> { + const { privateKey, publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + return { privateKey, publicJwk: await exportWebuiBrowserBridgeEcdhPublicKey(publicKey) }; +} + +/** + * Plays the host: seal a credential body to the extension's `hello` key. + * + * This is a second implementation of the host's seal, written here so the + * test is not asserting the extension's own helper against itself. It is the + * same construction the webui side ships + * (`sealWebuiBrowserBridgePairingCredential` in the shared contract), and the + * shared vector in `protocol.test.ts` pins both halves of it. + */ +async function hostPairFrame( + extensionPublicJwk: WebuiBrowserBridgeEcdhPublicKey, + secretBytes: Uint8Array, + keyId: string, +): Promise { + const salt = countingSecret(16); + const iv = countingSecret(WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES); + const host = await generateWebuiBrowserBridgeEcdhKeyPair(); + const peer = await importWebuiBrowserBridgeEcdhPublicKey(extensionPublicJwk); + const sharedBits = await crypto.subtle.deriveBits( + { name: "ECDH", public: peer }, + host.privateKey, + 256, + ); + const baseKey = await crypto.subtle.importKey("raw", sharedBits, { name: "HKDF" }, false, [ + "deriveKey", + ]); + const aesKey = await crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: toArrayBuffer(salt), + info: new TextEncoder().encode(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO), + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"], + ); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + aesKey, + toArrayBuffer(new TextEncoder().encode(encodeWebuiBrowserBridgeBase64(secretBytes))), + ), + ); + const payload = new Uint8Array(iv.length + ciphertext.length); + payload.set(iv, 0); + payload.set(ciphertext, iv.length); + return { + kind: "pair", + keyId, + publicKey: await exportWebuiBrowserBridgeEcdhPublicKey(host.publicKey), + salt: encodeBase64ForTest(salt), + payload: encodeBase64ForTest(payload), + }; +} + +describe("pairing credential — host-authored", () => { + it("holds no credential until a host pair frame delivers one", async () => { + const store = webuiBrowserBridgePairingStore(fakeArea()); + + // No credential is minted on first use: that is the defect this exchange + // exists to remove. The extension's own id would never match the host's. + expect(await loadWebuiBrowserBridgePairingCredential(store)).toBeUndefined(); + }); + + it("decrypts a pair frame and stores exactly the host's secret", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgePairingStore(area); + const { privateKey, publicJwk } = await mintEcdhPair(); + const hostSecret = makeHostSecret(7); + + const cipher = await hostPairFrame(publicJwk, hostSecret, "edge-host-issued-1"); + + // The wire frame must not carry the secret in any form. + const encodedSecret = encodeWebuiBrowserBridgeBase64(hostSecret); + expect(JSON.stringify(cipher)).not.toContain(encodedSecret); + expect(cipher).not.toHaveProperty("secret"); + + const stored = await applyWebuiBrowserBridgePairingCredential(store, cipher, privateKey); + + expect(stored.keyId).toBe("edge-host-issued-1"); + expect(encodeWebuiBrowserBridgeBase64(stored.secret)).toBe(encodedSecret); + expect(area.values[WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY]).toBeDefined(); + }); + + it("reloads the same credential after a worker restart", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgePairingStore(area); + const { privateKey, publicJwk } = await mintEcdhPair(); + await applyWebuiBrowserBridgePairingCredential( + store, + await hostPairFrame(publicJwk, makeHostSecret(11), "edge-restart"), + privateKey, + ); + + // A restarted worker rebuilds the client from storage alone. + const reloaded = await loadWebuiBrowserBridgePairingCredential(store); + + expect(reloaded?.keyId).toBe("edge-restart"); + expect(encodeWebuiBrowserBridgeBase64(reloaded!.secret)).toBe( + encodeWebuiBrowserBridgeBase64(makeHostSecret(11)), + ); + }); + + it("holds no credential when storage has a truncated one", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgePairingStore(area); + await area.set({ + [WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY]: { + keyId: "edge-broken", + secret: encodeWebuiBrowserBridgeBase64(new Uint8Array(4)), + }, + }); + + expect(await loadWebuiBrowserBridgePairingCredential(store)).toBeUndefined(); + }); + + it("replaces the stored credential when the host rotates it", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgePairingStore(area); + + // First connection: the switch was on, the host issued credential A. + const first = await mintEcdhPair(); + await applyWebuiBrowserBridgePairingCredential( + store, + await hostPairFrame(first.publicJwk, makeHostSecret(101), "edge-cred-a"), + first.privateKey, + ); + expect((await loadWebuiBrowserBridgePairingCredential(store))?.keyId).toBe("edge-cred-a"); + + // The person turned the switch off and on again: the host minted a new + // credential. The next connection delivers it, and the extension drops + // the old copy rather than keeping both. + const second = await mintEcdhPair(); + const rotated = await applyWebuiBrowserBridgePairingCredential( + store, + await hostPairFrame(second.publicJwk, makeHostSecret(202), "edge-cred-b"), + second.privateKey, + ); + const after = await loadWebuiBrowserBridgePairingCredential(store); + + expect(rotated.keyId).toBe("edge-cred-b"); + expect(after?.keyId).toBe("edge-cred-b"); + expect(encodeWebuiBrowserBridgeBase64(after!.secret)).toBe( + encodeWebuiBrowserBridgeBase64(makeHostSecret(202)), + ); + expect(encodeWebuiBrowserBridgeBase64(after!.secret)).not.toBe( + encodeWebuiBrowserBridgeBase64(makeHostSecret(101)), + ); + }); + + it("answers a host challenge with the credential the pair frame carried", async () => { + const store = webuiBrowserBridgePairingStore(fakeArea()); + const { privateKey, publicJwk } = await mintEcdhPair(); + const hostSecret = makeHostSecret(31); + const credential = await applyWebuiBrowserBridgePairingCredential( + store, + await hostPairFrame(publicJwk, hostSecret, "edge-answer"), + privateKey, + ); + + // A restarted worker: only storage remains, exactly as it is in a real MV3 + // service worker that was terminated between connections. + const afterRestart = await loadWebuiBrowserBridgePairingCredential(store); + const proof = await answerWebuiBrowserBridgeChallenge( + challengeFor("nonce-abc", credential.keyId), + afterRestart!, + ); + + // The host verifies against the secret it stored, not against anything the + // extension chose — so this is the end-to-end claim in one line. + await expect( + verifyWebuiBrowserBridgeProof(hostSecret, proof, "nonce-abc", "edge-answer"), + ).resolves.toBe(true); + }); + + it("refuses a tampered payload instead of storing half a credential", async () => { + const store = webuiBrowserBridgePairingStore(fakeArea()); + const { privateKey, publicJwk } = await mintEcdhPair(); + const cipher = await hostPairFrame(publicJwk, makeHostSecret(41), "edge-tampered"); + + // Flip one bit in the last byte of the payload: the AES-GCM tag covers it, + // so decryption must fail rather than return a plausible-looking secret. + const bytes = decodeBase64ForTest(cipher.payload); + bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0x01; + const tampered: WebuiBrowserBridgePair = { ...cipher, payload: encodeBase64ForTest(bytes) }; + + await expect(openWebuiBrowserBridgePairingCredential(tampered, privateKey)).rejects.toBeDefined(); + expect(await loadWebuiBrowserBridgePairingCredential(store)).toBeUndefined(); + }); + + it("refuses a payload encrypted to a different extension key", async () => { + const store = webuiBrowserBridgePairingStore(fakeArea()); + const mine = await mintEcdhPair(); + const someoneElse = await mintEcdhPair(); + // Sealed to a key this worker never generated. + const cipher = await hostPairFrame(someoneElse.publicJwk, makeHostSecret(47), "edge-not-mine"); + + await expect( + applyWebuiBrowserBridgePairingCredential(store, cipher, mine.privateKey), + ).rejects.toBeDefined(); + expect(await loadWebuiBrowserBridgePairingCredential(store)).toBeUndefined(); + }); + + it("forgets the credential only when the caller asks it to", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgePairingStore(area); + await area.set({ + [WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY]: { + keyId: "edge-doomed", + secret: encodeWebuiBrowserBridgeBase64(makeHostSecret(3)), + }, + }); + + await clearWebuiBrowserBridgePairingCredential(store); + + expect(area.values[WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY]).toBeUndefined(); + }); +}); + +describe("challenge answering", () => { + it("refuses a challenge for a credential it does not hold or an algorithm it does not implement", async () => { + const credential = { + keyId: "edge-key", + secret: makeHostSecret(13), + }; + + await expect( + answerWebuiBrowserBridgeChallenge( + challengeFor("n", "edge-someone-else"), + credential, + ), + ).rejects.toThrow(/does not hold/); + await expect( + answerWebuiBrowserBridgeChallenge( + { ...challengeFor("n", credential.keyId), algorithm: "HMAC-SHA1" as "HMAC-SHA256" }, + credential, + ), + ).rejects.toThrow(/algorithm/); + }); + + it("binds the proof to the nonce so an earlier one cannot stand in", async () => { + const credential = { keyId: "edge-key", secret: makeHostSecret(13) }; + const proof = await answerWebuiBrowserBridgeChallenge( + challengeFor("nonce-now", "edge-key"), + credential, + ); + + await expect( + verifyWebuiBrowserBridgeProof(credential.secret, proof, "nonce-later", "edge-key"), + ).resolves.toBe(false); + }); +}); + +describe("the installation key — what the options page stores", () => { + it("stores the pasted key in chrome.storage.local, under its own key", async () => { + const area = fakeArea(); + const store = webuiBrowserBridgeInstallationKeyStore(area); + const pasted = encodeWebuiBrowserBridgeBase64(makeHostSecret(7)); + + await saveWebuiBrowserBridgeInstallationKey(store, pasted); + + // Its own entry, not the pairing credential's: the two have different + // lifetimes, so one can never be cleared by clearing the other. + expect(area.values[WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_STORAGE_KEY]).toBeDefined(); + expect(area.values[WEBUI_BROWSER_BRIDGE_PAIRING_STORAGE_KEY]).toBeUndefined(); + + const loaded = await loadWebuiBrowserBridgeInstallationKey(store); + expect(loaded).toBeDefined(); + expect(loaded!.secret).toHaveLength(WEBUI_BROWSER_BRIDGE_INSTALLATION_KEY_BYTES); + }); + + it("refuses a paste that is not exactly 32 bytes", async () => { + const store = webuiBrowserBridgeInstallationKeyStore(fakeArea()); + const tooShort = encodeWebuiBrowserBridgeBase64(new Uint8Array(16)); + await expect(saveWebuiBrowserBridgeInstallationKey(store, tooShort)).rejects.toThrow(/32 bytes/); + expect( + await loadWebuiBrowserBridgeInstallationKey(store), + ).toBeUndefined(); + }); + + it("never returns the key after it is stored, only a fingerprint", async () => { + const store = webuiBrowserBridgeInstallationKeyStore(fakeArea()); + const pasted = encodeWebuiBrowserBridgeBase64(makeHostSecret(11)); + await saveWebuiBrowserBridgeInstallationKey(store, pasted); + + // This is the only reader the options page uses. Its return type carries + // no key field at all, so "the key is never displayed again" is a + // property of the type rather than of the markup. + const state = await webuiBrowserBridgeInstallationKeyStatus(store); + expect(state).toEqual({ stored: true, fingerprint: expect.stringMatching(/^[0-9a-f]{16}$/u) }); + expect(JSON.stringify(state)).not.toContain(pasted); + expect("key" in state).toBe(false); + }); + + it("reports an empty browser as having nothing stored", async () => { + expect(await webuiBrowserBridgeInstallationKeyStatus(webuiBrowserBridgeInstallationKeyStore(fakeArea()))).toEqual({ + stored: false, + }); + }); + + it("clears only the installation key", async () => { + const area = fakeArea(); + const pairing = webuiBrowserBridgePairingStore(area); + const installation = webuiBrowserBridgeInstallationKeyStore(area); + await saveWebuiBrowserBridgeInstallationKey(installation, encodeWebuiBrowserBridgeBase64(makeHostSecret(3))); + await pairing.write({ keyId: "edge", secret: encodeWebuiBrowserBridgeBase64(makeHostSecret(5)) }); + + await clearWebuiBrowserBridgeInstallationKey(installation); + + expect(await loadWebuiBrowserBridgeInstallationKey(installation)).toBeUndefined(); + expect(await loadWebuiBrowserBridgePairingCredential(pairing)).toBeDefined(); + }); +}); + +describe("answering with the installation key", () => { + it("signs with the installation key the host named on the challenge", async () => { + const challenge = challengeFor("nonce-first", "edge-credential-key"); + const installationKey = { secret: makeHostSecret(31) }; + + const proof = await answerWebuiBrowserBridgeInstallationChallenge(challenge, installationKey); + + // The key id is the one the host named, so the proof is bound to this + // host's key and cannot be replayed against another. + expect(proof.keyId).toBe(challenge.installationKeyId); + await expect( + verifyWebuiBrowserBridgeProof(installationKey.secret, proof, challenge.nonce, proof.keyId), + ).resolves.toBe(true); + }); + + it("prefers the installation key over a held pairing credential", async () => { + const challenge = challengeFor("nonce-both", "edge-credential-key"); + const installationKey = { secret: makeHostSecret(41) }; + const credential = { keyId: "edge-credential-key", secret: makeHostSecret(43) }; + + const proof = await answerWebuiBrowserBridgeChallengeWithStoredSecrets( + challenge, + installationKey, + credential, + ); + // The installation key survives a host-side credential rotation; a stale + // pairing credential does not. Preferring it is what lets a re-provisioned + // host re-pair without the person touching anything. + expect(proof.keyId).toBe(challenge.installationKeyId); + }); + + it("falls back to the pairing credential when no installation key is held", async () => { + const challenge = challengeFor("nonce-fallback", "edge-credential-key"); + const credential = { keyId: "edge-credential-key", secret: makeHostSecret(47) }; + + const proof = await answerWebuiBrowserBridgeChallengeWithStoredSecrets( + challenge, + undefined, + credential, + ); + expect(proof.keyId).toBe(credential.keyId); + }); + + it("refuses, with a message that says what to do, when it holds neither", async () => { + await expect( + answerWebuiBrowserBridgeChallengeWithStoredSecrets( + challengeFor("nonce-neither", "edge-credential-key"), + undefined, + undefined, + ), + ).rejects.toThrow(/paste the installation key/); + }); +}); + +describe("secret handling", () => { + it("never writes the secret, salt, payload or nonce to the console", async () => { + const calls: unknown[][] = []; + const original = { log: console.log }; + console.log = (...args: unknown[]) => { + calls.push(args); + }; + try { + const { privateKey, publicJwk } = await mintEcdhPair(); + const hostSecret = makeHostSecret(23); + const cipher = await hostPairFrame(publicJwk, hostSecret, "edge-quiet"); + await openWebuiBrowserBridgePairingCredential(cipher, privateKey); + await answerWebuiBrowserBridgeChallenge( + challengeFor("nonce-abc", "edge-quiet"), + { keyId: "edge-quiet", secret: hostSecret }, + ); + } finally { + console.log = original.log; + } + + expect(calls).toEqual([]); + const logged = JSON.stringify(calls); + expect(logged).not.toContain(encodeWebuiBrowserBridgeBase64(makeHostSecret(23))); + expect(logged).not.toContain("edge-quiet"); + }); +}); + +/* -------------------------------------------------------------------------- */ + +/** + * A host challenge, naming both key ids. + * + * A challenge without an `installationKeyId` is not one this extension will + * accept — see `parseWebuiBrowserBridgeHostFrame` — so every fixture here + * names it rather than omitting it. + */ +function challengeFor(nonce: string, keyId: string): WebuiBrowserBridgeChallenge { + return { + kind: "challenge", + nonce, + algorithm: "HMAC-SHA256", + keyId, + installationKeyId: "webui-browser-bridge-installation-edge-v1", + }; +} + +/** Base64 decode without `Buffer`: this package is type-checked against a browser. */ +function decodeBase64ForTest(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** Base64 encode without `Buffer`. */ +function encodeBase64ForTest(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** Copy into a plain `ArrayBuffer`: Web Crypto's `BufferSource` wants one. */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} \ No newline at end of file diff --git a/packages/browser-extension/test/protocol.test.ts b/packages/browser-extension/test/protocol.test.ts new file mode 100644 index 000000000..0993605da --- /dev/null +++ b/packages/browser-extension/test/protocol.test.ts @@ -0,0 +1,319 @@ +// No browser is involved here: the protocol module is pure, and everything below +// runs on Node's own Web Crypto. + +import { describe, expect, it } from "vitest"; + +import { + isWebuiBrowserBridgeInputCommand, + openWebuiBrowserBridgePairingCredential, + parseWebuiBrowserBridgeHostFrame, + verifyWebuiBrowserBridgeProof, + webuiBrowserBridgeProofMaterial, + webuiBrowserBridgeUrl, + WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO, + WEBUI_BROWSER_BRIDGE_PATH, + WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM, + signWebuiBrowserBridgeProof, + type WebuiBrowserBridgeEcdhPublicKey, + type WebuiBrowserBridgeProof, +} from "../src/protocol.js"; + +/** + * The cross-implementation vector. + * + * The host asserts this exact string in its own suite. A change to the material, + * the algorithm or the encoding on either side therefore breaks exactly one + * suite, which is the only thing keeping two hand-written copies of this + * protocol in step. + */ +const TEST_SECRET = new TextEncoder().encode("webui-browser-bridge-test-secret"); +const TEST_SIGNATURE = "xb7/GT5s+ScVsaiLkh+eRRndKi5oPQwqQMqU3aX8Znc="; + +/* -------------------------------------------------------------------------- */ +/* The pairing-transfer vector */ +/* -------------------------------------------------------------------------- */ + +/** + * A fixed P-256 pair per side, a fixed salt, a fixed IV and a fixed plaintext. + * + * The host suite asserts the same numbers against the same key pair, salt and + * plaintext. Two hand-written implementations of the same construction — one + * running on Node, one in a service worker — agree only because this string + * is the tie-breaker: change the `info`, the curve, the salt length or the + * plaintext encoding on either side and exactly one suite fails. + */ +const VECTOR_HOST_PUBLIC: WebuiBrowserBridgeEcdhPublicKey = { + kty: "EC", + crv: "P-256", + x: "XKwRVe-iSDOdOSBcT7wi5-6UDoXhSi_sJIva2FRwf-A", + y: "JOmNw0lXPLULOJ02rq1ElmxknaPEkcoSGN7aHuVzYZk", +}; + +const VECTOR_EXTENSION_PRIVATE_JWK = { + kty: "EC", + crv: "P-256", + x: "E97C62Slhb6xSK8jBY_vKNp_bXdZdUyq2z16I_ozyCA", + y: "-h7QnrdbdOmS5Wgxe8VSGJWBLpsptMBSCKmLKYcockY", + d: "1xQC1vkpguxZrUo9vhBkgX76gSaP3yiIv7YjgJUa4CA", +} as const; + +const VECTOR_SALT = "AAECAwQFBgcICQoLDA0ODw=="; +const VECTOR_PLAINTEXT = "ABEiM0RVZneImaq7zN3u/w=="; +const VECTOR_PAYLOAD = + "EBESExQVFhcYGRobrpzg8LW2dZC7oVrnYW8vU8zfjg8p/FNAc/YdMgkvUx7+O5ceWIULAw=="; +/** + * The plaintext the payload above carries, in hex. + * + * A published transfer vector, not a credential: the bytes are + * 0x00, 0x11, 0x22, … so that they are obviously synthetic. The ciphertext + * above is pinned to them, so the value cannot be changed — only the name. + */ +const VECTOR_PLAINTEXT_HEX = "00112233445566778899aabbccddeeff"; + +describe("browser bridge pairing transfer vector", () => { + it("pins the agreed HKDF info and the byte layout", () => { + expect(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO).toBe("webui-browser-bridge-pair-v1"); + }); + + it("opens the exact payload the host produced", async () => { + const privateKey = await crypto.subtle.importKey( + "jwk", + { ...VECTOR_EXTENSION_PRIVATE_JWK, key_ops: ["deriveBits"] }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + + const secret = await openWebuiBrowserBridgePairingCredential( + { publicKey: VECTOR_HOST_PUBLIC, salt: VECTOR_SALT, payload: VECTOR_PAYLOAD }, + privateKey, + ); + + expect(hexOf(secret)).toBe(VECTOR_PLAINTEXT_HEX); + // The plaintext on the wire is `base64(secret)`, not the raw bytes. + expect(encodeBase64(secret)).toBe(VECTOR_PLAINTEXT); + }); + + it("refuses a payload one bit away from the vector", async () => { + const privateKey = await crypto.subtle.importKey( + "jwk", + { ...VECTOR_EXTENSION_PRIVATE_JWK, key_ops: ["deriveBits"] }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + const bytes = decodeBase64(VECTOR_PAYLOAD); + bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0x01; + + await expect( + openWebuiBrowserBridgePairingCredential( + { publicKey: VECTOR_HOST_PUBLIC, salt: VECTOR_SALT, payload: encodeBase64(bytes) }, + privateKey, + ), + ).rejects.toBeDefined(); + }); + + it("refuses a salt that is not the one the host chose", async () => { + const privateKey = await crypto.subtle.importKey( + "jwk", + { ...VECTOR_EXTENSION_PRIVATE_JWK, key_ops: ["deriveBits"] }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + + await expect( + openWebuiBrowserBridgePairingCredential( + { publicKey: VECTOR_HOST_PUBLIC, salt: "AAECAwQFBgcICQoLDA0ODxA", payload: VECTOR_PAYLOAD }, + privateKey, + ), + ).rejects.toBeDefined(); + }); +}); + +describe("browser bridge protocol", () => { + it("signs the shared test vector", async () => { + await expect(signWebuiBrowserBridgeProof(TEST_SECRET, "nonce-abc", "key-1")).resolves.toBe( + TEST_SIGNATURE, + ); + }); + + it("signs the nonce and key id joined by exactly one space", () => { + expect(webuiBrowserBridgeProofMaterial("nonce-abc", "key-1")).toBe("nonce-abc key-1"); + expect(WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM).toBe("HMAC-SHA256"); + expect(WEBUI_BROWSER_BRIDGE_PATH).toBe("/browser-bridge"); + }); + + it("accepts a proof that answers the exact challenge", async () => { + const proof: WebuiBrowserBridgeProof = { + kind: "proof", + keyId: "key-1", + nonce: "nonce-abc", + signature: TEST_SIGNATURE, + }; + await expect(verifyWebuiBrowserBridgeProof(TEST_SECRET, proof, "nonce-abc", "key-1")).resolves.toBe( + true, + ); + }); + + it("rejects a proof whose nonce is not the challenge it answers", async () => { + const proof: WebuiBrowserBridgeProof = { + kind: "proof", + keyId: "key-1", + nonce: "nonce-xyz", + signature: TEST_SIGNATURE, + }; + await expect(verifyWebuiBrowserBridgeProof(TEST_SECRET, proof, "nonce-abc", "key-1")).resolves.toBe( + false, + ); + }); + + it("rejects a proof for a different key id", async () => { + const proof: WebuiBrowserBridgeProof = { + kind: "proof", + keyId: "key-2", + nonce: "nonce-abc", + signature: TEST_SIGNATURE, + }; + await expect(verifyWebuiBrowserBridgeProof(TEST_SECRET, proof, "nonce-abc", "key-1")).resolves.toBe( + false, + ); + }); + + it("rejects a tampered signature and one of the wrong length", async () => { + const tampered: WebuiBrowserBridgeProof = { + kind: "proof", + keyId: "key-1", + nonce: "nonce-abc", + signature: `xb7/GT5s+ScVsaiLkh+eRRndKi5oPQwqQMqU3aX8Znd=`, + }; + await expect(verifyWebuiBrowserBridgeProof(TEST_SECRET, tampered, "nonce-abc", "key-1")).resolves.toBe( + false, + ); + const short: WebuiBrowserBridgeProof = { ...tampered, signature: "xb7" }; + await expect(verifyWebuiBrowserBridgeProof(TEST_SECRET, short, "nonce-abc", "key-1")).resolves.toBe(false); + }); + + it("refuses to build a bridge URL off loopback", () => { + expect(webuiBrowserBridgeUrl("http://127.0.0.1:7777")).toBe("http://127.0.0.1:7777/browser-bridge"); + expect(webuiBrowserBridgeUrl("http://localhost:4319")).toBe("http://localhost:4319/browser-bridge"); + expect(() => webuiBrowserBridgeUrl("https://127.0.0.1:7777")).toThrow(/protocol/); + expect(() => webuiBrowserBridgeUrl("http://example.com")).toThrow(/loopback/); + }); +}); + +describe("host frame parsing", () => { + it("parses each declared host frame", () => { + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "ready", sessionId: "s-1" }))).toEqual({ + ok: true, + frame: { kind: "ready", sessionId: "s-1" }, + }); + expect( + parseWebuiBrowserBridgeHostFrame( + JSON.stringify({ + kind: "challenge", + nonce: "n-1", + algorithm: "HMAC-SHA256", + keyId: "key-1", + installationKeyId: "webui-browser-bridge-installation-edge-v1", + }), + ), + ).toMatchObject({ ok: true }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "ping", nonce: "p-1" }))).toMatchObject({ + ok: true, + }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "closed", reason: "revoked" }))).toMatchObject({ + ok: true, + }); + }); + + it("reports an unusable frame instead of guessing", () => { + expect(parseWebuiBrowserBridgeHostFrame("not json")).toEqual({ + ok: false, + reason: "frame is not valid JSON", + }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "surprise" }))).toEqual({ + ok: false, + reason: "unknown frame kind surprise", + }); + expect( + parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "challenge", nonce: "n", keyId: "k", algorithm: "none" })), + ).toMatchObject({ ok: false }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "challenge", keyId: "k" }))).toMatchObject({ + ok: false, + }); + expect( + parseWebuiBrowserBridgeHostFrame(JSON.stringify({ kind: "input", commandId: "c-1" })), + ).toMatchObject({ ok: false, reason: "input carries an invalid command" }); + }); + + it("requires an input command to name a target and a generation", () => { + expect( + isWebuiBrowserBridgeInputCommand({ kind: "pointer", targetId: "tab-7", generation: 2, x: 1, y: 2, button: "left", clickCount: 1 }), + ).toBe(true); + expect( + isWebuiBrowserBridgeInputCommand({ kind: "text", targetId: "tab-7", generation: 2, text: "hi" }), + ).toBe(true); + expect(isWebuiBrowserBridgeInputCommand({ kind: "text", targetId: "tab-7", text: "hi" })).toBe(false); + expect(isWebuiBrowserBridgeInputCommand({ kind: "text", targetId: "", generation: 2, text: "hi" })).toBe(false); + expect(isWebuiBrowserBridgeInputCommand({ kind: "scroll", targetId: "tab-7", generation: 2 })).toBe(false); + }); + + it("parses a pair frame and rejects one missing a field it cannot open", () => { + const frame = { + kind: "pair", + keyId: "edge-1", + publicKey: VECTOR_HOST_PUBLIC, + salt: VECTOR_SALT, + payload: VECTOR_PAYLOAD, + }; + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify(frame))).toMatchObject({ ok: true }); + + // A `pair` without the host's public key cannot be decrypted, and saying + // so at parse time is what stops the handshake hanging on it. + expect( + parseWebuiBrowserBridgeHostFrame(JSON.stringify({ ...frame, publicKey: undefined })), + ).toMatchObject({ ok: false, reason: "pair public key is missing or not a P-256 JWK" }); + expect( + parseWebuiBrowserBridgeHostFrame( + JSON.stringify({ ...frame, publicKey: { kty: "RSA", crv: "P-256", x: "a", y: "b" } }), + ), + ).toMatchObject({ ok: false }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ ...frame, salt: "" }))).toMatchObject({ + ok: false, + reason: "pair has no salt", + }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ ...frame, payload: "" }))).toMatchObject({ + ok: false, + reason: "pair has no payload", + }); + expect(parseWebuiBrowserBridgeHostFrame(JSON.stringify({ ...frame, keyId: "" }))).toMatchObject({ + ok: false, + reason: "pair has no key id", + }); + }); +}); + +/* -------------------------------------------------------------------------- */ + +/** Base64 without `Buffer` — this package is type-checked against a browser. */ +function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** Base64 decode without `Buffer`. */ +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** Hex, so a byte comparison in a failure message is readable. */ +function hexOf(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) out += byte.toString(16).padStart(2, "0"); + return out; +} \ No newline at end of file diff --git a/packages/browser-extension/tsconfig.json b/packages/browser-extension/tsconfig.json new file mode 100644 index 000000000..de4d502da --- /dev/null +++ b/packages/browser-extension/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + // The DOM libraries are the point: this package is type-checked against the + // browser it runs in, so an accidental reach for a Node global is an error + // here rather than a failure inside a service worker. + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} \ No newline at end of file diff --git a/packages/config/src/browser-config.ts b/packages/config/src/browser-config.ts index 616517046..949159345 100644 --- a/packages/config/src/browser-config.ts +++ b/packages/config/src/browser-config.ts @@ -2,17 +2,66 @@ export interface BrowserConfig { /** Optional Chrome executable path. Omit it to use platform discovery. */ chromePath?: string; + /** + * Whether the isolated Chromium this process launches and owns may be used. + * + * Off unless the configuration says otherwise: a browser that starts on its + * own is a browser the person did not ask for. + */ + isolatedChromium?: boolean; + /** Whether the person's Microsoft Edge may be driven through the extension bridge. */ + edge?: boolean; + /** Whether the person's Google Chrome may be driven through the extension bridge. */ + chrome?: boolean; + /** + * Whether a caller may send every DevTools command the active provider + * exposes, instead of only the restricted command set. + * + * This widens an already-authorized surface; it never authorizes one. It is + * deliberately a separate switch so that granting it is an explicit act + * rather than a side effect of allowing a browser. + */ + fullCdp?: boolean; } -export const BROWSER_CONFIG_DEFAULTS: BrowserConfig = {}; +/** + * The browser configuration a fresh install has: no browser, no Full CDP. + * + * Every authorization is absent rather than merely false, so a caller reading + * the configuration cannot mistake "not configured" for "configured off" and + * quietly widen a gate. + */ +export const BROWSER_CONFIG_DEFAULTS: BrowserConfig = { + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, +}; export function parseBrowserConfig(raw: unknown): BrowserConfig { if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { return { ...BROWSER_CONFIG_DEFAULTS }; } const chromePath = Reflect.get(raw, 'chromePath'); - if (typeof chromePath !== 'string' || !chromePath.trim()) { - return { ...BROWSER_CONFIG_DEFAULTS }; - } - return { chromePath: chromePath.trim() }; + const parsedChromePath = + typeof chromePath === 'string' && chromePath.trim() ? chromePath.trim() : undefined; + return { + ...BROWSER_CONFIG_DEFAULTS, + ...(parsedChromePath ? { chromePath: parsedChromePath } : {}), + isolatedChromium: parseBooleanFlag(raw, 'isolatedChromium'), + edge: parseBooleanFlag(raw, 'edge'), + chrome: parseBooleanFlag(raw, 'chrome'), + fullCdp: parseBooleanFlag(raw, 'fullCdp'), + }; +} + +/** + * A switch is on only when the configuration says `true` in so many words. + * + * Anything else — a string, a number, a missing key — leaves it off. A switch + * that turned itself on because a config file held `"true"` would be a switch + * that could be turned on by accident. + */ +function parseBooleanFlag(raw: object, key: string): boolean { + return Reflect.get(raw, key) === true; } diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index f249995a5..83b53f7ae 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -279,6 +279,36 @@ export interface LocalRuntimeApplication { enabled?: boolean; proactive?: boolean; }): Promise<{ enabled: boolean; proactive: boolean }>; + /** + * The four browser authorization switches, read and written as their own + * narrow surface. + * + * They are four booleans rather than one because they are four different + * grants: launching an isolated browser, reaching the person's Edge, + * reaching the person's Chrome, and letting a caller send every DevTools + * command instead of the restricted set. A single switch would make it + * impossible to allow one without allowing the rest. + * + * Optional on the same terms as the memory pair, and read with the same + * rule: an absent method means "this host cannot answer", never "off". + */ + getBrowserAuthorization?(): Promise<{ + isolatedChromium: boolean; + edge: boolean; + chrome: boolean; + fullCdp: boolean; + }>; + setBrowserAuthorization?(input: { + isolatedChromium?: boolean; + edge?: boolean; + chrome?: boolean; + fullCdp?: boolean; + }): Promise<{ + isolatedChromium: boolean; + edge: boolean; + chrome: boolean; + fullCdp: boolean; + }>; }; readonly models?: { list(input?: { sessionId?: string }): Promise; diff --git a/packages/local-runtime-v2/src/compat/v1/runtime.ts b/packages/local-runtime-v2/src/compat/v1/runtime.ts index 59c417bc5..757313325 100644 --- a/packages/local-runtime-v2/src/compat/v1/runtime.ts +++ b/packages/local-runtime-v2/src/compat/v1/runtime.ts @@ -774,6 +774,41 @@ function createPeripheralApplication( proactive: memory?.proactive === true, }; }, + /** + * The four browser authorization switches. + * + * Read as "on only when the configuration says `true`", so a missing + * `browser` tree is the documented default — nothing is authorized — rather + * than an error. Writing goes through `put` on the `browser` root for the + * same reason the memory pair does: the shallow-assign path would drop the + * sibling keys. + */ + getBrowserAuthorization: async () => { + const browser = host.apiHost.configGetter().browser; + return { + isolatedChromium: browser?.isolatedChromium === true, + edge: browser?.edge === true, + chrome: browser?.chrome === true, + fullCdp: browser?.fullCdp === true, + }; + }, + setBrowserAuthorization: async (input) => { + const put: Record = {}; + if (input.isolatedChromium !== undefined) put.isolatedChromium = input.isolatedChromium; + if (input.edge !== undefined) put.edge = input.edge; + if (input.chrome !== undefined) put.chrome = input.chrome; + if (input.fullCdp !== undefined) put.fullCdp = input.fullCdp; + if (Object.keys(put).length > 0) { + await updateLocalConfigFile({ field: "browser", put }); + } + const browser = host.apiHost.configGetter().browser; + return { + isolatedChromium: browser?.isolatedChromium === true, + edge: browser?.edge === true, + chrome: browser?.chrome === true, + fullCdp: browser?.fullCdp === true, + }; + }, }, backgroundTasks: { list: async ({ diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index 9815b2f3c..1ac22ecc9 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -854,6 +854,41 @@ export class CliService { return configuration.setMemorySettings(input); } + /** + * The browser authorization switches, optional on the capability for the same + * reason the memory pair is: a host that predates the surface has to fail + * closed with one clear message rather than crash on an absent method. + */ + getBrowserAuthorization(): Promise<{ + isolatedChromium: boolean; + edge: boolean; + chrome: boolean; + fullCdp: boolean; + }> { + const configuration = this.requireCapability("configuration", "Configuration"); + if (!configuration.getBrowserAuthorization) + throw new Error("Runtime host does not expose browser authorization"); + return configuration.getBrowserAuthorization(); + } + + setBrowserAuthorization( + input: Parameters< + NonNullable< + NonNullable["setBrowserAuthorization"] + > + >[0], + ): Promise<{ + isolatedChromium: boolean; + edge: boolean; + chrome: boolean; + fullCdp: boolean; + }> { + const configuration = this.requireCapability("configuration", "Configuration"); + if (!configuration.setBrowserAuthorization) + throw new Error("Runtime host does not expose browser authorization"); + return configuration.setBrowserAuthorization(input); + } + listModels( input?: Parameters< NonNullable["list"] diff --git a/packages/local-runtime/src/config/update.ts b/packages/local-runtime/src/config/update.ts index b91c28859..949271c2a 100644 --- a/packages/local-runtime/src/config/update.ts +++ b/packages/local-runtime/src/config/update.ts @@ -27,6 +27,7 @@ const LOCAL_CONFIG_MUTABLE_FIELDS = new Set([ 'review', 'worktreeRefreshBeforeCreate', 'agents', + 'browser', ]); const LOCAL_CONFIG_DOTTED_MUTABLE_ROOTS = new Set([ @@ -38,6 +39,7 @@ const LOCAL_CONFIG_DOTTED_MUTABLE_ROOTS = new Set([ 'thinking', 'memory', 'agents', + 'browser', ]); const LOCAL_PERMISSION_MODES = new Set([ @@ -412,6 +414,9 @@ function validateLocalConfigValue(field: string, value: unknown): unknown { if (typeof value === 'boolean') return value; throw new LocalConfigValidationError('Invalid worktreeRefreshBeforeCreate'); } + if (field === 'browser' || field.startsWith('browser.')) { + return validateBrowserConfigValue(field, value); + } if (field === 'review.mode') { return validateReviewMode(value); } @@ -440,6 +445,51 @@ function validateReviewConfigRecord(value: Record): Record>E: generate ephemeral ECDH P-256 key pair + E->>H: hello { browser, browserId, extensionVersion, publicKey } + H->>E: challenge { nonce, algorithm, keyId, installationKeyId } + E->>E: HMAC-SHA256 over nonce + " " + installationKeyId + E->>H: proof { keyId: installationKeyId, nonce, signature } + H->>H: verify against the installation key + H->>H: seal the credential to publicKey (HKDF-SHA256 + AES-256-GCM) + H->>E: pair { keyId, publicKey, salt, payload } + E->>E: open the payload, store { keyId, secret } + H->>E: ready { sessionId } +``` + +The challenge names **two** key ids: `installationKeyId`, which a peer proves to +be entitled to a credential, and `keyId`, the current pairing credential an +already-paired peer answers with. The key id in the proof selects which branch +the host takes, so a proof made for one anchor cannot be replayed as the other — +the key id is inside the signed material. + +A peer that already holds the pairing credential answers with `keyId` and is +admitted with **no `pair` frame**. Re-sealing a credential it already holds +would put the credential on the wire again for no gain, and would blur the one +property worth being able to check by reading one line: a `pair` frame is sent +only after an installation-key proof. + +The person has to paste the installation key once per browser — see +[`04-pairing-credentials.md`](04-pairing-credentials.md). Provisioning is explicit +because an unprovable peer identity cannot be automated away: the ADR's +"automatic pairing" is about not asking about **tabs**, and it does not survive a +peer that cannot be told apart from a local process. + +### The cipher — `implemented` + +| Step | Value | Pinned as | +| --- | --- | --- | +| Agreement | ECDH, `P-256`, ephemeral on both sides | `generateWebuiBrowserBridgeEcdhKeyPair` | +| Key derivation | HKDF-SHA256 → 256-bit AES key | `WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO` | +| HKDF `info` | `"webui-browser-bridge-pair-v1"`, ASCII, identical on both sides | `WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO` | +| HKDF salt | 16 random bytes from the host, sent in the clear | `WEBUI_BROWSER_BRIDGE_PAIR_HKDF_SALT_BYTES` | +| Encryption | AES-256-GCM, 12 random bytes of IV | `WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES` | +| Plaintext | `base64(secretBytes)` — one self-contained string | — | +| Payload | `base64( iv ‖ ciphertext )`; the ciphertext already carries its 16-byte GCM tag | — | + +The only bytes that cross the wire are the two public keys, the salt and the +encrypted payload. The `pair` frame carries the credential's **key id** in the +clear, which is not secret and which the challenge needs. + +A mismatch in the `info`, the curve, the salt width or the plaintext encoding is +a protocol break, not something to fall back from — the shared vector below is +what catches it. + +### Pinned cross-implementation vector for the transfer + +Both packages assert this exact payload. The host computes it in +`packages/webui/test/unit/browser-bridge-pairing.test.ts`; the extension opens +it in `packages/browser-extension/test/protocol.test.ts`. A change to the +construction on either side breaks exactly one suite. + +| Input | Value | +| --- | --- | +| Host private key, P-256 JWK | `x` `XKwRVe-iSDOdOSBcT7wi5-6UDoXhSi_sJIva2FRwf-A`, `y` `JOmNw0lXPLULOJ02rq1ElmxknaPEkcoSGN7aHuVzYZk`, `d` `JGSt5D3d1wIlUCrVHgcNvnsFK8uMWG_YUCcHFsySgd0` | +| Extension private key, P-256 JWK | `x` `E97C62Slhb6xSK8jBY_vKNp_bXdZdUyq2z16I_ozyCA`, `y` `-h7QnrdbdOmS5Wgxe8VSGJWBLpsptMBSCKmLKYcockY`, `d` `1xQC1vkpguxZrUo9vhBkgX76gSaP3yiIv7YjgJUa4CA` | +| Salt, base64 | `AAECAwQFBgcICQoLDA0ODw==` (bytes `00 01 … 0f`) | +| IV, base64 | `EBESExQVFhcYGRob` (bytes `10 11 … 1b`) | +| Plaintext (`base64(secret)`) | `ABEiM0RVZneImaq7zN3u/w==` | +| Secret, hex | `00112233445566778899aabbccddeeff` | +| **Expected payload, base64** | `EBESExQVFhcYGRobrpzg8LW2dZC7oVrnYW8vU8zfjg8p/FNAc/YdMgkvUx7+O5ceWIULAw==` | + +These are published test constants, not credentials. + +### Backward compatibility is explicit + +| Case | Behaviour | +| --- | --- | +| A `challenge` with no `installationKeyId` | The extension refuses the frame and closes the socket. Without it the extension cannot tell which key the host will accept a proof with, and answering with the pairing credential on a first connection would fail against a host that has none. | +| `hello` with no `publicKey`, but a valid installation-key proof | Refused with `no_peer_public_key`. There is nothing to seal the credential to, and admitting an incomplete handshake would be a lie. | +| A `publicKey` that is not a P-256 JWK | Refused by a structural check before any sealing, so no `pair`. | +| The surface's switch is off | The host holds no credential and issues no challenge; the existing admission already refuses the connection. | +| The host sends no `pair` (the peer proved only a pairing credential) | Nothing to do — the peer already holds that credential. | + +## Challenge and proof + +The algorithm is pinned as `WEBUI_BROWSER_BRIDGE_SIGNATURE_ALGORITHM` = `"HMAC-SHA256"`. + +`verifyWebuiBrowserBridgeProof` refuses in three ways before it compares: a `keyId` that is not the +expected one, a `nonce` that is not the outstanding one, and a signature whose length differs from the +expected signature. A proof that does not even have the right shape is a wrong proof, not an +exception. The comparison itself is constant-time. + +## HMAC material + +The signed material is produced by one shared function, `webuiBrowserBridgeProofMaterial`: + +```text +material = nonce + " " + keyId +signature = base64(HMAC-SHA256(secret, utf8(material))) +``` + +The key id is inside the signed material so a proof captured for one credential cannot be presented +against another. + +### Pinned cross-implementation test vector + +Both sides must compute the same signature. This vector is the check; if either implementation drifts, +it fails here. + +| Input | Value | +| --- | --- | +| Secret, UTF-8 | `webui-browser-bridge-test-secret` | +| Nonce | `nonce-abc` | +| Key id | `key-1` | +| Signed material | `nonce-abc key-1` | +| Expected signature, base64 | `xb7/GT5s+ScVsaiLkh+eRRndKi5oPQwqQMqU3aX8Znc=` | + +This is a published test constant, not a credential. It was recomputed independently during this +validation from the secret bytes and the material above, and matches the expected signature. + +### The portability caveat is resolved — this note is historical + +This document originally carried a caveat: `signWebuiBrowserBridgeProof` encoded with +`Buffer.from(signature).toString("base64")` and `verifyWebuiBrowserBridgeProof` compared through a +dynamic `import("node:crypto")`, both of which are Node-only, while the extension runs in a browser. +**That is no longer the case.** The current implementation is browser-safe on both sides: + +| Concern | Host contract | Extension | +| --- | --- | --- | +| HMAC | `crypto.subtle.importKey` + `crypto.subtle.sign` | `crypto.subtle` too (`packages/browser-extension/src/protocol.ts`) | +| Base64 | `encodeBase64` via `btoa` (`contracts/browser-bridge.ts`) | `encodeWebuiBrowserBridgeBase64` (`packages/browser-extension/src/protocol.ts`) | +| Constant-time compare | `constantTimeEqual`, a hand-rolled XOR-accumulate loop | the same shape | +| ECDH + HKDF + AES-GCM | `sealWebuiBrowserBridgePairingCredential` / `openWebuiBrowserBridgePairingCredential` | `openWebuiBrowserBridgePairingCredential` (`protocol.ts`) | +| `Buffer` / `node:crypto` | not used for any of the above | not used | + +Neither module references `Buffer` or `node:crypto` for signing or for the +transfer. The byte-for-byte parity requirement still stands, and the two test +vectors above — not the encoding call — are what pin the two implementations +together. + +`verifyWebuiBrowserBridgeProof` (`contracts/browser-bridge.ts`) refuses in three ways before it +compares: a `keyId` that is not the expected one, a `nonce` that is not the outstanding one, and an +`algorithm` that is present and is not the one the host issued. An **absent** `algorithm` is treated as +the older extension and accepted; a *different* one is refused. The length comparison then happens +inside `constantTimeEqual`, which returns false on a length mismatch rather than throwing. + +## Reconnect policy — `implemented` and wired + +A reconnect must never change what the user selected. The schedule is implemented in +`packages/webui/src/server/browser-bridge/reconnect.ts` and every value below is transcribed from the +constants there: + +| Rule | Value | Source | +| --- | --- | --- | +| Operation deadline during a disconnect | 15 s | `WEBUI_BRIDGE_OPERATION_RECONNECT_TOTAL_MS`, `reconnect.ts:24` | +| Retry delays | 250 ms, 500 ms, 1 s, 2 s, 4 s | `WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS`, `reconnect.ts:18` | +| Delay cap | 5 s | `WEBUI_BRIDGE_OPERATION_RECONNECT_CAP_MS`, `reconnect.ts:21` | +| Jitter | applied to each retry delay | `applyJitter` | +| Background reconnect backoff cap | 30 s | `WEBUI_BRIDGE_BACKGROUND_RECONNECT_CAP_MS`, `reconnect.ts:27` | +| Background reconnect continues while | the corresponding browser switch is on | by design; **not yet wired** | +| On deadline expiry | an explicit error; **no replay** of an operation whose execution is uncertain | by design | +| Selected target | kept | by design | +| Fallback | never silently switch tab, browser or isolated surface | by design | + +The first attempt costs nothing: `reconnect.ts:110` deliberately does not delay a fresh connection, so a +first try pays no 250 ms tax. + +The distinction between the two backoffs is deliberate. The 250 ms → 4 s ladder is an *in-flight +operation* trying to finish inside its own 15-second deadline. The 30 s cap is a *background* reconnect +that keeps trying for as long as the switch is on. An operation that times out fails; the connection +keeps trying. No replay is attempted because the operation's execution is uncertain — a click that was +dispatched before the disconnect may or may not have taken effect, and re-issuing it could duplicate an +effect the user already sees. + +On deadline expiry the operation reports `browser_operation_timeout` from the pinned error-code set +(`packages/webui/src/shared/contracts/browser.ts`). + +**`measured`, in part.** A real Chrome and a real Edge had the extension's service worker +terminated while an image stream was running: the host saw the session drop, the surface went +down, and the bridge was usable again as soon as the worker woke. The 250 ms → 4 s ladder and the +15-second operation deadline are exercised by the unit suite; no operation was left to time out +against a real browser, so the timeout branch is still a unit-suite result. + +## MV3 service-worker restart + +**`implemented`, and `measured` in a real browser.** A Manifest V3 extension service worker can terminate and restart at +the browser's discretion. Chrome documents that an active WebSocket connection extends the worker's +lifetime from Chrome 116 onward — and the extension's `manifest.json` sets +`"minimum_chrome_version": "116"` for exactly that reason — which reduces how often this happens. It +does not make it impossible. + +The consequence for this protocol: the connection lifecycle must tolerate a worker restart, and a +restart is not distinguishable on the wire from any other disconnect. It is therefore handled by the +same path as any other disconnect: the reconnect ladder for an in-flight operation, the 30 s +background ladder otherwise, and never a target switch. A restarted worker mints a fresh ephemeral +ECDH key, re-sends `hello`, receives a fresh `pair`, stores the credential it recovered in extension +local storage — which survives the worker — and answers the fresh challenge. + +**`measured` in a real Chrome and a real Edge.** The acceptance terminates the extension's service worker +while an image stream is running, by closing its target — which is what the browser does when it +reclaims an idle worker. The host sees the session drop, the surface goes down, the worker is +woken by the next event, and a DevTools command against the live tab answers again afterwards. +See [`09-acceptance-record.md`](09-acceptance-record.md). + +## Related + +| Concern | Document | +| --- | --- | +| Who may connect, and what is refused | [`03-extension-admission.md`](03-extension-admission.md) | +| Where the credential that answers the challenge lives | [`04-pairing-credentials.md`](04-pairing-credentials.md) | +| Target identity and generation over the bridge | [`05-target-identity.md`](05-target-identity.md) | + +## DevTools commands on the bridge + +A raw DevTools command travels as two frames: + +- **host → extension**: `{ kind: "cdp"; commandId; targetId; generation; method; params }` +- **extension → host**: `{ kind: "cdp-result"; commandId; accepted; result?; error?; unsupported? }` + +The order is the same as the credential exchange and for the same reason: the +**proof comes before the thing being asked for**. The host only sends a `cdp` +frame once its own gate has decided the command is allowed — the surface is +authorized, and the command is either a restricted browser-tool action or +permitted by the Full CDP switch. + +The extension decides the rest. `chrome.debugger` exposes only a **subset** of +the DevTools domains, so the extension keeps an explicit list of the commands +it will send (`WEBUI_BROWSER_BRIDGE_CDP_COMMANDS` in +`packages/browser-extension/src/cdp.ts`) and refuses anything outside it — +**before** it makes any browser call. A refusal carries `unsupported: true`, so +the host can tell "the extension will never run this" apart from "this +failed", which are different things to a caller. + +A browser that refuses a command the list allows comes back as a plain +refusal with the browser's own message. Chrome's answer is the authority; the +list is a pre-filter, not a substitute for it. + +A command that names a target no session announced, or one whose generation the +announcement moved past, is refused by the host before it is sent — the same +ownership check and the same per-target serialization the input path uses. diff --git a/packages/webui/docs/browser-use/03-extension-admission.md b/packages/webui/docs/browser-use/03-extension-admission.md new file mode 100644 index 000000000..0d971da18 --- /dev/null +++ b/packages/webui/docs/browser-use/03-extension-admission.md @@ -0,0 +1,197 @@ +# The dedicated extension admission path + +**Status:** This document mixes three kinds of statement, labelled inline: + +| Label | Means | Backed by | +| --- | --- | --- | +| `implemented` | The source enforces this today; the row names the file and line. | The cited file in this repository | +| `required` | A rule the contracts pin with no enforcing code at that point. | `packages/webui/src/shared/contracts/browser-bridge.ts` | +| `not yet measured` | An open question. Not a denial and not a promise. | Nothing | + +**The admission function is wired and exercised.** `admitBrowserBridgeUpgrade` is implemented in +`packages/webui/src/server/browser-bridge/admission.ts` and the server's upgrade dispatcher +(`packages/webui/src/server/websocket-server.ts`) branches on `/browser-bridge` **before** the generic +`admitWebSocketUpgrade`, so a bridge request never reaches the generic path and the generic path is +unchanged. A real Chrome and a real Edge have connected and been refused through it — see +[`07-review-findings.md`](07-review-findings.md). + +## Why a dedicated path + +The WebUI already admits WebSocket upgrades, in `admitWebSocketUpgrade` +(`packages/webui/src/server/access-policy.ts:51`). That function cannot admit an extension: + +- **The origin check rejects it.** `isAllowedOrigin` (`access-policy.ts:119`) parses the origin and + returns `false` unless the protocol is `http:` or `https:` and the hostname is `127.0.0.1` or + `localhost`. A `chrome-extension://` origin fails the protocol test at `access-policy.ts:127`. +- **The credential it expects is the wrong one.** `admitWebSocketUpgrade` reads a `token` query + parameter and checks it against the per-start WebUI credential. That credential is a fresh + `randomUUID()` for every service start (`createWebuiCredential`, + `packages/webui/src/server/credentials.ts:19`). An extension installed once cannot learn a value + that changes on every restart, and ADR 0019 forbids replacing the pairing credential with it. + +So the extension cannot be admitted by the existing path, and the existing path must not be widened to +admit it. The bridge gets its own admission. + +## The generic path, quoted exactly + +For reference, this is the rule the dedicated path must not inherit. From `admitWebSocketUpgrade` +(`packages/webui/src/server/access-policy.ts:51`): + +| Step | Check | Refusal | +| --- | --- | --- | +| 1 | `Host` header is present and its host part is loopback (`127.0.0.1`, `localhost`, `::1`, `[::1]`) | `403 Forbidden Host` | +| 2 | The URL hostname is `127.0.0.1` or `localhost` | `403 Forbidden Host` | +| 3 | If an `Origin` is present, it is a loopback HTTP(S) origin | `403 Forbidden Origin` | +| 4 | The `token` query parameter matches the per-start credential — **unless `dev` is true** | `401 Unauthorized` | + +Refusals are written by `rejectUpgrade` (`access-policy.ts:83`) as a bare HTTP status line followed by +`Connection: close`. + +## The `dev: true` bypass, and why it must not apply here + +Step 4 is skipped entirely when `context.dev` is true. The condition at +`packages/webui/src/server/access-policy.ts:76` reads: + +```ts +const presented = url.searchParams.get("token"); +if (!context.dev && !credentialMatches(context.credential, presented)) { + rejectUpgrade(socket, 401, "Unauthorized"); + return false; +} +``` + +**`dev: true` disables the credential check, not just the token requirement.** That is a real bypass, +and it is the current state of both composition roots: + +| Entry point | Line | `dev` | Bridge allowlist | +| --- | --- | --- | --- | +| `scripts/run-webui-server.mjs` | 56 | `dev: true` | development — `MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS` | +| `release/webui-npm/bin/mcode-webui.mjs` | 48 | `dev: true` | **store** — `MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS` | + +So on a packaged WebUI today, the generic path already accepts any upgrade that passes the loopback +Host, URL-host and origin checks **without presenting the token at all**. ADR 0019 already records that +both entries run with `dev: true`. + +**The two entries differ in one more way, and it is the one that matters here.** The packaged entry +passes `packaged: true` to the bridge, so it reads the **store** allowlist and never the development +one. An unpacked build's id is derived from the directory it was loaded from, so it is not an +identity anyone can rely on; a packaged host that accepted those ids would admit whatever unpacked +extension happened to be present. The development entry is the one that reads the development +allowlist, because that is where the ids of unpacked builds belong. The distinction is measured +against a real browser in [`09-acceptance-record.md`](09-acceptance-record.md): the id a store build +gets is the one its public key determines, a packaged host admits that id, and the same packaged host +refuses it when the id is named only in the development allowlist. + +**`implemented`: the bridge admission path never reads `dev`.** The pairing proof is verified +unconditionally, and the browser switch is checked unconditionally. `admission.ts:135` holds an +explicit no-op `if (context.dev)` branch whose only purpose is to make the absence of a bypass +reviewable: adding `return undefined` inside it would short-circuit every check below it, and the +branch's comment says so. That is a contract marker, not an enforcement mechanism — the enforcement is +that no such early return exists. + +The three requirements stand as written: + +- Extension admission does not consume `context.dev` as authority to skip a check. There is no `dev` + parameter on it and no branch inside it that grants anything. +- Pairing proof is verified unconditionally. There is no mode in which the bridge accepts a socket + without a valid proof. +- Browser authorization is checked unconditionally. A `dev: true` WebUI does not admit an extension + for a browser whose switch is off. + +Turning a development bypass into a way to control somebody's logged-in browser would defeat the +purpose of the whole pairing design. The exemption is scoped to the generic WebUI path and stays there. + +## Admission checks and refusal statuses — what the function does + +The order matters: cheap structural checks first, then identity, then the credential, then +authorization. This table is `implemented` and transcribed from `admission.ts:81` and `pairing.ts`: + +| # | Check | Source | Refusal returned | +| --- | --- | --- | --- | +| 0 | The upgrade URL parses | `admission.ts:89` | `400 Bad Request` | +| 1 | Request path is exactly `/browser-bridge` (`WEBUI_BROWSER_BRIDGE_PATH`) | `admission.ts:95` | `400 Bad Request` | +| 2 | `Host` header host part is loopback (`isLoopbackHost`) | `admission.ts:102` | `403 Forbidden Host` | +| 3 | URL hostname is `127.0.0.1` or `localhost` | `admission.ts:107` | `403 Forbidden Host` | +| 4 | `Origin` is a `chrome-extension:///` URL **and** some pairing's allowlist admits that id | `admission.ts:114`, `admission.ts:122` | `403 Forbidden Origin` | +| 5 | A challenge has been issued and the presented `proof` verifies: `keyId` matches, `nonce` matches, the algorithm is the issued one, and the signature compares equal | `pairing.ts`, via `verifyWebuiBrowserBridgeProof` (`contracts/browser-bridge.ts:218`) | closed with `reason` | +| 6 | The switch for the browser named in `hello` is on, and a current credential exists for it | `pairing.ts:152` | closed with `reason` | + +Two corrections to the shape this document originally specified, both now taken from the source: + +- A **path** mismatch is refused with `400`, not `404`. `admission.ts:92` comments that a wrong path is + a misroute from the generic upgrade dispatcher rather than a missing resource. +- The extension-identity check and the origin check are **one** check with one refusal. + `admission.ts:118` returns `403 Forbidden Origin` both when the origin is not a `chrome-extension://` + URL and when no pairing's allowlist admits that id. The implementation merges them deliberately. + +Checks 5 and 6 are independent of 1–4, and of each other: a correctly signed proof from a browser +whose switch is off is still refused, and a revoked credential fails even from an allowlisted +extension. + +**Wire-level codes.** The HTTP statuses above are on the upgrade. The post-upgrade refusals reported to +the client are the stable error codes in `packages/webui/src/shared/contracts/browser.ts` — +`browser_pairing_rejected` and `browser_surface_not_authorized` are both in that set, along with +`browser_credential_store_unavailable`. The extension-identity refusal has **no** stable code: an +extension refused for its identity is given `403 Forbidden Origin`, which is indistinguishable from a +malformed origin by design. This remains an open decision — either it reuses +`browser_pairing_rejected`, which is defensible since an unallowlisted extension must not learn that it +is unallowlisted rather than unpaired, or a code is added to the pinned list. This document does not +decide it; it records that the decision is outstanding. + +## The extension-identity allowlist + +Each store build's **exact** extension identity is validated at the bridge. The allowlist is by +identity, not by shape: + +| Rule | Detail | +| --- | --- | +| Packaged builds accept only their own store identities | A Chrome Web Store build and an Edge Add-ons build are distinct identities and neither is accepted for the other. | +| Development identities are explicit development configuration | An unpacked extension's identity is accepted only when the host is configured to accept it. | +| **A packaged build never accepts a development identity** | Development identities are not accepted by packaged builds. This is the rule that keeps an unpacked, locally-loaded build from being a way into a released client. | +| An identity outside the list is refused | It is not merely deprioritized or asked to confirm. | + +The allowlist is configuration, not code-level inference: the host must be told which identities it +accepts. Because the two stores are separate submissions generated from one source package, the two +identities differ and both must be registered. + +## The origin check is not broadened + +**Required, and stated separately because it is the tempting shortcut.** `isAllowedOrigin` +(`access-policy.ts:119`) is not modified. The rule that only loopback HTTP(S) origins are admitted +stays exactly as it is. `chrome-extension://` is not added to it. A `chrome-extension://` origin +continues to be rejected by the generic path, and that rejection is what forces the dedicated path to +exist rather than being an inconvenience to work around. + +Broadening the generic check would admit *every* extension origin against *every* WebSocket endpoint on +the loopback interface, which is a much larger grant than admitting one known extension to one known +path. + +## Summary of what is required versus what exists + +| Element | State | +| --- | --- | +| Loopback Host, URL-host and origin checks | `implemented` — `admitWebSocketUpgrade`, `access-policy.ts:51` | +| Per-start token check with a `dev: true` bypass | `implemented` — `access-policy.ts:76` | +| Rejection of `chrome-extension://` by the generic origin check | `implemented` — `access-policy.ts:127` | +| Per-start credential that changes on every start | `implemented` — `createWebuiCredential`, `credentials.ts:19` | +| Dedicated `/browser-bridge` path check | `implemented` — `admission.ts:95` | +| Extension-identity allowlist | `implemented` — `BrowserBridgePairing.admitsExtensionId`, `pairing.ts:142`; configured per pairing | +| Pairing-proof admission | `implemented` — `verifyWebuiBrowserBridgeProof`, `contracts/browser-bridge.ts:218` | +| Browser-switch authorization at admission | `implemented` — `pairing.ts:152` refuses when the switch is off | +| `dev` does not bypass the bridge | `implemented` — `admission.ts:135` carries an explicit no-op `if (context.dev)` branch documenting the bypass as absent | +| **The bridge reaching the upgrade path** | `implemented` — `websocket-server.ts` branches on `/browser-bridge` **before** the generic `admitWebSocketUpgrade` and returns; the generic path is unchanged | +| **A listener owning a bridge session** | `implemented` — `server/browser-bridge/server.ts` owns the bridge's own WebSocket server and registers a session on `ready`, not on socket open | +| **The switch driving the pairing** | `implemented` — the bridge does not switch a surface on by itself; the browser service reads the persisted authorization and calls `enableSurface` / `disableSurface` / `restoreSurface` | +| **A credential that survives a host restart** | `implemented` — `BrowserBridgePairing.restore` re-adopts the stored credential at startup instead of rotating it, so the extension's copy still matches | +| **An extension-identity refusal with a stable wire code** | **open decision** — see the wire-level codes note above | +| The extension connecting end to end | **implemented, and `not yet measured`** — the host and the extension share a credential through the encrypted exchange; it has not been performed against a real Edge or Chrome. See `07-review-findings.md` | +| **The peer is authenticated against a local process** | `implemented` — the `Origin` header still selects the allowlisted extension id, and it is no longer the thing that decides. A peer must additionally prove possession of the **installation key** before the host hands over a pairing credential, and a peer that proves only a credential this host already issued receives no credential at all. A local process can spoof the origin and supply its own ECDH key, but it holds neither secret, so no `pair` frame is sent to it. See [`04-pairing-credentials.md`](04-pairing-credentials.md) | +| **The specific refusal says what is missing** | `implemented` — `BrowserBridgeProofRefusal` distinguishes `switched_off`, `unknown_nonce`, `unknown_key_id`, `installation_key_mismatch`, `pairing_credential_mismatch` and `no_peer_public_key`, and each maps to its own stable `closed`-frame reason text in `describeRefusal` (`session.ts`). A generic "pairing proof failed" would leave the person unable to tell "paste your installation key" from "your host rotated". | + +## Related + +| Concern | Document | +| --- | --- | +| The wire shape admitted here | [`02-bridge-protocol.md`](02-bridge-protocol.md) | +| The credential presented at check 4 | [`04-pairing-credentials.md`](04-pairing-credentials.md) | +| Where each switch is enforced | [`06-runtime-authorization-trace.md`](06-runtime-authorization-trace.md) | \ No newline at end of file diff --git a/packages/webui/docs/browser-use/04-pairing-credentials.md b/packages/webui/docs/browser-use/04-pairing-credentials.md new file mode 100644 index 000000000..34599d7d3 --- /dev/null +++ b/packages/webui/docs/browser-use/04-pairing-credentials.md @@ -0,0 +1,348 @@ +# Pairing credentials + +**Status:** This document mixes three kinds of statement, labelled inline: + +| Label | Means | Backed by | +| --- | --- | --- | +| `implemented` | The source does this today; the row names the file. | `packages/webui/src/server/browser-bridge/credential-store.ts` and the extension's `src/pairing.ts` | +| `measured` | A probe observed this on the machine named in that section. | `.plan/board/validation/credential-store-evidence.json` | +| `not yet measured` | An open question. Not a denial and not a promise. | Nothing | + +The per-OS mechanisms below are `implemented`. Credential-store **availability** is `measured` on one +Linux host and is negative there — and that result is about that host alone. + +A pairing credential is what proves the thing connecting to `/browser-bridge` is the extension the user +paired, and not another browser on the same loopback interface. This document owns who authors that +credential and how it reaches the extension, where it lives, how it rotates, and what happens when the +platform offers nowhere safe to put it. + +## The store, per OS — what the code actually does + +WebUI-side credentials go in the operating system's secure credential store. Extension-side credentials +go in that extension's `chrome.storage.local`. The split is deliberate: the extension runs in the +browser and has no OS store to write to, and the host has no business reading extension storage. + +All of this is `implemented` in +`packages/webui/src/server/browser-bridge/credential-store.ts`, selected by the `switch` in +`createPlatformSecureCredentialStore` (`credential-store.ts:132`): + +| OS | Mechanism | Command actually run | Availability test | +| --- | --- | --- | --- | +| macOS | login Keychain via the `security` CLI | write: `security add-generic-password -s -a -U -w` (value on **stdin**); read: `security find-generic-password -s -a -w` | `security help` exits 0 (`credential-store.ts:329`) | +| Linux | freedesktop Secret Service via `secret-tool` | write: `secret-tool store --label=WebUI Browser Bridge service key ` (value on **stdin**); read: `secret-tool lookup …`; delete: `secret-tool clear …` | `secret-tool --version` exits 0 (`credential-store.ts:402`) | +| Windows | **DPAPI through PowerShell** — a `ConvertTo-SecureString` / `ConvertFrom-SecureString` round trip. The encrypted blob is written under `%LOCALAPPDATA%\WebUI\browser-bridge\\bridge-.blob` | `powershell.exe -NoProfile -NonInteractive -Command ", +].join("\n"); + +/** + * Starts the bridge behind an HTTP server the harness owns. + * + * `packaged: true` makes the host read the **store** allowlist instead of the + * development one, which is what a packaged production entry does. The two + * are different configurations, and the difference is the point: a packaged + * host must not accept a development identity. + * + * `allowlist` says which variable the id is written into. It is separate from + * `packaged` on purpose: the interesting case is a packaged host whose + * development allowlist names the extension — that is the configuration a + * packaged build has to refuse. + */ +export async function startBridge( + extensionId: string, + surface: "edge" | "chrome", + options: { + readonly packaged?: boolean; + readonly allowlist?: "store" | "development" | "both"; + } = {}, +): Promise<{ + bridge: BrowserBridgeServer; + origin: string; + pageUrl: string; + close: () => Promise; +}> { + const allowlist = options.allowlist ?? (options.packaged ? "store" : "development"); + const variable = (kind: "store" | "development", browser: "edge" | "chrome"): string => + kind === "store" + ? `MAVIS_BROWSER_STORE_${browser.toUpperCase()}_EXTENSION_IDS` + : `MAVIS_BROWSER_${browser.toUpperCase()}_EXTENSION_IDS`; + const env: Record = + allowlist === "both" + ? { + [variable("development", "edge")]: extensionId, + [variable("development", "chrome")]: extensionId, + } + : { [variable(allowlist, surface)]: extensionId }; + const bridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + ...(options.packaged ? { packaged: true } : {}), + credentialStore: createInMemorySecureCredentialStore(), + // The allowlist is per surface, and so is the pairing. A browser running + // the extension has to be admitted on **its own** surface, or the Edge + // run would prove nothing about the Edge switch. + env, + }); + await bridge.start(); + await bridge.enableSurface(surface); + // With one identity on both surfaces, both surfaces are switched on: that is + // the configuration the collision case is about. + if (allowlist === "both" && surface !== "chrome") await bridge.enableSurface("chrome"); + + const http = createServer((request, response) => { + // The query string is ignored: the harness serves the same page whatever + // the caller appends. + if (request.url?.startsWith("/test-page")) { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(BRIDGE_TEST_PAGE_HTML); + return; + } + response.writeHead(426).end(); + }); + await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); + const address = http.address(); + const port = typeof address === "object" && address ? address.port : 0; + http.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) => { + bridge.handleUpgrade(request, socket, head); + }); + return { + bridge, + // The extension takes the loopback origin in its http form and converts + // it to ws, the way the WebUI's own workspace-file URL does. It also + // appends the bridge path itself, so the origin must not carry one. + origin: `http://127.0.0.1:${port}`, + // A real http page the extension will report as a target. + pageUrl: `http://127.0.0.1:${port}/test-page`, + close: async () => { + await bridge.close(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +export async function waitForConnected( + bridge: BrowserBridgeServer, + surface: "edge" | "chrome", + connected: boolean, + worker?: Worker, +): Promise { + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (bridge.snapshot().surfaces[surface].connected === connected) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + // Both sides record what they thought happened; without them a failure here + // says only "not connected", which is not a diagnosis. + const snapshot = bridge.snapshot().surfaces[surface]; + const lastError = await worker + ?.evaluate(() => chrome.storage.session.get("webui.browserBridge.lastError")) + .catch(() => undefined); + throw new Error( + `the surface did not reach connected=${connected}: host=${JSON.stringify(snapshot)} extension=${JSON.stringify(lastError)}`, + ); +} diff --git a/packages/webui/test/gates/fixtures/path-metadata-f00.json b/packages/webui/test/gates/fixtures/path-metadata-f00.json index 50589f408..7ea3db4b4 100644 --- a/packages/webui/test/gates/fixtures/path-metadata-f00.json +++ b/packages/webui/test/gates/fixtures/path-metadata-f00.json @@ -390,6 +390,87 @@ "business": "account", "home": "target" }, + { + "path": "client/features/browser/application/browser-commands.ts", + "layers": [ + "application" + ], + "role": "feature-application", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/bindings/use-browser-run.public.ts", + "layers": [ + "bindings" + ], + "role": "feature-bindings", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/contracts/browser-panel.ts", + "layers": [ + "contracts" + ], + "role": "feature-contracts", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/contracts/browser-port.ts", + "layers": [ + "contracts" + ], + "role": "feature-contracts", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/projection/browser-state.ts", + "layers": [ + "view" + ], + "role": "feature-projection", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/ui/BrowserAuthorizationSwitches.public.tsx", + "layers": [ + "bindings" + ], + "role": "feature-ui", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/ui/BrowserPanel.public.tsx", + "layers": [ + "bindings" + ], + "role": "feature-ui", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/ui/BrowserPanelHost.public.tsx", + "layers": [ + "bindings" + ], + "role": "feature-ui", + "business": "browser", + "home": "target" + }, + { + "path": "client/features/browser/ui/browser.css", + "layers": [ + "bindings" + ], + "role": "feature-ui", + "business": "browser", + "home": "target" + }, { "path": "client/features/composer/application/command-workflows.ts", "layers": [ @@ -3315,6 +3396,78 @@ "business": null, "home": "outside" }, + { + "path": "runtime/browser/authorization.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/browser-service.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/errors.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/input.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/isolated-chromium/cdp-connection.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/isolated-chromium/provider.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/target-registry.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "runtime/browser/tool-adapter.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "runtime/check-in.ts", "layers": [ @@ -3360,6 +3513,15 @@ "business": null, "home": "outside" }, + { + "path": "runtime/harness/browser.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "runtime/harness/canvas-input.ts", "layers": [ @@ -3675,6 +3837,78 @@ "business": null, "home": "outside" }, + { + "path": "server/browser-bridge/admission.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/credential-store.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/index.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/pairing.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/reconnect.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/registry.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/server.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/browser-bridge/session.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/credentials.ts", "layers": [ @@ -3801,6 +4035,15 @@ "business": null, "home": "outside" }, + { + "path": "server/operation/browser.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/operation/common.ts", "layers": [ @@ -3837,6 +4080,15 @@ "business": null, "home": "outside" }, + { + "path": "server/operation/handlers/browser.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/operation/handlers/commands.ts", "layers": [ @@ -4107,6 +4359,24 @@ "business": null, "home": "outside" }, + { + "path": "shared/contracts/browser-bridge.ts", + "layers": [ + "shared" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "shared/contracts/browser.ts", + "layers": [ + "shared" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "shared/contracts/canvas.ts", "layers": [ diff --git a/packages/webui/test/integration/browser-use-real-input.test.ts b/packages/webui/test/integration/browser-use-real-input.test.ts new file mode 100644 index 000000000..f81fc7897 --- /dev/null +++ b/packages/webui/test/integration/browser-use-real-input.test.ts @@ -0,0 +1,263 @@ +// The browser input path, against a real browser and a real page. +// +// The unit suite proves the *decisions*: that a malformed command is refused, +// that a stale generation is refused, that a disabled surface is refused. It +// uses a fake provider, so it cannot prove that the decision reaches the page. +// +// This suite closes that gap. It launches the real isolated Chromium, drives a +// real page, and checks what the page actually received: +// +// * text input reaches the page the provider is showing; +// * after a navigation replaces the page, an input recorded against the +// previous page is refused and the new page does not receive it. +// +// The browser is optional: a machine without Chrome skips the suite rather +// than failing it, because "the browser is missing" is not a defect in the +// input path. The repository's `test:webui-browser` gate is the one that +// requires a browser. + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { WebuiIsolatedChromiumProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; +import type { WebuiBrowserTargetId } from "../../src/shared/contracts/browser.js"; +import { beginAcceptanceSuite, observeAcceptance } from "../browser/acceptance-record.js"; + +/** The record file this suite writes. */ +const SUITE = "isolated-chromium"; + +/** + * A page that records what it received. + * + * The value is written into the title, so the provider's own target listing + * can observe it: the test reads the page's state through the same public + * surface the panel uses, rather than through a private back channel. + */ +const PAGE_HTML = [ + "idle", + "", + "", + "", +].join("\n"); + +async function chromeIsAvailable(): Promise { + return (await resolveChromePath()) !== undefined; +} + +/** The Chrome the isolated provider drives, and its own version. */ +async function resolveChromePath(): Promise { + const { access } = await import("node:fs/promises"); + for (const candidate of [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ]) { + try { + await access(candidate); + return candidate; + } catch { + // Keep looking. + } + } + return undefined; +} + +/** + * The browser's own version. + * + * Read from the binary rather than assumed: an acceptance record that says + * "Chrome" without saying which Chrome is not a record. + */ +async function chromeVersion(path: string): Promise { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const run = promisify(execFile); + try { + const { stdout } = await run(path, ["--version"], { timeout: 20_000 }); + return stdout.trim(); + } catch { + return "unknown"; + } +} + +const available = await chromeIsAvailable(); + +describe.skipIf(!available)("the browser input path, against a real page", () => { + let dataDir: string; + let provider: WebuiIsolatedChromiumProvider; + let browserVersion = "unknown"; + + beforeAll(async () => { + // The record is emptied here, at the start of the run: a run that failed + // during setup would otherwise leave the previous run's cases in place. + beginAcceptanceSuite(SUITE); + dataDir = await mkdtemp(join(tmpdir(), "webui-browser-use-e2e-")); + provider = await WebuiIsolatedChromiumProvider.launch({ dataDir }); + const path = await resolveChromePath(); + if (path) browserVersion = await chromeVersion(path); + }, 60_000); + + afterAll(async () => { + await provider?.close(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + }); + + /** The field's centre, in page coordinates. */ + async function fieldCentre(): Promise<{ x: number; y: number }> { + const result = (await provider.sendCommand(await pageTarget(), "Runtime.evaluate", { + expression: + "(() => { const r = document.getElementById('field').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()", + returnByValue: true, + })) as { result?: { value?: unknown } } | undefined; + const value = result?.result?.value as { x?: number; y?: number } | undefined; + return { x: value?.x ?? 0, y: value?.y ?? 0 }; + } + + /** + * The identity of the page the provider is driving. + * + * It is the provider's own selected target, read back from the provider, so + * the command is aimed at the page the test is actually looking at. + */ + async function pageTarget(): Promise { + const selected = (await provider.listTargets()).find((entry) => entry.selected); + if (!selected) throw new Error("the provider reported no selected target"); + return selected.id; + } + + async function readFieldValue(): Promise { + const result = (await provider.sendCommand(await pageTarget(), "Runtime.evaluate", { + expression: "document.getElementById('field').value", + returnByValue: true, + })) as { result?: { value?: unknown } } | undefined; + return typeof result?.result?.value === "string" ? result.result.value : ""; + } + + async function navigate(url: string): Promise { + await provider.sendCommand(await pageTarget(), "Page.navigate", { url }); + // The renderer is swapped during a navigation; a command issued into that + // window fails with "Not attached to an active page", so the test waits + // for the page to settle before it asserts anything about it. + await new Promise((resolve) => setTimeout(resolve, 750)); + } + + it("delivers text input to the page the provider is showing", async () => { + await observeAcceptance( + SUITE, + { + surface: "isolated-chromium", + browser: "chrome", + browserVersion, + entry: "development", + case: "the isolated Chromium delivers text input to the page it is showing", + settings: { + // The isolated surface is driven directly by the provider, so there + // is no allowlist and no extension: the runtime's own switch is the + // only authorization, and it is on for this case. + surface: "isolated-chromium", + isolatedChromium: true, + edge: "not carried by the isolated surface", + chrome: "not carried by the isolated surface", + fullCdp: "not exercised by this case", + allowlist: "not applicable", + build: "not applicable", + }, + observed: "a pointer click and a text input both arrived in the page the provider was driving", + }, + async () => { + await navigate(`data:text/html,${encodeURIComponent(PAGE_HTML)}`); + // The provider marks the page it drives as the selected one. Picking + // "the first page" would pick a different tab when the browser has more + // than one, and the input would then be addressed to a page the provider + // is not driving. + const targets = await provider.listTargets(); + const target = targets.find((entry) => entry.selected); + expect(target).toBeDefined(); + expect(await readFieldValue()).toBe(""); + + const identity = { + surface: "isolated-chromium" as const, + browserId: provider.browserId, + targetId: target!.id.targetId, + generation: target!.id.generation, + }; + + // A real pointer click first: text input goes to whatever holds the + // focus, so the panel's user has to be able to aim at the field before + // typing into it. This is the same path the panel uses. + const centre = await fieldCentre(); + await provider.sendInput({ + kind: "pointer", + target: identity, + x: centre.x, + y: centre.y, + button: "left", + clickCount: 1, + }); + + await provider.sendInput({ kind: "text", target: identity, text: "hello" }); + + expect(await readFieldValue()).toBe("hello"); + }, + ); + }); + + it("refuses an input recorded against the page a navigation replaced", async () => { + await observeAcceptance( + SUITE, + { + surface: "isolated-chromium", + browser: "chrome", + browserVersion, + entry: "development", + case: "the isolated Chromium refuses an input recorded against the page a navigation replaced", + settings: { + // The isolated surface is driven directly by the provider, so there + // is no allowlist and no extension: the runtime's own switch is the + // only authorization, and it is on for this case. + surface: "isolated-chromium", + isolatedChromium: true, + edge: "not carried by the isolated surface", + chrome: "not carried by the isolated surface", + fullCdp: "not exercised by this case", + allowlist: "not applicable", + build: "not applicable", + }, + observed: + "the stale identity was refused with browser_target_stale and the page that replaced it received nothing", + }, + async () => { + await navigate(`data:text/html,${encodeURIComponent(PAGE_HTML)}`); + const before = (await provider.listTargets()).find((entry) => entry.selected); + expect(before).toBeDefined(); + + // The navigation replaces the page behind the tab. + await navigate(`data:text/html,${encodeURIComponent(PAGE_HTML)}`); + const after = (await provider.listTargets()).find((entry) => entry.selected); + expect(after).toBeDefined(); + + // The provider raised its generation, so the identity recorded before the + // navigation is stale. + const stale = { + surface: "isolated-chromium" as const, + browserId: provider.browserId, + targetId: before!.id.targetId, + generation: before!.id.generation, + }; + await expect( + provider.sendInput({ kind: "text", target: stale, text: "must not arrive" }), + ).rejects.toMatchObject({ code: "browser_target_stale" }); + + // The page that replaced it received nothing. + expect(await readFieldValue()).toBe(""); + }, + ); + }); +}); diff --git a/packages/webui/test/unit/browser-authorization.test.ts b/packages/webui/test/unit/browser-authorization.test.ts new file mode 100644 index 000000000..db469d420 --- /dev/null +++ b/packages/webui/test/unit/browser-authorization.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { + browserSwitchForSurface, + defaultBrowserAuthorization, + isBrowserCommandAllowed, + isBrowserSurfaceUsable, + normalizeBrowserAuthorization, +} from "../../src/runtime/browser/authorization.js"; +import { + WEBUI_BROWSER_AUTHORIZATION_DEFAULT, + type WebuiBrowserAuthorization, +} from "../../src/shared/contracts/browser.js"; + +/** + * The restricted command set ordinary Browser Use may send. A command outside + * it needs the Full CDP switch. + */ +const RESTRICTED = ["navigate", "click", "type", "screenshot"] as const; + +describe("browser authorization", () => { + it("defaults every switch off", () => { + const authorization = defaultBrowserAuthorization(); + expect(authorization).toEqual({ + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, + }); + expect(authorization).toEqual(WEBUI_BROWSER_AUTHORIZATION_DEFAULT); + }); + + it("reads a missing or partial answer as off", () => { + expect(normalizeBrowserAuthorization(undefined)).toEqual({ + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, + }); + // A host that answers with one switch has authorized that one and nothing + // else. Defaulting the missing keys to true would turn an incomplete + // answer into a grant. + expect( + normalizeBrowserAuthorization({ edge: true } as Partial), + ).toEqual({ + isolatedChromium: false, + edge: true, + chrome: false, + fullCdp: false, + }); + }); + + it("reads anything that is not exactly true as off", () => { + expect( + normalizeBrowserAuthorization({ + isolatedChromium: "true", + edge: 1, + chrome: null, + fullCdp: undefined, + } as unknown as Partial), + ).toEqual({ + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, + }); + }); + + it("gates each surface on its own switch", () => { + const authorization: WebuiBrowserAuthorization = { + isolatedChromium: true, + edge: false, + chrome: false, + fullCdp: false, + }; + expect(isBrowserSurfaceUsable(authorization, "isolated-chromium")).toBe(true); + expect(isBrowserSurfaceUsable(authorization, "edge")).toBe(false); + expect(isBrowserSurfaceUsable(authorization, "chrome")).toBe(false); + }); + + it("does not let Full CDP authorize a surface", () => { + const authorization: WebuiBrowserAuthorization = { + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: true, + }; + // Full CDP widens an authorized surface; it never authorizes one. + expect(isBrowserSurfaceUsable(authorization, "isolated-chromium")).toBe(false); + expect(isBrowserSurfaceUsable(authorization, "edge")).toBe(false); + expect(isBrowserSurfaceUsable(authorization, "chrome")).toBe(false); + }); + + it("allows the restricted command set whatever the Full CDP switch says", () => { + const off: WebuiBrowserAuthorization = { + isolatedChromium: true, + edge: false, + chrome: false, + fullCdp: false, + }; + for (const command of RESTRICTED) { + expect(isBrowserCommandAllowed(off, command, RESTRICTED)).toBe(true); + } + expect(isBrowserCommandAllowed(off, "Browser.grantPermissions", RESTRICTED)).toBe(false); + }); + + it("allows a full command only when the Full CDP switch is on", () => { + const on: WebuiBrowserAuthorization = { + isolatedChromium: true, + edge: false, + chrome: false, + fullCdp: true, + }; + expect(isBrowserCommandAllowed(on, "Browser.grantPermissions", RESTRICTED)).toBe(true); + // The restricted set is still allowed: turning Full CDP on widens the set, + // it does not replace it. + expect(isBrowserCommandAllowed(on, "click", RESTRICTED)).toBe(true); + }); + + it("maps every switch to the field that holds it", () => { + expect(browserSwitchForSurface("isolated-chromium")).toBe("isolatedChromium"); + expect(browserSwitchForSurface("edge")).toBe("edge"); + expect(browserSwitchForSurface("chrome")).toBe("chrome"); + expect(browserSwitchForSurface("full-cdp")).toBe("fullCdp"); + }); +}); diff --git a/packages/webui/test/unit/browser-bridge-admission.test.ts b/packages/webui/test/unit/browser-bridge-admission.test.ts new file mode 100644 index 000000000..ea078c6b1 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-admission.test.ts @@ -0,0 +1,331 @@ +// The dedicated bridge admission path. The tests here are the bridge's +// whole reason for existing: it sits BESIDE `admitWebSocketUpgrade`, +// admits `chrome-extension://` origins where the generic path refuses +// them, and — critically — does NOT let a `dev: true` flag bypass +// extension authentication. The headline test pins that property. + +import { describe, expect, it } from "vitest"; +import type { IncomingMessage } from "node:http"; +import { PassThrough } from "node:stream"; + +import { BrowserBridgePairing } from "../../src/server/browser-bridge/index.js"; +import { + admitBrowserBridgeUpgrade, + authorizeBrowserBridgeSession, + parseExtensionOrigin, + type BrowserBridgeAdmissionContext, +} from "../../src/server/browser-bridge/admission.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import { + createInMemorySecureCredentialStore, +} from "../../src/server/browser-bridge/index.js"; + +const KNOWN_EDGE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; +const KNOWN_CHROME_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba"; + +function makeContext(options?: { + readonly enabled?: ReadonlyArray<"edge" | "chrome">; + readonly dev?: boolean; +}): BrowserBridgeAdmissionContext { + const edgePairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_EDGE_ID]), + }); + const chromePairing = new BrowserBridgePairing({ + surface: "chrome", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_CHROME_ID]), + }); + const pairings = new Map< + "edge" | "chrome", + BrowserBridgePairing + >([ + ["edge", edgePairing], + ["chrome", chromePairing], + ]); + if (options?.enabled?.includes("edge")) { + void edgePairing.enable(); + } + if (options?.enabled?.includes("chrome")) { + void chromePairing.enable(); + } + return { + pairings, + host: "127.0.0.1", + dev: options?.dev ?? false, + }; +} + +/** A fake socket that records what the admission path writes to it. */ +class FakeSocket extends PassThrough { + static instances: FakeSocket[] = []; + readonly written: Buffer[] = []; + destroyed = false; + constructor() { + super(); + FakeSocket.instances.push(this); + } + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.written.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + } + override destroy(): this { + this.destroyed = true; + return this; + } +} + +function freshSocket(): FakeSocket { + FakeSocket.instances = []; + return new FakeSocket(); +} + +function headersOf(record: Readonly>): { readonly [key: string]: string | undefined } { + const out: Record = {}; + for (const [key, value] of Object.entries(record)) out[key.toLowerCase()] = value; + return out; +} + +describe("WebUI bridge admission — protocol surface", () => { + it("refuses an upgrade on any path that is not /browser-bridge", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade(request, "/somewhere-else", socket as never, context); + expect(result).toBeDefined(); + expect(result?.status).toBe(400); + expect(socket.destroyed).toBe(true); + }); + + it("refuses an upgrade when the path is missing", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade(request, undefined, socket as never, context); + expect(result).toBeDefined(); + expect(result?.status).toBe(400); + }); + + it("refuses an upgrade from a non-loopback Host with 403", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ + host: "evil.example.com:9999", + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result?.status).toBe(403); + expect(/Forbidden Host/.test(socket.written.join(""))).toBe(true); + }); + + it("refuses an upgrade with a Host header that is not loopback", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ + host: "0.0.0.1:51234", + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result?.status).toBe(403); + }); + + it("refuses an upgrade whose URL host is not loopback", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade( + request, + "ws://evil.example.com/browser-bridge", + socket as never, + context, + ); + expect(result?.status).toBe(403); + }); + + it("refuses an upgrade whose Origin is not a chrome-extension:// URL", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: "http://127.0.0.1:1/" }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result?.status).toBe(403); + expect(/Forbidden Origin/.test(socket.written.join(""))).toBe(true); + }); + + it("refuses an extension whose id is not on the allowlist", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: "chrome-extension://zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/" }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result?.status).toBe(403); + }); + + it("accepts a well-formed chrome-extension upgrade for an enabled browser", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: false }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result).toBeUndefined(); + expect(socket.destroyed).toBe(false); + expect(socket.written).toHaveLength(0); + }); + + it("admits an extension id from EITHER pairing's allowlist at upgrade time", () => { + // Upgrade-time admission checks only that the id is on SOME pairing's + // allowlist. Finer matching — id ↔ specific surface — happens at the + // session level when the extension sends its `hello` frame. We admit + // here so the session can do the matching. + const socket = freshSocket(); + const context = makeContext({ enabled: ["chrome"], dev: false }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result).toBeUndefined(); + }); +}); + +describe("WebUI bridge admission — dev bypass (the headline test)", () => { + it("refuses an unauthenticated extension even with dev: true", () => { + const socket = freshSocket(); + // dev: true, but NO browser switch is on. The dedicated path must NOT + // bypass the credential / authorization checks — that is the entire + // point of the dedicated path. + const context = makeContext({ enabled: [], dev: true }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + // The credential has not been challenged yet (the upgrade does not + // see the credential) — the FIRST gate is the allowlist and the loopback + // host. The id IS on the allowlist, so the response at the upgrade + // level is `undefined` (the upgrade is admitted for further checks). + // The further checks are exercised below in `post-upgrade`. + expect(result).toBeUndefined(); + }); + + it("refuses a wrong-path upgrade even with dev: true", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + const result = admitBrowserBridgeUpgrade(request, "/", socket as never, context); + expect(result?.status).toBe(400); + }); + + it("refuses an unknown extension id even with dev: true", () => { + const socket = freshSocket(); + const context = makeContext({ enabled: ["edge"], dev: true }); + const request = makeRequest({ origin: "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1/" }); // invalid id (digit 1) + const result = admitBrowserBridgeUpgrade( + request, + WEBUI_BROWSER_BRIDGE_PATH, + socket as never, + context, + ); + expect(result?.status).toBe(403); + }); + + it("authorizeBrowserBridgeSession maps 'switched_off' to 403 and 'invalid' to 401", async () => { + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_EDGE_ID]), + }); + await pairing.enable(); + expect( + await authorizeBrowserBridgeSession(pairing, { kind: "invalid" }), + ).toEqual({ status: 401, reason: "Unauthorized" }); + await pairing.disable(); + expect( + await authorizeBrowserBridgeSession(pairing, { kind: "switched_off" }), + ).toEqual({ status: 403, reason: "Forbidden Browser" }); + expect( + await authorizeBrowserBridgeSession(pairing, { kind: "store_unavailable" }), + ).toEqual({ status: 503, reason: "Credential Store Unavailable" }); + }); + + it("parses a chrome-extension origin into its id when the shape is correct", () => { + expect(parseExtensionOrigin("chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab/")).toBe( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", + ); + // Mixed case is normalised — uppercase letters in the [a-p] range still +// parse as their lowercase form. + const upperId = "A".repeat(31) + "B"; + expect(parseExtensionOrigin(`chrome-extension://${upperId}/`)).toBe( + "a".repeat(31) + "b", + ); + // Wrong protocol. + expect(parseExtensionOrigin("http://localhost/")).toBeUndefined(); + // Empty. + expect(parseExtensionOrigin("")).toBeUndefined(); + // Garbage. + expect(parseExtensionOrigin("not a url")).toBeUndefined(); + // Wrong-shape id (has digits Chrome would never mint). + expect(parseExtensionOrigin("chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1/")).toBeUndefined(); + }); +}); + +/* -------------------------------------------------------------------------- */ + +function makeRequest(headers: { + readonly host?: string; + readonly origin?: string; +}): IncomingMessage { + const lookup = headersOf({ host: "127.0.0.1:51234", ...headers }); + // Build the smallest valid `IncomingMessage` for admission: `headers` is + // the only field the bridge admission function reads. The rest of the + // surface is filled with plausible default values so the cast holds. + const stream = { + headers: { + host: lookup.host ?? "127.0.0.1:51234", + origin: lookup.origin, + }, + aborted: false, + httpVersion: "1.1", + httpVersionMajor: 1, + httpVersionMinor: 1, + complete: true, + method: "GET", + url: "/browser-bridge", + socket: {}, + connection: {}, + trailers: {}, + readable: true, + }; + return stream as unknown as IncomingMessage; +} diff --git a/packages/webui/test/unit/browser-bridge-adversarial.test.ts b/packages/webui/test/unit/browser-bridge-adversarial.test.ts new file mode 100644 index 000000000..5e1d69a9d --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-adversarial.test.ts @@ -0,0 +1,422 @@ +// The adversarial cases the acceptance matrix names. +// +// A bridge that only meets well-behaved peers proves nothing about the peers +// that are not well-behaved. These are the cases a person on the other side +// of the socket can produce on purpose, and each one has an answer: +// +// * an **oversized frame** — the socket is closed, and the host survives it; +// * a **malformed frame** — the socket is closed, and the host survives it; +// * a **duplicate connection** from the same extension — the earlier session +// is replaced rather than left alive beside the newer one; +// * a **revocation while an action is in flight** — the action fails +// instead of hanging or completing. +// +// The host surviving is the point of the first two: a bridge that fell over +// on one bad frame would be a bridge anybody could take down. + +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { + BrowserBridgeServer, + createInMemorySecureCredentialStore, +} from "../../src/server/browser-bridge/index.js"; +import { WebuiWebSocketServer } from "../../src/server/websocket-server.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import { createWebuiCredential } from "../../src/server/credentials.js"; + +const EDGE_EXTENSION_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; +const BROWSER_ID = "edge-instance-1"; +const TARGET_ID = "TAB-1"; + +interface Harness { + readonly bridge: BrowserBridgeServer; + readonly httpServer: Server; + readonly wsServer: WebuiWebSocketServer; + readonly port: number; + close(): Promise; +} + +const openHarnesses: Harness[] = []; + +afterEach(async () => { + while (openHarnesses.length > 0) { + await openHarnesses.pop()?.close(); + } +}); + +async function startHarness( + options: { + readonly maxMessageBytes?: number; + /** + * Which allowlists carry the extension's identity. + * + * `both` is the configuration a person ends up in for real: an unpacked + * build has one identity, and loading it in Edge and Chrome means the + * same identity is listed on both surfaces. + */ + readonly allowlist?: "edge" | "both"; + } = {}, +): Promise { + const bridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + credentialStore: createInMemorySecureCredentialStore(), + env: + options.allowlist === "both" + ? { + MAVIS_BROWSER_EDGE_EXTENSION_IDS: EDGE_EXTENSION_ID, + MAVIS_BROWSER_CHROME_EXTENSION_IDS: EDGE_EXTENSION_ID, + } + : { MAVIS_BROWSER_EDGE_EXTENSION_IDS: EDGE_EXTENSION_ID }, + ...(options.maxMessageBytes ? { maxMessageBytes: options.maxMessageBytes } : {}), + }); + await bridge.start(); + await bridge.enableSurface("edge"); + if (options.allowlist === "both") await bridge.enableSurface("chrome"); + const httpServer = createServer(); + const wsServer = new WebuiWebSocketServer({ + maxMessageBytes: options.maxMessageBytes ?? 256 * 1024, + heartbeatIntervalMs: 15_000, + host: "127.0.0.1", + boundTcpPort: () => (httpServer.address() as AddressInfo | null)?.port, + credential: createWebuiCredential(), + dev: true, + operations: new Map(), + isAccepting: () => true, + browserBridge: bridge, + }); + httpServer.on("upgrade", wsServer.handleUpgrade); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => resolve()); + }); + const harness: Harness = { + bridge, + httpServer, + wsServer, + port: (httpServer.address() as AddressInfo).port, + close: async () => { + await bridge.close(); + wsServer.closeConnections(); + await wsServer.close(); + httpServer.closeAllConnections(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, + }; + openHarnesses.push(harness); + return harness; +} + +interface BridgeFrame { + readonly kind: string; + readonly [key: string]: unknown; +} + +interface BridgeClient { + readonly socket: WebSocket; + readonly frames: BridgeFrame[]; + readonly opened: Promise; + readonly closed: Promise<{ readonly code: number }>; + waitFor(kind: string, timeoutMs?: number): Promise; +} + +function connectClient(harness: Harness): BridgeClient { + const frames: BridgeFrame[] = []; + const socket = new WebSocket( + `ws://127.0.0.1:${harness.port}${WEBUI_BROWSER_BRIDGE_PATH}`, + { headers: { origin: `chrome-extension://${EDGE_EXTENSION_ID}/` } }, + ); + socket.on("message", (data) => { + try { + frames.push(JSON.parse(String(data)) as BridgeFrame); + } catch { + // Not a bridge frame; the session reports that itself. + } + }); + const opened = new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("unexpected-response", (_request, response) => { + reject(new Error(`upgrade refused with ${response.statusCode}`)); + }); + socket.once("error", reject); + }); + const closed = new Promise<{ readonly code: number }>((resolve) => { + socket.once("close", (code) => resolve({ code })); + }); + const waitFor = async ( + kind: string, + timeoutMs = 4_000, + ): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = frames.find((frame) => frame.kind === kind); + if (found) return found as T & BridgeFrame; + if (Date.now() > deadline) throw new Error(`timed out waiting for a "${kind}" frame`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + return { socket, frames, opened, closed, waitFor }; +} + +/** + * Connect as an allowed extension and complete the handshake. + * + * The credential the host already issued is used, so the peer is admitted + * without the installation key exchange. + */ +async function pairEdge( + harness: Harness, + options: { readonly browser?: "edge" | "chrome" } = {}, +): Promise { + const browser = options.browser ?? "edge"; + const client = connectClient(harness); + await client.opened; + client.socket.send( + JSON.stringify({ + kind: "hello", + browser, + browserId: BROWSER_ID, + extensionVersion: "1.0.0", + }), + ); + const pairing = harness.bridge.pairings.get(browser); + if (!pairing) throw new Error(`no ${browser} pairing`); + const challenge = await client.waitFor<{ nonce: string; keyId: string }>("challenge"); + const signature = await pairing.signProof(challenge.nonce); + client.socket.send( + JSON.stringify({ kind: "proof", keyId: challenge.keyId, nonce: challenge.nonce, signature }), + ); + await client.waitFor("ready"); + await settle(); + return client; +} + +const settle = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 150)); +}; + +function announceTarget(client: BridgeClient, generation: number): void { + client.socket.send( + JSON.stringify({ + kind: "targets", + targets: [ + { + browserId: BROWSER_ID, + targetId: TARGET_ID, + title: "Example", + url: "https://example.com/", + generation, + }, + ], + }), + ); +} + +describe("the browser bridge, against an adversarial peer", () => { + it("closes an oversized frame and stays healthy", async () => { + const harness = await startHarness({ maxMessageBytes: 4 * 1024 }); + const attacker = connectClient(harness); + await attacker.opened; + + // Far past the limit the host declares. + attacker.socket.send("x".repeat(64 * 1024)); + + // The host closes it rather than reading it. + const close = await attacker.closed; + expect(close.code).toBeGreaterThan(0); + + // The host is still serving: a well-behaved peer still pairs. + const client = await pairEdge(harness); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + client.socket.terminate(); + }); + + it("closes a malformed frame and stays healthy", async () => { + const harness = await startHarness(); + const attacker = connectClient(harness); + await attacker.opened; + + // Not JSON at all. + attacker.socket.send("this is not a bridge frame"); + + const close = await attacker.closed; + expect(close.code).toBeGreaterThan(0); + + // The host is still serving. + const client = await pairEdge(harness); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + client.socket.terminate(); + }); + + it("replaces the earlier session when the same extension connects again", async () => { + const harness = await startHarness(); + const first = await pairEdge(harness); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + + // The same extension, connecting again. The second connection is the live + // one; the first must not be left alive beside it, or the host would have + // two sessions answering for one browser. + const second = await pairEdge(harness); + const firstClosed = await first.closed; + expect(firstClosed.code).toBeGreaterThan(0); + + // The surface is still live, and it is the new session that holds it. + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + const provider = harness.bridge.createProvider("edge"); + expect(provider).toBeDefined(); + second.socket.terminate(); + }); + + it("fails an action that was in flight when the credential was revoked", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + announceTarget(client, 1); + await settle(); + + const provider = harness.bridge.createProvider("edge"); + expect(provider).toBeDefined(); + const targets = await provider!.listTargets(); + expect(targets).toHaveLength(1); + + // A command the peer never answers. It is in flight when the revocation + // lands, so it has to fail rather than hang. + const inFlight = provider!.sendCommand(targets[0]!.id, "Runtime.evaluate", { + expression: "1", + returnByValue: true, + }); + await settle(); + + // The switch goes off: the credential is revoked and the sessions close. + await harness.bridge.disableSurface("edge"); + + await expect(inFlight).rejects.toBeDefined(); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(false); + client.socket.terminate(); + }); + + it("puts each browser on its own surface when one identity is listed on both", async () => { + // The configuration a person ends up in for real: an unpacked build has one + // identity, and loading it in Edge and Chrome lists that identity on both + // surfaces. The identity alone cannot say which browser is connecting, so + // the peer names the browser it runs in and the host answers with that + // surface's pairing. Taking the first match instead would put the Chrome + // browser on the Edge pairing, and report the Edge surface as connected + // while the person is looking at Chrome. + const harness = await startHarness({ allowlist: "both" }); + const edge = await pairEdge(harness, { browser: "edge" }); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + expect(harness.bridge.snapshot().surfaces.chrome.connected).toBe(false); + + const chrome = await pairEdge(harness, { browser: "chrome" }); + expect(harness.bridge.snapshot().surfaces.chrome.connected).toBe(true); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + + // Each surface's provider belongs to that surface. + expect(harness.bridge.createProvider("chrome")?.surface).toBe("chrome"); + expect(harness.bridge.createProvider("edge")?.surface).toBe("edge"); + edge.socket.terminate(); + chrome.socket.terminate(); + }); + + it("refuses a peer that names a browser this host does not admit", async () => { + // The chrome surface is not configured here, so a peer that says it is + // Chrome is refused rather than attached to the Edge pairing. + const harness = await startHarness(); + const client = connectClient(harness); + await client.opened; + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: "chrome", + browserId: BROWSER_ID, + extensionVersion: "1.0.0", + }), + ); + + const closed = await client.waitFor<{ reason: string }>("closed", 5_000); + expect(closed.reason).toContain("not registered on this host"); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(false); + expect(harness.bridge.snapshot().surfaces.chrome.connected).toBe(false); + client.socket.terminate(); + }); + + it("refuses a peer that names a browser it cannot prove", async () => { + // The name in the `hello` frame selects *which* secret the peer has to + // prove; it does not stand in for the proof. A peer that calls itself + // Edge and answers the Edge challenge with the Chrome surface's + // installation key is refused, because the Edge pairing checks the Edge + // surface's own key. + const harness = await startHarness({ allowlist: "both" }); + const client = connectClient(harness); + await client.opened; + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: BROWSER_ID, + extensionVersion: "1.0.0", + }), + ); + const challenge = await client.waitFor<{ nonce: string }>("challenge", 5_000); + // The Chrome pairing's installation key, answering an Edge challenge. + const chrome = harness.bridge.pairings.get("chrome"); + if (!chrome) throw new Error("no chrome pairing"); + const signature = await chrome.signInstallationKeyProof(challenge.nonce); + const chromeKeyId = chrome.installationKeyId; + client.socket.send( + JSON.stringify({ + kind: "proof", + keyId: chromeKeyId, + nonce: challenge.nonce, + signature, + }), + ); + + const closed = await client.waitFor<{ reason: string }>("closed", 5_000); + // The Edge pairing does not hold the Chrome installation key, so the + // proof cannot be answered with it — whatever the peer called itself. + expect(closed.reason).toContain("a key this host does not hold for this surface"); + // And no surface took the connection. + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(false); + expect(harness.bridge.snapshot().surfaces.chrome.connected).toBe(false); + client.socket.terminate(); + }); + + it("refuses a second browser on a surface that is already served", async () => { + // A surface is one browser: the panel and the Agent's tools have one + // provider per surface, so a second live session would be a browser the + // host could not address. The session that is already live keeps it. + const harness = await startHarness(); + const first = await pairEdge(harness); + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + + const second = connectClient(harness); + await second.opened; + second.socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: "edge-instance-2", + extensionVersion: "1.0.0", + }), + ); + const pairing = harness.bridge.pairings.get("edge"); + if (!pairing) throw new Error("no edge pairing"); + const challenge = await second.waitFor<{ nonce: string; keyId: string }>("challenge", 5_000); + const signature = await pairing.signProof(challenge.nonce); + second.socket.send( + JSON.stringify({ kind: "proof", keyId: challenge.keyId, nonce: challenge.nonce, signature }), + ); + + const closed = await second.waitFor<{ reason: string }>("closed", 5_000); + expect(closed.reason).toContain("already served by another browser"); + // The first browser still holds the surface. + expect(harness.bridge.snapshot().surfaces.edge.connected).toBe(true); + first.socket.terminate(); + second.socket.terminate(); + }); +}); diff --git a/packages/webui/test/unit/browser-bridge-cdp.test.ts b/packages/webui/test/unit/browser-bridge-cdp.test.ts new file mode 100644 index 000000000..2e30205c4 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-cdp.test.ts @@ -0,0 +1,230 @@ +// The bridge's DevTools command routing. +// +// A DevTools command must not be able to reach a tab that an input command +// could not, and two DevTools commands against one tab must not interleave. +// These tests pin both: the routing, the ownership check and the +// serialization are the input path's, and the DevTools path uses them. + +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + BrowserBridgePairing, + BrowserBridgeRegistry, + createInMemorySecureCredentialStore, + type BrowserBridgeCommandOutcome, +} from "../../src/server/browser-bridge/index.js"; +import type { WebuiBrowserBridgeTarget } from "../../src/shared/contracts/browser-bridge.js"; +import type { WebuiBridgeBrowserSurface } from "../../src/shared/contracts/browser.js"; + +const KNOWN_EDGE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; + +/** A fake session that records the DevTools commands it was handed. */ +class FakeSession extends EventEmitter { + readonly pairing: BrowserBridgePairing; + readonly sessionId: string; + readonly cdpReceived: { + readonly targetId: string; + readonly generation: number; + readonly method: string; + readonly params: Record; + }[] = []; + /** Lets a test hold a command open, for the serialization checks. */ + onSendCdp: + | ((command: { + readonly targetId: string; + readonly generation: number; + readonly method: string; + readonly params: Record; + }) => Promise | void) + | undefined; + + constructor(pairing: BrowserBridgePairing, sessionId: string) { + super(); + this.pairing = pairing; + this.sessionId = sessionId; + } + + async sendCdp(command: { + readonly targetId: string; + readonly generation: number; + readonly method: string; + readonly params: Record; + }): Promise { + this.cdpReceived.push(command); + if (this.onSendCdp) await this.onSendCdp(command); + return { kind: "accepted", commandId: "cdp-1", result: { echoed: command.method } }; + } + + close(reason: unknown): void { + this.emit("closed", reason); + } +} + +function makePairing(surface: WebuiBridgeBrowserSurface): BrowserBridgePairing { + return new BrowserBridgePairing({ + surface, + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_EDGE_ID]), + }); +} + +function makeRegistry( + surfaces: readonly WebuiBridgeBrowserSurface[] = ["edge", "chrome"], +): BrowserBridgeRegistry { + return new BrowserBridgeRegistry({ + pairings: new Map(surfaces.map((surface) => [surface, makePairing(surface)])), + createSession: ((pairing: BrowserBridgePairing) => + new FakeSession(pairing, "sess-1") as unknown as never) as never, + }); +} + +function announced( + browserId: string, + targetId: string, + generation: number, +): WebuiBrowserBridgeTarget { + return { browserId, targetId, title: "t", url: "https://example.test/", generation }; +} + +async function admit( + registry: BrowserBridgeRegistry, + surface: WebuiBridgeBrowserSurface, + browserId: string, + sessionId: string, +): Promise { + const session = new FakeSession(registry.pairings.get(surface)!, sessionId); + await registry.registerSession(surface, browserId, session as unknown as never); + return session; +} + +describe("the bridge's DevTools command routing", () => { + let registry: BrowserBridgeRegistry; + + beforeEach(() => { + registry = makeRegistry(["edge"]); + }); + + it("delivers a DevTools command to the session that announced the target", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab9", 3)]); + + const outcome = await registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab9", generation: 3 }, + method: "Runtime.evaluate", + params: { expression: "1" }, + }); + + expect(outcome.kind).toBe("accepted"); + expect(session.cdpReceived).toHaveLength(1); + expect(session.cdpReceived[0]?.method).toBe("Runtime.evaluate"); + expect(session.cdpReceived[0]?.targetId).toBe("tab9"); + }); + + it("refuses a DevTools command for a target no session announced", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab9", 3)]); + + const outcome = await registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab-other", generation: 1 }, + method: "Runtime.evaluate", + params: {}, + }); + + expect(outcome.kind).toBe("rejected"); + expect(session.cdpReceived).toHaveLength(0); + }); + + it("refuses a DevTools command whose generation the announcement moved past", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab9", 3)]); + + const outcome = await registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab9", generation: 2 }, + method: "Runtime.evaluate", + params: {}, + }); + + expect(outcome.kind).toBe("rejected"); + expect(session.cdpReceived).toHaveLength(0); + }); + + it("refuses a DevTools command that names a surface the owner does not serve", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab9", 1)]); + + const outcome = await registry.dispatchCdp({ + target: { surface: "chrome", browserId: "browserA", targetId: "tab9", generation: 1 }, + method: "Runtime.evaluate", + params: {}, + }); + + expect(outcome.kind).toBe("rejected"); + expect(session.cdpReceived).toHaveLength(0); + }); + + it("serializes DevTools commands against the same tab", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab9", 1)]); + + const order: string[] = []; + let release: (() => void) | undefined; + session.onSendCdp = async (command) => { + order.push(`start:${command.method}`); + if (command.method === "first") { + await new Promise((resolve) => { + release = resolve; + }); + } + order.push(`end:${command.method}`); + }; + + const first = registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab9", generation: 1 }, + method: "first", + params: {}, + }); + // The second command must not start until the first has finished. + const second = registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab9", generation: 1 }, + method: "second", + params: {}, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(order).toEqual(["start:first"]); + release?.(); + await Promise.all([first, second]); + expect(order).toEqual(["start:first", "end:first", "start:second", "end:second"]); + }); + + it("lets DevTools commands against different tabs proceed in parallel", async () => { + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [ + announced("browserA", "tab1", 1), + announced("browserA", "tab2", 1), + ]); + + const order: string[] = []; + session.onSendCdp = async (command) => { + order.push(`start:${command.targetId}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push(`end:${command.targetId}`); + }; + + await Promise.all([ + registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab1", generation: 1 }, + method: "Runtime.evaluate", + params: {}, + }), + registry.dispatchCdp({ + target: { surface: "edge", browserId: "browserA", targetId: "tab2", generation: 1 }, + method: "Runtime.evaluate", + params: {}, + }), + ]); + + // Two tabs interleaved; the same tab would not have. + expect(order).toEqual(["start:tab1", "start:tab2", "end:tab1", "end:tab2"]); + }); +}); diff --git a/packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts b/packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts new file mode 100644 index 000000000..0adc01a22 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts @@ -0,0 +1,294 @@ +// The credential must never appear in output. +// +// The ticket asks for a test that *observes* a credential never appearing in +// captured log output, and that is a different kind of assertion from the +// others in this area: the others check what the code decides, and this one +// checks what the code *emits*. A credential that reaches a log has been +// disclosed — logs are copied, shipped and read by people who are not the +// person whose browser it is. +// +// So the whole exchange is run with the output captured: the console, both +// process streams, and the frames the host sends. The credential the host +// minted is watched in every form it could take — the raw bytes, the base64 +// the store keeps, the hex — and none of them may appear. +// +// The frames are captured too, because a credential that travels in the clear +// inside the `pair` frame would be a disclosure too. It must be sealed. + +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { + BrowserBridgeServer, + exportWebuiBrowserBridgeEcdhPublicKey, + generateWebuiBrowserBridgeEcdhKeyPair, + type SecureCredentialStore, +} from "../../src/server/browser-bridge/index.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import type { WebuiBrowserBridgeHostFrame } from "../../src/shared/contracts/browser-bridge.js"; + +const EDGE_EXTENSION_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; +const BROWSER_ID = "edge-instance-1"; + +/** + * A store that remembers what it was asked to keep. + * + * The in-memory store deliberately hides its contents, and it should: the + * production code must not be able to read the secret back casually. This + * spy exists so the *test* can see the bytes, which is the one place they + * have to be visible. + */ +function createSpyStore(): { readonly store: SecureCredentialStore; readonly secrets: Uint8Array[] } { + const secrets: Uint8Array[] = []; + const entries = new Map(); + return { + secrets, + store: { + async isAvailable() { + return true; + }, + async save(keyId, secret) { + secrets.push(secret); + entries.set(keyId, { secret, savedAt: Date.now() }); + }, + async load(keyId) { + return entries.get(keyId)?.secret; + }, + async delete(keyId) { + entries.delete(keyId); + }, + async list() { + return [...entries.keys()].map((keyId) => ({ + keyId, + fingerprint: "spy", + savedAt: 0, + })); + }, + }, + }; +} + +/** + * Captures everything the process writes while a body runs. + * + * The console methods and both process streams are the places a diagnostic + * ends up. A credential in any of them has been disclosed. + * + * The console arguments are captured in every form they could carry the + * secret: a string is itself, a byte array is captured as base64, as hex and + * as the decimal list `String()` would produce, and an object is captured as + * JSON with byte arrays expanded. `String(arg)` alone would turn an object + * into `[object Object]` and a byte array into a comma-separated decimal + * list, and the test would then not notice a leak in either. + */ +async function captureOutput(body: () => Promise): Promise<{ readonly value: T; readonly output: string }> { + const chunks: string[] = []; + // Every console method that writes. `dir` and `table` are not exotic: a + // diagnostic that dumps an object uses them, and a capture that replaced + // only the five common ones would miss exactly that. + const consoleMethods = [ + "log", + "info", + "warn", + "error", + "debug", + "dir", + "table", + "trace", + ] as const; + const originals = new Map void>(); + for (const method of consoleMethods) { + const original = console[method].bind(console) as (...args: never[]) => void; + originals.set(method, original); + console[method] = ((...args: never[]) => { + chunks.push(args.map(describeArgument).join(" ")); + }) as typeof console.log; + } + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const value = await body(); + return { value, output: chunks.join("\n") }; + } finally { + for (const method of consoleMethods) { + console[method] = originals.get(method)! as unknown as typeof console.log; + } + process.stdout.write = stdout; + process.stderr.write = stderr; + } +} + +/** + * Renders one logged argument in every form it could carry a secret. + */ +function describeArgument(value: unknown): string { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) { + return [ + Buffer.from(value).toString("base64"), + Buffer.from(value).toString("hex"), + Array.from(value).join(","), + ].join(" "); + } + try { + const json = JSON.stringify(value, (_key, item: unknown) => + item instanceof Uint8Array ? Array.from(item) : item, + ); + return json ?? String(value); + } catch { + return String(value); + } +} + +describe("WebUI bridge credential secrecy", () => { + const open: (() => Promise)[] = []; + + afterEach(async () => { + while (open.length > 0) { + await open.pop()?.(); + } + }); + + it("never writes the credential to the console, the process streams, or a frame", async () => { + const spy = createSpyStore(); + const bridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + credentialStore: spy.store, + env: { MAVIS_BROWSER_EDGE_EXTENSION_IDS: EDGE_EXTENSION_ID }, + }); + await bridge.start(); + await bridge.enableSurface("edge"); + const httpServer: Server = createServer(); + const frames: WebuiBrowserBridgeHostFrame[] = []; + let client: WebSocket | undefined; + + httpServer.on("upgrade", (request, socket, head) => { + bridge.handleUpgrade(request, socket, head); + }); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (httpServer.address() as AddressInfo).port; + open.push(async () => { + client?.terminate(); + await bridge.close(); + httpServer.closeAllConnections(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }); + + const captured = await captureOutput(async () => { + const socket = new WebSocket(`ws://127.0.0.1:${port}${WEBUI_BROWSER_BRIDGE_PATH}`, { + headers: { origin: `chrome-extension://${EDGE_EXTENSION_ID}/` }, + }); + client = socket; + socket.on("message", (data) => { + try { + frames.push(JSON.parse(String(data)) as WebuiBrowserBridgeHostFrame); + } catch { + // Not a bridge frame. + } + }); + await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", reject); + }); + + // The extension introduces itself with its own ephemeral public key, and + // answers the challenge with the installation key — the branch in which + // the host seals the credential and sends it. + const { publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: BROWSER_ID, + extensionVersion: "1.0.0", + publicKey: await exportWebuiBrowserBridgeEcdhPublicKey(publicKey), + }), + ); + const pairing = bridge.pairings.get("edge"); + if (!pairing) throw new Error("no edge pairing"); + const challenge = await waitForKind(frames, "challenge"); + socket.send( + JSON.stringify({ + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + signature: await pairing.signInstallationKeyProof(challenge.nonce), + }), + ); + await waitForKind(frames, "pair"); + await waitForKind(frames, "ready"); + }); + + // The host stored what it keeps: the installation key and the pairing + // credential. Both are secret material, so neither may appear in output. + expect(spy.secrets.length).toBeGreaterThanOrEqual(1); + for (const secret of spy.secrets) { + expect(secret.byteLength).toBeGreaterThan(0); + } + + // Every form a secret could take in a log. + const forms = spy.secrets.flatMap((secret) => [ + Buffer.from(secret).toString("base64"), + Buffer.from(secret).toString("base64url"), + Buffer.from(secret).toString("hex"), + Buffer.from(secret).toString("utf8"), + // The comma-separated decimal list `String(bytes)` produces. + Array.from(secret).join(","), + ]); + for (const form of forms) { + expect(captured.output).not.toContain(form); + } + + // And the capture has teeth: a secret written to the console this way is + // found. Without this, a capture that silently captured nothing would + // pass the assertions above for the wrong reason. + const selfCheck = await captureOutput(async () => { + console.log("self-check", spy.secrets[0]); + }); + expect(selfCheck.output).toContain(Buffer.from(spy.secrets[0]!).toString("base64")); + + // And **every** frame the host sent must not carry them either: the + // credential travels sealed, not in the clear. Checking only the `pair` + // frame would miss a credential that leaked in another one. + expect(frames.length).toBeGreaterThan(0); + const framesText = JSON.stringify(frames); + for (const form of forms) { + expect(framesText).not.toContain(form); + } + // The sealed credential is there, in the frame that carries it, so the + // assertion above is about the sealing and not about a frame that never + // arrived. + const pairFrame = frames.find((frame) => frame.kind === "pair"); + expect(pairFrame).toBeDefined(); + expect(typeof pairFrame?.payload).toBe("string"); + }); +}); + +async function waitForKind( + frames: readonly WebuiBrowserBridgeHostFrame[], + kind: K, +): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const found = frames.find((frame) => frame.kind === kind) as + | Extract + | undefined; + if (found) return found; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for "${kind}" frame after 5s`); +} diff --git a/packages/webui/test/unit/browser-bridge-credential-store.test.ts b/packages/webui/test/unit/browser-bridge-credential-store.test.ts new file mode 100644 index 000000000..1db11bfb3 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-credential-store.test.ts @@ -0,0 +1,457 @@ +// The OS credential store: invariants the bridge depends on and the one +// property the ADR refuses a fallback for. +// +// The most important assertions here are about the unavailable branch — +// when no OS store is reachable, a `save()` call throws +// `BrowserCredentialStoreUnavailableError` AND no file is written. We do +// not need a real filesystem to prove that; an explicit `noFileWritten` +// spy on the test side is sufficient. +// +// The second group covers the three rules every platform shares: the secret +// never reaches argv, the stored form is a reversible encoding of arbitrary +// bytes, and `load()` asks the platform rather than an in-process cache. The +// platform command runs are exercised through a recording fake runner, so +// these assertions are about what the code *sends*, not about what a local +// `security` / `secret-tool` / `powershell.exe` happens to do — none of the +// three exists on this Linux host. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + BrowserCredentialStoreUnavailableError, + createInMemorySecureCredentialStore, + createPlatformSecureCredentialStore, + fingerprint, + generateBridgeCredential, + type SecureCredentialReference, +} from "../../src/server/browser-bridge/index.js"; +import { + decodeCredentialFromStore, + encodeCredentialForStore, + type CommandOutput, + type RunPlatformCommand, +} from "../../src/server/browser-bridge/credential-store.js"; + +describe("WebUI bridge credential store", () => { + it("round-trips a secret through the in-memory store and surfaces a fingerprint without the secret", async () => { + const store = createInMemorySecureCredentialStore(); + const { keyId, secret } = generateBridgeCredential(); + await store.save(keyId, secret); + const loaded = await store.load(keyId); + expect(loaded).toBeDefined(); + expect(Buffer.from(loaded!).toString("hex")).toBe(Buffer.from(secret).toString("hex")); + const list = await store.list(); + expect(list).toHaveLength(1); + expect(list[0]!.keyId).toBe(keyId); + // The fingerprint must not contain the secret bytes. + expect(list[0]!.fingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(list[0]!.fingerprint).not.toContain(Buffer.from(secret).toString("hex")); + }); + + it("returns undefined for a missing key without throwing", async () => { + const store = createInMemorySecureCredentialStore(); + expect(await store.load("missing")).toBeUndefined(); + }); + + it("deletes an entry idempotently", async () => { + const store = createInMemorySecureCredentialStore(); + await store.save("k", new TextEncoder().encode("v")); + await store.delete("k"); + await store.delete("k"); + expect(await store.load("k")).toBeUndefined(); + expect(await store.list()).toHaveLength(0); + }); + + it("reports the secret store as unavailable on an unsupported platform", async () => { + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: "haiku" as NodeJS.Platform, + }); + expect(await store.isAvailable()).toBe(false); + }); + + it("throws a typed error when no OS store is available and writes nothing to disk", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "bridge-credential-store-")); + const before = await listFilesRecursive(tmp); + try { + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: "haiku" as NodeJS.Platform, + }); + const { keyId, secret } = generateBridgeCredential(); + let captured: unknown; + try { + await store.save(keyId, secret); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(BrowserCredentialStoreUnavailableError); + const after = await listFilesRecursive(tmp); + // No file may have been written; the ADR's whole point is "no + // plaintext fall back". The test scratch directory would surface any + // such write. + expect(after).toEqual(before); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it("refuses to overwrite the secret to disk on any code path through the unavailable store", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "bridge-credential-store-overwrite-")); + try { + // Plant a known file in the scratch directory. A test runner that + // produced a stray write here would change its mtime. + const decoy = path.join(tmp, "decoy.txt"); + await writeFile(decoy, ""); + const beforeStat = (await import("node:fs/promises")).stat(decoy); + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: "haiku" as NodeJS.Platform, + }); + const { keyId, secret } = generateBridgeCredential(); + try { + await store.save(keyId, secret); + } catch { + // expected + } + const afterStat = await beforeStat; + expect(afterStat.mtimeMs).toBe((await (await import("node:fs/promises")).stat(decoy)).mtimeMs); + // No additional file appears besides the decoy. + const files = await listFilesRecursive(tmp); + expect(files.length).toBeLessThanOrEqual(1); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it("reports no credential from an unavailable store even for a key it was asked to keep", async () => { + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: "haiku" as NodeJS.Platform, + }); + // An unavailable store has no platform to read from, so it must answer + // "no credential" rather than keep a side map of what a failed save + // could not persist anyway. + expect(await store.load("anything")).toBeUndefined(); + expect(await store.list()).toEqual([]); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The stored encoding */ +/* -------------------------------------------------------------------------- */ + +describe("WebUI bridge credential storage encoding", () => { + it("round-trips bytes that are not valid UTF-8 through the store helpers", () => { + // Deliberately not UTF-8: a lone 0xff, an invalid 0xfe, an embedded NUL + // and a bare continuation byte. This is what 32 bytes of CSPRNG output + // routinely look like. + const secret = new Uint8Array([0xff, 0xfe, 0x00, 0x80]); + const encoded = encodeCredentialForStore(secret); + expect(encoded).toBe(Buffer.from(secret).toString("base64")); + const decoded = decodeCredentialFromStore(encoded); + expect(decoded.byteLength).toBe(secret.byteLength); + expect(Buffer.from(decoded).toString("hex")).toBe(Buffer.from(secret).toString("hex")); + }); + + it("round-trips a full 32-byte generated credential exactly", () => { + const { secret } = generateBridgeCredential(); + const decoded = decodeCredentialFromStore(encodeCredentialForStore(secret)); + expect(Buffer.from(decoded).toString("hex")).toBe(Buffer.from(secret).toString("hex")); + }); + + it("would not survive a UTF-8 round trip, which is why the store encodes instead", () => { + const secret = new Uint8Array([0xff, 0xfe, 0x00, 0x80]); + // The shape the implementation must not have: bytes → text → bytes. + const viaText = new Uint8Array(Buffer.from(Buffer.from(secret).toString("utf8"), "utf8")); + expect(Buffer.from(viaText).toString("hex")).not.toBe(Buffer.from(secret).toString("hex")); + // The shape it does have. + const viaBase64 = decodeCredentialFromStore(encodeCredentialForStore(secret)); + expect(Buffer.from(viaBase64).toString("hex")).toBe(Buffer.from(secret).toString("hex")); + }); + + it("tolerates the single trailing newline the lookup commands print", () => { + const secret = new Uint8Array([0xff, 0xfe, 0x00, 0x80]); + const encoded = encodeCredentialForStore(secret); + expect(Buffer.from(decodeCredentialFromStore(`${encoded}\n`)).toString("hex")).toBe( + Buffer.from(secret).toString("hex"), + ); + expect(Buffer.from(decodeCredentialFromStore(`${encoded}\r\n`)).toString("hex")).toBe( + Buffer.from(secret).toString("hex"), + ); + }); + + it("rejects a stored value that is not canonical base64", () => { + // `Buffer.from(x, "base64")` silently skips what it does not understand, + // so a lenient decode would hand back a short, plausible, wrong secret. + expect(() => decodeCredentialFromStore("not base64!!")).toThrow(); + expect(() => decodeCredentialFromStore("QQ")).toThrow(); + expect(() => decodeCredentialFromStore("")).toThrow(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Per-platform command construction */ +/* -------------------------------------------------------------------------- */ + +interface RecordedCall { + readonly command: string; + readonly args: readonly string[]; + readonly stdin: string | undefined; +} + +/** A stand-in for the OS store, keyed by whatever the CLI would use. */ +interface FakeOsPlatform { + readonly run: RunPlatformCommand; + readonly calls: RecordedCall[]; + /** The stored values, keyed by key id — the "OS side" a restart returns to. */ + readonly items: Map; + readonly controls: { failWrites: boolean }; +} + +function createFakeOsPlatform(): FakeOsPlatform { + const items = new Map(); + const calls: RecordedCall[] = []; + const controls = { failWrites: false }; + + // Each store names its key differently on the wire; recovering it is what + // lets one fake stand in for all three. + const keyFor = (command: string, args: readonly string[]): string => { + if (command === "security") { + const index = args.indexOf("-a"); + return args[index + 1] ?? ""; + } + if (command === "secret-tool") { + const index = args.indexOf("key"); + return args[index + 1] ?? ""; + } + return /bridge-([A-Za-z0-9_-]+)\.blob/.exec(args.join(" "))?.[1] ?? ""; + }; + + const run: RunPlatformCommand = async ( + command: string, + args: readonly string[], + options, + ): Promise => { + // The stores always send text on stdin; normalise once so both the + // recorded call and the fake store agree on its shape. + const stdin = options?.stdin === undefined ? undefined : String(options.stdin); + calls.push({ command, args: [...args], stdin }); + const script = args.join(" "); + // Availability probes. + if (args.includes("help") || args.includes("--version") || script.includes("PSVersionTable")) { + return { stdout: "1\n", stderr: "", exitCode: 0 }; + } + const key = keyFor(command, args); + const isWrite = script.includes("add-generic-password") + ? true + : args[0] === "store" + ? true + : script.includes("WriteAllText"); + const isRead = args[0] === "find-generic-password" + ? true + : args[0] === "lookup" + ? true + : script.includes("ReadAllText"); + const isDelete = args[0] === "delete-generic-password" + ? true + : args[0] === "clear" + ? true + : script.includes("Delete"); + if (isWrite) { + if (controls.failWrites) { + return { stdout: "", stderr: "the store is locked", exitCode: 1 }; + } + // The OS store keeps the encoded payload, not the raw bytes. + items.set(key, stdin ?? ""); + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (isRead) { + const stored = items.get(key); + if (stored === undefined) { + return { stdout: "", stderr: "the specified item could not be found", exitCode: 44 }; + } + return { stdout: `${stored}\n`, stderr: "", exitCode: 0 }; + } + if (isDelete) { + items.delete(key); + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + + return { run, calls, items, controls }; +} + +const PLATFORM_CASES = [ + { platform: "darwin" as NodeJS.Platform, label: "macOS Keychain", command: "security" }, + { platform: "linux" as NodeJS.Platform, label: "Secret Service", command: "secret-tool" }, + { platform: "win32" as NodeJS.Platform, label: "Windows DPAPI", command: "powershell.exe" }, +]; + +const NON_UTF8_SECRET = new Uint8Array([0xff, 0xfe, 0x00, 0x80, 0xc3, 0x28, 0x80]); +const TEST_KEY_ID = "11111111-2222-3333-4444-555555555555"; + +describe("WebUI bridge credential store — platform commands", () => { + // The Windows store derives its blob path from LOCALAPPDATA at call time. + // Pointing it at a scratch directory makes "wrote no file" checkable and + // keeps a test run from touching the real user profile. + let scratch = ""; + let previousLocalAppData: string | undefined; + + beforeEach(async () => { + scratch = await mkdtemp(path.join(tmpdir(), "bridge-credential-platform-")); + previousLocalAppData = process.env.LOCALAPPDATA; + process.env.LOCALAPPDATA = scratch; + }); + + afterEach(async () => { + if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = previousLocalAppData; + await rm(scratch, { recursive: true, force: true }); + }); + + for (const { platform, label, command } of PLATFORM_CASES) { + it(`keeps the ${label} secret out of argv and writes it on stdin`, async () => { + const os = createFakeOsPlatform(); + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: platform, + runCommand: os.run, + }); + expect(await store.isAvailable()).toBe(true); + await store.save(TEST_KEY_ID, NON_UTF8_SECRET); + + const encoded = encodeCredentialForStore(NON_UTF8_SECRET); + // argv is readable by any process on the machine, so no argument of any + // call may carry the secret in any of its forms. + for (const call of os.calls) { + const argv = [call.command, ...call.args].join(" "); + expect(argv).not.toContain(encoded); + expect(argv).not.toContain(Buffer.from(NON_UTF8_SECRET).toString("hex")); + expect(argv).not.toContain(Buffer.from(NON_UTF8_SECRET).toString("utf8")); + } + // The write carried it on stdin, not on the command line. + const write = os.calls.find((call) => call.stdin !== undefined); + expect(write?.stdin).toBe(encoded); + expect(write?.command).toBe(command); + // Nothing was written into the scratch directory by any of this. + expect(await listFilesRecursive(scratch)).toEqual([]); + }); + + it(`reads a saved ${label} credential back after a restart`, async () => { + const os = createFakeOsPlatform(); + const options = { + serviceName: "webui-browser-bridge", + platformOverride: platform, + runCommand: os.run, + }; + await createPlatformSecureCredentialStore(options).save(TEST_KEY_ID, NON_UTF8_SECRET); + + // A brand new store instance: the in-process state of the writer is + // gone, so a value that resolves now came from the platform. + const afterRestart = createPlatformSecureCredentialStore(options); + const loaded = await afterRestart.load(TEST_KEY_ID); + expect(loaded).toBeDefined(); + expect(Buffer.from(loaded!).toString("hex")).toBe( + Buffer.from(NON_UTF8_SECRET).toString("hex"), + ); + }); + + it(`answers "no credential" from the ${label} platform when the entry is gone`, async () => { + const os = createFakeOsPlatform(); + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: platform, + runCommand: os.run, + }); + await store.save(TEST_KEY_ID, NON_UTF8_SECRET); + // The store's own memory must not be the answer: once the platform no + // longer holds it, load() has to say so rather than serve a stale copy. + os.items.clear(); + expect(await store.load(TEST_KEY_ID)).toBeUndefined(); + }); + + it(`throws the typed unavailable error when the ${label} platform refuses the write`, async () => { + const os = createFakeOsPlatform(); + os.controls.failWrites = true; + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: platform, + runCommand: os.run, + }); + await expect(store.save(TEST_KEY_ID, NON_UTF8_SECRET)).rejects.toBeInstanceOf( + BrowserCredentialStoreUnavailableError, + ); + expect(await listFilesRecursive(scratch)).toEqual([]); + }); + + it(`deletes a ${label} credential so a later load finds nothing`, async () => { + const os = createFakeOsPlatform(); + const store = createPlatformSecureCredentialStore({ + serviceName: "webui-browser-bridge", + platformOverride: platform, + runCommand: os.run, + }); + await store.save(TEST_KEY_ID, NON_UTF8_SECRET); + await store.delete(TEST_KEY_ID); + // Idempotent: a second delete must not throw. + await store.delete(TEST_KEY_ID); + expect(await store.load(TEST_KEY_ID)).toBeUndefined(); + expect(await store.list()).toEqual([]); + }); + } +}); + +describe("WebUI bridge credential generation", () => { + it("emits a 32-byte secret and a UUID-shaped key id", () => { + const { keyId, secret } = generateBridgeCredential(); + expect(keyId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(secret.byteLength).toBeGreaterThanOrEqual(32); + }); + + it("yields a stable, non-secret fingerprint that does not include the secret bytes", () => { + const a = generateBridgeCredential(); + const b = generateBridgeCredential(); + // Same secret, same fingerprint. + expect(fingerprint(a.secret)).toBe(fingerprint(a.secret)); + // Different secrets, different fingerprints. + expect(fingerprint(a.secret)).not.toBe(fingerprint(b.secret)); + // Fingerprints are 16 hex characters and never accidentally swallow the + // secret bytes verbatim — they are SHA-256 over a namespace prefix. + const sample = fingerprint(a.secret); + expect(sample).toMatch(/^[0-9a-f]{16}$/); + expect(sample).not.toContain(Buffer.from(a.secret).toString("hex").slice(0, 16)); + }); +}); + +/* -------------------------------------------------------------------------- */ + +async function listFilesRecursive(root: string): Promise { + const { readdir, stat } = await import("node:fs/promises"); + const out: string[] = []; + async function walk(dir: string): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (entry.isFile()) { + const s = await stat(full); + out.push(`${full}:${s.size}`); + } + } + } + await walk(root); + return out; +} + +// Reserved import to keep TypeScript happy when the test file shrinks. +void (null as unknown as SecureCredentialReference); \ No newline at end of file diff --git a/packages/webui/test/unit/browser-bridge-pairing.test.ts b/packages/webui/test/unit/browser-bridge-pairing.test.ts new file mode 100644 index 000000000..81c53e6f1 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-pairing.test.ts @@ -0,0 +1,1040 @@ +// Pairing is the credential lifecycle. The tests pin the behavioural +// claims the ADR makes: +// +// * Each browser owns a distinct, high-entropy credential; the two +// browsers never share. +// * A valid challenge/proof round-trips through `verifyProof`. +// * A wrong nonce, wrong key id, or tampered signature is rejected. +// * A challenge can be answered only once. +// * Re-enabling mints a fresh credential, distinct from the prior one. +// * Disabling revokes the credential and rejects every later +// presentation of the old one. +// +// The last third of the file is the credential *transfer*: the host seals its +// own credential to the extension's per-connection public key, the extension +// opens it, and the credential it recovers answers a real challenge. Those +// tests deliberately do not share a secret between the two sides, because +// doing so is the defect the exchange exists to remove — a suite that fakes +// the shared secret proves nothing about two independently minted ones. + +import { describe, expect, it } from "vitest"; + +import { + BrowserBridgePairing, + BrowserCredentialStoreUnavailableError, + createInMemorySecureCredentialStore, + exportWebuiBrowserBridgeEcdhPublicKey, + generateBridgeCredential, + generateWebuiBrowserBridgeEcdhKeyPair, + openWebuiBrowserBridgePairingCredential, + PairingSwitchedOffError, + sealWebuiBrowserBridgePairingCredential, + signWebuiBrowserBridgeProof, + verifyWebuiBrowserBridgeProof, + WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES, + WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO, + WEBUI_BROWSER_BRIDGE_PAIR_HKDF_SALT_BYTES, + type WebuiBrowserBridgeEcdhPublicKey, + type WebuiBrowserBridgePair, +} from "../../src/server/browser-bridge/index.js"; + +const KNOWN_EXTENSION_IDS = new Set([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", // 32 letters, all in the a-p range — matches `chrome-extension://` id regex +]); +const DEV_EXTENSION_IDS = new Set([ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba", +]); + +function makePairing(surface: "edge" | "chrome") { + return new BrowserBridgePairing({ + surface, + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: + surface === "edge" ? KNOWN_EXTENSION_IDS : DEV_EXTENSION_IDS, + }); +} + +describe("BrowserBridgePairing — initial state", () => { + it("starts disabled with no credential", () => { + const pairing = makePairing("edge"); + expect(pairing.enabled).toBe(false); + expect(pairing.keyId).toBeUndefined(); + expect(pairing.status().enabled).toBe(false); + }); +}); + +describe("BrowserBridgePairing — admit extension ids", () => { + it("admits ids on the allowlist", () => { + const edge = makePairing("edge"); + const chrome = makePairing("chrome"); + expect(edge.admitsExtensionId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab")).toBe(true); + expect(chrome.admitsExtensionId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba")).toBe(true); + }); + + it("rejects ids that are not on the allowlist", () => { + const edge = makePairing("edge"); + // Chrome's id is not on edge's allowlist. + expect(edge.admitsExtensionId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba")).toBe(false); + }); +}); + +describe("BrowserBridgePairing — challenge and proof", () => { + it("round-trips a proof for a fresh credential", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + expect(challenge.kind).toBe("challenge"); + expect(challenge.algorithm).toBe("HMAC-SHA256"); + expect(challenge.keyId).toBe(pairing.keyId); + expect(challenge.nonce.length).toBeGreaterThanOrEqual(32); + const signed = await pairing.signProof(challenge.nonce); + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: signed, + }); + expect(valid.kind).toBe("pairing_credential"); + }); + + it("refuses a wrong nonce", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + const signed = await pairing.signProof(challenge.nonce); + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + signature: signed, + }); + expect(valid).toEqual({ kind: "refused", reason: expect.any(String) }); + }); + + it("refuses a wrong key id", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + const signed = await pairing.signProof(challenge.nonce); + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", + nonce: challenge.nonce, + signature: signed, + }); + expect(valid).toEqual({ kind: "refused", reason: expect.any(String) }); + }); + + it("refuses a tampered signature", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + const signed = await pairing.signProof(challenge.nonce); + const tampered = signed.endsWith("A") + ? signed.slice(0, -1) + "B" + : signed.slice(0, -1) + "A"; + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: tampered, + }); + expect(valid).toEqual({ kind: "refused", reason: expect.any(String) }); + }); + + it("refuses a proof when the switch is off", async () => { + const pairing = makePairing("edge"); + await expect(pairing.issueChallenge()).rejects.toBeInstanceOf(PairingSwitchedOffError); + }); + + it("refuses the same challenge twice (single-use nonce)", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + const signed = await pairing.signProof(challenge.nonce); + const first = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: signed, + }); + expect(first.kind).toBe("pairing_credential"); + expect(pairing.outstandingNonceCount()).toBe(0); + const second = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: signed, + }); + expect(second).toEqual({ kind: "refused", reason: expect.any(String) }); + }); + + it("emits a different nonce per call", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const a = await pairing.issueChallenge(); + const b = await pairing.issueChallenge(); + expect(a.nonce).not.toBe(b.nonce); + }); +}); + +describe("BrowserBridgePairing — credential lifecycle", () => { + it("mints a fresh, distinct credential on each enable", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const first = pairing.keyId; + const firstSecret = await loadStoredSecret(pairing); + await pairing.enable(); + const second = pairing.keyId; + const secondSecret = await loadStoredSecret(pairing); + expect(first).not.toBe(second); + expect(Buffer.from(firstSecret!).toString("hex")).not.toBe( + Buffer.from(secondSecret!).toString("hex"), + ); + }); + + it("rejects the OLD credential after re-enable, even when re-presented late", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const oldKeyId = pairing.keyId; + const oldChallenge = await pairing.issueChallenge(); + const oldSigned = await pairing.signProof(oldChallenge.nonce); + // Forget the nonce — we want the test to fail because the credential + // was revoked, not because the nonce was spent. + pairing.forgetChallenge(oldChallenge.nonce); + await pairing.enable(); + // Now feed the OLD proof back. + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: oldKeyId!, + nonce: oldChallenge.nonce, + signature: oldSigned, + }); + expect(valid).toEqual({ kind: "refused", reason: expect.any(String) }); + }); + + it("refuses ALL operations after disable", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + await pairing.disable(); + expect(pairing.enabled).toBe(false); + await expect(pairing.issueChallenge()).rejects.toBeInstanceOf(PairingSwitchedOffError); + expect(pairing.status().enabled).toBe(false); + }); + + it("drops the disabled secret from the underlying credential store", async () => { + const store = createInMemorySecureCredentialStore(); + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await pairing.enable(); + const enabledKeyId = pairing.keyId!; + expect(await store.load(enabledKeyId)).toBeDefined(); + await pairing.disable(); + expect(await store.load(enabledKeyId)).toBeUndefined(); + }); +}); + +describe("BrowserBridgePairing — surviving a restart", () => { + it("keeps the credential the extension still holds when the host restarts", async () => { + // One store, two host processes: the second is the first one restarted. + const store = createInMemorySecureCredentialStore(); + const first = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await first.enable(); + const keyIdBefore = first.keyId!; + + // The host restarts. It must NOT mint a fresh credential: the extension + // still holds the old one, and a fresh secret on this side would leave the + // two unable to prove possession of the same thing. + const restarted = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await restarted.restore(); + expect(restarted.keyId).toBe(keyIdBefore); + + // The extension's proof, made with the secret it kept, is accepted. + const secret = await store.load(keyIdBefore); + expect(secret).toBeDefined(); + const challenge = await restarted.issueChallenge(); + const proof = await signWebuiBrowserBridgeProof( + secret!, + challenge.nonce, + challenge.keyId, + ); + const valid = await restarted.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: proof, + }); + expect(valid.kind).toBe("pairing_credential"); + }); + + it("mints a fresh credential when the surface was switched off in between", async () => { + const store = createInMemorySecureCredentialStore(); + const first = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await first.enable(); + const keyIdBefore = first.keyId!; + + // The person switched the browser off, then the host restarted. The + // credential was revoked, so there is nothing to restore and a fresh one + // is minted. + await first.disable(); + + const restarted = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await restarted.restore(); + expect(restarted.keyId).toBeDefined(); + expect(restarted.keyId).not.toBe(keyIdBefore); + }); +}); + +describe("BrowserBridgePairing — browser isolation", () => { + it("the two bridges never share a credential", async () => { + const edge = makePairing("edge"); + const chrome = makePairing("chrome"); + await edge.enable(); + await chrome.enable(); + expect(edge.keyId).not.toBe(chrome.keyId); + const edgeSecret = await loadStoredSecret(edge); + const chromeSecret = await loadStoredSecret(chrome); + expect(Buffer.from(edgeSecret!).toString("hex")).not.toBe( + Buffer.from(chromeSecret!).toString("hex"), + ); + }); + + it("disabling one bridge does not disturb the other", async () => { + const edge = makePairing("edge"); + const chrome = makePairing("chrome"); + await edge.enable(); + await chrome.enable(); + const chromeKeyBefore = chrome.keyId; + await edge.disable(); + expect(edge.enabled).toBe(false); + expect(chrome.enabled).toBe(true); + expect(chrome.keyId).toBe(chromeKeyBefore); + }); +}); + +describe("BrowserBridgePairing — proof construction pinning", () => { + it("uses the same construction as the shared contract module", async () => { + // The pairing must not diverge from the contract's signer. A test + // here pins both code paths to the same algorithm by computing the + // signature with the shared signer and asserting the pairing accepts + // it. + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + // Reach into the in-memory store to fetch the secret and re-sign with + // the shared module — this is what an extension would do. + const secret = await loadStoredSecret(pairing); + const signed = await signWebuiBrowserBridgeProof(secret!, challenge.nonce, challenge.keyId); + const valid = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: signed, + }); + expect(valid.kind).toBe("pairing_credential"); + }); +}); + +/* -------------------------------------------------------------------------- */ + +async function loadStoredSecret( + pairing: BrowserBridgePairing, +): Promise { + const store = (pairing as unknown as { + credentialStore: { load: (keyId: string) => Promise }; + }).credentialStore; + if (!pairing.keyId) return undefined; + return store.load(pairing.keyId); +} + +// Keep an import that anchors tree-shaking in case a refactor drops `generateBridgeCredential`. +void generateBridgeCredential; + +/* ========================================================================== */ +/* The credential transfer, host and extension both real */ +/* ========================================================================== */ + +/** The extension's side of one connection: a fresh ephemeral key pair. */ +async function extensionHandshake(): Promise<{ + readonly privateKey: CryptoKey; + readonly publicKey: WebuiBrowserBridgeEcdhPublicKey; +}> { + const { privateKey, publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + return { privateKey, publicKey: await exportWebuiBrowserBridgeEcdhPublicKey(publicKey) }; +} + +/** + * Replays what the extension's service worker does when a `pair` frame lands: + * open it and take the credential it recovers. No store is involved — what is + * under test is the bytes that cross, not how Chrome persists them. + */ +async function extensionAppliesPair( + frame: WebuiBrowserBridgePair, + ownPrivateKey: CryptoKey, +): Promise<{ readonly keyId: string; readonly secret: Uint8Array }> { + const secret = await openWebuiBrowserBridgePairingCredential(frame, ownPrivateKey); + return { keyId: frame.keyId, secret }; +} + +/** Turn a sealed result into the wire frame the session would send. */ +function toPairFrame( + sealed: { keyId: string; publicKey: WebuiBrowserBridgeEcdhPublicKey; salt: string; payload: string }, +): WebuiBrowserBridgePair { + return { + kind: "pair", + keyId: sealed.keyId, + publicKey: sealed.publicKey, + salt: sealed.salt, + payload: sealed.payload, + }; +} + +describe("the pairing exchange — host and extension both real", () => { + it("delivers a credential the recovered secret can answer a challenge with", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + + // The extension opens a connection and offers its per-connection key. + const extension = await extensionHandshake(); + + // The host seals its own credential to that key. + const sealed = await pairing.sealCredentialForExtension(extension.publicKey); + expect(sealed.kind).toBe("sealed"); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + + // The extension opens the frame and keeps what it recovered. + const credential = await extensionAppliesPair(toPairFrame(sealed), extension.privateKey); + + // The host issues a challenge naming the same key id. + const challenge = await pairing.issueChallenge(); + expect(challenge.keyId).toBe(credential.keyId); + + // The extension signs with what it recovered... + const proof = await signWebuiBrowserBridgeProof( + credential.secret, + challenge.nonce, + credential.keyId, + ); + + // ...and the host accepts it against its own stored secret. Nothing between + // the two sides is shared except the wire. + expect( + await pairing.verifyProof({ + kind: "proof", + keyId: credential.keyId, + nonce: challenge.nonce, + signature: proof, + }), + ).toEqual({ kind: "pairing_credential" }); + }); + + it("delivers the credential again on a second connection, replacing the stored one", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + + // Connection one: credential A. + const first = await extensionHandshake(); + const firstSealed = await pairing.sealCredentialForExtension(first.publicKey); + if (firstSealed.kind !== "sealed") throw new Error("unreachable"); + const firstStored = await extensionAppliesPair(toPairFrame(firstSealed), first.privateKey); + + // The person turns the switch off and on again: the credential is revoked + // and a fresh one minted. The extension cannot reach it on its own. + await pairing.enable(); + expect(pairing.keyId).not.toBe(firstSealed.keyId); + + // Connection two: the host sends the new credential unasked. + const second = await extensionHandshake(); + const secondSealed = await pairing.sealCredentialForExtension(second.publicKey); + if (secondSealed.kind !== "sealed") throw new Error("unreachable"); + const secondStored = await extensionAppliesPair(toPairFrame(secondSealed), second.privateKey); + + // Replaced, not merged. + expect(secondStored.keyId).not.toBe(firstStored.keyId); + expect(hexOf(secondStored.secret)).not.toBe(hexOf(firstStored.secret)); + + // And the new credential answers. + const challenge = await pairing.issueChallenge(); + expect( + await pairing.verifyProof({ + kind: "proof", + keyId: secondStored.keyId, + nonce: challenge.nonce, + signature: await signWebuiBrowserBridgeProof( + secondStored.secret, + challenge.nonce, + secondStored.keyId, + ), + }), + ).toEqual({ kind: "pairing_credential" }); + }); + + it("sends no credential at all when the surface's switch is off", async () => { + const pairing = makePairing("edge"); + // Never enabled: there is nothing to seal. + const extension = await extensionHandshake(); + + expect(await pairing.sealCredentialForExtension(extension.publicKey)).toEqual({ kind: "off" }); + expect(pairing.enabled).toBe(false); + // And nothing for a proof to be checked against either. + await expect(pairing.issueChallenge()).rejects.toBeInstanceOf(PairingSwitchedOffError); + }); + + it("sends no credential once the switch has been turned back off", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + expect((await pairing.sealCredentialForExtension(extension.publicKey)).kind).toBe("sealed"); + + await pairing.disable(); + + expect(await pairing.sealCredentialForExtension(extension.publicKey)).toEqual({ kind: "off" }); + }); + + it("keeps the two surfaces' transfers independent", async () => { + const edge = makePairing("edge"); + const chrome = makePairing("chrome"); + await edge.enable(); + await chrome.enable(); + const extension = await extensionHandshake(); + + const edgeSealed = await edge.sealCredentialForExtension(extension.publicKey); + const chromeSealed = await chrome.sealCredentialForExtension(extension.publicKey); + if (edgeSealed.kind !== "sealed" || chromeSealed.kind !== "sealed") throw new Error("unreachable"); + + const edgeCredential = await extensionAppliesPair(toPairFrame(edgeSealed), extension.privateKey); + const chromeCredential = await extensionAppliesPair( + toPairFrame(chromeSealed), + extension.privateKey, + ); + + // A credential from one surface does not answer the other's challenge. + expect(edgeCredential.keyId).not.toBe(chromeCredential.keyId); + const challenge = await edge.issueChallenge(); + expect( + await edge.verifyProof({ + kind: "proof", + keyId: chromeCredential.keyId, + nonce: challenge.nonce, + signature: await signWebuiBrowserBridgeProof( + chromeCredential.secret, + challenge.nonce, + chromeCredential.keyId, + ), + }), + ).toEqual({ kind: "refused", reason: "unknown_key_id" }); + }); + + it("cannot be opened by a key that is not the one offered in hello", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const mine = await extensionHandshake(); + const sealed = await pairing.sealCredentialForExtension(mine.publicKey); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + + const someoneElse = await extensionHandshake(); + + await expect( + extensionAppliesPair(toPairFrame(sealed), someoneElse.privateKey), + ).rejects.toBeDefined(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The installation key */ +/* -------------------------------------------------------------------------- */ + +describe("BrowserBridgePairing — the installation key", () => { + it("provisions one when the surface is switched on and none is stored", async () => { + const pairing = makePairing("edge"); + expect(await pairing.installationKeyForDisplay()).toBeUndefined(); + + await pairing.enable(); + + const view = await pairing.installationKeyForDisplay(); + expect(view).toBeDefined(); + expect(view!.surface).toBe("edge"); + expect(view!.keyId).toBe(pairing.installationKeyId); + // 32 bytes, base64 — the same width as the pairing credential. + expect(Buffer.from(view!.key, "base64")).toHaveLength(32); + }); + + it("stores it in the OS secure store, and only there", async () => { + const store = createInMemorySecureCredentialStore(); + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await pairing.enable(); + + // In the store, under its own key id, alongside but apart from the + // pairing credential. + const stored = await store.load(pairing.installationKeyId); + expect(stored).toHaveLength(32); + const references = (await store.list()).map((reference) => reference.keyId); + expect(references).toContain(pairing.installationKeyId); + expect(references).toContain(pairing.keyId!); + + // And nowhere in any rendered form of the pairing's own reporting: the + // status snapshot the UI reads carries no key material of either kind. + const serialised = JSON.stringify(pairing.status()); + expect(serialised).not.toContain(Buffer.from(stored!).toString("base64")); + expect(serialised).not.toContain(hexOf(stored!)); + }); + + it("never puts the key in an error message", async () => { + const store = createInMemorySecureCredentialStore(); + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await pairing.enable(); + const view = await pairing.installationKeyForDisplay(); + + const messages: string[] = []; + // Signing without a key, and reading from a store that throws, are the two + // error paths that could plausibly interpolate the secret. + const hostile: BrowserBridgePairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: { + isAvailable: async () => false, + save: async () => { + throw new BrowserCredentialStoreUnavailableError("no store", "cause"); + }, + load: async () => undefined, + delete: async () => undefined, + list: async () => [], + }, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + for (const attempt of [ + () => hostile.enable(), + () => hostile.signInstallationKeyProof("nonce"), + ]) { + await attempt().catch((error: unknown) => { + messages.push(error instanceof Error ? `${error.message}\u0000${error.stack ?? ""}` : String(error)); + }); + } + expect(messages).not.toEqual([]); + for (const message of messages) { + expect(message).not.toContain(view!.key); + expect(message).not.toContain(Buffer.from(view!.key, "base64").toString("hex")); + } + }); + + it("keeps the key when the surface is switched off, and reuses it on the way back", async () => { + const store = createInMemorySecureCredentialStore(); + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await pairing.enable(); + const before = (await pairing.installationKeyForDisplay())!.key; + const credentialBefore = pairing.keyId; + + await pairing.disable(); + + // The credential is gone; the installation key is not. Destroying it would + // send the person back to the extension's options page on every toggle, + // with no security gain — the credential it authorises was just revoked. + expect(pairing.keyId).toBeUndefined(); + expect(await store.load(credentialBefore!)).toBeUndefined(); + const after = await pairing.installationKeyForDisplay(); + expect(after?.key).toBe(before); + + // And switching back on mints a NEW credential against the SAME key. + await pairing.enable(); + expect(pairing.keyId).not.toBe(credentialBefore); + expect((await pairing.installationKeyForDisplay())!.key).toBe(before); + }); + + it("survives a restart, and is never adopted as the pairing credential", async () => { + const store = createInMemorySecureCredentialStore(); + const first = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await first.enable(); + const credentialKeyId = first.keyId!; + const installationKey = (await first.installationKeyForDisplay())!.key; + + const restarted = new BrowserBridgePairing({ + surface: "edge", + credentialStore: store, + allowedExtensionIds: KNOWN_EXTENSION_IDS, + }); + await restarted.restore(); + + // The install key alone must never win the "newest reference" race that + // restores the pairing credential, or every extension would be verifying + // against the wrong secret. + expect(restarted.keyId).toBe(credentialKeyId); + expect((await restarted.installationKeyForDisplay())!.key).toBe(installationKey); + }); + + it("admits a proof made with the installation key", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + expect(challenge.installationKeyId).toBe(pairing.installationKeyId); + + const outcome = await pairing.verifyProof({ + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + signature: await pairing.signInstallationKeyProof(challenge.nonce), + }); + // This is the outcome that authorises sending the credential, so it is + // distinct from "already paired" rather than a boolean. + expect(outcome).toEqual({ kind: "installation_key" }); + }); + + it("refuses an installation key from a different host", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const other = makePairing("edge"); + await other.enable(); + + const challenge = await pairing.issueChallenge(); + expect( + await pairing.verifyProof({ + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + signature: await other.signInstallationKeyProof(challenge.nonce), + }), + ).toEqual({ kind: "refused", reason: "installation_key_mismatch" }); + }); + + it("refuses a proof for a nonce it never issued", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + expect( + await pairing.verifyProof({ + kind: "proof", + keyId: pairing.installationKeyId, + nonce: "a-nonce-this-host-never-issued", + signature: await pairing.signInstallationKeyProof("a-nonce-this-host-never-issued"), + }), + ).toEqual({ kind: "refused", reason: "unknown_nonce" }); + }); + + it("answers a challenge with the installation key before the pairing credential", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const challenge = await pairing.issueChallenge(); + const signature = await pairing.signInstallationKeyProof(challenge.nonce); + + // Proved with the installation key first, because the pairing credential + // the host currently holds may be newer than the one this browser was + // given — only the installation key survives a rotation. + expect(await pairing.verifyProof({ + kind: "proof", + keyId: pairing.installationKeyId, + nonce: challenge.nonce, + signature, + })).toEqual({ kind: "installation_key" }); + + // The same nonce cannot be reused to buy a second credential. + expect(await pairing.verifyProof({ + kind: "proof", + keyId: pairing.installationKeyId, + nonce: challenge.nonce, + signature, + })).toEqual({ kind: "refused", reason: "unknown_nonce" }); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* What actually crosses the wire */ +/* -------------------------------------------------------------------------- */ + +describe("the pairing exchange — the secret never crosses in the clear", () => { + it("carries no secret in any encoding and declares no secret field", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + const sealed = await pairing.sealCredentialForExtension(extension.publicKey); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + const hostSecret = (await loadStoredSecret(pairing))!; + + const serialised = JSON.stringify(toPairFrame(sealed)); + + // Neither the base64, the base64url nor the hex of the secret appears + // anywhere in the frame — and there is no field that carries it either. + expect(serialised).not.toContain(Buffer.from(hostSecret).toString("base64")); + expect(serialised).not.toContain(hexOf(hostSecret)); + expect(serialised).not.toContain(Buffer.from(hostSecret).toString("base64url")); + expect(serialised).not.toContain("secret"); + // The key id IS in the clear, which is correct: it is not secret, and the + // challenge names it. + expect(Object.keys(JSON.parse(serialised) as Record).sort()).toEqual([ + "keyId", + "kind", + "payload", + "publicKey", + "salt", + ]); + }); + + it("uses a fresh salt and IV on every seal", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + + const first = await pairing.sealCredentialForExtension(extension.publicKey); + const second = await pairing.sealCredentialForExtension(extension.publicKey); + if (first.kind !== "sealed" || second.kind !== "sealed") throw new Error("unreachable"); + + expect(first.salt).not.toBe(second.salt); + // The IV is the head of the payload, so the first base64 quantum differs + // even though both payloads start with the same IV length. + expect(first.payload.slice(0, 24)).not.toBe(second.payload.slice(0, 24)); + }); + + it("fails to open a tampered payload rather than returning half a credential", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + const sealed = await pairing.sealCredentialForExtension(extension.publicKey); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + + const payload = Buffer.from(sealed.payload, "base64"); + payload[payload.length - 1] = payload[payload.length - 1]! ^ 0x01; + + await expect( + extensionAppliesPair({ ...toPairFrame(sealed), payload: payload.toString("base64") }, extension.privateKey), + ).rejects.toBeDefined(); + }); + + it("fails to open when the salt is not the one the host generated", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + const sealed = await pairing.sealCredentialForExtension(extension.publicKey); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + + await expect( + extensionAppliesPair( + { + ...toPairFrame(sealed), + salt: Buffer.alloc(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_SALT_BYTES, 7).toString("base64"), + }, + extension.privateKey, + ), + ).rejects.toBeDefined(); + }); + + it("fails to open a payload too short to be an AES-GCM frame", async () => { + const pairing = makePairing("edge"); + await pairing.enable(); + const extension = await extensionHandshake(); + const sealed = await pairing.sealCredentialForExtension(extension.publicKey); + if (sealed.kind !== "sealed") throw new Error("unreachable"); + + await expect( + extensionAppliesPair( + { ...toPairFrame(sealed), payload: Buffer.alloc(8).toString("base64") }, + extension.privateKey, + ), + ).rejects.toThrow(/too short/); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The cross-implementation vector */ +/* -------------------------------------------------------------------------- */ + +describe("the pairing exchange — the cross-implementation vector", () => { + /** + * The same numbers `packages/browser-extension/test/protocol.test.ts` asserts. + * Two hand-written copies of this cipher — one on Node, one in a browser + * service worker — agree only because this string is the tie-breaker between + * them. A change to the curve, the `info`, the salt length or the plaintext + * encoding on either side breaks exactly one suite. + */ + const HOST_PRIVATE_JWK = { + kty: "EC", + crv: "P-256", + x: "XKwRVe-iSDOdOSBcT7wi5-6UDoXhSi_sJIva2FRwf-A", + y: "JOmNw0lXPLULOJ02rq1ElmxknaPEkcoSGN7aHuVzYZk", + d: "JGSt5D3d1wIlUCrVHgcNvnsFK8uMWG_YUCcHFsySgd0", + } as const; + const EXTENSION_PUBLIC: WebuiBrowserBridgeEcdhPublicKey = { + kty: "EC", + crv: "P-256", + x: "E97C62Slhb6xSK8jBY_vKNp_bXdZdUyq2z16I_ozyCA", + y: "-h7QnrdbdOmS5Wgxe8VSGJWBLpsptMBSCKmLKYcockY", + }; + const SALT = "AAECAwQFBgcICQoLDA0ODw=="; + const PLAINTEXT = "ABEiM0RVZneImaq7zN3u/w=="; + const PAYLOAD = "EBESExQVFhcYGRobrpzg8LW2dZC7oVrnYW8vU8zfjg8p/FNAc/YdMgkvUx7+O5ceWIULAw=="; + /** + * The plaintext the payload carries, in hex. + * + * A published transfer vector, not a credential: the bytes are + * 0x00, 0x11, 0x22, … so that they are obviously synthetic. The ciphertext + * is pinned to them, so the value cannot be changed — only the name. + */ + const PLAINTEXT_HEX = "00112233445566778899aabbccddeeff"; + + /** + * One continuous 0x00, 0x01, 0x02, … stream, so the first 16 bytes are the + * salt and the next 12 are the IV — which is what the vector was built from. + */ + function countingRandom(): (length: number) => Uint8Array { + let next = 0; + return (length: number) => { + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index += 1) bytes[index] = next++ & 0xff; + return bytes; + }; + } + + it("pins the agreed HKDF info and the salt and IV widths", () => { + expect(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_INFO).toBe("webui-browser-bridge-pair-v1"); + expect(WEBUI_BROWSER_BRIDGE_PAIR_HKDF_SALT_BYTES).toBe(16); + expect(WEBUI_BROWSER_BRIDGE_PAIR_AES_IV_BYTES).toBe(12); + }); + + it("produces the exact payload the extension's suite opens", async () => { + const privateKey = await crypto.subtle.importKey( + "jwk", + { ...HOST_PRIVATE_JWK, key_ops: ["deriveBits"] }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + const publicKey = await crypto.subtle.importKey( + "jwk", + { + kty: HOST_PRIVATE_JWK.kty, + crv: HOST_PRIVATE_JWK.crv, + x: HOST_PRIVATE_JWK.x, + y: HOST_PRIVATE_JWK.y, + ext: true, + key_ops: [], + }, + { name: "ECDH", namedCurve: "P-256" }, + true, + [], + ); + + const cipher = await sealWebuiBrowserBridgePairingCredential( + Buffer.from(PLAINTEXT_HEX, "hex"), + EXTENSION_PUBLIC, + { randomBytes: countingRandom(), hostKeyPair: { privateKey, publicKey } }, + ); + + expect(cipher.salt).toBe(SALT); + expect(cipher.payload).toBe(PAYLOAD); + expect(cipher.publicKey).toEqual({ + kty: "EC", + crv: "P-256", + x: HOST_PRIVATE_JWK.x, + y: HOST_PRIVATE_JWK.y, + }); + // The plaintext the two suites agree on is `base64(secret)`. + expect(Buffer.from(PLAINTEXT_HEX, "hex").toString("base64")).toBe(PLAINTEXT); + }); + + it("opens the same vector from the extension's fixed private key", async () => { + const extensionPrivate = await crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + x: "E97C62Slhb6xSK8jBY_vKNp_bXdZdUyq2z16I_ozyCA", + y: "-h7QnrdbdOmS5Wgxe8VSGJWBLpsptMBSCKmLKYcockY", + d: "1xQC1vkpguxZrUo9vhBkgX76gSaP3yiIv7YjgJUa4CA", + key_ops: ["deriveBits"], + }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + + const secret = await openWebuiBrowserBridgePairingCredential( + { + publicKey: { + kty: "EC", + crv: "P-256", + x: HOST_PRIVATE_JWK.x, + y: HOST_PRIVATE_JWK.y, + }, + salt: SALT, + payload: PAYLOAD, + }, + extensionPrivate, + ); + + expect(hexOf(secret)).toBe(PLAINTEXT_HEX); + expect(Buffer.from(secret).toString("base64")).toBe(PLAINTEXT); + }); + + it("refuses a vector payload one bit from the agreed value", async () => { + const extensionPrivate = await crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + x: "E97C62Slhb6xSK8jBY_vKNp_bXdZdUyq2z16I_ozyCA", + y: "-h7QnrdbdOmS5Wgxe8VSGJWBLpsptMBSCKmLKYcockY", + d: "1xQC1vkpguxZrUo9vhBkgX76gSaP3yiIv7YjgJUa4CA", + key_ops: ["deriveBits"], + }, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + const payload = Buffer.from(PAYLOAD, "base64"); + payload[payload.length - 1] = payload[payload.length - 1]! ^ 0x01; + + await expect( + openWebuiBrowserBridgePairingCredential( + { + publicKey: { + kty: "EC", + crv: "P-256", + x: HOST_PRIVATE_JWK.x, + y: HOST_PRIVATE_JWK.y, + }, + salt: SALT, + payload: payload.toString("base64"), + }, + extensionPrivate, + ), + ).rejects.toBeDefined(); + }); +}); + +/* -------------------------------------------------------------------------- */ + +function hexOf(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) out += byte.toString(16).padStart(2, "0"); + return out; +} diff --git a/packages/webui/test/unit/browser-bridge-reconnect.test.ts b/packages/webui/test/unit/browser-bridge-reconnect.test.ts new file mode 100644 index 000000000..175415269 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-reconnect.test.ts @@ -0,0 +1,133 @@ +// The reconnect schedule is the ADR's most concrete behavioural claim: an +// in-flight command retries for at most 15 seconds with the named step +// pattern, the steps double and cap at 5 s, and background reconnects cap +// at 30 s. The schedule is implemented as a pure function so the wiring +// code does not need to be present for it to be tested. + +import { describe, expect, it } from "vitest"; + +import { + bridgeBackgroundReconnectDelayMs, + bridgeOperationReconnectDelayMs, + WEBUI_BRIDGE_BACKGROUND_RECONNECT_CAP_MS, + WEBUI_BRIDGE_OPERATION_RECONNECT_CAP_MS, + WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS, + WEBUI_BRIDGE_OPERATION_RECONNECT_TOTAL_MS, + withBridgeOperationRetry, + BridgeOperationError, + type BridgeOperationAttempt, +} from "../../src/server/browser-bridge/index.js"; + +describe("WebUI bridge reconnect schedule", () => { + it("pins the operation steps to 250 / 500 / 1 000 / 2 000 / 4 000 ms", () => { + expect([...WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS]).toEqual([250, 500, 1_000, 2_000, 4_000]); + expect(WEBUI_BRIDGE_OPERATION_RECONNECT_CAP_MS).toBe(5_000); + expect(WEBUI_BRIDGE_OPERATION_RECONNECT_TOTAL_MS).toBe(15_000); + }); + + it("pins the background reconnect cap to 30 seconds", () => { + expect(WEBUI_BRIDGE_BACKGROUND_RECONNECT_CAP_MS).toBe(30_000); + }); + + it("returns exactly the named steps when no jitter is applied", () => { + // The minimum boundary: a -1 attempt would otherwise ask for the + // largest step; we want the function to clamp. + for (let attempt = 0; attempt < WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS.length; attempt += 1) { + const withFloor = bridgeOperationReconnectDelayMs(attempt); + expect(withFloor).toBeGreaterThanOrEqual(WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS[attempt]! * 0.8); + expect(withFloor).toBeLessThanOrEqual(WEBUI_BRIDGE_OPERATION_RECONNECT_STEPS_MS[attempt]! * 1.2); + } + }); + + it("caps the operation delay at 5 s for every attempt beyond the named steps", () => { + for (const attempt of [5, 6, 7, 8, 20]) { + const value = bridgeOperationReconnectDelayMs(attempt); + // The cap is 5 s; with ±20% jitter the lower bound is 4 s and the + // upper bound is 5 s. A value that exceeds the cap is a regression. + expect(value).toBeLessThanOrEqual(WEBUI_BRIDGE_OPERATION_RECONNECT_CAP_MS); + } + }); + + it("caps the background delay at 30 s once the doubling reaches the ceiling", () => { + for (const attempt of [7, 8, 20]) { + const value = bridgeBackgroundReconnectDelayMs(attempt); + expect(value).toBeLessThanOrEqual(WEBUI_BRIDGE_BACKGROUND_RECONNECT_CAP_MS); + } + }); + + it("doubles the background delay by 2 each step until the cap", () => { + // Without jitter (we replicate the algorithm rather than read it), the + // first eight attempts double as 250, 500, 1_000, 2_000, 4_000, + // 8_000, 16_000, 30_000. The eighth step hits the cap. + const expected = [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + for (let attempt = 0; attempt < expected.length; attempt += 1) { + // Pull a single sample and check it sits inside the band ±20 % of the + // expected value. The first seven steps are well clear of the cap. + const observed = bridgeBackgroundReconnectDelayMs(attempt); + const floor = expected[attempt]! * 0.8; + const ceiling = expected[attempt]! * 1.2 + 1; + expect(observed).toBeGreaterThanOrEqual(floor); + expect(observed).toBeLessThanOrEqual(ceiling); + } + }); +}); + +describe("WebUI bridge operation retry", () => { + it("returns the first success without retrying", async () => { + let calls = 0; + const attempt = async (): Promise> => { + calls += 1; + return { kind: "success", value: "ok" }; + }; + const value = await withBridgeOperationRetry(attempt, { deadlineMs: 100 }); + expect(value).toBe("ok"); + expect(calls).toBe(1); + }); + + it("retries transient failures until the deadline", async () => { + let calls = 0; + const attempt: (index: number) => Promise<{ kind: "transient"; message: string } | { kind: "success"; value: string }> = async () => { + calls += 1; + if (calls < 3) return { kind: "transient", message: `transient #${calls}` }; + return { kind: "success", value: "ok" }; + }; + const sleepCalls: number[] = []; + const value = await withBridgeOperationRetry(attempt, { + deadlineMs: 1_000, + sleep: (ms) => { + sleepCalls.push(ms); + return Promise.resolve(); + }, + }); + expect(value).toBe("ok"); + expect(calls).toBe(3); + expect(sleepCalls).toHaveLength(2); + // The first retry sleeps roughly 250ms, the second roughly 500ms. + expect(sleepCalls[0]).toBeGreaterThanOrEqual(200); + expect(sleepCalls[0]).toBeLessThanOrEqual(350); + }); + + it("throws a typed timeout error when the deadline expires mid-retry", async () => { + let calls = 0; + const attempt: (index: number) => Promise<{ kind: "transient"; message: string }> = async () => { + calls += 1; + return { kind: "transient", message: `transient #${calls}` }; + }; + await expect( + withBridgeOperationRetry(attempt, { deadlineMs: 5, sleep: () => Promise.resolve() }), + ).rejects.toBeInstanceOf(BridgeOperationError); + expect(calls).toBeGreaterThan(1); + }); + + it("does not retry a terminal refusal", async () => { + let calls = 0; + const attempt: (index: number) => Promise<{ kind: "terminal"; reason: "refused"; message: string }> = async () => { + calls += 1; + return { kind: "terminal", reason: "refused", message: "no" }; + }; + await expect( + withBridgeOperationRetry(attempt, { deadlineMs: 1_000, sleep: () => Promise.resolve() }), + ).rejects.toBeInstanceOf(BridgeOperationError); + expect(calls).toBe(1); + }); +}); diff --git a/packages/webui/test/unit/browser-bridge-registry.test.ts b/packages/webui/test/unit/browser-bridge-registry.test.ts new file mode 100644 index 000000000..0471ef06e --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-registry.test.ts @@ -0,0 +1,461 @@ +// Where a command goes, and where it is refused. +// +// The registry's switch lifecycle — disable revokes, closes the live +// session, and rejects any subsequent proof; re-enable mints a fresh +// credential — is covered first. The dispatch half is the larger part: a +// command must reach the session that announced its exact +// `(browserId, targetId, generation)`, and anything else must be refused +// rather than delivered to whichever session answered most recently. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; +import type { WebSocket } from "ws"; + +import { + BrowserBridgePairing, + BrowserBridgeRegistry, + createInMemorySecureCredentialStore, + type BrowserBridgeCommandOutcome, +} from "../../src/server/browser-bridge/index.js"; +import type { + WebuiBrowserBridgeInputCommand, + WebuiBrowserBridgeTarget, +} from "../../src/shared/contracts/browser-bridge.js"; +import type { + WebuiBridgeBrowserSurface, + WebuiBrowserInputCommand, +} from "../../src/shared/contracts/browser.js"; + +const KNOWN_EDGE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; + +/** A fake session — closeable, observable, with a pairing. */ +class FakeSession extends EventEmitter { + static instances: FakeSession[] = []; + readonly pairing: BrowserBridgePairing; + readonly sessionId: string; + readonly socket: { terminateCalls: number; pinged: number }; + isAdmitted = true; + closedReason: unknown = undefined; + /** Every wire command this session was handed, in order. */ + readonly received: WebuiBrowserBridgeInputCommand[] = []; + /** Lets a test hold a command open, for the serialization checks. */ + onSendInput: ((wire: WebuiBrowserBridgeInputCommand) => Promise | void) | undefined; + constructor(pairing: BrowserBridgePairing, sessionId: string) { + super(); + this.pairing = pairing; + this.sessionId = sessionId; + this.socket = { terminateCalls: 0, pinged: 0 }; + FakeSession.instances.push(this); + } + async sendInput(command: WebuiBrowserBridgeInputCommand): Promise { + this.received.push(command); + if (this.onSendInput) await this.onSendInput(command); + return { kind: "accepted", commandId: "accepted" }; + } + close(reason: unknown): void { + if (this.closedReason) return; + this.closedReason = reason; + this.emit("closed", reason); + } +} + +/** A factory that returns a FakeSession recording its construction. */ +function makeFactory(): (pairing: BrowserBridgePairing) => FakeSession { + return (pairing) => new FakeSession(pairing, `s-${FakeSession.instances.length}`); +} + +describe("BrowserBridgeRegistry — switch lifecycle", () => { + let registry: BrowserBridgeRegistry; + + beforeEach(() => { + registry = new BrowserBridgeRegistry({ + pairings: new Map([ + ["edge", new BrowserBridgePairing({ + surface: "edge", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_EDGE_ID]), + })], + ["chrome", new BrowserBridgePairing({ + surface: "chrome", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([KNOWN_EDGE_ID]), + })], + ]), + createSession: makeFactory() as unknown as (p: BrowserBridgePairing) => never, + }); + }); + + afterEach(() => { + void registry.shutdown(); + FakeSession.instances = []; + }); + + it("starts with both surfaces disabled", () => { + expect(registry.snapshot().surfaces.edge.enabled).toBe(false); + expect(registry.snapshot().surfaces.chrome.enabled).toBe(false); + }); + + it("enableSurface mints a credential, disableSurface revokes it", async () => { + await registry.enableSurface("edge"); + const enabled = registry.snapshot().surfaces.edge; + expect(enabled.enabled).toBe(true); + expect(enabled.keyId).toBeDefined(); + expect(enabled.fingerprint).toMatch(/^[0-9a-f]{16}$/); + await registry.disableSurface("edge"); + expect(registry.snapshot().surfaces.edge.enabled).toBe(false); + expect(registry.snapshot().surfaces.edge.keyId).toBeUndefined(); + }); + + it("enableSurface mints a DIFFERENT credential each time", async () => { + await registry.enableSurface("edge"); + const first = registry.snapshot().surfaces.edge.keyId; + await registry.disableSurface("edge"); + await registry.enableSurface("edge"); + const second = registry.snapshot().surfaces.edge.keyId; + expect(first).not.toBe(second); + }); + + it("disableSurface closes every live session for that browser", async () => { + const edge = registry.pairings.get("edge")!; + await edge.enable(); + const session = new FakeSession(edge, "sess-A"); + await registry.registerSession("edge", "browserA", session as unknown as never); + expect(FakeSession.instances.at(-1)).toBe(session); + await registry.disableSurface("edge"); + expect(session.closedReason).toBeDefined(); + }); + + it("a later presentation of the old credential is refused after disable", async () => { + const edge = registry.pairings.get("edge")!; + await edge.enable(); + const oldKeyId = edge.keyId; + const oldChallenge = await edge.issueChallenge(); + const oldSigned = await edge.signProof(oldChallenge.nonce); + edge.forgetChallenge(oldChallenge.nonce); + await registry.disableSurface("edge"); + // The pairing is now off. A new pairing is enabled (later in the test) + // AFTER the disable, but the OLD credential must still fail. + // First check: directly through the pairing, the credential is gone. + expect(edge.enabled).toBe(false); + // Second check: a presentation of the old credential must fail. + const valid = await edge.verifyProof({ + kind: "proof", + keyId: oldKeyId!, + nonce: oldChallenge.nonce, + signature: oldSigned, + }); + expect(valid).toEqual({ kind: "refused", reason: "switched_off" }); + }); + + it("does not affect the other surface", async () => { + await registry.enableSurface("edge"); + await registry.enableSurface("chrome"); + const chromeKeyBefore = registry.snapshot().surfaces.chrome.keyId; + await registry.disableSurface("edge"); + expect(registry.snapshot().surfaces.edge.enabled).toBe(false); + expect(registry.snapshot().surfaces.chrome.enabled).toBe(true); + expect(registry.snapshot().surfaces.chrome.keyId).toBe(chromeKeyBefore); + }); + + it("enabling a surface revokes the prior credential and closes the prior session", async () => { + const edge = registry.pairings.get("edge")!; + await edge.enable(); + const session = new FakeSession(edge, "sess-B"); + await registry.registerSession("edge", "browserA", session as unknown as never); + await registry.enableSurface("edge"); + expect(session.closedReason).toBeDefined(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Target routing */ +/* -------------------------------------------------------------------------- */ + +/** The parts of a pairing the registry actually reads. */ +function makePairing(surface: WebuiBridgeBrowserSurface): BrowserBridgePairing { + const credential = { keyId: "k", fingerprint: "f", savedAt: 0 }; + return { + surface, + status: () => ({ enabled: true, credential }), + enable: async () => credential, + disable: async () => undefined, + } as unknown as BrowserBridgePairing; +} + +function makeRoutingRegistry( + surfaces: readonly WebuiBridgeBrowserSurface[] = ["edge", "chrome"], +): BrowserBridgeRegistry { + return new BrowserBridgeRegistry({ + pairings: new Map(surfaces.map((surface) => [surface, makePairing(surface)])), + }); +} + +function announced( + browserId: string, + targetId: string, + generation: number, +): WebuiBrowserBridgeTarget { + return { browserId, targetId, title: "t", url: "https://example.test/", generation }; +} + +function textCommand( + surface: WebuiBridgeBrowserSurface, + browserId: string, + targetId: string, + generation: number, + text = "x", +): WebuiBrowserInputCommand { + return { kind: "text", target: { surface, browserId, targetId, generation }, text }; +} + +async function admit( + registry: BrowserBridgeRegistry, + surface: WebuiBridgeBrowserSurface, + browserId: string, + sessionId: string, +): Promise { + const session = new FakeSession(registry.pairings.get(surface)!, sessionId); + await registry.registerSession(surface, browserId, session as unknown as never); + return session; +} + +describe("BrowserBridgeRegistry — target ownership", () => { + it("delivers a command only to the session that announced that target", async () => { + const registry = makeRoutingRegistry(["edge"]); + const first = await admit(registry, "edge", "browserA", "sess-1"); + const second = await admit(registry, "edge", "browserB", "sess-2"); + // Two Edge windows are live and both admitted. `second` was admitted + // last, which is exactly the case where "most recent" would answer. + registry.announceTargets(first as never, [announced("browserA", "tab1", 1)]); + registry.announceTargets(second as never, [announced("browserB", "tab2", 1)]); + + const outcome = await registry.dispatch(textCommand("edge", "browserA", "tab1", 1)); + expect(outcome.kind).toBe("accepted"); + expect(first.received).toHaveLength(1); + expect(first.received[0]).toMatchObject({ kind: "text", targetId: "tab1" }); + expect(second.received).toHaveLength(0); + + await registry.dispatch(textCommand("edge", "browserB", "tab2", 1, "y")); + expect(second.received).toHaveLength(1); + expect(first.received).toHaveLength(1); + }); + + it("routes each tab of the same browser to the session that announced it", async () => { + const registry = makeRoutingRegistry(["chrome"]); + const first = await admit(registry, "chrome", "browserC", "sess-c1"); + const second = await admit(registry, "chrome", "browserC", "sess-c2"); + registry.announceTargets(first as never, [announced("browserC", "tab1", 1)]); + registry.announceTargets(second as never, [announced("browserC", "tab2", 4)]); + + await registry.dispatch(textCommand("chrome", "browserC", "tab2", 4, "second")); + expect(second.received).toHaveLength(1); + expect(first.received).toHaveLength(0); + }); + + it("refuses a target no session announced instead of guessing a session", async () => { + const registry = makeRoutingRegistry(["edge"]); + const live = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(live as never, [announced("browserA", "tab1", 1)]); + + const outcome = await registry.dispatch(textCommand("edge", "browserA", "ghost", 1)); + expect(outcome.kind).toBe("rejected"); + expect(outcome.kind === "rejected" ? outcome.reason : "").toContain("ghost"); + // The live session must not have been handed a command for a tab it + // never claimed. + expect(live.received).toHaveLength(0); + }); + + it("refuses a command whose generation does not match the announcement", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 7)]); + + const outcome = await registry.dispatch(textCommand("edge", "browserA", "tab1", 6)); + expect(outcome.kind).toBe("rejected"); + expect(outcome.kind === "rejected" ? outcome.reason : "").toContain("moved on"); + expect(session.received).toHaveLength(0); + + // The matching generation still goes through. + const accepted = await registry.dispatch(textCommand("edge", "browserA", "tab1", 7)); + expect(accepted.kind).toBe("accepted"); + expect(session.received).toHaveLength(1); + }); + + it("lets a higher generation supersede the earlier claim on a tab", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 1)]); + // The tab navigated: same tab id, new page, new generation. + registry.announceTargets(session as never, [announced("browserA", "tab1", 2)]); + + const stale = await registry.dispatch(textCommand("edge", "browserA", "tab1", 1)); + expect(stale.kind).toBe("rejected"); + const current = await registry.dispatch(textCommand("edge", "browserA", "tab1", 2)); + expect(current.kind).toBe("accepted"); + }); + + it("ignores a late announcement from a generation the extension moved past", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 5)]); + // A duplicated, out-of-order frame must not roll the table back to a + // page that no longer exists. + registry.announceTargets(session as never, [announced("browserA", "tab1", 2)]); + + const stale = await registry.dispatch(textCommand("edge", "browserA", "tab1", 2)); + expect(stale.kind).toBe("rejected"); + const current = await registry.dispatch(textCommand("edge", "browserA", "tab1", 5)); + expect(current.kind).toBe("accepted"); + expect(session.received).toHaveLength(1); + }); + + it("stops routing to a target once the announcing session has gone away", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 1)]); + expect((await registry.dispatch(textCommand("edge", "browserA", "tab1", 1))).kind).toBe( + "accepted", + ); + + session.close({ kind: "remote" }); + + const afterClose = await registry.dispatch(textCommand("edge", "browserA", "tab1", 1)); + expect(afterClose.kind).toBe("rejected"); + }); + + it("learns ownership from the session's own targets frame", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + // The shape `BrowserBridgeSession` emits; the registry subscribes to it + // so wiring code does not have to relay announcements. + session.emit("targets", "browserA", [announced("browserA", "tab9", 3)]); + + const outcome = await registry.dispatch(textCommand("edge", "browserA", "tab9", 3)); + expect(outcome.kind).toBe("accepted"); + expect(session.received).toHaveLength(1); + }); + + it("refuses a command whose surface disagrees with the owning session", async () => { + const registry = makeRoutingRegistry(["edge", "chrome"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 1)]); + + const outcome = await registry.dispatch(textCommand("chrome", "browserA", "tab1", 1)); + expect(outcome.kind).toBe("rejected"); + expect(outcome.kind === "rejected" ? outcome.reason : "").toContain("edge"); + expect(session.received).toHaveLength(0); + }); + + it("refuses a command that names no bridge surface", async () => { + const registry = makeRoutingRegistry(["edge"]); + const session = await admit(registry, "edge", "browserA", "sess-1"); + registry.announceTargets(session as never, [announced("browserA", "tab1", 1)]); + + const outcome = await registry.dispatch({ + kind: "text", + target: { surface: "isolated-chromium", browserId: "browserA", targetId: "tab1", generation: 1 }, + text: "x", + } as WebuiBrowserInputCommand); + expect(outcome.kind).toBe("rejected"); + expect(session.received).toHaveLength(0); + }); +}); + +describe("BrowserBridgeRegistry — command dispatch", () => { + it("serializes operations against the SAME tab but allows independent tabs", async () => { + // Track per-target concurrency. A serialized queue must NEVER see + // overlap on the same target; independent tabs may overlap freely. + const inFlightPerTarget = new Map(); + const maxInFlightPerTarget = new Map(); + const orderPerTarget = new Map(); + + function note(target: string, slot: string) { + const order = orderPerTarget.get(target) ?? []; + order.push(slot); + orderPerTarget.set(target, order); + } + function enter(target: string): void { + const current = inFlightPerTarget.get(target) ?? 0; + const next = current + 1; + inFlightPerTarget.set(target, next); + const max = maxInFlightPerTarget.get(target) ?? 0; + if (next > max) maxInFlightPerTarget.set(target, next); + } + function leave(target: string): void { + const current = inFlightPerTarget.get(target) ?? 0; + inFlightPerTarget.set(target, current - 1); + } + + const session = new EventEmitter() as unknown as { + isAdmitted: boolean; + pairing: BrowserBridgePairing; + sendInput: (input: unknown) => Promise<{ readonly kind: string }>; + close: (reason: unknown) => void; + sessionId: string; + }; + session.pairing = { surface: "edge" } as BrowserBridgePairing; + session.isAdmitted = true; + session.sessionId = "sess-serial"; + session.sendInput = async (input: unknown) => { + const wire = input as { readonly targetId: string; readonly text?: string }; + const targetId = wire.targetId; + note(targetId, `start:${wire.text ?? ""}`); + enter(targetId); + try { + // Simulate the round-trip to the bridge. + await new Promise((r) => setImmediate(r)); + note(targetId, `end:${wire.text ?? ""}`); + return { kind: "accepted" }; + } finally { + leave(targetId); + } + }; + session.close = () => undefined; + + const registry = new BrowserBridgeRegistry({ + pairings: new Map([ + ["edge", { pairing: session.pairing, admitsExtensionId: () => true, issueChallenge: () => Promise.reject(new Error("nope")), verifyProof: () => Promise.resolve(false), signProof: () => Promise.resolve(""), enable: () => Promise.resolve({ keyId: "k", fingerprint: "f", savedAt: 0 }), disable: () => Promise.resolve(), status: () => ({ enabled: true, credential: { keyId: "k", fingerprint: "f", savedAt: 0 } }), forgetChallenge: () => undefined, outstandingNonceCount: () => 0 } as unknown as BrowserBridgePairing], + ]), + }); + await registry.registerSession("edge", "browserA", session as never); + // Serialization is per announced target, so both tabs have to be known + // before any command is routed. + registry.announceTargets(session as never, [ + announced("browserA", "tab1", 1), + announced("browserA", "tab2", 1), + ]); + + const sameTab1 = textCommand("edge", "browserA", "tab1", 1, "1"); + const sameTab2 = textCommand("edge", "browserA", "tab1", 1, "2"); + const otherTab = textCommand("edge", "browserA", "tab2", 1, "3"); + + const p1 = registry.dispatch(sameTab1); + const p2 = registry.dispatch(sameTab2); + const p3 = registry.dispatch(otherTab); + await Promise.all([p1, p2, p3]); + + // The same target never has more than one in-flight call. The + // independent tab can overlap with the same-tab serial queue; the + // per-target invariant is the property the ADR requires. + expect(maxInFlightPerTarget.get("tab1") ?? 0).toBe(1); + // Independent tab may have overlapped; we only assert it ran. + expect((orderPerTarget.get("tab2") ?? []).length).toBeGreaterThanOrEqual(2); + // The same-tab commands ran in submission order: `1` before `2`. + const tab1 = orderPerTarget.get("tab1") ?? []; + const start1 = tab1.indexOf("start:1"); + const end1 = tab1.indexOf("end:1"); + const start2 = tab1.indexOf("start:2"); + expect(start1).toBeLessThan(end1); + expect(end1).toBeLessThan(start2); + }); + + it("returns an explicit refusal when no live session matches the target", async () => { + const registry = makeRoutingRegistry(["edge"]); + const outcome = await registry.dispatch(textCommand("edge", "ghost", "ghost", 1)); + expect(outcome.kind).toBe("rejected"); + }); +}); + +// Reserved imports keep tree-shaking honest for typings that are only used +// through duck-typing in this file. +void (null as unknown as WebSocket); \ No newline at end of file diff --git a/packages/webui/test/unit/browser-bridge-server.test.ts b/packages/webui/test/unit/browser-bridge-server.test.ts new file mode 100644 index 000000000..096faace2 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-server.test.ts @@ -0,0 +1,504 @@ +// The providers the bridge lends to the browser service. +// +// The bridge is only worth anything if the Edge and Chrome surfaces resolve to +// the live extension session: the panel's image stream, the panel's input and +// the Agent's browser tools all address the session through these providers. +// These tests drive the real chain — a loopback `http.Server`, the production +// `WebuiWebSocketServer` and a real `BrowserBridgeServer` — with a real `ws` +// extension client, so what is asserted is what a paired extension would see. + +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { + BrowserBridgeServer, + createInMemorySecureCredentialStore, +} from "../../src/server/browser-bridge/index.js"; +import { WebuiWebSocketServer } from "../../src/server/websocket-server.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import { createWebuiCredential } from "../../src/server/credentials.js"; +import type { + WebuiBrowserImageFrame, + WebuiBrowserInputCommand, +} from "../../src/shared/contracts/browser.js"; + +const EDGE_EXTENSION_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; +const BROWSER_ID = "edge-instance-1"; +const TARGET_ID = "TAB-1"; + +interface Harness { + readonly bridge: BrowserBridgeServer; + readonly httpServer: Server; + readonly wsServer: WebuiWebSocketServer; + readonly port: number; + close(): Promise; +} + +const openHarnesses: Harness[] = []; + +afterEach(async () => { + while (openHarnesses.length > 0) { + await openHarnesses.pop()?.close(); + } +}); + +async function startHarness(): Promise { + const bridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + credentialStore: createInMemorySecureCredentialStore(), + env: { MAVIS_BROWSER_EDGE_EXTENSION_IDS: EDGE_EXTENSION_ID }, + }); + await bridge.start(); + // The bridge does not switch a surface on by itself: the browser switches + // own the pairings, and the service is where the two meet. A test switches + // on exactly the surfaces its environment configures, which is what the + // persisted switches do in a running host. + await bridge.enableSurface("edge"); + const httpServer = createServer(); + const wsServer = new WebuiWebSocketServer({ + maxMessageBytes: 256 * 1024, + heartbeatIntervalMs: 15_000, + host: "127.0.0.1", + boundTcpPort: () => (httpServer.address() as AddressInfo | null)?.port, + credential: createWebuiCredential(), + dev: true, + operations: new Map(), + isAccepting: () => true, + browserBridge: bridge, + }); + httpServer.on("upgrade", wsServer.handleUpgrade); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => resolve()); + }); + const harness: Harness = { + bridge, + httpServer, + wsServer, + port: (httpServer.address() as AddressInfo).port, + close: async () => { + await bridge.close(); + wsServer.closeConnections(); + await wsServer.close(); + // A test that failed mid-handshake can leave a half-open socket; the + // listener would otherwise wait for it for the platform TCP timeout. + httpServer.closeAllConnections(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, + }; + openHarnesses.push(harness); + return harness; +} + +interface BridgeFrame { + readonly kind: string; + readonly [key: string]: unknown; +} + +/** + * A real extension client. + * + * Every frame is recorded from the moment the socket exists, before `open` is + * awaited. That matters: the host issues its challenge as soon as the upgrade + * completes, so a listener attached after `await open` can miss it. + */ +interface BridgeClient { + readonly socket: WebSocket; + readonly frames: BridgeFrame[]; + readonly opened: Promise; + waitFor(kind: string, timeoutMs?: number): Promise; +} + +function connectClient(url: string, headers: Record): BridgeClient { + const frames: BridgeFrame[] = []; + const socket = new WebSocket(url, { headers }); + socket.on("message", (data) => { + try { + frames.push(JSON.parse(String(data)) as BridgeFrame); + } catch { + // Not a bridge frame; the session reports that itself. + } + }); + const opened = new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("unexpected-response", (_request, response) => { + reject(new Error(`upgrade refused with ${response.statusCode}`)); + }); + socket.once("error", reject); + }); + const waitFor = async ( + kind: string, + timeoutMs = 4_000, + ): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = frames.find((frame) => frame.kind === kind); + if (found) return found as T & BridgeFrame; + if (Date.now() > deadline) throw new Error(`timed out waiting for a "${kind}" frame`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + return { socket, frames, opened, waitFor }; +} + +/** + * Connect as an allowed extension, answer the challenge with the pairing's own + * credential, and wait until the host reports the session ready. + */ +async function pairEdge(harness: Harness): Promise { + const client = connectClient(`ws://127.0.0.1:${harness.port}${WEBUI_BROWSER_BRIDGE_PATH}`, { + origin: `chrome-extension://${EDGE_EXTENSION_ID}/`, + }); + await client.opened; + // A real extension introduces itself; the host reads the instance identity + // off this frame. + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: BROWSER_ID, + extensionVersion: "1.0.0", + }), + ); + const pairing = harness.bridge.pairings.get("edge"); + if (!pairing) throw new Error("no edge pairing"); + const challenge = await client.waitFor<{ nonce: string; keyId: string }>("challenge"); + const signature = await pairing.signProof(challenge.nonce); + client.socket.send( + JSON.stringify({ kind: "proof", keyId: challenge.keyId, nonce: challenge.nonce, signature }), + ); + await client.waitFor("ready"); + // The provider is handed out on `ready`, so let the registration settle. + await settle(); + return client; +} + +const settle = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 150)); +}; + +function announceTarget(client: BridgeClient, generation: number): void { + client.socket.send( + JSON.stringify({ + kind: "targets", + targets: [ + { + browserId: BROWSER_ID, + targetId: TARGET_ID, + title: "Example", + url: "https://example.com/", + generation, + }, + ], + }), + ); +} + +describe("WebUI browser bridge — the providers it lends to the browser service", () => { + it("answers undefined for a surface with no connected extension", async () => { + const harness = await startHarness(); + // "Nothing is paired" and "you may not use this surface" are different + // facts; the first is what an unpaired Chrome reports here. + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + expect(harness.bridge.createProvider("chrome")).toBeUndefined(); + }); + + it("lists the announced targets of the live extension session", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge"); + expect(provider).toBeDefined(); + expect(provider?.surface).toBe("edge"); + // The instance identity comes from the extension's own `hello` frame. + expect(provider?.browserId).toBe(BROWSER_ID); + + announceTarget(client, 4); + await settle(); + + const targets = await provider!.listTargets(); + expect(targets).toHaveLength(1); + expect(targets[0]?.id).toEqual({ + surface: "edge", + browserId: BROWSER_ID, + targetId: TARGET_ID, + generation: 4, + }); + expect(targets[0]?.title).toBe("Example"); + expect(targets[0]?.status).toBe("open"); + client.socket.terminate(); + }); + + it("stops answering once the extension session goes away", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + expect(harness.bridge.createProvider("edge")).toBeDefined(); + client.socket.terminate(); + await settle(); + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + }); + + it("forwards a raw DevTools command rather than refusing it", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge"); + expect(provider).toBeDefined(); + + // The runtime decides whether a raw DevTools method is allowed — the + // Full CDP switch — and the provider does not keep a second opinion + // about that. What it must not do is refuse the command on the surface's + // own authority, because that would make the Full CDP switch a lie. + const outcome = await provider! + .sendCommand( + { targetId: "tab-1", generation: 1 }, + "Page.navigate", + { url: "https://example.com/" }, + ) + .catch((error: unknown) => error); + expect(outcome).not.toMatchObject({ code: "browser_capability_denied" }); + client.socket.terminate(); + }); + + it("drops an extension frame whose generation the announcement moved past", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + announceTarget(client, 7); + await settle(); + + const received: WebuiBrowserImageFrame[] = []; + await provider.startImageStream(TARGET_ID, (frame) => received.push(frame)); + // A frame produced for the page the tab was on before the announcement + // moved on. Relabelling it with the announcement's generation would put + // the previous page on screen as the current one, so it is dropped. + client.socket.send( + JSON.stringify({ + kind: "frame", + frame: { + browserId: BROWSER_ID, + targetId: TARGET_ID, + generation: 1, + data: "c3RhbGU=", + format: "png", + sequence: 4, + }, + }), + ); + await settle(); + + expect(received).toHaveLength(0); + }); + + it("holds no credential and admits no session for a surface that is off", async () => { + const harness = await startHarness(); + // The surface is on because the harness switched it on, the way a + // switched-on browser would. + const on = harness.bridge.snapshot().surfaces.edge; + expect(on.enabled).toBe(true); + + // Turning it off revokes the credential, closes the session and refuses + // the old credential from then on. The other surface is untouched: the two + // browsers' authorizations are independent. + await harness.bridge.disableSurface("edge"); + const off = harness.bridge.snapshot().surfaces.edge; + expect(off.enabled).toBe(false); + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + + // A fresh session is refused: the pairing holds no credential to answer a + // challenge with. + const client = connectClient(`ws://127.0.0.1:${harness.port}${WEBUI_BROWSER_BRIDGE_PATH}`, { + origin: `chrome-extension://${EDGE_EXTENSION_ID}/`, + }); + await client.opened; + client.socket.send( + JSON.stringify({ kind: "hello", browser: "edge", browserId: "edge-1", extensionVersion: "1.0.0" }), + ); + await settle(); + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + }); + + it("delivers an extension frame to startImageStream with the announced identity", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + announceTarget(client, 7); + await settle(); + + const received: WebuiBrowserImageFrame[] = []; + await provider.startImageStream(TARGET_ID, (frame) => received.push(frame)); + client.socket.send( + JSON.stringify({ + kind: "frame", + frame: { + browserId: BROWSER_ID, + targetId: TARGET_ID, + // The frame's generation matches the announcement's, so it belongs to + // the page the tab is on now. + generation: 7, + data: "aW1hZ2U=", + format: "png", + sequence: 3, + }, + }), + ); + await settle(); + + expect(received).toHaveLength(1); + expect(received[0]?.target).toEqual({ + surface: "edge", + browserId: BROWSER_ID, + targetId: TARGET_ID, + generation: 7, + }); + expect(received[0]?.data).toBe("aW1hZ2U="); + expect(received[0]?.format).toBe("png"); + expect(received[0]?.sequence).toBe(3); + + await provider.stopImageStream(TARGET_ID); + client.socket.send( + JSON.stringify({ + kind: "frame", + frame: { + browserId: BROWSER_ID, + targetId: TARGET_ID, + generation: 7, + data: "aWdub3JlZA==", + format: "png", + sequence: 4, + }, + }), + ); + await settle(); + expect(received).toHaveLength(1); + client.socket.terminate(); + }); + + it("dispatches an input through the registry and answers accepted", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + announceTarget(client, 1); + await settle(); + + const command: WebuiBrowserInputCommand = { + kind: "pointer", + target: { surface: "edge", browserId: BROWSER_ID, targetId: TARGET_ID, generation: 1 }, + x: 120, + y: 240, + button: "left", + clickCount: 1, + }; + const pending = provider.sendInput(command); + const input = await client.waitFor<{ commandId: string }>("input"); + client.socket.send( + JSON.stringify({ kind: "input-result", commandId: input.commandId, accepted: true }), + ); + await expect(pending).resolves.toBeUndefined(); + client.socket.terminate(); + }); + + it("refuses an input naming a target no session announced, in the registry's words", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + + // The routing is the registry's, so the refusal is the registry's explicit + // one — this provider only chooses the code that goes with it. + await expect( + provider.sendInput({ + kind: "text", + target: { + surface: "edge", + browserId: BROWSER_ID, + targetId: "TAB-NEVER-ANNOUNCED", + generation: 1, + }, + text: "hello", + }), + ).rejects.toMatchObject({ code: "browser_target_unknown" }); + await expect( + provider.sendInput({ + kind: "text", + target: { + surface: "edge", + browserId: BROWSER_ID, + targetId: "TAB-NEVER-ANNOUNCED", + generation: 1, + }, + text: "hello", + }), + ).rejects.toThrow(/no bridge session announced target/u); + client.socket.terminate(); + }); + + it("refuses an input whose generation the announcement has moved past", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + announceTarget(client, 9); + await settle(); + + await expect( + provider.sendInput({ + kind: "key", + target: { surface: "edge", browserId: BROWSER_ID, targetId: TARGET_ID, generation: 8 }, + key: "a", + modifiers: [], + }), + ).rejects.toMatchObject({ code: "browser_target_stale" }); + client.socket.terminate(); + }); + + it("keeps close() a no-op so a borrowed provider cannot drop a live session", async () => { + const harness = await startHarness(); + const client = await pairEdge(harness); + const provider = harness.bridge.createProvider("edge")!; + await provider.close(); + // The bridge owns the session's lifetime, not the provider's. + expect(harness.bridge.createProvider("edge")).toBeDefined(); + client.socket.terminate(); + }); +}); + +describe("the bridge's extension identity", () => { + it("a packaged host accepts the store identity and refuses the development one", async () => { + // The development allowlist names the ids of unpacked builds. A packaged + // host must not accept them: an unpacked build's id is derived from the + // directory it was loaded from, so it is not an identity anyone can rely + // on. + const developmentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; + const storeId = "cccccccccccccccccccccccccccccccd"; + + const packaged = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + packaged: true, + credentialStore: createInMemorySecureCredentialStore(), + env: { + MAVIS_BROWSER_CHROME_EXTENSION_IDS: developmentId, + MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS: storeId, + }, + }); + expect( + packaged.pairings.get("chrome")!.admitsExtensionId(developmentId), + "a packaged host must not admit a development identity", + ).toBe(false); + expect(packaged.pairings.get("chrome")!.admitsExtensionId(storeId)).toBe(true); + await packaged.close(); + }); + + it("a development host accepts the development identity", async () => { + const developmentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; + const development = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + credentialStore: createInMemorySecureCredentialStore(), + env: { MAVIS_BROWSER_CHROME_EXTENSION_IDS: developmentId }, + }); + expect(development.pairings.get("chrome")!.admitsExtensionId(developmentId)).toBe(true); + await development.close(); + }); +}); diff --git a/packages/webui/test/unit/browser-bridge-session.test.ts b/packages/webui/test/unit/browser-bridge-session.test.ts new file mode 100644 index 000000000..ce6cd278e --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-session.test.ts @@ -0,0 +1,357 @@ +// End-to-end wire test: a real `ws` server on loopback, a real client, +// and the bridge session handling the post-upgrade exchange. The test +// proves: +// * A valid challenge and proof results in a `ready` frame and an open +// session. +// * A wrong proof never results in `ready`. +// * `dev: true` on the runtime side does NOT cause an unaccepted +// extension (one without a proof) to ever receive `ready`. +// +// The test stands up its own loopback HTTP server; it does not touch any +// file owned by other engineers. + +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; + +import { + BrowserBridgePairing, + BrowserBridgeSession, + admitBrowserBridgeUpgrade, + createInMemorySecureCredentialStore, + exportWebuiBrowserBridgeEcdhPublicKey, + generateWebuiBrowserBridgeEcdhKeyPair, + type BrowserBridgeAdmissionContext, + type WebuiBrowserBridgeEcdhPublicKey, +} from "../../src/server/browser-bridge/index.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import type { + WebuiBrowserBridgeChallenge, + WebuiBrowserBridgeClientFrame, + WebuiBrowserBridgeHostFrame, +} from "../../src/shared/contracts/browser-bridge.js"; + +const KNOWN_EDGE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; + +interface Harness { + url: string; + readonly close: () => Promise; + closed: boolean; + readonly wss: WebSocketServer; + readonly context: BrowserBridgeAdmissionContext; + readonly pairing: BrowserBridgePairing; + readonly sessions: BrowserBridgeSession[]; + readonly receivedClientFrames: WebuiBrowserBridgeClientFrame[]; +} + +async function startHarness( + extensionId: string, + dev: boolean, + options: { readonly pingIntervalMs?: number } = {}, +): Promise { + const pairing = new BrowserBridgePairing({ + surface: "edge", + credentialStore: createInMemorySecureCredentialStore(), + allowedExtensionIds: new Set([extensionId]), + }); + await pairing.enable(); + + const context: BrowserBridgeAdmissionContext = { + pairings: new Map([["edge", pairing]]), + host: "127.0.0.1", + dev, + }; + + const httpServer = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const sessions: BrowserBridgeSession[] = []; + const receivedClientFrames: WebuiBrowserBridgeClientFrame[] = []; + + const harness: Harness = { + url: "", + close: async () => { + for (const session of sessions) session.close({ kind: "host_revoked" }); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => httpServer.close(() => resolve())); + harness.closed = true; + }, + closed: false, + wss, + context, + pairing, + sessions, + receivedClientFrames, + }; + + httpServer.on("upgrade", (request, socket, head) => { + const rejection = admitBrowserBridgeUpgrade(request, request.url, socket, context); + if (rejection) return; + wss.handleUpgrade(request, socket, head, (ws) => { + const session = new BrowserBridgeSession({ + pairings: [pairing], + socket: ws as unknown as never, + sessionId: `s-${sessions.length}`, + ...(options.pingIntervalMs ? { pingIntervalMs: options.pingIntervalMs } : {}), + }); + sessions.push(session); + }); + }); + + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const port = (httpServer.address() as AddressInfo).port; + harness.url = `ws://127.0.0.1:${port}${WEBUI_BROWSER_BRIDGE_PATH}`; + return harness; +} + +async function connectClient( + harness: Harness, + extensionId: string, + options: { readonly publicKey?: unknown } = {}, +): Promise<{ readonly client: WebSocket; readonly frames: WebuiBrowserBridgeHostFrame[] }> { + const port = new URL(harness.url).port; + const client = new WebSocket(harness.url, { + origin: `chrome-extension://${extensionId}/`, + headers: { host: `127.0.0.1:${port}` }, + }); + const frames: WebuiBrowserBridgeHostFrame[] = []; + client.on("message", (data: RawData) => { + frames.push(JSON.parse(data.toString()) as WebuiBrowserBridgeHostFrame); + }); + await once(client, "open"); + // Send a `hello` so the session can send `pair` and `challenge` together; + // the production client does this from `WebuiBrowserBridgeClient`. An + // absent `publicKey` is treated as "this build is too old to pair", and + // is exercised in another test. + client.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: "browser-test-1", + extensionVersion: "test", + ...(options.publicKey !== undefined ? { publicKey: options.publicKey } : {}), + }), + ); + return { client, frames }; +} + +describe("WebUI bridge session (wire-level)", () => { + let harness: Harness; + + beforeEach(async () => { + harness = await startHarness(KNOWN_EDGE_ID, false); + }); + + afterEach(async () => { + if (!harness.closed) await harness.close(); + }); + + it("sends no pair before the proof: a peer that cannot prove gets no credential", async () => { + const publicKey = await makeClientPublicKey(); + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { publicKey }); + + // The order is the security property: `challenge` leaves first and + // nothing else does. A `pair` frame here would hand this host's + // credential to anything that could open a socket, which is exactly the + // defect the installation key exists to close. + await waitForKind(frames, "challenge"); + expect(frames.some((frame) => frame.kind === "pair")).toBe(false); + client.close(); + }); + + it("emits a challenge after the upgrade and admits a valid proof", async () => { + const publicKey = await makeClientPublicKey(); + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { publicKey }); + + const challengeFrame = await waitForKind(frames, "challenge"); + expect(challengeFrame.kind).toBe("challenge"); + expect(challengeFrame.algorithm).toBe("HMAC-SHA256"); + expect(challengeFrame.installationKeyId).toMatch(/^[!-~]+$/u); + expect(harness.sessions).toHaveLength(1); + expect(harness.sessions[0]!.isAdmitted).toBe(false); + + // Answer with the pairing credential this host already issued. That is + // the "already paired" branch: admitted, and no `pair` frame, because the + // peer already holds that exact credential. + const proof = await harness.pairing.signProof(challengeFrame.nonce); + client.send( + JSON.stringify({ + kind: "proof", + keyId: challengeFrame.keyId, + nonce: challengeFrame.nonce, + signature: proof, + }), + ); + + const readyFrame = await waitForKind(frames, "ready"); + expect(readyFrame.kind).toBe("ready"); + expect(frames.some((frame) => frame.kind === "pair")).toBe(false); + expect(harness.sessions[0]!.isAdmitted).toBe(true); + client.close(); + }); + + it("delivers the sealed credential to a peer that proves the installation key", async () => { + const publicKey = await makeClientPublicKey(); + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { publicKey }); + + const challengeFrame = await waitForKind(frames, "challenge"); + client.send( + JSON.stringify({ + kind: "proof", + keyId: challengeFrame.installationKeyId, + nonce: challengeFrame.nonce, + signature: await harness.pairing.signInstallationKeyProof(challengeFrame.nonce), + }), + ); + + // Only now does a credential leave the host. + const pairFrame = await waitForKind(frames, "pair"); + expect(pairFrame.kind).toBe("pair"); + expect(pairFrame.keyId).toBe(harness.pairing.keyId); + expect(pairFrame.keyId).toBe(challengeFrame.keyId); + await waitForKind(frames, "ready"); + client.close(); + }); + + it("sends no pair for a hello without a public key, and the handshake cannot complete", async () => { + // An older extension build sends no public key. The host must still send + // the challenge — refusing the connection outright would make the + // incompatibility a silent hang rather than a wrong proof the extension + // reports on. + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID); + + const challengeFrame = await waitForKind(frames, "challenge"); + expect(frames.some((frame) => frame.kind === "pair")).toBe(false); + + // This side never received the credential, so any proof it could write is + // a guess. The session closes on the wrong proof rather than sitting open. + client.send( + JSON.stringify({ + kind: "proof", + keyId: challengeFrame.keyId, + nonce: challengeFrame.nonce, + signature: "an-extension-that-was-never-paired", + }), + ); + await once(client, "close"); + expect(frames.some((frame) => frame.kind === "ready")).toBe(false); + }); + + it("refuses a public key that is not a P-256 JWK", async () => { + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { + publicKey: { kty: "RSA", crv: "P-256", x: "AAAA", y: "AAAA" }, + }); + + await waitForKind(frames, "challenge"); + expect(frames.some((frame) => frame.kind === "pair")).toBe(false); + client.close(); + }); + + it("sends no pair once the surface's switch has been turned off", async () => { + // The session is already admitted over an open socket, and the person + // then flips the switch. A NEW connection must not be handed anything. + await harness.pairing.disable(); + + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { + publicKey: await makeClientPublicKey(), + }); + + // No pair, and no challenge either: the pairing holds no credential, so + // the session closes rather than inviting a proof it cannot verify. + await once(client, "close"); + expect(frames.some((frame) => frame.kind === "pair")).toBe(false); + expect(frames.some((frame) => frame.kind === "challenge")).toBe(false); + }); + + it("the dev: true flag does NOT bypass extension authentication", async () => { + await harness.close(); + harness = await startHarness(KNOWN_EDGE_ID, true); + + const publicKey = await makeClientPublicKey(); + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { publicKey }); + await waitForKind(frames, "challenge"); + const challengeFrame = frames.find((frame) => frame.kind === "challenge")!; + // Send a deliberately wrong proof — the extension did NOT authenticate. + client.send( + JSON.stringify({ + kind: "proof", + keyId: challengeFrame.keyId, + nonce: challengeFrame.nonce, + signature: "this-is-not-the-proof", + }), + ); + + // The session must close the socket before any "ready" frame is sent. + await once(client, "close"); + + // No "ready" frame must ever have arrived. + expect(frames.some((frame) => frame.kind === "ready")).toBe(false); + // A `closed` frame is the documented refusal at the session level. + // (Either the host sent it explicitly, or the socket went away; either + // path is fine — what we care about is that `ready` did not appear.) + expect(harness.pairing.enabled).toBe(true); + }); + + it("emits a liveness ping and closes the session when the pong does not come", async () => { + await harness.close(); + harness = await startHarness(KNOWN_EDGE_ID, true, { pingIntervalMs: 60 }); + + const publicKey = await makeClientPublicKey(); + const { client, frames } = await connectClient(harness, KNOWN_EDGE_ID, { publicKey }); + + // Admit the peer first: the liveness ping belongs to an admitted session. + const challengeFrame = await waitForKind(frames, "challenge"); + const proof = await harness.pairing.signProof(challengeFrame.nonce); + client.send( + JSON.stringify({ + kind: "proof", + keyId: challengeFrame.keyId, + nonce: challengeFrame.nonce, + signature: proof, + }), + ); + await waitForKind(frames, "ready"); + + // The host pings once the peer is admitted, and the peer here does not + // answer: a socket that looks open but has nobody behind it is closed, + // so the extension's reconnect ladder can pick it up. + const ping = await waitForKind(frames, "ping"); + expect(ping.nonce).toMatch(/^ping-/u); + + const closed = await once(client, "close"); + expect(closed).toBeDefined(); + const closeFrame = frames.find((frame) => frame.kind === "closed"); + expect(closeFrame && "reason" in closeFrame ? closeFrame.reason : "").toContain( + "liveness ping", + ); + }); +}); + +/* -------------------------------------------------------------------------- */ + +async function waitForKind( + frames: readonly WebuiBrowserBridgeHostFrame[], + kind: K, +): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const found = frames.find((frame) => frame.kind === kind) as + | Extract + | undefined; + if (found) return found; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for "${kind}" frame after 5s`); +} + +/** + * Build an extension-side ECDH public key for the test. The private half is + * discarded — the test only exercises the host's encrypt side, never the + * decrypt side, and not keeping it makes that intent obvious. + */ +async function makeClientPublicKey(): Promise { + const { publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + return exportWebuiBrowserBridgeEcdhPublicKey(publicKey); +} + +void (null as unknown as void); diff --git a/packages/webui/test/unit/browser-bridge-upgrade.test.ts b/packages/webui/test/unit/browser-bridge-upgrade.test.ts new file mode 100644 index 000000000..0ab6c3de0 --- /dev/null +++ b/packages/webui/test/unit/browser-bridge-upgrade.test.ts @@ -0,0 +1,508 @@ +// The dedicated `/browser-bridge` upgrade branch. +// +// This file is the headline proof for F3: the bridge is reachable from the +// running WebUI's upgrade path, AND the generic path's development-mode +// bypass provably does not admit an unauthenticated extension. Both +// composition roots run with `dev: true`, so a bypass here would hand every +// Browser Use call to any unpacked extension during a mis-served dev cycle. +// +// The harness stands up the real chain the composition root builds — an +// `http.Server`, the production `WebuiWebSocketServer` with the bridge in its +// context, and the real `BrowserBridgeServer` — so the branch under test is +// the one that ships, not a re-implementation of it. + +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { + BrowserBridgeServer, + createInMemorySecureCredentialStore, + exportWebuiBrowserBridgeEcdhPublicKey, + generateWebuiBrowserBridgeEcdhKeyPair, + type WebuiBrowserBridgeEcdhPublicKey, +} from "../../src/server/browser-bridge/index.js"; +import { WebuiWebSocketServer } from "../../src/server/websocket-server.js"; +import { WEBUI_BROWSER_BRIDGE_PATH } from "../../src/shared/contracts/browser-bridge.js"; +import { createWebuiCredential } from "../../src/server/credentials.js"; + +const KNOWN_EDGE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; +const KNOWN_CHROME_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba"; +const UNKNOWN_EXTENSION_ID = "cccccccccccccccccccccccccccccccd"; + +interface Harness { + readonly bridge?: BrowserBridgeServer; + readonly httpServer: Server; + readonly wsServer: WebuiWebSocketServer; + readonly port: number; + close(): Promise; +} + +const openHarnesses: Harness[] = []; + +afterEach(async () => { + while (openHarnesses.length > 0) { + await openHarnesses.pop()?.close(); + } +}); + +function attachBridge(httpServer: Server, bridge: BrowserBridgeServer | undefined): WebuiWebSocketServer { + const wsServer = new WebuiWebSocketServer({ + maxMessageBytes: 256 * 1024, + heartbeatIntervalMs: 15_000, + host: "127.0.0.1", + boundTcpPort: () => (httpServer.address() as AddressInfo | null)?.port, + credential: createWebuiCredential(), + dev: true, + operations: new Map(), + isAccepting: () => true, + ...(bridge ? { browserBridge: bridge } : {}), + }); + httpServer.on("upgrade", wsServer.handleUpgrade); + return wsServer; +} + +async function startHarness(options?: { + readonly dev?: boolean; + readonly env?: Record; +}): Promise { + const dev = options?.dev ?? true; + const bridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev, + credentialStore: createInMemorySecureCredentialStore(), + env: options?.env ?? { MAVIS_BROWSER_EDGE_EXTENSION_IDS: KNOWN_EDGE_ID }, + }); + await bridge.start(); + // The bridge does not switch a surface on by itself: the browser switches + // own the pairings, and the service is where the two meet. A test switches + // on exactly the surfaces its environment configures, which is what the + // persisted switches do in a running host. + const configuredEnv = options?.env ?? { MAVIS_BROWSER_EDGE_EXTENSION_IDS: KNOWN_EDGE_ID }; + if (configuredEnv.MAVIS_BROWSER_EDGE_EXTENSION_IDS) await bridge.enableSurface("edge"); + if (configuredEnv.MAVIS_BROWSER_CHROME_EXTENSION_IDS) await bridge.enableSurface("chrome"); + const httpServer = createServer(); + const wsServer = attachBridge(httpServer, bridge); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => resolve()); + }); + const harness = track({ + bridge, + httpServer, + wsServer, + port: (httpServer.address() as AddressInfo).port, + close: async () => { + await bridge.close(); + wsServer.closeConnections(); + await wsServer.close(); + // A test that failed mid-handshake can leave a half-open socket; the + // listener would otherwise wait for it for the platform TCP timeout. + httpServer.closeAllConnections(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, + }); + return harness as Harness & { readonly bridge: BrowserBridgeServer }; +} + +function track(harness: Harness): Harness { + openHarnesses.push(harness); + return harness; +} + +const settle = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 150)); +}; + +/** + * Attempt one bridge upgrade and report how the host answered. + * + * `ws` surfaces a non-101 answer as `unexpected-response`, which is the + * refusal carrying its status; a socket destroyed without one surfaces as + * `error`. Both are refusals — the distinction is only which side spoke first. + */ +async function attemptUpgrade( + port: number, + headers: Record, + timeoutMs = 4_000, +): Promise<{ readonly refused: boolean; readonly status?: number }> { + return await new Promise<{ readonly refused: boolean; readonly status?: number }>((resolve) => { + const client = new WebSocket(`ws://127.0.0.1:${port}${WEBUI_BROWSER_BRIDGE_PATH}`, { + headers, + }); + const finish = (result: { readonly refused: boolean; readonly status?: number }): void => { + clearTimeout(timer); + try { + client.terminate(); + } catch { + // already gone + } + resolve(result); + }; + const timer = setTimeout(() => finish({ refused: true }), timeoutMs); + client.once("open", () => finish({ refused: false })); + client.once("unexpected-response", (_request, response) => { + finish({ refused: true, status: response.statusCode }); + }); + client.once("error", () => finish({ refused: true })); + }); +} + +interface BridgeFrame { + readonly kind: string; + readonly [key: string]: unknown; +} + +/** A real extension client that records every frame from the socket's birth. */ +function connectClient(port: number, headers: Record) { + const frames: BridgeFrame[] = []; + const socket = new WebSocket(`ws://127.0.0.1:${port}${WEBUI_BROWSER_BRIDGE_PATH}`, { headers }); + socket.on("message", (data) => { + try { + frames.push(JSON.parse(String(data)) as BridgeFrame); + } catch { + // Not a bridge frame; the session reports that itself. + } + }); + const opened = new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("unexpected-response", (_request, response) => { + reject(new Error(`upgrade refused with ${response.statusCode}`)); + }); + socket.once("error", reject); + }); + const waitFor = async ( + kind: string, + timeoutMs = 4_000, + ): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = frames.find((frame) => frame.kind === kind); + if (found) return found as T & BridgeFrame; + if (Date.now() > deadline) throw new Error(`timed out waiting for a "${kind}" frame`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + return { socket, frames, opened, waitFor }; +} + +/** A public key for tests that don't care about pairing, just about the wire shape. */ +async function bogusClientPublicKey(): Promise { + const { publicKey } = await generateWebuiBrowserBridgeEcdhKeyPair(); + return exportWebuiBrowserBridgeEcdhPublicKey(publicKey); +} + +/** Connect, answer the challenge with the pairing's own secret, wait for ready. */ +async function pairExtension( + harness: Harness & { readonly bridge: BrowserBridgeServer }, + surface: "edge" | "chrome", + browserId: string, +) { + const extensionId = surface === "edge" ? KNOWN_EDGE_ID : KNOWN_CHROME_ID; + const client = connectClient(harness.port, { origin: `chrome-extension://${extensionId}/` }); + await client.opened; + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: surface, + browserId, + extensionVersion: "1.0.0", + publicKey: await bogusClientPublicKey(), + }), + ); + const pairing = harness.bridge.pairings.get(surface); + if (!pairing) throw new Error(`no pairing for ${surface}`); + const challenge = await client.waitFor<{ nonce: string; keyId: string }>("challenge"); + const signature = await pairing.signProof(challenge.nonce); + client.socket.send( + JSON.stringify({ kind: "proof", keyId: challenge.keyId, nonce: challenge.nonce, signature }), + ); + return client; +} + +describe("WebUI browser bridge — the dedicated upgrade branch", () => { + // The headline: with the WebUI in development mode — which is how both + // composition roots actually run — the bridge still refuses an extension + // that has not proved anything. Each case is one way an unauthenticated + // extension could try to get in. + it("refuses every unauthenticated extension while the WebUI runs in dev mode", async () => { + const harness = await startHarness({ dev: true }); + + // A loopback HTTP page is what the generic path admits. Here it is not an + // extension at all, so it is not admitted. + const wrongOrigin = await attemptUpgrade(harness.port, { origin: "http://127.0.0.1:9999/" }); + expect(wrongOrigin.refused).toBe(true); + expect(wrongOrigin.status).toBe(403); + + const noOrigin = await attemptUpgrade(harness.port, {}); + expect(noOrigin.refused).toBe(true); + + // A real `chrome-extension://` origin whose id is on nobody's allowlist. + const unlistedExtension = await attemptUpgrade(harness.port, { + origin: `chrome-extension://${UNKNOWN_EXTENSION_ID}/`, + }); + expect(unlistedExtension.refused).toBe(true); + expect(unlistedExtension.status).toBe(403); + + // A forged Host header is the shape of a DNS-rebinding attempt. The host + // this service bound to is loopback; anything else is refused before the + // origin is even read. + const wrongHost = await attemptUpgrade(harness.port, { + host: `evil.example.com:${harness.port}`, + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + expect(wrongHost.refused).toBe(true); + expect(wrongHost.status).toBe(403); + + // An extension that connects but cannot prove possession of the pairing + // credential never receives `ready`, so it never becomes a live session. + const client = connectClient(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + await client.opened; + // A hello with a valid public key kicks the gated handshake. The + // extension on this side is a fake that just answers the challenge — + // the point of the test is what the host does with a WRONG proof. + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId: "edge-bogus", + extensionVersion: "1.0.0", + publicKey: await bogusClientPublicKey(), + }), + ); + const challenge = await client.waitFor<{ nonce: string; keyId: string }>("challenge"); + client.socket.send( + JSON.stringify({ + kind: "proof", + keyId: challenge.keyId, + nonce: challenge.nonce, + signature: "not-a-valid-hmac", + }), + ); + await settle(); + expect(client.frames.map((frame) => frame.kind)).not.toContain("ready"); + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + client.socket.terminate(); + }); + + it("refuses a bridge upgrade with 404 when no bridge is wired", async () => { + const httpServer = createServer(); + const wsServer = attachBridge(httpServer, undefined); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (httpServer.address() as AddressInfo).port; + const result = await attemptUpgrade(port, { origin: `chrome-extension://${KNOWN_EDGE_ID}/` }); + expect(result.refused).toBe(true); + expect(result.status).toBe(404); + wsServer.closeConnections(); + await wsServer.close(); + httpServer.closeAllConnections(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }); + + it("admits an allowed extension and reports ready after a valid proof", async () => { + const harness = await startHarness({ + dev: true, + env: { MAVIS_BROWSER_CHROME_EXTENSION_IDS: KNOWN_CHROME_ID }, + }); + const client = await pairExtension(harness, "chrome", "chrome-1"); + const ready = await client.waitFor<{ sessionId: string }>("ready"); + expect(ready.sessionId).toBeTruthy(); + await settle(); + expect(harness.bridge.createProvider("chrome")).toBeDefined(); + // Edge was never configured, so nothing may pair on it — an absent + // allowlist fails closed rather than open. + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + const snapshot = harness.bridge.snapshot(); + expect(snapshot.surfaces.chrome.connected).toBe(true); + expect(snapshot.surfaces.edge.configured).toBe(false); + client.socket.terminate(); + }); + + it("leaves a surface off when its extension allowlist is empty", async () => { + const harness = await startHarness({ dev: true, env: {} }); + const snapshot = harness.bridge.snapshot(); + expect(snapshot.surfaces.edge.configured).toBe(false); + expect(snapshot.surfaces.chrome.configured).toBe(false); + // The pairing still holds its own minted credential, but no extension id + // may use it, so admission has nothing to match. + const refused = await attemptUpgrade(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + expect(refused.refused).toBe(true); + expect(refused.status).toBe(403); + }); + + it("does not let a bridge path reach the generic bearer-token admission", async () => { + const harness = await startHarness({ dev: false }); + // `dev: false` is the production posture: the generic path demands the + // per-start bearer token. A bridge request must still be judged by the + // bridge's own rules, never by that one — so an extension with a correct + // id is judged on the pairing, and the page token is irrelevant either way. + const refusedWithoutProof = await attemptUpgrade(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + expect(refusedWithoutProof.refused).toBe(false); + const client = await pairExtension(harness, "edge", "edge-1"); + await client.waitFor("ready"); + await settle(); + expect(harness.bridge.createProvider("edge")).toBeDefined(); + client.socket.terminate(); + }); +}); + +/** + * The installation-key exchange, over a real socket. + * + * Finding N1: admission trusted the `Origin` header to pick an allowlisted + * extension id, and the host then sealed its credential to whatever key the + * peer's `hello` carried. Any local process could spoof that origin, mint its + * own ECDH key, decrypt the credential and answer the challenge — so "the + * origin is allowlisted" was doing no work. + * + * These tests are the wire-level counterpart. They open an actual `ws` socket + * with the allowlisted origin, so what is asserted is what a real attacker + * would observe: the frames that arrive, and the ones that never do. + */ +describe("WebUI browser bridge — the installation key gates the credential", () => { + /** Hello with the allowlisted origin and a key the host will seal to. */ + async function sayHello( + harness: Harness & { readonly bridge: BrowserBridgeServer }, + client: ReturnType, + browserId: string, + ): Promise { + client.socket.send( + JSON.stringify({ + kind: "hello", + browser: "edge", + browserId, + extensionVersion: "1.0.0", + publicKey: await bogusClientPublicKey(), + }), + ); + } + + it("sends no pair frame at all to a peer that cannot prove the installation key", async () => { + const harness = await startHarness({ dev: true }); + const pairing = harness.bridge.pairings.get("edge")!; + + // The attacker: a local process that knows the allowlisted extension id, + // spoofs the origin, and holds a secret of its own choosing. + const client = connectClient(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + await client.opened; + await sayHello(harness, client, "edge-attacker"); + + const challenge = await client.waitFor<{ nonce: string; keyId: string; installationKeyId: string }>( + "challenge", + ); + + // It answers with its own key and its own signature. Both halves are + // wrong: the key id is not one this host issued for that purpose, and the + // HMAC is not over this host's secret. + client.socket.send( + JSON.stringify({ + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }), + ); + await settle(); + + // The headline assertion, on the wire: no `pair` frame was ever sent, so + // there was no sealed credential to decrypt and nothing to replay. + expect(client.frames.map((frame) => frame.kind)).not.toContain("pair"); + expect(client.frames.map((frame) => frame.kind)).not.toContain("ready"); + // And the refusal names what was missing rather than failing generically. + const closed = client.frames.find((frame) => frame.kind === "closed") as + | { readonly reason: string } + | undefined; + expect(closed?.reason).toContain("installation key does not match"); + expect(harness.bridge.createProvider("edge")).toBeUndefined(); + // The host still holds its own credential; nothing was leaked or revoked. + expect(pairing.keyId).toBeTruthy(); + client.socket.terminate(); + }); + + it("sends the sealed credential to a peer that proves the installation key", async () => { + const harness = await startHarness({ dev: true }); + const pairing = harness.bridge.pairings.get("edge")!; + + const client = connectClient(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + await client.opened; + await sayHello(harness, client, "edge-real"); + + const challenge = await client.waitFor<{ nonce: string; keyId: string; installationKeyId: string }>( + "challenge", + ); + expect(challenge.installationKeyId).toBe(pairing.installationKeyId); + + client.socket.send( + JSON.stringify({ + kind: "proof", + keyId: challenge.installationKeyId, + nonce: challenge.nonce, + signature: await pairing.signInstallationKeyProof(challenge.nonce), + }), + ); + + // The credential arrives only now, after the proof. + const pair = await client.waitFor<{ keyId: string; payload: string }>("pair"); + expect(pair.keyId).toBe(pairing.keyId); + await client.waitFor("ready"); + await settle(); + expect(harness.bridge.createProvider("edge")).toBeDefined(); + client.socket.terminate(); + }); + + it("admits a peer that holds the pairing credential without the installation key", async () => { + const harness = await startHarness({ dev: true }); + const client = await pairExtension(harness, "edge", "edge-already-paired"); + await client.waitFor("ready"); + await settle(); + // Already paired means already holding the credential, so no `pair` frame + // is re-sent: re-sealing it would put the credential on the wire again for + // no gain, and would blur the property that `pair` follows the installation + // key proof. + expect(client.frames.map((frame) => frame.kind)).not.toContain("pair"); + expect(harness.bridge.createProvider("edge")).toBeDefined(); + client.socket.terminate(); + }); + + it("refuses an unknown key id with a reason that names it", async () => { + const harness = await startHarness({ dev: true }); + const client = connectClient(harness.port, { + origin: `chrome-extension://${KNOWN_EDGE_ID}/`, + }); + await client.opened; + await sayHello(harness, client, "edge-unknown-key"); + + const challenge = await client.waitFor<{ nonce: string }>("challenge"); + client.socket.send( + JSON.stringify({ + kind: "proof", + keyId: "some-key-this-host-never-issued", + nonce: challenge.nonce, + signature: "AAAA", + }), + ); + await settle(); + const closed = client.frames.find((frame) => frame.kind === "closed") as + | { readonly reason: string } + | undefined; + expect(closed?.reason).toContain("names a key this host does not hold"); + expect(client.frames.map((frame) => frame.kind)).not.toContain("pair"); + client.socket.terminate(); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/unit/browser-input-command.test.ts b/packages/webui/test/unit/browser-input-command.test.ts new file mode 100644 index 000000000..270db98c5 --- /dev/null +++ b/packages/webui/test/unit/browser-input-command.test.ts @@ -0,0 +1,297 @@ +// Browser input commands: what may be forwarded, and what may not. +// +// The panel is the only place that knows what the user aimed at, so it is also +// the only place that can refuse a gesture before it becomes a page action. These +// functions are pure precisely so that refusal can be asserted here rather than +// discovered in a browser: this package has no DOM in its test environment, and +// the decision under test is a decision, not an interaction. + +import { describe, expect, it } from "vitest"; + +import { + WEBUI_BROWSER_AUTHORIZATION_DEFAULT, + type WebuiBrowserInputModifier, + type WebuiBrowserTargetId, +} from "../../src/shared/contracts/browser.js"; +import { + WEBUI_BROWSER_TEXT_MAX_LENGTH, + applyWebuiBrowserAuthorizationChange, + buildWebuiBrowserInputCommand, + checkWebuiBrowserInputTarget, + prepareWebuiBrowserInputCommand, + webuiBrowserAuthorizationChange, + webuiBrowserModifierFlags, + webuiBrowserPointerButton, +} from "../../src/client/features/browser/application/browser-commands.js"; +import type { WebuiBrowserPanelState } from "../../src/client/features/browser/contracts/browser-panel.js"; + +function target(overrides: Partial = {}): WebuiBrowserTargetId { + return { surface: "isolated-chromium", browserId: "chromium-1", targetId: "tab-1", generation: 7, ...overrides }; +} + +function panelState(selected?: WebuiBrowserTargetId): WebuiBrowserPanelState { + return { + authorization: WEBUI_BROWSER_AUTHORIZATION_DEFAULT, + surface: "isolated-chromium", + targets: [], + frames: {}, + status: selected ? "loading" : "idle", + droppedFrameCount: 0, + selectedTarget: selected, + }; +} + +describe("malformed input is refused, never forwarded", () => { + it("refuses a non-finite coordinate with a stable reason", () => { + for (const x of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + const result = buildWebuiBrowserInputCommand( + { kind: "pointer", x, y: 4, button: "left", clickCount: 1 }, + target(), + ); + expect(result.ok).toBe(false); + if (result.ok) continue; + expect(result.reason).toBe("pointer_coordinate"); + expect(result.error.code).toBe("browser_command_invalid"); + } + }); + + it("refuses a negative coordinate rather than clamping it to the page edge", () => { + // A clamped coordinate aims at somewhere the user never pointed, which is a + // worse failure than a refused one. + const result = buildWebuiBrowserInputCommand( + { kind: "pointer", x: -1, y: 10, button: "left", clickCount: 1 }, + target(), + ); + expect(result).toMatchObject({ ok: false, reason: "pointer_coordinate" }); + }); + + it("refuses an unknown button and a fractional click count", () => { + expect( + buildWebuiBrowserInputCommand( + { kind: "pointer", x: 1, y: 1, button: "back" as unknown as "left", clickCount: 1 }, + target(), + ), + ).toMatchObject({ ok: false, reason: "pointer_button" }); + expect( + buildWebuiBrowserInputCommand( + { kind: "pointer", x: 1, y: 1, button: "left", clickCount: 1.5 }, + target(), + ), + ).toMatchObject({ ok: false, reason: "pointer_click_count" }); + }); + + it("refuses an empty text payload but keeps a single space", () => { + // The person typing a space meant a space; trimming it would insert nothing + // where they expected one character. + expect(buildWebuiBrowserInputCommand({ kind: "text", text: "" }, target())).toMatchObject({ + ok: false, + reason: "text_empty", + }); + expect(buildWebuiBrowserInputCommand({ kind: "text", text: " " }, target())).toMatchObject({ ok: true }); + }); + + it("refuses a text payload past the cap instead of truncating it", () => { + const result = buildWebuiBrowserInputCommand( + { kind: "text", text: "a".repeat(WEBUI_BROWSER_TEXT_MAX_LENGTH + 1) }, + target(), + ); + expect(result).toMatchObject({ ok: false, reason: "text_too_long" }); + }); + + it("refuses an unknown modifier instead of dropping it", () => { + // Dropping it would send a bare keystroke where the user held a key down, + // which is how a shortcut turns into the command it stood in for. + const result = buildWebuiBrowserInputCommand( + { + kind: "key", + key: "k", + modifiers: ["super"] as unknown as readonly WebuiBrowserInputModifier[], + }, + target(), + ); + expect(result).toMatchObject({ ok: false, reason: "key_modifier" }); + }); + + it("refuses an empty key name and an unusable target identity", () => { + expect(buildWebuiBrowserInputCommand({ kind: "key", key: " ", modifiers: [] }, target())).toMatchObject({ + ok: false, + reason: "key_name", + }); + expect( + buildWebuiBrowserInputCommand({ kind: "text", text: "hi" }, target({ targetId: "" })), + ).toMatchObject({ ok: false, reason: "target_identity" }); + expect( + buildWebuiBrowserInputCommand({ kind: "text", text: "hi" }, target({ generation: Number.NaN })), + ).toMatchObject({ ok: false, reason: "target_identity" }); + }); +}); + +describe("a command carries the generation it was aimed at", () => { + it("builds the command against the target it was given", () => { + const aimed = target({ generation: 12, targetId: "tab-9" }); + const result = buildWebuiBrowserInputCommand({ kind: "pointer", x: 3, y: 4, button: "right", clickCount: 2 }, aimed); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.command).toEqual({ + kind: "pointer", + target: aimed, + x: 3, + y: 4, + button: "right", + clickCount: 2, + }); + expect(result.command.target.generation).toBe(12); + }); + + it("keeps the modifiers a set, in a fixed order", () => { + const result = buildWebuiBrowserInputCommand( + { kind: "key", key: "Enter", code: "Enter", modifiers: ["shift", "alt", "shift"] }, + target(), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.command.kind).toBe("key"); + if (result.command.kind !== "key") return; + expect(result.command.modifiers).toEqual(["alt", "shift"]); + // A named key produces no character, so none is carried. + expect(result.command.text).toBeUndefined(); + }); + + it("reads DOM modifier flags in a fixed order", () => { + expect( + webuiBrowserModifierFlags({ altKey: false, ctrlKey: true, metaKey: false, shiftKey: true }), + ).toEqual(["ctrl", "shift"]); + }); + + it("maps a mouse button number, treating anything unknown as a left click", () => { + expect(webuiBrowserPointerButton(0)).toBe("left"); + expect(webuiBrowserPointerButton(1)).toBe("middle"); + expect(webuiBrowserPointerButton(2)).toBe("right"); + expect(webuiBrowserPointerButton(4)).toBe("left"); + }); +}); + +describe("a command is refused unless it names the page being shown", () => { + it("accepts a command aimed at the shown target and generation", () => { + const shown = target(); + const result = prepareWebuiBrowserInputCommand(panelState(shown), { + kind: "pointer", + x: 10, + y: 20, + button: "left", + clickCount: 1, + }); + + expect(result.ok).toBe(true); + }); + + it("refuses a command bound to a generation the page has moved past", () => { + const shown = target({ generation: 8 }); + const stale = checkWebuiBrowserInputTarget(shown, target({ generation: 7 })); + + expect(stale).toMatchObject({ + ok: false, + reason: "target_generation_behind", + error: { code: "browser_target_stale" }, + }); + }); + + it("refuses a command bound to a generation the panel has not adopted yet", () => { + // Forwarding it would act on a page the person cannot see. + expect(checkWebuiBrowserInputTarget(target({ generation: 8 }), target({ generation: 9 }))).toMatchObject({ + ok: false, + reason: "target_generation_ahead", + }); + }); + + it("refuses a command aimed at another tab entirely", () => { + expect(checkWebuiBrowserInputTarget(target({ targetId: "tab-1" }), target({ targetId: "tab-2" }))).toMatchObject({ + ok: false, + error: { code: "browser_target_stale" }, + }); + }); + + it("refuses every command while no target is selected", () => { + const result = prepareWebuiBrowserInputCommand(panelState(undefined), { kind: "text", text: "hi" }); + + expect(result).toMatchObject({ + ok: false, + reason: "target_unknown", + error: { code: "browser_target_unknown" }, + }); + }); + + it("refuses a malformed gesture even when the target is right", () => { + // The binding check must not paper over a bad payload: the two are separate + // facts and the caller is told which one failed. + const shown = target(); + expect( + prepareWebuiBrowserInputCommand(panelState(shown), { + kind: "pointer", + x: Number.NaN, + y: 0, + button: "left", + clickCount: 1, + }), + ).toMatchObject({ ok: false, reason: "pointer_coordinate" }); + }); +}); + +describe("the four authorization switches", () => { + const allOn = { isolatedChromium: true, edge: true, chrome: true, fullCdp: true }; + + it("defaults every switch off", () => { + expect(WEBUI_BROWSER_AUTHORIZATION_DEFAULT).toEqual({ + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, + }); + }); + + it("names exactly one field on the wire, so a write cannot clear the others", () => { + for (const [surface, field] of [ + ["isolated-chromium", "isolatedChromium"], + ["edge", "edge"], + ["chrome", "chrome"], + ["full-cdp", "fullCdp"], + ] as const) { + const change = webuiBrowserAuthorizationChange({ surface, enabled: true }); + expect(change).toEqual({ [field]: true }); + // The other three are absent, not `false`: absent is what the change does + // not speak about, and the write therefore cannot clear them. + expect(Object.keys(change)).toHaveLength(1); + } + }); + + it("changes exactly the switch it names", () => { + const off = WEBUI_BROWSER_AUTHORIZATION_DEFAULT; + expect(applyWebuiBrowserAuthorizationChange(off, webuiBrowserAuthorizationChange({ surface: "edge", enabled: true }))) + .toEqual({ isolatedChromium: false, edge: true, chrome: false, fullCdp: false }); + expect(applyWebuiBrowserAuthorizationChange(off, webuiBrowserAuthorizationChange({ surface: "chrome", enabled: true }))) + .toEqual({ isolatedChromium: false, edge: false, chrome: true, fullCdp: false }); + expect(applyWebuiBrowserAuthorizationChange(off, webuiBrowserAuthorizationChange({ surface: "isolated-chromium", enabled: true }))) + .toEqual({ isolatedChromium: true, edge: false, chrome: false, fullCdp: false }); + }); + + it("leaves the surfaces untouched when Full CDP moves", () => { + // The independence the ADR requires: Full CDP widens the command set of a + // browser that is already on, and never enables one. + expect( + applyWebuiBrowserAuthorizationChange(allOn, webuiBrowserAuthorizationChange({ surface: "full-cdp", enabled: false })), + ).toEqual({ isolatedChromium: true, edge: true, chrome: true, fullCdp: false }); + + const offSurfaces = { isolatedChromium: false, edge: false, chrome: false, fullCdp: false }; + expect( + applyWebuiBrowserAuthorizationChange(offSurfaces, webuiBrowserAuthorizationChange({ surface: "full-cdp", enabled: true })), + ).toEqual({ isolatedChromium: false, edge: false, chrome: false, fullCdp: true }); + }); + + it("leaves Full CDP untouched when a surface moves", () => { + expect( + applyWebuiBrowserAuthorizationChange(allOn, webuiBrowserAuthorizationChange({ surface: "edge", enabled: false })), + ).toEqual({ isolatedChromium: true, edge: false, chrome: true, fullCdp: true }); + }); +}); diff --git a/packages/webui/test/unit/browser-panel-state.test.ts b/packages/webui/test/unit/browser-panel-state.test.ts new file mode 100644 index 000000000..5545d50d6 --- /dev/null +++ b/packages/webui/test/unit/browser-panel-state.test.ts @@ -0,0 +1,400 @@ +// The browser panel's state transitions. +// +// These are the decisions the panel cannot make in a browser without a DOM: +// which frame survives, what happens when a tab navigates under it, and how much +// a live stream is allowed to accumulate. The view renders whatever this file +// returns, so every property asserted here is a property the panel has. + +import { describe, expect, it } from "vitest"; + +import type { + WebuiBrowserImageFrame, + WebuiBrowserTargetId, + WebuiBrowserTargetList, +} from "../../src/shared/contracts/browser.js"; +import { + initialWebuiBrowserPanelState, + reduceWebuiBrowserPanelState, + selectWebuiBrowserFrame, + selectWebuiBrowserStreamTarget, + webuiBrowserStreamTargetKey, + selectedWebuiBrowserRunTarget, + webuiBrowserFrameDataUrl, +} from "../../src/client/features/browser/projection/browser-state.js"; +import { + webuiBrowserFrameKey, + type WebuiBrowserPanelState, +} from "../../src/client/features/browser/contracts/browser-panel.js"; + +function target(overrides: Partial = {}): WebuiBrowserTargetId { + return { + surface: "isolated-chromium", + browserId: "chromium-1", + targetId: "tab-1", + generation: 3, + ...overrides, + }; +} + +function frame(overrides: Partial = {}): WebuiBrowserImageFrame { + return { + target: target(), + data: "AAAA", + format: "png", + sequence: 1, + ...overrides, + }; +} + +/** A panel showing `selected`, with one frame already delivered for it. */ +function showingSelectedTarget(selected: WebuiBrowserTargetId = target()): WebuiBrowserPanelState { + const withSelection = reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "target-selected", + target: selected, + }); + return reduceWebuiBrowserPanelState(withSelection, { type: "frame", frame: frame({ target: selected }) }); +} + +describe("the frame buffer", () => { + it("keeps only the newest frame per target", () => { + // A live stream pushes a frame per visual change, so an append-only buffer + // would grow for as long as the panel is open. The bound is structural here: + // one entry per target key, whatever arrives afterwards. + let state = showingSelectedTarget(); + for (let sequence = 2; sequence <= 40; sequence += 1) { + state = reduceWebuiBrowserPanelState(state, { + type: "frame", + frame: frame({ sequence, data: `QUJD${sequence}` }), + }); + } + + expect(Object.keys(state.frames)).toHaveLength(1); + expect(selectWebuiBrowserFrame(state)?.sequence).toBe(40); + // Base64 alphabet only: the frame's bytes go into a `data:` URL verbatim, so + // this is also the shape the panel is willing to render. + expect(webuiBrowserFrameDataUrl(selectWebuiBrowserFrame(state))).toBe( + "data:image/png;base64,QUJD40", + ); + }); + + it("holds one frame per target rather than one per target page", () => { + // The key excludes the generation on purpose, so a tab that navigated + // replaces its entry instead of adding a second one. + const before = target({ generation: 3 }); + const after = target({ generation: 4 }); + let state = reduceWebuiBrowserPanelState(showingSelectedTarget(before), { + type: "frame", + frame: frame({ target: before, sequence: 5 }), + }); + state = reduceWebuiBrowserPanelState(state, { + type: "frame", + frame: frame({ target: after, sequence: 1 }), + }); + + expect(webuiBrowserFrameKey(before)).toBe(webuiBrowserFrameKey(after)); + expect(Object.keys(state.frames)).toHaveLength(1); + expect(selectWebuiBrowserFrame(state)?.target.generation).toBe(4); + }); +}); + +describe("generation awareness", () => { + it("drops a frame older than the generation being shown", () => { + // The panel moved to generation 4; a frame for generation 3 is a picture of + // the page that has already been replaced. Displaying it is the exact + // failure ADR 0019 names: the panel shows one page while the Agent drives + // another. + const shown = target({ generation: 4 }); + const state = reduceWebuiBrowserPanelState(showingSelectedTarget(shown), { + type: "frame", + frame: frame({ target: target({ generation: 3 }), sequence: 9 }), + }); + + expect(selectWebuiBrowserFrame(state)?.target.generation).toBe(4); + expect(state.droppedFrameCount).toBe(1); + }); + + it("drops a reordered frame rather than going backwards in the stream", () => { + const held = reduceWebuiBrowserPanelState(showingSelectedTarget(), { + type: "frame", + frame: frame({ sequence: 12 }), + }); + const state = reduceWebuiBrowserPanelState(held, { + type: "frame", + frame: frame({ sequence: 11, data: "STALE" }), + }); + + expect(selectWebuiBrowserFrame(state)?.data).toBe("AAAA"); + expect(state.droppedFrameCount).toBe(1); + }); + + it("reports a newer generation from the target list as a changed target", () => { + // The Agent's run navigated the tab. The panel must say so: the page the + // person was clicking is not the page that is there now, so their previous + // input is no longer valid and the buffered picture of it must go. + const before = target({ generation: 3 }); + const after = target({ generation: 4 }); + const list: WebuiBrowserTargetList = { + surface: "isolated-chromium", + targets: [{ id: after, title: "Example", url: "https://example.test/", status: "open", selected: false }], + }; + + const state = reduceWebuiBrowserPanelState(showingSelectedTarget(before), { + type: "targets-loaded", + list, + }); + + expect(state.targetChange).toEqual({ target: after, previousGeneration: 3 }); + expect(state.selectedTarget).toEqual(after); + // The buffered frame belonged to generation 3. + expect(selectWebuiBrowserFrame(state)).toBeUndefined(); + }); + + it("clears the selection rather than promoting another tab when the chosen one is gone", () => { + // ADR 0019: never silently switch to another tab. An empty panel says what + // happened; a different page would not. + const other = target({ targetId: "tab-2" }); + const state = reduceWebuiBrowserPanelState(showingSelectedTarget(), { + type: "targets-loaded", + list: { + surface: "isolated-chromium", + targets: [{ id: other, title: "Other", url: "https://other.test/", status: "open", selected: false }], + }, + }); + + expect(state.selectedTarget).toBeUndefined(); + expect(selectWebuiBrowserFrame(state)).toBeUndefined(); + expect(state.targets[0]?.selected).toBe(false); + expect(state.status).toBe("empty"); + }); + + it("follows the active run's target and drops the frame it was showing", () => { + const previous = target(); + const next = target({ targetId: "tab-2" }); + const state = reduceWebuiBrowserPanelState(showingSelectedTarget(previous), { + type: "target-changed", + target: next, + }); + + expect(state.selectedTarget).toEqual(next); + expect(state.targetChange).toEqual({ target: next, previousGeneration: 3 }); + expect(selectWebuiBrowserFrame(state)).toBeUndefined(); + }); +}); + +describe("the panel's status", () => { + it("is ready only while the selected target holds a frame of that generation", () => { + const selected = target({ generation: 2 }); + const empty = reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "target-selected", + target: selected, + }); + expect(empty.status).toBe("loading"); + + const ready = showingSelectedTarget(selected); + expect(ready.status).toBe("ready"); + }); + + it("reports an empty surface rather than an empty frame", () => { + const state = reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "targets-loaded", + list: { surface: "edge", targets: [] }, + }); + + expect(state.status).toBe("empty"); + expect(state.targets).toEqual([]); + }); + + it("carries the stable error code the panel branches on", () => { + const state = reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "failed", + error: { code: "browser_pairing_rejected", message: "配对被拒绝。", retryable: false }, + }); + + expect(state.status).toBe("error"); + expect(state.error?.code).toBe("browser_pairing_rejected"); + }); +}); + +/** A target list, as the host's read for one run returns it. */ +function listOf( + targets: readonly WebuiBrowserTargetId[], + selected?: WebuiBrowserTargetId, +): WebuiBrowserTargetList { + return { + surface: "isolated-chromium", + targets: targets.map((id) => ({ + id, + title: "Example", + url: "https://example.test/", + status: "open" as const, + selected: selected !== undefined && webuiBrowserFrameKey(id) === webuiBrowserFrameKey(selected), + })), + }; +} + +/** + * The panel follows the active run (ADR 0019). + * + * The selection belongs to a run, so these are the decisions the projection + * makes when runs take over from each other: whose target is marked, and what + * the panel shows while it waits for the new run's answer. + */ +describe("following the active run", () => { + const first = target({ targetId: "tab-1" }); + const second = target({ targetId: "tab-2" }); + + it("reads the run's own selected flag rather than the panel's memory", () => { + // The list is what the service answered for the run the panel asked as. A + // run that has selected nothing answers with nothing selected. + expect(selectedWebuiBrowserRunTarget(listOf([first, second], second))).toEqual(second); + expect(selectedWebuiBrowserRunTarget(listOf([first, second]))).toBeUndefined(); + }); + + it("marks the active run's target and follows it when the run changes", () => { + const showingFirst = reduceWebuiBrowserPanelState( + reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "targets-loaded", + list: listOf([first, second], first), + selected: first, + }), + { type: "frame", frame: frame({ target: first }) }, + ); + expect(showingFirst.targets.map((entry) => entry.selected)).toEqual([true, false]); + + // The next read is for the run that took over, and its answer is tab-2. + const following = reduceWebuiBrowserPanelState(showingFirst, { + type: "targets-loaded", + list: listOf([first, second], second), + selected: second, + }); + expect(following.selectedTarget).toEqual(second); + expect(following.targets.map((entry) => entry.selected)).toEqual([false, true]); + }); + + it("forgets the previous run's target when the active run changes", () => { + const showingFirst = reduceWebuiBrowserPanelState( + reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "targets-loaded", + list: listOf([first, second], first), + selected: first, + }), + { type: "frame", frame: frame({ target: first }) }, + ); + + // Between the two runs' answers the panel claims no target: showing the + // previous run's page as if it were the new run's is the disagreement the + // ADR names. The buffered frames stay — they are per tab, not per run. + const between = reduceWebuiBrowserPanelState(showingFirst, { type: "run-changed" }); + expect(between.selectedTarget).toBeUndefined(); + expect(between.targets.every((entry) => !entry.selected)).toBe(true); + expect(Object.keys(between.frames)).toHaveLength(1); + + // A new run that picks the same tab shows it at once, without a new frame. + const resumed = reduceWebuiBrowserPanelState(between, { + type: "targets-loaded", + list: listOf([first, second], first), + selected: first, + }); + expect(selectWebuiBrowserFrame(resumed)?.target).toEqual(first); + }); + + it("shows nothing selected while the active run has selected nothing", () => { + // The panel with no run behind it: the no-run list has no selected target, + // and the panel must not keep the previous run's selection in its place. + const previous = reduceWebuiBrowserPanelState( + reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "targets-loaded", + list: listOf([first, second], first), + selected: first, + }), + { type: "run-changed" }, + ); + const idle = reduceWebuiBrowserPanelState(previous, { + type: "targets-loaded", + list: listOf([first, second]), + }); + + expect(idle.selectedTarget).toBeUndefined(); + expect(idle.targets.every((entry) => !entry.selected)).toBe(true); + }); +}); + +describe("webuiBrowserFrameDataUrl", () => { + it("builds a data URL for a frame it can render", () => { + expect(webuiBrowserFrameDataUrl(frame({ data: "QUJD", format: "jpeg" }))).toBe( + "data:image/jpeg;base64,QUJD", + ); + }); + + it("refuses a payload outside the base64 alphabet instead of handing it to src", () => { + // A `data:` URL is built from the payload verbatim, so anything that is not + // image bytes must not reach `src` at all. + expect(webuiBrowserFrameDataUrl(frame({ data: "" }))).toBeUndefined(); + expect(webuiBrowserFrameDataUrl(frame({ data: "" }))).toBeUndefined(); + }); + + it("refuses a format outside the two the contract allows", () => { + const forged = { ...frame(), format: "svg+xml" } as unknown as WebuiBrowserImageFrame; + expect(webuiBrowserFrameDataUrl(forged)).toBeUndefined(); + }); +}); + +describe("the target the live image stream is open for", () => { + it("is the run's selected target", () => { + const state = reduceWebuiBrowserPanelState( + initialWebuiBrowserPanelState, + { + type: "targets-loaded", + list: { + surface: "isolated-chromium", + targets: [ + { + id: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }, + title: "Example", + url: "https://example.test", + status: "open", + selected: false, + }, + ], + }, + }, + ); + const selected = { + surface: "isolated-chromium" as const, + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }; + const afterSelection = reduceWebuiBrowserPanelState(state, { + type: "target-selected", + target: selected, + }); + expect(selectWebuiBrowserStreamTarget(afterSelection)).toEqual(selected); + }); + + it("is nothing when the run has selected nothing", () => { + // The server refuses a stream for a run that has selected nothing, so the + // panel opens no subscription: a subscription that can never deliver a + // frame is a subscription that leaves the panel blank. + expect(selectWebuiBrowserStreamTarget(initialWebuiBrowserPanelState)).toBeUndefined(); + }); + + it("names the browser, the tab and the generation, so a replaced page re-opens the stream", () => { + const target = { + surface: "isolated-chromium" as const, + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }; + expect(webuiBrowserStreamTargetKey(target)).toBe("browser-1:tab-1:1"); + // A tab that was replaced is a different page even though it is the same + // tab, so the key has to change with the generation. + expect(webuiBrowserStreamTargetKey({ ...target, generation: 2 })).toBe("browser-1:tab-1:2"); + expect(webuiBrowserStreamTargetKey(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/webui/test/unit/browser-panel-view.test.tsx b/packages/webui/test/unit/browser-panel-view.test.tsx new file mode 100644 index 000000000..c5f34fe71 --- /dev/null +++ b/packages/webui/test/unit/browser-panel-view.test.tsx @@ -0,0 +1,339 @@ +// The Browser panel's markup. +// +// Rendered with `renderToStaticMarkup`, which runs no effects — so the injected +// port acts purely as a capability-presence toggle here and none of its callbacks +// is invoked. What is asserted is what the panel says and shows in the frame a +// person sees before any load resolves, plus the two things a static render is +// uniquely good at proving: that the picture is an `` over a `data:` URL and +// not an embedded document, and that the switch row a Settings page mounts is +// four independent switches with Full CDP apart from the other three. + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + WEBUI_BROWSER_AUTHORIZATION_DEFAULT, + type WebuiBrowserAuthorization, + type WebuiBrowserTargetState, +} from "../../src/shared/contracts/browser.js"; +import type { BrowserPort } from "../../src/client/features/browser/contracts/browser-port.js"; +import { initialWebuiBrowserPanelState, reduceWebuiBrowserPanelState } from "../../src/client/features/browser/projection/browser-state.js"; +import { BrowserPanel } from "../../src/client/features/browser/ui/BrowserPanel.public.js"; +import { + BrowserAuthorizationSwitches, + BrowserInstallationKey, +} from "../../src/client/features/browser/ui/BrowserAuthorizationSwitches.public.js"; + +function targetState(overrides: Partial = {}): WebuiBrowserTargetState { + return { + id: { surface: "isolated-chromium", browserId: "chromium-1", targetId: "tab-1", generation: 4 }, + title: "Example Domain", + url: "https://example.test/", + status: "open", + selected: true, + ...overrides, + }; +} + +/** A panel state with one authorized surface, one selected tab and one frame. */ +function loadedState() { + const selected = targetState().id; + const authorized: WebuiBrowserAuthorization = { ...WEBUI_BROWSER_AUTHORIZATION_DEFAULT, isolatedChromium: true }; + let state = reduceWebuiBrowserPanelState({ ...initialWebuiBrowserPanelState, authorization: authorized }, { + type: "targets-loaded", + list: { surface: "isolated-chromium", targets: [targetState()] }, + selected, + }); + state = reduceWebuiBrowserPanelState(state, { + type: "frame", + frame: { target: selected, data: "QUJD", format: "png", sequence: 7 }, + }); + return state; +} + +/** All-optional, like every port in this package: presence is the assertion. */ +const wiredPort: BrowserPort = { + getBrowserAuthorization: () => Promise.resolve(WEBUI_BROWSER_AUTHORIZATION_DEFAULT), + listBrowserTargets: () => Promise.resolve({ surface: "isolated-chromium", targets: [] }), + sendBrowserInput: () => Promise.resolve({ accepted: true }), + watchBrowserFrames: () => () => {}, +}; + +describe("the Browser panel", () => { + it("shows the live frame as an image over a data URL, not an embedded page", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="browser-panel-frame"'); + expect(markup).toContain('src="data:image/png;base64,QUJD"'); + expect(markup).toContain('data-target-generation="4"'); + expect(markup).toContain('data-frame-sequence="7"'); + }); + + it("embeds nothing: no iframe, no srcdoc, no blob URL, no inner HTML", () => { + // The whole isolation story of this panel is that the target site is never a + // document in this page. An iframe would give it the WebUI's origin and the + // session token; the assertion is here because that mistake is silent — the + // panel would still look right. + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain(" { + // `renderToStaticMarkup` cannot see a forbidden API that a branch never + // reached, so the file itself is checked for the three ways a document could + // get in here. Comments are stripped first: this module explains *why* it + // uses an image, and the word appears in that explanation. + const source = readFileSync( + new URL("../../src/client/features/browser/ui/BrowserPanel.public.tsx", import.meta.url), + "utf8", + ) + .replace(/\/\*[\s\S]*?\*\//gu, " ") + .replace(/(^|[^:])\/\/[^\n]*/gu, "$1 "); + + expect(source).not.toContain("iframe"); + expect(source).not.toContain("srcdoc"); + expect(source).not.toContain("blob:"); + expect(source).not.toContain("dangerouslySetInnerHTML"); + }); + + it("lists every target with its browser, generation and status", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="browser-target-list"'); + expect(markup).toContain('data-testid="browser-target-chromium-1-tab-1"'); + expect(markup).toContain("Example Domain"); + expect(markup).toContain("https://example.test/"); + expect(markup).toContain("第 4 代"); + expect(markup).toContain('aria-current="true"'); + }); + + it("renders a closed target rather than hiding it", () => { + // A closed target stays in the list so a stale reference is explainable. + const state = reduceWebuiBrowserPanelState(loadedState(), { + type: "targets-loaded", + list: { + surface: "isolated-chromium", + targets: [targetState({ status: "closed" })], + }, + }); + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-target-status="closed"'); + expect(markup).toContain("已关闭"); + }); + + it("says the browser is switched off instead of showing a dead frame", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="browser-panel-unauthorized"'); + expect(markup).toContain('data-testid="browser-panel-frame-empty"'); + expect(markup).not.toContain('data-testid="browser-panel-frame"'); + }); + + it("announces a target whose generation moved, so stale input is not silently kept", () => { + const state = reduceWebuiBrowserPanelState(loadedState(), { + type: "targets-loaded", + list: { + surface: "isolated-chromium", + targets: [ + targetState({ + id: { surface: "isolated-chromium", browserId: "chromium-1", targetId: "tab-1", generation: 5 }, + selected: false, + }), + ], + }, + }); + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-testid="browser-panel-target-changed"'); + expect(markup).toContain("第 5 代"); + // The buffered picture belonged to generation 4, so it is not displayed. + expect(markup).not.toContain('data-testid="browser-panel-frame"'); + }); + + it("reports the stable error code alongside the message", () => { + const state = reduceWebuiBrowserPanelState(initialWebuiBrowserPanelState, { + type: "failed", + error: { code: "browser_provider_unavailable", message: "浏览器提供方不可用。", retryable: true }, + }); + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-error-code="browser_provider_unavailable"'); + expect(markup).toContain("浏览器提供方不可用。"); + }); + + it("names each of the three browser surfaces and whether it is authorized", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-testid="browser-surface-isolated-chromium"'); + expect(markup).toContain('data-testid="browser-surface-edge"'); + expect(markup).toContain('data-testid="browser-surface-chrome"'); + expect(markup).toContain('data-authorized="true"'); + expect(markup).toContain('data-authorized="false"'); + }); + + describe("capability states", () => { + // A panel that renders as an empty box when the family is missing is + // indistinguishable from one that is merely still loading, so each state gets + // its own sentence. + it("declines an absent family", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-testid="browser-panel-absent"'); + }); + + it("declines a not-ready family", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-testid="browser-panel-not-ready"'); + }); + + it("declines an unknown family rather than assuming support", () => { + // No manifest at all is `unknown`, which is neither "everything works" + // nor "nothing works" — and a panel that assumed support would render as + // though a browser were available. + const markup = renderToStaticMarkup(); + expect(markup).toContain('data-testid="browser-panel-unknown"'); + }); + + it("keeps the input surface out of reach until a target is selected", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-testid="browser-panel-input"'); + expect(markup).toContain('role="application"'); + expect(markup).toContain('data-testid="browser-panel-text-input"'); + }); + }); +}); + +describe("the authorization switches a Settings page mounts", () => { + it("renders four switches, all off before any read resolves", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-testid="browser-authorization-isolated-chromium"'); + expect(markup).toContain('data-testid="browser-authorization-edge"'); + expect(markup).toContain('data-testid="browser-authorization-chrome"'); + expect(markup).toContain('data-testid="browser-authorization-full-cdp"'); + expect(markup).not.toMatch(/aria-checked="true"/u); + }); + + it("keeps every switch out of reach until a read gives them a baseline", () => { + // Not merely rendered off: a switch showing an unknown state that still + // accepts a click is how a setting gets flipped by accident. + const markup = renderToStaticMarkup(); + + for (const id of [ + "browser-authorization-isolated-chromium", + "browser-authorization-edge", + "browser-authorization-chrome", + "browser-authorization-full-cdp", + ]) { + expect(markup).toMatch(new RegExp(`]*data-testid="${id}"[^>]*disabled=""`, "u")); + } + }); + + it("shows one browser's switch moving without moving the other three", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/]*aria-checked="true"[^>]*data-testid="browser-authorization-edge"/u); + expect(markup).toMatch(/]*aria-checked="false"[^>]*data-testid="browser-authorization-chrome"/u); + expect(markup).toMatch(/]*aria-checked="false"[^>]*data-testid="browser-authorization-full-cdp"/u); + expect(markup).toMatch(/]*aria-checked="false"[^>]*data-testid="browser-authorization-isolated-chromium"/u); + }); + + it("keeps Full CDP off while a surface is on", () => { + // The independence, seen in the markup: an authorized browser is not an + // authorized command set. + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/]*aria-checked="false"[^>]*data-testid="browser-authorization-full-cdp"/u); + }); + + it("keeps Full CDP in its own section and describes it as widening the command set", () => { + // The two are different risks — launching a browser versus sending any + // DevTools command — so they are not one row, and the wording says which one + // Full CDP is rather than describing it as enabling a browser. + const markup = renderToStaticMarkup( + , + ); + const fullCdpSection = markup.slice(markup.indexOf('data-testid="browser-authorization-full-cdp"')); + const surfaceSection = markup.slice( + 0, + markup.indexOf('data-testid="browser-authorization-full-cdp"'), + ); + + expect(surfaceSection).not.toContain("Full CDP"); + expect(fullCdpSection).toContain("DevTools 命令"); + expect(fullCdpSection).toContain("不会启用任何浏览器"); + expect(fullCdpSection).toContain('data-testid="browser-authorization-full-cdp-row"'); + }); +}); + +describe("the installation key a Settings page mounts", () => { + it("renders no key before one is asked for", () => { + // A static render runs no effects, and the key is fetched on a click, so + // there is nothing to leak into a first paint: the row exists, the value + // does not. + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-testid="browser-installation-key"'); + expect(markup).toContain('data-testid="browser-installation-key-edge-reveal"'); + expect(markup).toContain('data-testid="browser-installation-key-chrome-reveal"'); + expect(markup).not.toContain('data-testid="browser-installation-key-value"'); + }); + + it("keeps the reveal buttons out of reach when the host wires no bridge", () => { + const markup = renderToStaticMarkup(); + + for (const id of [ + "browser-installation-key-edge-reveal", + "browser-installation-key-chrome-reveal", + ]) { + expect(markup).toMatch(new RegExp(`]*data-testid="${id}"[^>]*disabled=""`, "u")); + } + }); + + it("offers the reveal when the host does wire the bridge", () => { + const markup = renderToStaticMarkup( + ({ + present: false, + surface: "edge", + reason: "not_provisioned", + })} + />, + ); + + for (const id of [ + "browser-installation-key-edge-reveal", + "browser-installation-key-chrome-reveal", + ]) { + expect(markup).not.toMatch(new RegExp(`]*data-testid="${id}"[^>]*disabled=""`, "u")); + } + // Still no key on the first paint: the reader is only ever called from the + // click handler, and nothing else can render one. + expect(markup).not.toContain('data-testid="browser-installation-key-value"'); + }); +}); diff --git a/packages/webui/test/unit/browser-service.test.ts b/packages/webui/test/unit/browser-service.test.ts new file mode 100644 index 000000000..9cdbd80fe --- /dev/null +++ b/packages/webui/test/unit/browser-service.test.ts @@ -0,0 +1,945 @@ +import { describe, expect, it } from "vitest"; + +import { + WebuiBrowserService, + type WebuiBrowserServiceOptions, +} from "../../src/runtime/browser/browser-service.js"; +import { WebuiBrowserOperationError } from "../../src/runtime/browser/errors.js"; +import type { WebuiBrowserSurfaceProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; +import type { + WebuiBrowserAuthorization, + WebuiBrowserImageFrame, + WebuiBrowserInputCommand, + WebuiBrowserTargetId, + WebuiBrowserTargetState, +} from "../../src/shared/contracts/browser.js"; + +/** + * A provider that records what it was asked to do. + * + * The service is what decides whether a browser may be touched at all; the + * provider only does what it is told. A fake that records the calls is + * therefore enough to prove the *decisions*: that a disabled surface is never + * reached, that a stale target is refused before the provider is asked, and + * that the input reaches the same provider the panel's frames come from. + * + * Tabs and frame handlers are keyed per target rather than held singly, because + * two runs on one surface may be watching two different tabs at the same time — + * the case the run-scoped selection exists for. + */ +class RecordingProvider implements WebuiBrowserSurfaceProvider { + readonly surface = "isolated-chromium" as const; + readonly browserId = "browser-1"; + readonly inputs: WebuiBrowserInputCommand[] = []; + readonly commands: { command: string; params: Record }[] = []; + readonly startedStreams: string[] = []; + readonly stoppedStreams: string[] = []; + readonly targetChangeListeners = new Set< + (change: { targetId: string; generation: number }) => void + >(); + /** The tabs this browser has, in listing order. */ + readonly tabIds: readonly string[]; + /** One frame handler per open stream, keyed by the tab it watches. */ + readonly frameHandlers = new Map void>(); + /** The most recently opened stream's handler. */ + frameHandler: ((frame: WebuiBrowserImageFrame) => void) | undefined; + closed = false; + + constructor(tabIds: readonly string[] = ["tab-1"]) { + this.tabIds = tabIds; + } + + onTargetChanged( + listener: (change: { targetId: string; generation: number }) => void, + ): () => void { + this.targetChangeListeners.add(listener); + return () => { + this.targetChangeListeners.delete(listener); + }; + } + + /** Announces that the page behind the target was replaced. */ + announceTargetChange(generation: number, targetId = "tab-1"): void { + for (const listener of this.targetChangeListeners) { + listener({ targetId, generation }); + } + } + + async sendCommand( + _target: unknown, + command: string, + params: Record, + ): Promise { + this.commands.push({ command, params }); + } + + async listTargets(): Promise { + return this.tabIds.map((targetId) => ({ + id: { + surface: this.surface, + browserId: this.browserId, + targetId, + generation: 1, + }, + title: "Example", + url: "https://example.test", + status: "open" as const, + selected: true, + })); + } + + async startImageStream( + targetId: string, + onFrame: (frame: WebuiBrowserImageFrame) => void, + ): Promise { + this.startedStreams.push(targetId); + this.frameHandlers.set(targetId, onFrame); + this.frameHandler = onFrame; + } + + /** + * Pushes one frame down one tab's stream. + * + * The frame is pushed on demand rather than on subscription, so a test can + * decide *when* it arrives — which is the whole point of the fencing: a + * frame that arrives after the panel moved on has to be dropped, and a frame + * that belongs to another run's tab must be dropped too. + */ + pushFrame(generation: number, targetId = "tab-1"): void { + this.frameHandlers.get(targetId)?.({ + target: { + surface: this.surface, + browserId: this.browserId, + targetId, + generation, + }, + data: "AAAA", + format: "jpeg", + sequence: 1, + }); + } + + async stopImageStream(targetId: string): Promise { + this.stoppedStreams.push(targetId); + this.frameHandlers.delete(targetId); + } + + async sendInput(command: WebuiBrowserInputCommand): Promise { + this.inputs.push(command); + } + + async close(): Promise { + this.closed = true; + } +} + +function createService( + initial: WebuiBrowserAuthorization, + tabIds: readonly string[] = ["tab-1"], +): { + service: WebuiBrowserService; + provider: RecordingProvider; + state: { authorization: WebuiBrowserAuthorization }; +} { + const provider = new RecordingProvider(tabIds); + const state = { authorization: initial }; + const options: WebuiBrowserServiceOptions = { + authorization: { + read: async () => state.authorization, + write: async (input) => { + state.authorization = { ...state.authorization, ...input }; + return state.authorization; + }, + }, + dataDir: "/tmp/webui-browser-use-test", + createIsolatedProvider: async () => provider, + }; + return { service: new WebuiBrowserService(options), provider, state }; +} + +/** A tab identity at the generation the fake provider lists it under. */ +function tab(targetId: string, generation = 1): WebuiBrowserTargetId { + return { + surface: "isolated-chromium", + browserId: "browser-1", + targetId, + generation, + }; +} + +/** The target the named run currently has selected, as the list reports it. */ +async function selectedTargetOf( + service: WebuiBrowserService, + runId?: string, +): Promise { + const list = await service.listTargets("isolated-chromium", runId); + return list.targets.find((target) => target.selected)?.id.targetId; +} + +const ALL_OFF: WebuiBrowserAuthorization = { + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, +}; + +describe("browser service", () => { + it("refuses a surface that is not authorized before touching the provider", async () => { + const { service, provider } = createService(ALL_OFF); + await expect(service.listTargets("isolated-chromium")).rejects.toMatchObject({ + code: "browser_surface_not_authorized", + }); + // The provider was never reached: a refusal that names the switch is + // different from a browser that failed. + expect(provider.inputs).toHaveLength(0); + await service.close(); + }); + + it("refuses the input of a surface that is not authorized", async () => { + const { service, provider } = createService(ALL_OFF); + await expect( + service.sendInput({ + kind: "text", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }, + text: "hello", + }), + ).rejects.toMatchObject({ code: "browser_surface_not_authorized" }); + expect(provider.inputs).toHaveLength(0); + await service.close(); + }); + + it("lists the targets of an authorized surface", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + const list = await service.listTargets("isolated-chromium"); + expect(list.surface).toBe("isolated-chromium"); + expect(list.targets).toHaveLength(1); + expect(list.targets[0]?.id.targetId).toBe("tab-1"); + await service.close(); + }); + + it("refuses an input aimed at a target whose generation moved on", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + // The registry observed the target at generation 1. + await expect( + service.sendInput({ + kind: "text", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 7, + }, + text: "hello", + }), + ).rejects.toMatchObject({ code: "browser_target_stale" }); + // The input was not delivered to whatever replaced the page. + expect(provider.inputs).toHaveLength(0); + await service.close(); + }); + + it("delivers an input that names the current generation", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await service.sendInput({ + kind: "text", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }, + text: "hello", + }); + expect(provider.inputs).toHaveLength(1); + expect(provider.inputs[0]?.kind).toBe("text"); + await service.close(); + }); + + it("refuses a malformed input before it reaches the provider", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await expect( + service.sendInput({ + kind: "pointer", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }, + x: Number.NaN, + y: 10, + button: "left", + clickCount: 1, + }), + ).rejects.toMatchObject({ code: "browser_command_invalid" }); + expect(provider.inputs).toHaveLength(0); + await service.close(); + }); + + it("stops the provider when its surface is switched off", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await service.writeAuthorization({ isolatedChromium: false }); + expect(provider.closed).toBe(true); + await expect(service.listTargets("isolated-chromium")).rejects.toMatchObject({ + code: "browser_surface_not_authorized", + }); + await service.close(); + }); + + it("keeps the four switches independent", async () => { + const { service, state } = createService(ALL_OFF); + await service.writeAuthorization({ edge: true }); + expect(state.authorization).toEqual({ + isolatedChromium: false, + edge: true, + chrome: false, + fullCdp: false, + }); + await service.writeAuthorization({ fullCdp: true }); + expect(state.authorization).toEqual({ + isolatedChromium: false, + edge: true, + chrome: false, + fullCdp: true, + }); + await service.close(); + }); + + it("reports the capability matrix per surface", async () => { + const { service } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + const { entries } = await service.listCapabilities(); + const isolated = entries.find((entry) => entry.surface === "isolated-chromium"); + const chrome = entries.find((entry) => entry.surface === "chrome"); + expect(isolated?.liveImages).toBe(true); + expect(isolated?.bridged).toBe(false); + expect(chrome?.liveImages).toBe(false); + expect(chrome?.bridged).toBe(true); + await service.close(); + }); + + it("carries the browser error code on the wire", () => { + const error = new WebuiBrowserOperationError({ + code: "browser_target_stale", + message: "stale", + retryable: false, + }); + expect(error.code).toBe("browser_target_stale"); + expect(error.retryable).toBe(false); + }); + + it("refuses an input recorded against the page a navigation replaced", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + // The provider is the party that knows the page was replaced. When it says + // so, the registry raises the generation and the input recorded against + // the previous page stops being current. + provider.announceTargetChange(2); + + await expect( + service.sendInput({ + kind: "text", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }, + text: "hello", + }), + ).rejects.toMatchObject({ code: "browser_target_stale" }); + expect(provider.inputs).toHaveLength(0); + await service.close(); + }); + + it("delivers an input recorded against the page that is current", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + provider.announceTargetChange(2); + await service.sendInput({ + kind: "text", + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 2, + }, + text: "hello", + }); + expect(provider.inputs).toHaveLength(1); + await service.close(); + }); + + it("refuses a command outside the restricted set while Full CDP is off", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await expect( + service.sendCommand("isolated-chromium", "Network.setCookies"), + ).rejects.toMatchObject({ code: "browser_capability_denied" }); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("allows a command outside the restricted set once Full CDP is on", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + fullCdp: true, + }); + // A command acts on a page, so a page has to be selected first. + await service.listTargets("isolated-chromium"); + await service.selectTarget({ + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }); + await service.sendCommand("isolated-chromium", "Network.setCookies", { + name: "a", + }); + expect(provider.commands).toHaveLength(1); + expect(provider.commands[0]?.command).toBe("Network.setCookies"); + await service.close(); + }); + + it("allows a restricted command whatever the Full CDP switch says", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await service.selectTarget({ + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }); + await service.sendCommand("isolated-chromium", "reload"); + expect(provider.commands.map((entry) => entry.command)).toEqual(["reload"]); + await service.close(); + }); + + it("refuses a command on a surface that is not authorized", async () => { + const { service, provider } = createService(ALL_OFF); + await expect( + service.sendCommand("isolated-chromium", "reload"), + ).rejects.toMatchObject({ code: "browser_surface_not_authorized" }); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("does not let Full CDP authorize a surface", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + fullCdp: true, + }); + await expect( + service.sendCommand("isolated-chromium", "reload"), + ).rejects.toMatchObject({ code: "browser_surface_not_authorized" }); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + /** Waits until the stream has subscribed, then pushes one frame. */ + async function pushWhenSubscribed( + provider: RecordingProvider, + generation: number, + ): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (provider.frameHandler) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + provider.pushFrame(generation); + } + + it("shows a frame that names the page the panel is showing", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await service.selectTarget(tab("tab-1"), "run-panel"); + + const controller = new AbortController(); + const received: WebuiBrowserImageFrame[] = []; + const stream = service.watchFrames("isolated-chromium", "run-panel", controller.signal); + const consumer = (async () => { + for await (const frame of stream) { + received.push(frame); + if (received.length >= 1) break; + } + })(); + + await pushWhenSubscribed(provider, 1); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(); + await consumer.catch(() => undefined); + + expect(received).toHaveLength(1); + expect(received[0]?.target.generation).toBe(1); + await service.close(); + }); + + it("drops a frame that names a target the panel is no longer showing", async () => { + const { service, provider } = createService({ + ...ALL_OFF, + isolatedChromium: true, + }); + await service.listTargets("isolated-chromium"); + await service.selectTarget(tab("tab-1"), "run-panel"); + + const controller = new AbortController(); + const received: WebuiBrowserImageFrame[] = []; + const stream = service.watchFrames("isolated-chromium", "run-panel", controller.signal); + const consumer = (async () => { + for await (const frame of stream) { + received.push(frame); + if (received.length >= 1) break; + } + })(); + + // The provider announces that the page behind the tab was replaced. A frame + // that arrives afterwards still names the previous page, and the panel has + // already moved past it, so it must not reach the screen. + provider.announceTargetChange(2); + await pushWhenSubscribed(provider, 1); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(); + await consumer.catch(() => undefined); + + expect(received).toHaveLength(0); + await service.close(); + }); +}); + +/** + * The selection belongs to the run that made it (ADR 0019), so these are the + * decisions two concurrent runs force on the service: neither may overwrite the + * other's selection, each stream must carry only its own run's target, and the + * panel with no run behind it must still have somewhere to put its own. + */ +describe("run-scoped selection", () => { + const TWO_TABS: WebuiBrowserAuthorization = { + ...ALL_OFF, + isolatedChromium: true, + }; + + it("keeps two runs' selections apart on one surface", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("isolated-chromium", "run-b"); + + await service.selectTarget(tab("tab-1"), "run-a"); + await service.selectTarget(tab("tab-2"), "run-b"); + + // Neither write cleared the other: the second selection did not become the + // surface's one true selection. + expect(await selectedTargetOf(service, "run-a")).toBe("tab-1"); + expect(await selectedTargetOf(service, "run-b")).toBe("tab-2"); + await service.close(); + }); + + it("reports one run's selected flag to that run only", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-2"), "run-a"); + + // The panel asks as the run it is following, so the flag it reads is that + // run's selection and not the other run's. + expect(await selectedTargetOf(service, "run-a")).toBe("tab-2"); + expect(await selectedTargetOf(service, "run-b")).toBeUndefined(); + await service.close(); + }); + + it("keeps the no-run selection separate from every run's", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium"); + await service.selectTarget(tab("tab-1")); + + // The person pointing at a page before any run exists owns that selection. + expect(await selectedTargetOf(service)).toBe("tab-1"); + expect(await selectedTargetOf(service, "run-a")).toBeUndefined(); + + await service.selectTarget(tab("tab-2"), "run-a"); + // A run's selection does not disturb the panel's own, and the other way + // round: they are different runs' selections, not two writers of one. + expect(await selectedTargetOf(service, "run-a")).toBe("tab-2"); + expect(await selectedTargetOf(service)).toBe("tab-1"); + await service.close(); + }); + + it("refuses a run's command when that run has selected nothing", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + // Another run's selection is not this run's to act on. + await service.selectTarget(tab("tab-1"), "run-b"); + + await expect( + service.sendCommandToSelection("isolated-chromium", "run-a", "reload"), + ).rejects.toMatchObject({ code: "browser_target_unknown" }); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("gives each run's stream only that run's target's frames", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("isolated-chromium", "run-b"); + await service.selectTarget(tab("tab-1"), "run-a"); + await service.selectTarget(tab("tab-2"), "run-b"); + + const first = collectFrames(service, "run-a"); + const second = collectFrames(service, "run-b"); + await waitForStreams(provider, 2); + + provider.pushFrame(1, "tab-1"); + provider.pushFrame(1, "tab-2"); + await settleFrames(); + + expect(first.frames.map((frame) => frame.target.targetId)).toEqual(["tab-1"]); + expect(second.frames.map((frame) => frame.target.targetId)).toEqual(["tab-2"]); + await stop(service, [first, second]); + }); + + it("drops a frame that belongs to another run's target", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("isolated-chromium", "run-b"); + await service.selectTarget(tab("tab-1"), "run-a"); + await service.selectTarget(tab("tab-2"), "run-b"); + + const first = collectFrames(service, "run-a"); + const second = collectFrames(service, "run-b"); + await waitForStreams(provider, 2); + + // The provider hands run A's stream a frame of run B's tab. Keeping the + // streams apart is the service's job, not the provider's, so the fence is + // against this run's own selection and the foreign frame is dropped. + provider.frameHandlers.get("tab-1")?.({ + target: { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-2", + generation: 1, + }, + data: "QlRSQVk=", + format: "jpeg", + sequence: 1, + }); + await settleFrames(); + + expect(first.frames).toHaveLength(0); + expect(second.frames).toHaveLength(0); + await stop(service, [first, second]); + }); + + it("moves every run's selection with the tab its page navigated", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("isolated-chromium", "run-b"); + await service.selectTarget(tab("tab-1"), "run-a"); + await service.selectTarget(tab("tab-2"), "run-b"); + + // The tab run A is on navigated, and so did the tab run B is on. Each run's + // selection follows *its own* tab to the new generation — otherwise run A's + // tools would refuse its own page as stale, and its stream would drop every + // frame of it. + provider.announceTargetChange(2); + provider.announceTargetChange(2, "tab-2"); + + const forA = await service.listTargets("isolated-chromium", "run-a"); + expect(forA.targets.find((target) => target.selected)?.id).toEqual( + tab("tab-1", 2), + ); + const forB = await service.listTargets("isolated-chromium", "run-b"); + expect(forB.targets.find((target) => target.selected)?.id).toEqual( + tab("tab-2", 2), + ); + await service.close(); + }); +}); + +/** + * The stream follows the run's selection (a panel that keeps watching the tab + * the person left is a panel that never shows the tab they moved to), and one + * run's retarget is one run's business. + */ +describe("stream retargeting", () => { + const TWO_TABS: WebuiBrowserAuthorization = { + ...ALL_OFF, + isolatedChromium: true, + }; + + it("refuses a stream for a run that has selected nothing", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + // Another run's selection is not this one's to stream, and streaming + // nothing would leave the panel watching a page it cannot see. + await service.selectTarget(tab("tab-1"), "run-b"); + + await expect(drainStream(service, "run-a")).rejects.toMatchObject({ + code: "browser_target_unknown", + }); + // The refusal is the whole outcome: nothing was started on the provider. + expect(provider.startedStreams).toHaveLength(0); + await service.close(); + }); + + it("starts the stream for the target the run selected at the time", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-2"), "run-a"); + + const stream = collectFrames(service, "run-a"); + await waitUntil(() => provider.startedStreams.includes("tab-2")); + await stop(service, [stream]); + + expect(provider.startedStreams).toEqual(["tab-2"]); + }); + + it("moves the stream onto the target the run selected next and stops the old one", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-1"), "run-a"); + + const stream = collectFrames(service, "run-a"); + await waitUntil(() => provider.startedStreams.includes("tab-1")); + const oldHandler = provider.frameHandlers.get("tab-1"); + + // The person points at another tab while the panel is watching this one. + await service.selectTarget(tab("tab-2"), "run-a"); + await waitUntil(() => provider.startedStreams.includes("tab-2")); + + // The screencast for the tab they moved away from is released rather than + // left running: a stream left attached to it would hold the browser's + // resources for a page nobody is looking at. + expect(provider.stoppedStreams).toEqual(["tab-1"]); + + // A frame of the tab the run is on now arrives on the same stream. + provider.pushFrame(1, "tab-2"); + // A frame that arrives from the old tab afterwards is dropped by the fence: + // it names a page this run is no longer showing. The handler is the one + // captured before the retarget, so the drop is the fence's doing and not + // the provider's. + oldHandler?.({ + target: tab("tab-1"), + data: "QlRSQVk=", + format: "jpeg", + sequence: 2, + }); + await settleFrames(); + + expect(stream.frames.map((frame) => frame.target.targetId)).toEqual(["tab-2"]); + await stop(service, [stream]); + }); + + it("keeps another run's stream when one run retargets", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2", "tab-3"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("isolated-chromium", "run-b"); + await service.selectTarget(tab("tab-1"), "run-a"); + await service.selectTarget(tab("tab-2"), "run-b"); + + const first = collectFrames(service, "run-a"); + const second = collectFrames(service, "run-b"); + await waitUntil(() => provider.startedStreams.length >= 2); + expect(new Set(provider.startedStreams)).toEqual(new Set(["tab-1", "tab-2"])); + + await service.selectTarget(tab("tab-3"), "run-a"); + await waitUntil(() => provider.startedStreams.includes("tab-3")); + + // Run B's screencast belongs to run B. Stopping it because run A moved + // would leave another run's panel without a stream at all. + expect(provider.stoppedStreams).toEqual(["tab-1"]); + + provider.pushFrame(1, "tab-3"); + provider.pushFrame(1, "tab-2"); + await settleFrames(); + + expect(first.frames.map((frame) => frame.target.targetId)).toEqual(["tab-3"]); + expect(second.frames.map((frame) => frame.target.targetId)).toEqual(["tab-2"]); + await stop(service, [first, second]); + }); + + it("keeps the stream on the tab whose page navigated", async () => { + const { service, provider } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-1"), "run-a"); + + const stream = collectFrames(service, "run-a"); + await waitUntil(() => provider.startedStreams.includes("tab-1")); + + // The tab is still the one the run selected; only the page behind it was + // replaced. The screencast follows the tab on its own, so nothing is + // restarted — and the new page's frame is what the panel then shows. + provider.announceTargetChange(2); + // The stream loop re-reads the selection once per pass, so this covers a + // whole pass before the restart counters are read. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(provider.startedStreams).toEqual(["tab-1"]); + expect(provider.stoppedStreams).toEqual([]); + + provider.pushFrame(2, "tab-1"); + await settleFrames(); + + expect(stream.frames.map((frame) => frame.target.generation)).toEqual([2]); + await stop(service, [stream]); + }); +}); + +/** + * Which surface a run is working on, read out of that run's own selections — + * the question the Agent's tool path asks, because its tool context carries no + * surface of its own. + */ +describe("the surface a run is working on", () => { + const TWO_TABS: WebuiBrowserAuthorization = { + ...ALL_OFF, + isolatedChromium: true, + }; + + it("reports the surface the run has a selection on", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-1"), "run-a"); + expect(service.selectionSurfaceOf("run-a")).toBe("isolated-chromium"); + await service.close(); + }); + + it("reports nothing for a run that has selected nowhere", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-1"), "run-b"); + // Another run's selection does not make this run work on that surface: + // the answer has to be about the run that asked. + expect(service.selectionSurfaceOf("run-a")).toBeUndefined(); + await service.close(); + }); + + it("forgets the surface when its provider is stopped", async () => { + const { service } = createService(TWO_TABS, ["tab-1", "tab-2"]); + await service.listTargets("isolated-chromium", "run-a"); + await service.selectTarget(tab("tab-1"), "run-a"); + await service.writeAuthorization({ isolatedChromium: false }); + // The browser the run was working on is gone. Reporting it as the run's + // surface would send the next tool call to a browser nobody authorized. + expect(service.selectionSurfaceOf("run-a")).toBeUndefined(); + await service.close(); + }); +}); + +/** One open stream, plus what it has delivered so far. */ +interface OpenStream { + readonly controller: AbortController; + readonly frames: WebuiBrowserImageFrame[]; +} + +/** Starts one run's stream and drains it in the background. */ +function collectFrames(service: WebuiBrowserService, runId: string): OpenStream { + const controller = new AbortController(); + const stream: OpenStream = { controller, frames: [] }; + void (async () => { + for await (const frame of service.watchFrames( + "isolated-chromium", + runId, + controller.signal, + )) { + stream.frames.push(frame); + if (stream.frames.length >= 1) break; + } + })().catch(() => undefined); + return stream; +} + +async function waitForStreams( + provider: RecordingProvider, + count: number, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (provider.startedStreams.length >= count) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** + * Waits until a condition holds. + * + * The service reacts to a selection change on its own pass through the stream + * loop, so a test asks for the outcome rather than for a delay — and a test + * that never gets there fails here instead of asserting on nothing. + */ +async function waitUntil(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("The browser service did not reach the expected state."); +} + +/** + * Reads one run's stream, so a refusal on the way in is a rejection the caller + * can read: an async generator that is never iterated never runs at all. + */ +function drainStream(service: WebuiBrowserService, runId: string): Promise { + const controller = new AbortController(); + return (async () => { + try { + for await (const _frame of service.watchFrames( + "isolated-chromium", + runId, + controller.signal, + )) { + return; + } + } finally { + controller.abort(); + } + })(); +} + +/** Gives the streams long enough to have yielded what was pushed. */ +async function settleFrames(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +async function stop( + service: WebuiBrowserService, + streams: readonly OpenStream[], +): Promise { + for (const stream of streams) stream.controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await service.close(); +} diff --git a/packages/webui/test/unit/browser-target-registry.test.ts b/packages/webui/test/unit/browser-target-registry.test.ts new file mode 100644 index 000000000..dcdc177ed --- /dev/null +++ b/packages/webui/test/unit/browser-target-registry.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { WebuiBrowserTargetRegistry } from "../../src/runtime/browser/target-registry.js"; +import type { WebuiBrowserTargetId } from "../../src/shared/contracts/browser.js"; + +const TARGET = { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", +} as const; + +describe("browser target registry", () => { + it("gives a first sighting its first generation", () => { + const registry = new WebuiBrowserTargetRegistry(); + const state = registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + expect(state.id.generation).toBe(1); + expect(state.title).toBe("Example"); + expect(state.status).toBe("open"); + }); + + it("does not raise the generation when the provider reports the target again", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + // The provider reports its targets repeatedly. Raising the generation on + // every sighting would invalidate every reference the moment the provider + // refreshed its list. + const again = registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + expect(again.id.generation).toBe(1); + }); + + it("raises the generation when the page behind the target is replaced", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + const raised = registry.raiseGeneration(TARGET); + expect(raised?.id.generation).toBe(2); + }); + + it("refuses an identity whose generation is behind the current one", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + const stale: WebuiBrowserTargetId = { ...TARGET, generation: 1 }; + expect(registry.isCurrent(stale)).toBe(true); + + registry.raiseGeneration(TARGET); + + // The tab is still there, but the page behind it is not the one the + // reference was recorded against. + expect(registry.isCurrent(stale)).toBe(false); + expect(registry.isCurrent({ ...TARGET, generation: 2 })).toBe(true); + }); + + it("refuses an identity for a target it never saw", () => { + const registry = new WebuiBrowserTargetRegistry(); + expect(registry.isCurrent({ ...TARGET, generation: 1 })).toBe(false); + }); + + it("refuses an identity for a closed target", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "Example", url: "https://example.test" }); + registry.close(TARGET); + expect(registry.isCurrent({ ...TARGET, generation: 1 })).toBe(false); + // The entry is kept so a stale reference is explainable rather than merely + // unknown. + expect(registry.resolve({ ...TARGET, generation: 1 })?.status).toBe("closed"); + }); + + it("separates two browsers of the same surface", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "One", url: "https://one.test" }); + registry.observe({ + ...TARGET, + browserId: "browser-2", + title: "Two", + url: "https://two.test", + }); + expect(registry.list("isolated-chromium")).toHaveLength(2); + // A generation raised on one browser says nothing about the other. + registry.raiseGeneration(TARGET); + expect(registry.isCurrent({ ...TARGET, generation: 1 })).toBe(false); + expect( + registry.isCurrent({ ...TARGET, browserId: "browser-2", generation: 1 }), + ).toBe(true); + }); + + it("lists only the surface that was asked for", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "Isolated", url: "https://one.test" }); + registry.observe({ + surface: "chrome", + browserId: "browser-9", + targetId: "tab-9", + title: "Chrome", + url: "https://two.test", + }); + expect(registry.list("isolated-chromium")).toHaveLength(1); + expect(registry.list("chrome")).toHaveLength(1); + expect(registry.list("edge")).toHaveLength(0); + }); + + it("forgets a browser entirely when it is switched off", () => { + const registry = new WebuiBrowserTargetRegistry(); + registry.observe({ ...TARGET, title: "One", url: "https://one.test" }); + registry.observe({ + ...TARGET, + browserId: "browser-2", + title: "Two", + url: "https://two.test", + }); + registry.forgetBrowser("isolated-chromium", "browser-1"); + expect(registry.list("isolated-chromium")).toHaveLength(1); + expect(registry.resolve({ ...TARGET, generation: 1 })).toBeUndefined(); + }); +}); diff --git a/packages/webui/test/unit/browser-tool-adapter.test.ts b/packages/webui/test/unit/browser-tool-adapter.test.ts new file mode 100644 index 000000000..a0bf96343 --- /dev/null +++ b/packages/webui/test/unit/browser-tool-adapter.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, it } from "vitest"; + +import { + WebuiBrowserService, + type WebuiBrowserServiceOptions, +} from "../../src/runtime/browser/browser-service.js"; +import { WebuiBrowserOperationError } from "../../src/runtime/browser/errors.js"; +import type { WebuiBrowserSurfaceProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; +import { + createWebuiBrowserToolAdapter, + type WebuiBrowserToolCommandSurface, +} from "../../src/runtime/browser/tool-adapter.js"; +import type { WebuiBrowserAdapter } from "../../src/runtime/browser-provider.js"; +import type { + WebuiBrowserAuthorization, + WebuiBrowserImageFrame, + WebuiBrowserInputCommand, + WebuiBrowserSurface, + WebuiBrowserTargetId, + WebuiBrowserTargetState, +} from "../../src/shared/contracts/browser.js"; +import { WEBUI_BROWSER_RESTRICTED_COMMANDS } from "../../src/shared/contracts/browser.js"; + +/** + * A provider that records what it was asked to do. + * + * The service decides whether a browser may be touched at all and the adapter + * only translates a tool call, so a provider that records its calls is enough + * to prove the decisions that matter: which browser received the command, and + * which refusals left every browser untouched. One instance per surface, so a + * test can see that a run was driven on the browser the person pointed at and + * not on the one this process owns. + */ +class RecordingProvider implements WebuiBrowserSurfaceProvider { + readonly surface: WebuiBrowserSurface; + readonly browserId: string; + readonly commands: { command: string; params: Record }[] = []; + /** The tabs this browser has, so two runs can hold different selections. */ + readonly tabIds: readonly string[]; + /** When set, a command is recorded but never answers. */ + neverSettle = false; + closed = false; + + constructor(surface: WebuiBrowserSurface, tabIds: readonly string[] = ["tab-1"]) { + this.surface = surface; + this.browserId = surface === "isolated-chromium" ? "browser-1" : `${surface}-browser`; + this.tabIds = tabIds; + } + + async sendCommand( + _target: unknown, + command: string, + params: Record, + ): Promise { + this.commands.push({ command, params }); + if (this.neverSettle) await new Promise(() => {}); + return { command, params }; + } + + async listTargets(): Promise { + return this.tabIds.map((targetId) => ({ + id: { + surface: this.surface, + browserId: this.browserId, + targetId, + generation: 1, + }, + title: "Example", + url: "https://example.test", + status: "open" as const, + selected: true, + })); + } + + async startImageStream( + _targetId: string, + _onFrame: (frame: WebuiBrowserImageFrame) => void, + ): Promise {} + + async stopImageStream(_targetId: string): Promise {} + + async sendInput(_command: WebuiBrowserInputCommand): Promise {} + + async close(): Promise { + this.closed = true; + } +} + +const ALL_OFF: WebuiBrowserAuthorization = { + isolatedChromium: false, + edge: false, + chrome: false, + fullCdp: false, +}; + +const SURFACE_AUTHORIZED: WebuiBrowserAuthorization = { + ...ALL_OFF, + isolatedChromium: true, +}; + +const SELECTED_TARGET: WebuiBrowserTargetId = { + surface: "isolated-chromium", + browserId: "browser-1", + targetId: "tab-1", + generation: 1, +}; + +const OTHER_TARGET: WebuiBrowserTargetId = { + ...SELECTED_TARGET, + targetId: "tab-2", +}; + +/** A tab of the Edge session the bridge lends the service. */ +const EDGE_TARGET: WebuiBrowserTargetId = { + surface: "edge", + browserId: "edge-browser", + targetId: "tab-edge", + generation: 1, +}; + +/** + * A service whose stores and providers a test can reach. + * + * `edge` is absent unless the test asks for it, because "the bridge lends Edge + * a session" is a fact about the host, not a default. + */ +function createService( + authorization: WebuiBrowserAuthorization, + options: { + /** The isolated Chromium's tabs. */ + readonly isolatedTabs?: readonly string[]; + /** When given, the bridge lends Edge a session with these tabs. */ + readonly edgeTabs?: readonly string[]; + } = {}, +): { + service: WebuiBrowserService; + /** The isolated Chromium's provider. */ + provider: RecordingProvider; + /** Edge's provider, when the bridge had one to lend. */ + edge: RecordingProvider | undefined; + /** The switches as the service reads them; a host may change them itself. */ + state: { authorization: WebuiBrowserAuthorization }; +} { + const provider = new RecordingProvider( + "isolated-chromium", + options.isolatedTabs ?? ["tab-1"], + ); + const edge = options.edgeTabs + ? new RecordingProvider("edge", options.edgeTabs) + : undefined; + const state = { authorization }; + const serviceOptions: WebuiBrowserServiceOptions = { + authorization: { + read: async () => state.authorization, + write: async (input) => { + state.authorization = { ...state.authorization, ...input }; + return state.authorization; + }, + }, + dataDir: "/tmp/webui-browser-tool-adapter-test", + createIsolatedProvider: async () => provider, + ...(edge + ? { createBridgeProvider: async (surface) => (surface === "edge" ? edge : undefined) } + : {}), + }; + return { service: new WebuiBrowserService(serviceOptions), provider, edge, state }; +} + +/** + * Puts the surface in the state a tool call assumes: authorized, with the + * target the panel is showing selected. + * + * The selection is the run's, not the tool's: an Agent browser-tool call acts + * on the selected target of *its own run*, so a test that skipped this would be + * testing the refusal path. The surface follows the target, so a run that + * selected in Edge is set up in Edge. + */ +async function selectRunTarget( + service: WebuiBrowserService, + runId: string, + target: WebuiBrowserTargetId = SELECTED_TARGET, +): Promise { + await service.listTargets(target.surface, runId); + await service.selectTarget(target, runId); +} + +/** + * The runtime's tool context and action type, read off the adapter contract. + * + * Derived from the WebUI's own `WebuiBrowserAdapter` rather than imported from + * `@mavis/agent-tools`: that package is not this package's dependency, and the + * adapter contract already *is* the runtime's `LocalBrowserAdapter`. + */ +type WebuiBrowserToolContext = Parameters[0]; +type WebuiBrowserToolAction = Parameters[1]; + +/** The tool context: the adapter reads the run off it, and nothing else. */ +function toolContextOf(runId: string): WebuiBrowserToolContext { + return { sessionId: runId } as WebuiBrowserToolContext; +} + +const TOOL_CONTEXT = toolContextOf("run-a"); + +/** + * A command surface that records instead of driving a browser. + * + * Used for the surface rule alone: which surface a tool call lands on is a + * decision the adapter makes before a browser is involved, and asserting it + * through a real browser would be an expensive way to read one argument. What + * the run is working on is answered here, so the adapter has to ask. + */ +function recordingCommandSurface(surfaceOf: (runId: string) => WebuiBrowserSurface | undefined): { + browser: WebuiBrowserToolCommandSurface; + /** The runs the adapter asked about, in order. */ + asked: string[]; + calls: { + surface: WebuiBrowserSurface; + runId: string; + command: string; + params?: Record; + signal?: AbortSignal; + }[]; +} { + const asked: string[] = []; + const calls: { + surface: WebuiBrowserSurface; + runId: string; + command: string; + params?: Record; + signal?: AbortSignal; + }[] = []; + return { + asked, + calls, + browser: { + selectionSurfaceOf: (runId) => { + asked.push(runId); + return surfaceOf(runId); + }, + sendCommandToSelection: async (surface, runId, command, params, signal) => { + calls.push({ surface, runId, command, params, signal }); + return { accepted: true as const, target: SELECTED_TARGET, result: "done" }; + }, + }, + }; +} + +describe("Agent browser-tool adapter", () => { + it("declares the restricted command set as its whole capability", () => { + const { service } = createService(SURFACE_AUTHORIZED); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + const capabilities = adapter.getCapabilities?.(); + expect(capabilities?.provider).toBe("webui-browser"); + expect(capabilities?.version).toBe(1); + // Exactly the restricted set: an action the set does not contain must not + // be offered to the model, and one it does contain must not be withheld. + expect([...(capabilities?.actions ?? [])]).toEqual([ + ...WEBUI_BROWSER_RESTRICTED_COMMANDS, + ]); + // The person can take over the page these actions drive: it is the page + // the panel is showing. + expect(capabilities?.interactiveTakeover).toBe(true); + }); + + it("forwards a restricted action to the browser and returns the result", async () => { + const { service, provider } = createService(SURFACE_AUTHORIZED); + await selectRunTarget(service, "run-a"); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + const result = await adapter.execute( + TOOL_CONTEXT, + "navigate", + { url: "https://example.test/page" }, + ); + + expect(provider.commands).toEqual([ + { command: "navigate", params: { url: "https://example.test/page" } }, + ]); + // The provider's own answer, not the service's envelope: the runtime's + // browser tools read the result at the top level. + expect(result).toEqual({ + command: "navigate", + params: { url: "https://example.test/page" }, + }); + await service.close(); + }); + + it("refuses an action outside the restricted set, with the browser's own code", async () => { + // Full CDP is on, so the service would forward a raw DevTools method on the + // explicit path. The Agent's browser-tool path declares the restricted set + // as its capability, so it must not widen here — that authorization belongs + // to the panel's `sendBrowserCommand`. + const { service, provider } = createService({ + ...SURFACE_AUTHORIZED, + fullCdp: true, + }); + await selectRunTarget(service, "run-a"); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + // A caller that reaches past the declared set is modelled as the runtime + // would never send it: the string reaches `execute` as an action. + const rawDevToolsMethod = "Page.captureScreenshot" as unknown as WebuiBrowserToolAction; + const refusal = await adapter + .execute(TOOL_CONTEXT, rawDevToolsMethod, {}) + .then( + () => undefined, + (error: unknown) => error, + ); + + // The refusal keeps the browser's code and its type: a caller that cannot + // tell "not allowed" from "the browser broke" retries the wrong thing. + expect(refusal).toBeInstanceOf(WebuiBrowserOperationError); + expect(refusal).toMatchObject({ code: "browser_capability_denied" }); + expect((refusal as Error).message).toContain(rawDevToolsMethod); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("refuses a surface that is not authorized before reaching the browser", async () => { + const { service, provider, edge, state } = createService( + { ...ALL_OFF, edge: true }, + { edgeTabs: ["tab-edge"] }, + ); + await selectRunTarget(service, "run-a", EDGE_TARGET); + // The switch changes under the running service, which is what a host that + // reloads its configuration does. The run still holds the selection it + // made, so the tool path still knows which surface it meant — and the + // authorization gate still refuses it. + state.authorization = { ...state.authorization, edge: false }; + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + await expect( + adapter.execute(TOOL_CONTEXT, "reload", {}), + ).rejects.toMatchObject({ code: "browser_surface_not_authorized" }); + expect(edge?.commands).toHaveLength(0); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("refuses rather than driving another browser after its surface was switched off", async () => { + const { service, provider, edge } = createService( + { ...ALL_OFF, isolatedChromium: true, edge: true }, + { isolatedTabs: ["tab-1"], edgeTabs: ["tab-edge"] }, + ); + await selectRunTarget(service, "run-a", EDGE_TARGET); + await service.writeAuthorization({ edge: false }); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + // The run still holds the selection it made on Edge, and Edge is still the + // surface it is working on — but the switch is off, so the authorization + // gate refuses it by name. What must not happen is the call landing on the + // isolated Chromium instead: that is a browser the person never pointed + // this run at. + await expect(adapter.execute(TOOL_CONTEXT, "reload", {})).rejects.toMatchObject({ + code: "browser_surface_not_authorized", + }); + expect(edge?.commands).toHaveLength(0); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("refuses a surface with no selected target rather than choosing one", async () => { + const { service, provider } = createService(SURFACE_AUTHORIZED); + // The surface is authorized and the browser has a tab; nothing is selected. + await service.listTargets("isolated-chromium"); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + await expect( + adapter.execute(TOOL_CONTEXT, "navigate", { url: "https://example.test" }), + ).rejects.toMatchObject({ code: "browser_target_unknown" }); + // Defaulting to the first tab would drive a page nobody chose. + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("aborts an in-flight command instead of reporting its result", async () => { + const { service, provider } = createService(SURFACE_AUTHORIZED); + await selectRunTarget(service, "run-a"); + provider.neverSettle = true; + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + const controller = new AbortController(); + + const call = adapter.execute( + TOOL_CONTEXT, + "navigate", + { url: "https://example.test" }, + controller.signal, + ); + // The command has reached the browser; the answer has not come back. + await Promise.resolve(); + controller.abort(); + + await expect(call).rejects.toMatchObject({ code: "browser_operation_cancelled" }); + expect(provider.commands).toHaveLength(1); + await service.close(); + }); + + it("asks the service which surface the calling run is working on", async () => { + // The tool context carries no surface, and the runtime cannot be asked for + // one. The service holds the answer — it is the state the panel wrote when + // the person picked a target — so the adapter asks and acts on that. + const { browser, asked, calls } = recordingCommandSurface(() => "edge"); + const adapter = createWebuiBrowserToolAdapter({ browser }); + await adapter.execute(TOOL_CONTEXT, "reload", {}); + expect(asked).toEqual(["run-a"]); + expect(calls).toEqual([ + { + surface: "edge", + runId: "run-a", + command: "reload", + params: {}, + signal: undefined, + }, + ]); + }); + + it("refuses when the run has no surface to act on, rather than choosing one", async () => { + const { browser, calls } = recordingCommandSurface(() => undefined); + const adapter = createWebuiBrowserToolAdapter({ browser }); + + await expect(adapter.execute(TOOL_CONTEXT, "reload", {})).rejects.toMatchObject({ + code: "browser_target_unknown", + }); + // A browser chosen here would be one the run never pointed at. + expect(calls).toHaveLength(0); + }); + + it("names the calling run as the selection the command acts on", async () => { + // The run is the tool context's session, and the selection belongs to it + // (ADR 0019): the adapter passing no run would be asking about whichever + // selection happened to be there. + const { browser, asked, calls } = recordingCommandSurface(() => "isolated-chromium"); + const adapter = createWebuiBrowserToolAdapter({ browser }); + await adapter.execute(toolContextOf("run-b"), "reload", {}); + expect(asked).toEqual(["run-b"]); + expect(calls[0]?.runId).toBe("run-b"); + }); + + it("acts on the surface the run selected, not on the isolated Chromium", async () => { + const { service, provider, edge } = createService( + { ...ALL_OFF, isolatedChromium: true, edge: true }, + { isolatedTabs: ["tab-1"], edgeTabs: ["tab-edge"] }, + ); + // The person pointed the panel at Edge and selected a tab there. The + // isolated Chromium is authorized and connected too, so a default would + // have found a browser to drive — the wrong one, and the split ADR 0019 + // exists to prevent. + await selectRunTarget(service, "run-a", EDGE_TARGET); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + await adapter.execute(TOOL_CONTEXT, "reload", {}); + + expect(edge?.commands).toEqual([{ command: "reload", params: {} }]); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("follows the run to the surface it selected last", async () => { + const { service, provider, edge } = createService( + { ...ALL_OFF, isolatedChromium: true, edge: true }, + { isolatedTabs: ["tab-1"], edgeTabs: ["tab-edge"] }, + ); + // The run worked in the isolated Chromium, and the person then moved the + // panel to Edge and selected a tab there. The last choice is the one the + // panel is showing, so it is the one the Agent's tools act on. + await selectRunTarget(service, "run-a", SELECTED_TARGET); + await selectRunTarget(service, "run-a", EDGE_TARGET); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + await adapter.execute(TOOL_CONTEXT, "reload", {}); + + expect(edge?.commands).toHaveLength(1); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); + + it("refuses a run that has selected nothing on any surface", async () => { + const { service, provider, edge } = createService( + { ...ALL_OFF, isolatedChromium: true, edge: true }, + { isolatedTabs: ["tab-1"], edgeTabs: ["tab-edge"] }, + ); + // Both browsers are authorized, connected and have a tab. Nothing is + // selected, so there is no browser this run is working on. + await service.listTargets("isolated-chromium", "run-a"); + await service.listTargets("edge", "run-a"); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + const refusal = await adapter.execute(TOOL_CONTEXT, "reload", {}).then( + () => undefined, + (error: unknown) => error, + ); + + // The refusal carries the browser's own code, so the run is told to select a + // page rather than shown a browser failure nobody can act on. + expect(refusal).toBeInstanceOf(WebuiBrowserOperationError); + expect(refusal).toMatchObject({ code: "browser_target_unknown" }); + expect(provider.commands).toHaveLength(0); + expect(edge?.commands).toHaveLength(0); + await service.close(); + }); + + it("acts on the calling run's own selected target, not another run's", async () => { + const { service, provider } = createService(SURFACE_AUTHORIZED, { isolatedTabs: ["tab-1", "tab-2"] }); + // Two runs, one surface, two different pages. + await selectRunTarget(service, "run-a", SELECTED_TARGET); + await selectRunTarget(service, "run-b", OTHER_TARGET); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + const first = await adapter.execute(toolContextOf("run-a"), "reload", {}); + const second = await adapter.execute(toolContextOf("run-b"), "reload", {}); + + // Each run drove the page it selected, and neither was moved onto the + // other's: the command carries the resolved target back, so the run that + // drove tab-2 could not have driven tab-1. + expect(first).toEqual({ command: "reload", params: {} }); + expect(second).toEqual({ command: "reload", params: {} }); + expect(await selectedTabOf(service, "run-a")).toBe("tab-1"); + expect(await selectedTabOf(service, "run-b")).toBe("tab-2"); + expect(provider.commands).toHaveLength(2); + await service.close(); + }); + + it("refuses a run that has selected nothing rather than using another's", async () => { + const { service, provider } = createService(SURFACE_AUTHORIZED, { isolatedTabs: ["tab-1", "tab-2"] }); + await selectRunTarget(service, "run-b", OTHER_TARGET); + const adapter = createWebuiBrowserToolAdapter({ browser: service }); + + // run-a never selected anything. Acting on run-b's page would be two runs + // sharing one page, which is what the run-scoped selection forbids. + await expect( + adapter.execute(toolContextOf("run-a"), "reload", {}), + ).rejects.toMatchObject({ code: "browser_target_unknown" }); + expect(provider.commands).toHaveLength(0); + await service.close(); + }); +}); + +/** The tab the named run currently has selected, as the list reports it. */ +async function selectedTabOf( + service: WebuiBrowserService, + runId: string, +): Promise { + const list = await service.listTargets("isolated-chromium", runId); + return list.targets.find((target) => target.selected)?.id.targetId; +} \ No newline at end of file diff --git a/packages/webui/test/unit/browser-workspace-panel.test.tsx b/packages/webui/test/unit/browser-workspace-panel.test.tsx new file mode 100644 index 000000000..80020527b --- /dev/null +++ b/packages/webui/test/unit/browser-workspace-panel.test.tsx @@ -0,0 +1,78 @@ +// The browser tab in the workspace panel's registry. +// +// The Browser panel is implemented and gated, and the panel that should mount it +// (`features/workspace/ui/WorkspacePanels.tsx`) cannot: a browser tab's `addMenu` +// entry carries a `WorkspacePanelCommand`, and the published command's `open-tab` +// names only `files | canvas | terminal` +// (`features/workspace/contracts/panel-command.ts:43`). Nothing can open the tab +// until that union names `browser`, so what is pinned here is the half of the work +// that is real — the kind is declared, labelled and placed in the menu order — plus +// the two properties that must survive that change rather than be rewritten by it. +// +// The second group is the reason the panel is not mounted yet, stated as a test +// because it is the rule that would otherwise be rediscovered by running the +// dependency gate: the workspace panel is presentation, so it may read the browser +// business's contracts and its `.public` components, and nothing else. The browser +// state machine lives in that business's private projection, so the panel cannot +// drive it from here — the owner has to be published by the business that owns it. + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +import { + WEBUI_WORKSPACE_ADD_MENU_ORDER, + WEBUI_WORKSPACE_TAB_KINDS, + webuiWorkspaceAddMenu, + webuiWorkspaceTabLabel, + type WorkspacePanelTab, +} from "../../src/client/features/workspace/projection/workspace-panel-state.js"; + +const PANEL_SOURCE = new URL( + "../../src/client/features/workspace/ui/WorkspacePanels.tsx", + import.meta.url, +); + +describe("the browser tab kind", () => { + it("is a declared kind with its own word", () => { + expect(WEBUI_WORKSPACE_TAB_KINDS.browser.label).toBe("浏览器"); + // Built the way `open-tab` builds one: no path, no workspace directory, and + // the optional instance fields the add menu sends with every such command. + const tab: WorkspacePanelTab = { id: "browser:::", kind: "browser" }; + expect(webuiWorkspaceTabLabel(tab)).toBe("浏览器"); + }); + + it("leads the file views in the add-menu order", () => { + const order = WEBUI_WORKSPACE_ADD_MENU_ORDER; + expect(order.indexOf("browser")).toBeGreaterThan(order.indexOf("terminal")); + expect(order.indexOf("browser")).toBeLessThan(order.indexOf("files")); + // Every kind is named once, and every name is a declared kind. + expect(new Set(order).size).toBe(order.length); + for (const kind of order) expect(WEBUI_WORKSPACE_TAB_KINDS[kind]).toBeDefined(); + }); + + it("reaches the menu through the registry rather than a second list", () => { + // Whatever a row's `addMenu` says, the menu is the rows that have one, in the + // order list — so widening the command's `open-tab` union adds the entry + // without this file or the component naming it. + const expected = WEBUI_WORKSPACE_ADD_MENU_ORDER.filter( + (kind) => WEBUI_WORKSPACE_TAB_KINDS[kind].addMenu !== null, + ); + expect(webuiWorkspaceAddMenu().map((entry) => entry.kind)).toEqual(expected); + for (const entry of webuiWorkspaceAddMenu()) { + expect(entry.label).toBe(WEBUI_WORKSPACE_TAB_KINDS[entry.kind].label); + } + }); +}); + +describe("the workspace panel reaches the browser business only through its published surface", () => { + it("reads no private module of the business whose panel it would mount", () => { + const source = readFileSync(PANEL_SOURCE, "utf8"); + + // Contracts and `.public` components are the crossing; a reducer, a command + // builder and the assembled slices are not, and reaching for any of them is + // what the dependency gate reports as a private-peer or assembly import. + expect(source).not.toContain("browser/projection/"); + expect(source).not.toContain("browser/application/"); + expect(source).not.toContain("application/port-slices"); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/unit/webui-port-slices.test.ts b/packages/webui/test/unit/webui-port-slices.test.ts index bda3842e6..6eedaf9df 100644 --- a/packages/webui/test/unit/webui-port-slices.test.ts +++ b/packages/webui/test/unit/webui-port-slices.test.ts @@ -51,6 +51,13 @@ const EXPECTED: Record< "createTerminal", "listTerminals", "writeTerminal", "resizeTerminal", "disposeTerminal", "watchTerminal", ], + browser: [ + "getBrowserAuthorization", "setBrowserAuthorization", "listBrowserTargets", + "selectBrowserTarget", "sendBrowserInput", "sendBrowserCommand", + "watchBrowserFrames", + "listBrowserCapabilities", + "getBrowserInstallationKey", + ], composer: ["runCommand", "enqueueMessage", "listQueueMessages", "deleteQueueItem"], interactions: [ "listPendingPermissions", "getPendingQuestionnaire", "replyPermission", diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts index d72bceaa4..ef2024a61 100644 --- a/packages/webui/test/unit/webui-shell.test.ts +++ b/packages/webui/test/unit/webui-shell.test.ts @@ -230,7 +230,9 @@ describe("WebUI shell", () => { expect(markup).toContain("画布"); expect(markup).toContain("终端"); expect(markup).not.toContain('data-webui-progress-panel="true"'); - expect(markup).not.toContain("浏览器"); + // The browser panel is a supported add-menu tab now: it is mounted by the + // browser business's published host, and the menu offers it. + expect(markup).toContain("浏览器"); expect(markup).not.toContain("Mini App"); }); diff --git a/packages/webui/test/unit/webui-unary-operation-typing.test.ts b/packages/webui/test/unit/webui-unary-operation-typing.test.ts index 55224ba0d..4f93c5338 100644 --- a/packages/webui/test/unit/webui-unary-operation-typing.test.ts +++ b/packages/webui/test/unit/webui-unary-operation-typing.test.ts @@ -27,6 +27,7 @@ import { import type { WebuiOperationPort } from "../../src/server/operation/bind-handlers.js"; import { createStreamHandlerEntries } from "../../src/server/operation/handlers/stream.js"; import { watchTerminalOperation } from "../../src/server/operation/workspace.js"; +import { watchBrowserFramesOperation } from "../../src/server/operation/browser.js"; import { WEBUI_STREAM_OPERATION_NAMES, type OperationSpec, @@ -115,13 +116,14 @@ describe("the unary request interface over OperationSpec", () => { `${name} is a stream and must not be unary`, ); } - assert.equal(streams.length, 4); + assert.equal(streams.length, 5); }); it("matches the server's registered stream descriptors exactly", () => { const registered = [ ...createStreamHandlerEntries(inertPort).keys(), watchTerminalOperation.name, + watchBrowserFramesOperation.name, ].sort(); assert.deepEqual(registered, [...WEBUI_STREAM_OPERATION_NAMES].sort()); }); diff --git a/packages/webui/test/unit/webui-w0-css-structure.test.ts b/packages/webui/test/unit/webui-w0-css-structure.test.ts index 17474be2b..74d2ade51 100644 --- a/packages/webui/test/unit/webui-w0-css-structure.test.ts +++ b/packages/webui/test/unit/webui-w0-css-structure.test.ts @@ -477,6 +477,7 @@ describe("W0 · stylesheet composition", () => { "../features/transcript/ui/transcript.css", "../features/sessions/ui/sessions.css", "../features/workspace/ui/workspace.css", + "../features/browser/ui/browser.css", "../features/goal/ui/goal.css", "../features/account/ui/account.css", "../features/transcript/ui/transcript-widgets.css", @@ -736,7 +737,7 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => { // asked for reduced motion. The exact list is the point -- adding an // animated rule without registering its override here is the same defect, // in the other direction. - expect(reducedMotion).toHaveLength(11); + expect(reducedMotion).toHaveLength(12); for (const rule of reducedMotion) expect(rule.body).toMatch(/(?:animation|transition):\s*none/u); @@ -755,6 +756,9 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => { // carries the same motion, so it carries the same override. ".webui-dropdown-popup, .webui-dropdown-popup.is-leaving, .webui-dropdown-surface", ".webui-message-actions", + // The model menu's list and panel slide in during the transition, so a + // reader who asked for reduced motion would otherwise watch both move. + ".webui-model-menu[data-webui-model-transitioning=\"true\"] .webui-model-list, .webui-model-menu[data-webui-model-transitioning=\"true\"] .webui-model-panel", // Added with the project-first rail: the section that holds a project's // sessions opens and closes by transitioning its height, so a reader who // asked for reduced motion would otherwise watch the section slide. @@ -848,6 +852,9 @@ describe("W0 · at-rules and animations W5/W6 must not remove", () => { "webui-dropdown-out-reverse", "webui-menu-enter", "webui-modal-enter", + // The model menu's list and panel each slide in from their own side. + "webui-model-list-enter", + "webui-model-panel-enter", "webui-overlay-enter", "webui-panel-enter-right", "webui-popover-enter", diff --git a/packages/webui/test/unit/workspace-tab-registry.test.ts b/packages/webui/test/unit/workspace-tab-registry.test.ts index e03380cb1..e7d4388e8 100644 --- a/packages/webui/test/unit/workspace-tab-registry.test.ts +++ b/packages/webui/test/unit/workspace-tab-registry.test.ts @@ -85,12 +85,25 @@ describe("the registry answers for the whole closed set", () => { it("offers the add menu in one order, and only where a menu entry exists", () => { const menu = webuiWorkspaceAddMenu(); - expect(menu.map((entry) => entry.kind)).toEqual(["review", "terminal", "canvas", "files"]); - expect(menu.map((entry) => entry.label)).toEqual(["变更", "终端", "画布", "查看文件"]); + expect(menu.map((entry) => entry.kind)).toEqual([ + "review", + "terminal", + "canvas", + "browser", + "files", + ]); + expect(menu.map((entry) => entry.label)).toEqual([ + "变更", + "终端", + "画布", + "浏览器", + "查看文件", + ]); expect(menu.map((entry) => entry.opens)).toEqual([ { type: "open-workspace-review" }, { type: "open-tab", kind: "terminal" }, { type: "open-tab", kind: "canvas" }, + { type: "open-tab", kind: "browser" }, { type: "open-tab", kind: "files" }, ]); // Every menu entry names a declared kind, and the order names each one once. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a14e6e7..b1aa5b210 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,15 @@ importers: specifier: 4.1.11 version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + packages/browser-extension: + devDependencies: + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@24.12.4)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + packages/config: dependencies: '@mavis/shared': @@ -6153,7 +6162,7 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/expect@4.1.11': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2dc219098..d5fa7ce10 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - "packages/agent-tools" - "packages/agent-modules/background-task" - "packages/browser-core" + - "packages/browser-extension" - "packages/config" - "packages/agent-modules/context-manager" - "packages/agent-modules/conversation-contract" diff --git a/release/extraction.json b/release/extraction.json index f7f2f18ed..9c010ff90 100644 --- a/release/extraction.json +++ b/release/extraction.json @@ -19,6 +19,7 @@ "packages/agent-runtime", "packages/agent-tools", "packages/browser-core", + "packages/browser-extension", "packages/config", "packages/local-runtime", "packages/local-runtime-v2", diff --git a/release/public-source.json b/release/public-source.json index 4674d4350..c50697520 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -500,6 +500,29 @@ "packages/browser-core/src/element-map-manager.ts", "packages/browser-core/src/index.ts", "packages/browser-core/src/operation-timeout.ts", + "packages/browser-extension/README.md", + "packages/browser-extension/STORE-DISCLOSURES.md", + "packages/browser-extension/manifest.json", + "packages/browser-extension/options.html", + "packages/browser-extension/package.json", + "packages/browser-extension/scripts/build.mjs", + "packages/browser-extension/src/background/service-worker.ts", + "packages/browser-extension/src/bridge-client.ts", + "packages/browser-extension/src/cdp.ts", + "packages/browser-extension/src/chrome.d.ts", + "packages/browser-extension/src/images.ts", + "packages/browser-extension/src/input.ts", + "packages/browser-extension/src/options.ts", + "packages/browser-extension/src/pairing.ts", + "packages/browser-extension/src/protocol.ts", + "packages/browser-extension/src/targets.ts", + "packages/browser-extension/test-results/.last-run.json", + "packages/browser-extension/test/bridge-client.test.ts", + "packages/browser-extension/test/cdp.test.ts", + "packages/browser-extension/test/input.test.ts", + "packages/browser-extension/test/pairing.test.ts", + "packages/browser-extension/test/protocol.test.ts", + "packages/browser-extension/tsconfig.json", "packages/config/package.json", "packages/config/src/agent-capabilities.ts", "packages/config/src/agent-runtime-config.ts", @@ -3485,6 +3508,15 @@ "packages/webui/docs/architecture/design.md", "packages/webui/docs/architecture/layered-model.md", "packages/webui/docs/architecture/module-map.md", + "packages/webui/docs/browser-use/01-capability-matrix.md", + "packages/webui/docs/browser-use/02-bridge-protocol.md", + "packages/webui/docs/browser-use/03-extension-admission.md", + "packages/webui/docs/browser-use/04-pairing-credentials.md", + "packages/webui/docs/browser-use/05-target-identity.md", + "packages/webui/docs/browser-use/06-runtime-authorization-trace.md", + "packages/webui/docs/browser-use/07-review-findings.md", + "packages/webui/docs/browser-use/09-acceptance-record.md", + "packages/webui/docs/browser-use/README.md", "packages/webui/docs/client/composer.md", "packages/webui/docs/client/floating-surfaces.md", "packages/webui/docs/client/ownership.md", @@ -3634,6 +3666,15 @@ "packages/webui/src/client/features/account/ui/UserMenuSignin.tsx", "packages/webui/src/client/features/account/ui/UserMenuUsage.tsx", "packages/webui/src/client/features/account/ui/account.css", + "packages/webui/src/client/features/browser/application/browser-commands.ts", + "packages/webui/src/client/features/browser/bindings/use-browser-run.public.ts", + "packages/webui/src/client/features/browser/contracts/browser-panel.ts", + "packages/webui/src/client/features/browser/contracts/browser-port.ts", + "packages/webui/src/client/features/browser/projection/browser-state.ts", + "packages/webui/src/client/features/browser/ui/BrowserAuthorizationSwitches.public.tsx", + "packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx", + "packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx", + "packages/webui/src/client/features/browser/ui/browser.css", "packages/webui/src/client/features/composer/application/command-workflows.ts", "packages/webui/src/client/features/composer/application/composer-controller.ts", "packages/webui/src/client/features/composer/application/composer-history.ts", @@ -3939,11 +3980,20 @@ "packages/webui/src/runtime/auth-context.ts", "packages/webui/src/runtime/auth-session.ts", "packages/webui/src/runtime/browser-provider.ts", + "packages/webui/src/runtime/browser/authorization.ts", + "packages/webui/src/runtime/browser/browser-service.ts", + "packages/webui/src/runtime/browser/errors.ts", + "packages/webui/src/runtime/browser/input.ts", + "packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts", + "packages/webui/src/runtime/browser/isolated-chromium/provider.ts", + "packages/webui/src/runtime/browser/target-registry.ts", + "packages/webui/src/runtime/browser/tool-adapter.ts", "packages/webui/src/runtime/check-in.ts", "packages/webui/src/runtime/commands/descriptors.ts", "packages/webui/src/runtime/commands/runner.ts", "packages/webui/src/runtime/harness/account.ts", "packages/webui/src/runtime/harness/adapter.ts", + "packages/webui/src/runtime/harness/browser.ts", "packages/webui/src/runtime/harness/canvas-input.ts", "packages/webui/src/runtime/harness/execution.ts", "packages/webui/src/runtime/harness/host-contract.ts", @@ -3978,6 +4028,14 @@ "packages/webui/src/runtime/workspace-archive/tar-adapter.ts", "packages/webui/src/runtime/workspace-archive/zip-adapter.ts", "packages/webui/src/server/access-policy.ts", + "packages/webui/src/server/browser-bridge/admission.ts", + "packages/webui/src/server/browser-bridge/credential-store.ts", + "packages/webui/src/server/browser-bridge/index.ts", + "packages/webui/src/server/browser-bridge/pairing.ts", + "packages/webui/src/server/browser-bridge/reconnect.ts", + "packages/webui/src/server/browser-bridge/registry.ts", + "packages/webui/src/server/browser-bridge/server.ts", + "packages/webui/src/server/browser-bridge/session.ts", "packages/webui/src/server/credentials.ts", "packages/webui/src/server/envelope.ts", "packages/webui/src/server/frame-handler.ts", @@ -3992,9 +4050,11 @@ "packages/webui/src/server/operation/agent-memory.ts", "packages/webui/src/server/operation/bind-handlers.ts", "packages/webui/src/server/operation/binding-mechanism.ts", + "packages/webui/src/server/operation/browser.ts", "packages/webui/src/server/operation/common.ts", "packages/webui/src/server/operation/goal.ts", "packages/webui/src/server/operation/handlers/account.ts", + "packages/webui/src/server/operation/handlers/browser.ts", "packages/webui/src/server/operation/handlers/commands.ts", "packages/webui/src/server/operation/handlers/messages.ts", "packages/webui/src/server/operation/handlers/stream.ts", @@ -4025,6 +4085,8 @@ "packages/webui/src/shared/contracts/account-billing.ts", "packages/webui/src/shared/contracts/account-usage.ts", "packages/webui/src/shared/contracts/account.ts", + "packages/webui/src/shared/contracts/browser-bridge.ts", + "packages/webui/src/shared/contracts/browser.ts", "packages/webui/src/shared/contracts/canvas.ts", "packages/webui/src/shared/contracts/commands.ts", "packages/webui/src/shared/contracts/delivery.ts", @@ -4052,8 +4114,12 @@ "packages/webui/tailwind.config.cjs", "packages/webui/test-results/.last-run.json", "packages/webui/test/browser/README.md", + "packages/webui/test/browser/acceptance-record.ts", "packages/webui/test/browser/account-login.spec.mjs", "packages/webui/test/browser/architecture-acceptance.spec.mjs", + "packages/webui/test/browser/browser-bridge-real-browser.spec.ts", + "packages/webui/test/browser/browser-bridge-real-external.spec.ts", + "packages/webui/test/browser/browser-bridge-real-packaged.spec.ts", "packages/webui/test/browser/composer.spec.mjs", "packages/webui/test/browser/connection-status.spec.mjs", "packages/webui/test/browser/context-usage-ring.spec.mjs", @@ -4070,6 +4136,7 @@ "packages/webui/test/browser/rail-pin.spec.mjs", "packages/webui/test/browser/rail-search.spec.mjs", "packages/webui/test/browser/rail-transfer.spec.mjs", + "packages/webui/test/browser/real-browser-bridge.ts", "packages/webui/test/browser/review-consumers.spec.mjs", "packages/webui/test/browser/server.mjs", "packages/webui/test/browser/settings-account-tab.spec.mjs", @@ -4088,6 +4155,7 @@ "packages/webui/test/gates/webui-boundary-check.test.ts", "packages/webui/test/gates/webui-dependency-check.test.ts", "packages/webui/test/integration/adapter-conversions.test.ts", + "packages/webui/test/integration/browser-use-real-input.test.ts", "packages/webui/test/integration/loopback-harness-path.test.ts", "packages/webui/test/integration/real-host-loopback.test.ts", "packages/webui/test/integration/real-host-model-turn.test.ts", @@ -4096,6 +4164,25 @@ "packages/webui/test/unit/account-menu-panels.test.ts", "packages/webui/test/unit/account-workflows.test.ts", "packages/webui/test/unit/activity-indicator.test.tsx", + "packages/webui/test/unit/browser-authorization.test.ts", + "packages/webui/test/unit/browser-bridge-admission.test.ts", + "packages/webui/test/unit/browser-bridge-adversarial.test.ts", + "packages/webui/test/unit/browser-bridge-cdp.test.ts", + "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts", + "packages/webui/test/unit/browser-bridge-credential-store.test.ts", + "packages/webui/test/unit/browser-bridge-pairing.test.ts", + "packages/webui/test/unit/browser-bridge-reconnect.test.ts", + "packages/webui/test/unit/browser-bridge-registry.test.ts", + "packages/webui/test/unit/browser-bridge-server.test.ts", + "packages/webui/test/unit/browser-bridge-session.test.ts", + "packages/webui/test/unit/browser-bridge-upgrade.test.ts", + "packages/webui/test/unit/browser-input-command.test.ts", + "packages/webui/test/unit/browser-panel-state.test.ts", + "packages/webui/test/unit/browser-panel-view.test.tsx", + "packages/webui/test/unit/browser-service.test.ts", + "packages/webui/test/unit/browser-target-registry.test.ts", + "packages/webui/test/unit/browser-tool-adapter.test.ts", + "packages/webui/test/unit/browser-workspace-panel.test.tsx", "packages/webui/test/unit/commands.test.ts", "packages/webui/test/unit/composer-field-transitions.test.ts", "packages/webui/test/unit/composer-goal-outcome.test.ts", diff --git a/release/webui-npm/bin/mcode-webui.mjs b/release/webui-npm/bin/mcode-webui.mjs index 071ebd949..32cfd9f79 100644 --- a/release/webui-npm/bin/mcode-webui.mjs +++ b/release/webui-npm/bin/mcode-webui.mjs @@ -7,9 +7,11 @@ import { fileURLToPath } from "node:url"; import { WebuiService, createOperationRegistry, + BrowserBridgeServer, } from "../server/server.js"; import { WebuiTerminalManager, + createBrowserAdapter, createHarnessPortFromHost, createWebuiRuntimeHost, } from "../server/runtime.js"; @@ -25,23 +27,75 @@ if (!Number.isInteger(tcpPort) || tcpPort < 1 || tcpPort > 65535) { const dataDir = process.env.MINIMAX_DATA_DIR?.trim() || path.join(os.homedir(), ".minimax"); process.env.MAVIS_BUILTIN_AGENTS_V2_DIR ??= path.join(packageRoot, "assets/agents"); process.env.MAVIS_BUILTIN_AGENTS_DIR ??= path.join(packageRoot, "assets/agents"); -const assembled = await createWebuiRuntimeHost({ dataDir, appVersion: version }); +// The extension bridge is assembled here, beside the browser adapter, because +// the layer matrix forbids `src/runtime/**` from importing `src/server/**`. This +// file is the composition root: it is the one place that can hand the bridge's +// provider factory to the adapter AND the same instance to the service's +// `/browser-bridge` upgrade branch, so the extension and the browser service +// address one session rather than two. `dev: true` is passed through +// unchanged; the bridge's admission ignores it by design. +// +// `packaged: true` is what makes this the **packaged** entry. A packaged host +// reads the store allowlist (`MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`, +// `MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS`) and never the development one +// (`MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS`). +// The difference matters: an unpacked build's id is derived from the directory +// it was loaded from, so it is not an identity anyone can rely on, and a +// packaged host that accepted those ids would admit whatever unpacked +// extension happened to be present. The development entry +// (`scripts/run-webui-server.mjs`) is the one that reads the development +// allowlist. +const browserBridge = new BrowserBridgeServer({ + host: "127.0.0.1", + dev: true, + packaged: true, +}); +await browserBridge.start(); +// The browser adapter is built before the runtime host, because the host is +// what takes the browser provider: the adapter reads its authorization +// switches through the host, so the host is attached to it immediately after. +// +// The same binding carries both faces of the one browser session — the port the +// browser operations bind to, and the Agent's browser tools. Building the tool +// adapter anywhere else would put the Agent on a second browser, and the panel +// would show the first. +const browser = createBrowserAdapter({ dataDir, bridge: browserBridge }); +const assembled = await createWebuiRuntimeHost({ + dataDir, + appVersion: version, + browserProvider: { + adapter: browser.toolAdapter, + close: () => browser.close(), + }, + // One compact Browser tool over the restricted command set. The detailed + // per-action tools would re-offer the same actions under their own names. + browserToolExposure: "compact", +}); +browser.attachHost(assembled.host); const port = createHarnessPortFromHost(assembled.host); const terminal = new WebuiTerminalManager(); +// The browser port is absent when the host cannot answer the browser +// authorization at all, and the browser operations then stay unregistered. +// +// The bridge's pairings follow the switches, so the persisted authorization has +// to reach them before the service starts serving. +await browser.initialize(); const service = new WebuiService({ port, - operations: createOperationRegistry(port, terminal), + operations: createOperationRegistry(port, terminal, browser.port, browserBridge), terminal, host: "127.0.0.1", tcpPort, dev: true, clientDir: path.join(packageRoot, "client"), + browserBridge, }); let closing; const close = () => { closing ??= (async () => { await service.close(); + await browser.close(); await assembled.host.apiHost.close(); })(); return closing; diff --git a/scripts/run-webui-server.mjs b/scripts/run-webui-server.mjs index ba02838db..9e78f32cd 100644 --- a/scripts/run-webui-server.mjs +++ b/scripts/run-webui-server.mjs @@ -27,11 +27,12 @@ if (!process.env.WEBUI_SERVER_TSX) { } else { const os = await import("node:os"); const path = await import("node:path"); - const { createWebuiRuntimeHost, createHarnessPortFromHost, WebuiTerminalManager } = await import( - "../packages/webui/src/runtime/index.ts" - ); + const { createWebuiRuntimeHost, createHarnessPortFromHost, createBrowserAdapter, WebuiTerminalManager } = + await import("../packages/webui/src/runtime/index.ts"); const { WebuiService, createOperationRegistry } = await import("../packages/webui/src/server/index.ts"); + const { BrowserBridgeServer } = + await import("../packages/webui/src/server/browser-bridge/index.ts"); const dataDir = process.env.MINIMAX_DATA_DIR?.trim() || path.join(os.homedir(), ".minimax"); const tcpPort = Number(process.env.WEBUI_SERVER_PORT ?? 8787); @@ -39,28 +40,68 @@ if (!process.env.WEBUI_SERVER_TSX) { path.dirname(fileURLToPath(import.meta.url)), "../dist-webui/client", ); + // The extension bridge is assembled here rather than inside the adapter: the + // layer matrix forbids `src/runtime/**` from importing `src/server/**`, so + // this file — outside the layer system — is the one place that can hold the + // bridge, hand its provider factory to the adapter and give the same instance + // to the service's `/browser-bridge` upgrade branch. `dev: true` is passed + // through unchanged; the bridge's admission ignores it by design. + // + // This is the **development** entry, so the bridge is not `packaged`: it + // reads the development allowlist (`MAVIS_BROWSER_EDGE_EXTENSION_IDS`, + // `MAVIS_BROWSER_CHROME_EXTENSION_IDS`), which is where the ids of unpacked + // builds belong. The packaged entry + // (`release/webui-npm/bin/mcode-webui.mjs`) reads the store allowlist and + // refuses these. + const browserBridge = new BrowserBridgeServer({ host: "127.0.0.1", dev: true }); + await browserBridge.start(); + // The browser adapter is built before the runtime host, because the host is + // what takes the browser provider: the adapter reads its authorization + // switches through the host, so the host is attached to it immediately after. + // + // The same binding carries both faces of the one browser session — the port + // the browser operations bind to, and the Agent's browser tools. Building the + // tool adapter anywhere else would put the Agent on a second browser, and the + // panel would show the first. + const browser = createBrowserAdapter({ dataDir, bridge: browserBridge }); const assembled = await createWebuiRuntimeHost({ dataDir, appVersion: "webui-dev", + browserProvider: { + adapter: browser.toolAdapter, + close: () => browser.close(), + }, + // One compact Browser tool over the restricted command set. The detailed + // per-action tools would re-offer the same actions under their own names. + browserToolExposure: "compact", }); + browser.attachHost(assembled.host); const port = createHarnessPortFromHost(assembled.host); // The registry and the terminal port are assembled here, by the host, and the // network service binds what it was handed (T14). The manager itself belongs to // this service's resource scope — one per service, not a host-wide singleton // (T15) — and the service ends every PTY it owns in `close()`. const terminal = new WebuiTerminalManager(); + // The adapter is absent when the host cannot answer the browser + // authorization at all, and the browser operations then stay unregistered. + // + // The bridge's pairings follow the switches, so the persisted authorization + // has to reach them before the service starts serving. + await browser.initialize(); const service = new WebuiService({ port, - operations: createOperationRegistry(port, terminal), + operations: createOperationRegistry(port, terminal, browser.port, browserBridge), terminal, tcpPort, dev: true, clientDir, + browserBridge, }); const info = await service.start(); console.log(`[webui] server listening at ${info.boundUrl}`); const close = async () => { await service.close(); + await browser.close(); }; process.once("SIGINT", () => void close()); process.once("SIGTERM", () => void close()); diff --git a/scripts/source-inventory.mjs b/scripts/source-inventory.mjs index 3a4fd6b22..2b85b3308 100644 --- a/scripts/source-inventory.mjs +++ b/scripts/source-inventory.mjs @@ -28,9 +28,38 @@ const skipped = new Set([ // the tool installed fails `check:source` over a file they never wrote. ".codegraph", ]); +/** + * The unpacked store builds, by name. + * + * A store build is built into its own directory beside `dist/`, because the + * packaged build's manifest carries the store's public key and the development + * build's does not. They are build outputs and they are machine-local, in the + * same way the archives beside them are. + */ +const STORE_BUILD_DIRECTORIES = new Set(["dist-store-chrome", "dist-store-edge"]); + +/** + * Whether a name is a build output rather than source. + * + * The packaged store archives are named after the directory they are built + * into (`dist-chrome.zip`, `dist-edge.zip`). They are build outputs, not + * source, and they are machine-local: the archive the store takes is + * produced by the build, not committed. Leaving them in would make the + * inventory record a file the export does not carry, and the export gate + * would fail with a difference that no one can act on. + */ +function isBuildOutput(name) { + if (STORE_BUILD_DIRECTORIES.has(name)) return true; + return name.startsWith("dist-") && name.endsWith(".zip"); +} + function filesIn(directory, prefix = "") { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - if (skipped.has(entry.name) || entry.name.endsWith(".tsbuildinfo")) + if ( + skipped.has(entry.name) || + entry.name.endsWith(".tsbuildinfo") || + isBuildOutput(entry.name) + ) return []; const relative = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isSymbolicLink()) diff --git a/scripts/verify-webui-package.mjs b/scripts/verify-webui-package.mjs index ed5784a15..6c77e2182 100644 --- a/scripts/verify-webui-package.mjs +++ b/scripts/verify-webui-package.mjs @@ -5,9 +5,22 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { once } from "node:events"; +import { WebSocket } from "ws"; const root = fileURLToPath(new URL("../", import.meta.url)); const { values } = parseArgs({ options: { "package-dir": { type: "string" } } }); + +/** + * The two extension identities the packaged host is configured with. + * + * They are synthetic: nothing signs anything with them and they are only ever + * compared. A Chrome extension id is 32 letters from `a` to `p`, and the + * bridge's origin parser rejects anything else, so the shape has to be right + * for the check to mean anything. + */ +const STORE_EXTENSION_ID = "a".repeat(32); +const DEVELOPMENT_EXTENSION_ID = "b".repeat(32); + const scratch = process.env.MCODE_WEBUI_VERIFY_DIR ?? path.resolve(root, `../${path.basename(root)}-webui-work/package-checks`); if (!path.relative(root, path.resolve(scratch)).startsWith("..")) throw new Error("WebUI package verification directory must be outside the repository"); mkdirSync(scratch, { recursive: true }); @@ -35,7 +48,16 @@ try { await new Promise((resolve, reject) => probe.close((error) => error ? reject(error) : resolve())); child = spawn(process.execPath, [path.join(installed, manifest.bin["mcode-webui"])], { cwd: work, - env: { ...process.env, MINIMAX_DATA_DIR: path.join(work, "synthetic-data"), WEBUI_SERVER_PORT: String(port) }, + env: { + ...process.env, + MINIMAX_DATA_DIR: path.join(work, "synthetic-data"), + WEBUI_SERVER_PORT: String(port), + // The packaged host is configured with both allowlists, on purpose: the + // store identity has to be admitted and the development identity has to + // be refused, and the only way to show that is to have both present. + MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS: STORE_EXTENSION_ID, + MAVIS_BROWSER_CHROME_EXTENSION_IDS: DEVELOPMENT_EXTENSION_ID, + }, stdio: ["ignore", "pipe", "pipe"], }); let output = ""; @@ -55,12 +77,21 @@ try { const response = await fetch(`${origin}/${asset}`, { signal: AbortSignal.timeout(5000) }); if (!response.ok || (await response.arrayBuffer()).byteLength === 0) throw new Error(`Installed WebUI asset missing: ${asset}`); } + // The packaged host's bridge has to be a **packaged** host: it admits the + // store identity and refuses the development one, even though both are + // configured. The bridge's own path is checked here, against the process + // that was actually started, rather than against a bridge the test built + // itself. + const admitted = await bridgeUpgrade(port, STORE_EXTENSION_ID); + if (!admitted) throw new Error("The installed WebUI refused the store extension identity"); + const refused = await bridgeUpgrade(port, DEVELOPMENT_EXTENSION_ID); + if (refused) throw new Error("The installed WebUI accepted a development extension identity"); const exited = once(child, "exit"); child.kill("SIGTERM"); const [code, signal] = await Promise.race([exited, new Promise((_, reject) => setTimeout(() => reject(new Error("Installed WebUI shutdown timed out")), 10_000).unref())]); if (code !== 0 || signal !== null) throw new Error(`Installed WebUI shutdown failed: ${code ?? signal}`); child = undefined; - console.log(`Installed WebUI package PASS: ${manifest.name}@${manifest.version}; archive installation, real runtime startup, HTTP assets and shutdown`); + console.log(`Installed WebUI package PASS: ${manifest.name}@${manifest.version}; archive installation, real runtime startup, HTTP assets, the packaged bridge's admission and shutdown`); } finally { if (child?.pid && child.exitCode === null && child.signalCode === null) { const exited = once(child, "exit"); @@ -69,3 +100,40 @@ try { } rmSync(work, { recursive: true, force: true }); } + +/** + * Whether the bridge admitted an extension with this identity. + * + * `true` means the upgrade was accepted — the socket opened — and `false` + * means the upgrade was refused. The first frame the host sends is a + * `challenge`, or a `closed` frame when the surface's switch is off; either + * one proves the identity got past admission, because a refused identity + * never gets a socket at all. + */ +async function bridgeUpgrade(port, extensionId) { + return new Promise((resolve) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/browser-bridge`, { + origin: `chrome-extension://${extensionId}/`, + }); + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // The error listener stays attached: tearing a socket down mid-handshake + // reports an error, and an unhandled one would take the process down. + try { + socket.terminate(); + } catch { + // The socket is already gone; nothing to close. + } + resolve(value); + }; + const timer = setTimeout(() => settle(false), 10_000); + socket.once("open", () => settle(true)); + socket.once("error", () => settle(false)); + // A refused upgrade never opens a socket: the host answers the upgrade + // request itself with a status line. + socket.once("unexpected-response", () => settle(false)); + }); +} diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 08fde79a7..99ad7d068 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -231,7 +231,12 @@ "packages/local-runtime/test/unit/agent-prompt-profile-validation.test.ts", "packages/local-runtime/test/unit/runtime-owner-policy.test.ts", "packages/local-runtime/test/unit/runtime-owner-policy-identity.test.ts", - "packages/local-runtime/test/unit/runtime-owner-surface-capabilities.test.ts" + "packages/local-runtime/test/unit/runtime-owner-surface-capabilities.test.ts", + "packages/browser-extension/test/protocol.test.ts", + "packages/browser-extension/test/pairing.test.ts", + "packages/browser-extension/test/bridge-client.test.ts", + "packages/browser-extension/test/input.test.ts", + "packages/browser-extension/test/cdp.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" @@ -423,7 +428,27 @@ "packages/webui/test/unit/workspace-review-owner.test.ts", "packages/webui/test/unit/workspace-tab-registry.test.ts", "packages/webui/test/unit/worktree-panel.test.tsx", - "packages/webui/test/unit/worktree-state.test.ts" + "packages/webui/test/unit/worktree-state.test.ts", + "packages/webui/test/unit/browser-bridge-reconnect.test.ts", + "packages/webui/test/unit/browser-bridge-credential-store.test.ts", + "packages/webui/test/unit/browser-bridge-pairing.test.ts", + "packages/webui/test/unit/browser-bridge-admission.test.ts", + "packages/webui/test/unit/browser-bridge-registry.test.ts", + "packages/webui/test/unit/browser-bridge-session.test.ts", + "packages/webui/test/unit/browser-authorization.test.ts", + "packages/webui/test/unit/browser-target-registry.test.ts", + "packages/webui/test/unit/browser-service.test.ts", + "packages/webui/test/unit/browser-panel-state.test.ts", + "packages/webui/test/unit/browser-input-command.test.ts", + "packages/webui/test/unit/browser-panel-view.test.tsx", + "packages/webui/test/integration/browser-use-real-input.test.ts", + "packages/webui/test/unit/browser-bridge-server.test.ts", + "packages/webui/test/unit/browser-bridge-upgrade.test.ts", + "packages/webui/test/unit/browser-workspace-panel.test.tsx", + "packages/webui/test/unit/browser-tool-adapter.test.ts", + "packages/webui/test/unit/browser-bridge-cdp.test.ts", + "packages/webui/test/unit/browser-bridge-adversarial.test.ts", + "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts" ] } }