From 8362f74c995b0cad040303394e70f54da78ef733 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 03:15:54 -0700 Subject: [PATCH] feat(browser): add all-tabs extension access (#120995) * feat(browser): add all-tabs extension access * fix(browser): preserve cancel revocation during startup * test(browser): align all-tabs Chromium fixtures --- docs/gateway/security/index.md | 6 + docs/tools/chrome-extension.md | 80 +- .../background.access-mode.test.ts | 1066 +++++++++++++++++ .../browser/chrome-extension/background.js | 521 ++++---- .../background.test-harness.ts | 455 +++++++ .../background.test-support.ts | 9 +- .../chrome-extension/background.test.ts | 619 +++------- .../browser/chrome-extension/manifest.json | 4 +- .../modules/copilot-background-shared.d.ts | 2 +- .../modules/copilot-background-shared.js | 4 +- .../modules/copilot-background.js | 46 +- .../modules/copilot-background.test.ts | 48 +- .../modules/copilot-session.js | 30 +- .../modules/copilot-session.test.ts | 97 ++ .../modules/page-share-background.js | 84 ++ .../modules/popup-background.js | 301 +++++ .../modules/relay-command-handler.d.ts | 10 +- .../modules/relay-command-handler.js | 22 +- .../modules/relay-command-handler.test.ts | 75 ++ .../chrome-extension/modules/relay-core.d.ts | 5 + .../chrome-extension/modules/relay-core.js | 45 +- .../modules/relay-core.test.ts | 73 +- .../modules/relay-tab-groups.js | 20 +- .../modules/tab-access-events.d.ts | 59 + .../modules/tab-access-events.js | 213 ++++ .../modules/tab-access-events.test.ts | 323 +++++ .../chrome-extension/modules/tab-access.d.ts | 70 ++ .../chrome-extension/modules/tab-access.js | 404 +++++++ .../modules/tab-access.test.ts | 388 ++++++ .../modules/tab-eligibility.d.ts | 25 + .../modules/tab-eligibility.js | 39 + .../chrome-extension/page-share.e2e.test.ts | 166 ++- .../chrome-extension/popup-errors.test.ts | 109 +- .../browser/chrome-extension/popup.html | 100 +- extensions/browser/chrome-extension/popup.js | 61 +- .../chrome-extension/sidepanel.e2e-support.ts | 12 +- .../chrome-extension/sidepanel.e2e.test.ts | 16 +- .../browser/chrome-extension/sidepanel.html | 6 +- .../browser/chrome-extension/sidepanel.js | 8 +- .../browser/scripts/copy-chrome-extension.mjs | 6 +- .../extension-relay/relay-bridge.test.ts | 20 +- .../browser/extension-relay/relay-bridge.ts | 20 +- .../browser/extension-relay/relay-protocol.ts | 18 +- .../relay-server.auth-v2.test.ts | 4 +- 44 files changed, 4764 insertions(+), 925 deletions(-) create mode 100644 extensions/browser/chrome-extension/background.access-mode.test.ts create mode 100644 extensions/browser/chrome-extension/background.test-harness.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-session.test.ts create mode 100644 extensions/browser/chrome-extension/modules/page-share-background.js create mode 100644 extensions/browser/chrome-extension/modules/popup-background.js create mode 100644 extensions/browser/chrome-extension/modules/relay-command-handler.test.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-access-events.d.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-access-events.js create mode 100644 extensions/browser/chrome-extension/modules/tab-access-events.test.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-access.d.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-access.js create mode 100644 extensions/browser/chrome-extension/modules/tab-access.test.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-eligibility.d.ts create mode 100644 extensions/browser/chrome-extension/modules/tab-eligibility.js diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 9991bdade02d..433368c6089f 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -492,6 +492,12 @@ Enabling browser control gives the model a real browser. If that profile already window. This temporarily accepts old Bearer, Basic, and token-subprotocol relay clients. Update every relay client, then set it to `false`. V2 clients never downgrade after a failed proof or unsupported response. +- Chrome extension pairing stores its access mode in extension-owned Chrome + storage, not Gateway config. **All tabs** exposes every eligible ordinary tab + in that Chrome profile except session-paused tabs; **Selected tabs** uses the + OpenClaw tab group as its ACL. Existing pairings migrate to **Selected tabs**, + while new personal-browser pairings recommend **All tabs**. Incognito and + internal Chrome pages remain excluded in either mode. - Run a **node host** on the browser machine and let the Gateway proxy browser actions when the Gateway is remote from the browser (see [Browser tool](/tools/browser)); treat node pairing like admin access, keep Gateway and node host on the same tailnet, and avoid exposing relay/control ports over LAN, public internet, or Tailscale Funnel. ### Browser SSRF policy (strict by default) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 4124470b50d6..69b53f822d48 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -33,15 +33,16 @@ Three parts: for same-host and browser-node setups, or through the Gateway's relay-authenticated WebSocket route for direct remote setups. It presents a Chrome DevTools Protocol endpoint to OpenClaw and speaks to the extension. -- **OpenClaw Chrome extension** (MV3): attaches to tabs with `chrome.debugger`, - forwards CDP traffic, and manages the **OpenClaw tab group**. +- **OpenClaw Chrome extension** (MV3): owns the access policy, attaches to + allowed tabs with `chrome.debugger`, forwards CDP traffic, and manages the + **OpenClaw tab group**. -OpenClaw only sees and controls tabs that are in the **OpenClaw tab group**. The -group is the consent boundary: the relay advertises only grouped tabs, and the -extension rechecks current group membership before every authority-bearing -command for an existing tab. Drag a tab in to share it; drag it out (or click -the toolbar button) to revoke access instantly, even if a relay client still -has stale tab state. +Pairing chooses one of two extension-owned access modes. **All tabs** makes +every eligible ordinary tab in this Chrome profile available and is the +recommended default for a personal browser. **Selected tabs** exposes only tabs +in the **OpenClaw tab group**. The extension advertises only currently +accessible tabs and rechecks eligibility, mode, and revocation state before and +after authority-bearing commands, so stale relay state cannot restore access. ## Install and pair @@ -60,8 +61,9 @@ has stale tab state. openclaw browser extension pair ``` -4. Click the OpenClaw toolbar icon and paste the pairing string into the popup. - The badge turns **ON** when the extension connects to the relay. +4. Click the OpenClaw toolbar icon, paste the pairing string, and choose + **All tabs** or **Selected tabs**. New pairings default to **All tabs**. The + badge turns **ON** when the extension connects to the relay. The pairing key is a **per-host secret** created on first use and stored under `credentials/` in the state directory (mode `0600`). Each machine that @@ -95,16 +97,44 @@ openclaw config set browser.defaultProfile chrome } ``` -- Share a tab: click the OpenClaw toolbar button on that tab (it joins the - OpenClaw tab group), or drag any tab into the group. -- The agent can also open new tabs; those land in the group automatically. -- Revoke: click the button again, drag the tab out of the group, or dismiss - Chrome's debugging banner. The agent loses access to that tab immediately. +### Choose tab access + +- **All tabs**: OpenClaw can enumerate and control every eligible ordinary tab + in this Chrome profile, including signed-in sites. The OpenClaw group is an + organizational marker for agent-created tabs; dragging a tab into or out of + it does not change access. Use **Pause OpenClaw on this tab** in the toolbar + popup to exclude one tab, and **Allow OpenClaw on this tab** to restore it. +- **Selected tabs**: group membership is the access-control boundary. Click + **Share this tab with OpenClaw** or drag a tab into the OpenClaw group to + grant access. Click **Stop sharing this tab** or drag it out to revoke access. + +Change **Access** in the popup settings at any time. Switching from **All tabs** +to **Selected tabs** immediately detaches every ungrouped tab, including an +attach already in flight. Switching back refreshes the available targets. +Paused tab IDs remain paused for the rest of the browser session and become +effective again if you return to **All tabs**. + +Existing valid pairings created before access modes migrate to **Selected +tabs**, preserving their original group-only security promise. A malformed +stored mode is also repaired to **Selected tabs** without discarding the +pairing. New pairings explicitly choose a mode and default to **All tabs**. + +Agent-created tabs enter the OpenClaw group in both modes. Chrome shows its +dismissible “OpenClaw started debugging this browser” banner while a tab is +attached. Choosing **Cancel** pauses that tab for the browser session in **All +tabs** mode; in **Selected tabs** mode it removes the tab from the group. + +The extension never exposes incognito tabs, tabs without a current ID or URL, +or internal pages such as `chrome://`, `chrome-extension://`, `devtools://`, +and origin-inheriting `about:blank`. It permits ordinary `http://`, `https://`, +and `data:` documents, `blob:` documents backed by an HTTP(S) origin, and +`file://` pages when Chrome's **Allow access to file URLs** setting permits +debugger attachment. ### Authenticated external CDP clients The relay supports Browser Relay Authentication v2 clients such as mcporter. -They use the same paired Chrome and the same tab-group consent boundary, +They use the same paired Chrome and the same extension access policy, without Chrome's "Allow remote debugging?" prompt. Print the non-secret v2 endpoint metadata: @@ -171,7 +201,7 @@ hatch only during the migration window. ### Tab copilot side panel After pairing the extension, click **Open tab copilot** in its toolbar popup. -OpenClaw configures `sidepanel.html` for that exact Chrome tab; the manifest has +OpenClaw configures `sidepanel.html` for that exact accessible Chrome tab; the manifest has no global side-panel path. Each tab therefore gets a separate panel document, Gateway session, message subscription, and typed browser-tool binding. @@ -295,15 +325,19 @@ relay authentication remains enabled and tells you when to set version, role, transport, method, resource, flow, profile, and relay instance. - In v2, the per-host key is never transmitted. Failed proof validation does not fall back to legacy Bearer, Basic, or token-subprotocol auth. -- The relay exposes only tabs in the **OpenClaw tab group**, and the extension - independently rechecks group membership before each authority-bearing - existing-tab command. Your other tabs stay private. +- The relay exposes only tabs allowed by the selected access mode. The + extension independently rechecks eligibility and current policy before and + after each authority-bearing existing-tab command. In **Selected tabs**, the + group is the ACL; in **All tabs**, explicitly paused tabs stay hidden. - Side-panel runs are scoped twice: Gateway delivery uses a per-session allowlist, and browser tools enforce the Chrome tab/target binding carried outside the prompt. -- Compared with the `user` (Chrome MCP) profile, which exposes your whole - signed-in browser once you approve the remote-debugging prompt, the extension - keeps the shared surface scoped to a tab group you control at a glance. +- **All tabs** grants broad authority over eligible signed-in sites in this + profile in exchange for the best unattended experience. **Selected tabs** + reduces that authority to a visible group but requires deliberate tab + management. Compared with the `user` (Chrome MCP) profile, the extension adds + this mode choice and per-tab session pauses without a blocking + remote-debugging prompt. See also: [Browser](/tools/browser) for the full profile model and the managed `openclaw` and Chrome MCP `user` profiles. diff --git a/extensions/browser/chrome-extension/background.access-mode.test.ts b/extensions/browser/chrome-extension/background.access-mode.test.ts new file mode 100644 index 000000000000..5d432468b6af --- /dev/null +++ b/extensions/browser/chrome-extension/background.access-mode.test.ts @@ -0,0 +1,1066 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadBackground, RELAY_SECRET, sendRuntimeMessage } from "./background.test-harness.js"; + +const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog"; + +describe("relay command authorization", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects every authority-bearing command after tab-group revocation", async () => { + const harness = await loadBackground(); + const socket = harness.sockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + harness.shareTab(41); + harness.unshareTab(41); + + socket.receive({ type: "attach", seq: 1, tabId: 41 }); + socket.receive({ type: "cdp", seq: 2, tabId: 41, method: "Runtime.evaluate" }); + socket.receive({ type: "closeTab", seq: 3, tabId: 41 }); + socket.receive({ type: "activateTab", seq: 4, tabId: 41 }); + + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect( + frames + .filter((frame) => frame.type === "error") + .map((frame) => frame.seq) + .toSorted((left, right) => left - right), + ).toEqual([1, 2, 3, 4]); + }); + expect(harness.debuggerAttach).not.toHaveBeenCalled(); + expect(harness.debuggerSendCommand).not.toHaveBeenCalled(); + expect(harness.tabsRemove).not.toHaveBeenCalled(); + expect(harness.tabsUpdate).not.toHaveBeenCalled(); + expect(harness.windowsUpdate).not.toHaveBeenCalled(); + }); + + it("controls an eligible ungrouped tab in all mode", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 41, url: "https://example.com/all", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + const hello = socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .find((frame) => frame.type === "hello"); + expect(hello.tabs).toContainEqual( + expect.objectContaining({ tabId: 41, url: "https://example.com/all" }), + ); + + socket.receive({ type: "attach", seq: 20, tabId: 41 }); + socket.receive({ type: "cdp", seq: 21, tabId: 41, method: "Runtime.evaluate" }); + + await vi.waitFor(() => { + expect(harness.debuggerAttach).toHaveBeenCalledWith({ tabId: 41 }, "1.3"); + expect(harness.debuggerSendCommand).toHaveBeenCalledWith( + { tabId: 41 }, + "Runtime.evaluate", + {}, + ); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ type: "result", seq: 21, result: {} }); + }); + }); + + it("closes the old selected relay before a replacement pairing widens access", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "selected", + }, + initialTabs: [{ id: 45, url: "https://example.com/private", groupId: -1 }], + }); + const oldSocket = harness.relaySockets[0]; + if (!oldSocket) { + throw new Error("expected old relay socket"); + } + await harness.authenticate(oldSocket); + + await expect( + sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${"b".repeat(64)}`, + accessMode: "all", + }), + ).resolves.toEqual({ ok: true }); + + expect(oldSocket.close).toHaveBeenCalledOnce(); + const oldFrames = oldSocket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect( + oldFrames.some( + (frame) => + frame.type === "tabs" && frame.tabs?.some((tab: { tabId?: number }) => tab.tabId === 45), + ), + ).toBe(false); + + const replacement = harness.relaySockets.find( + (socket) => socket.url === "ws://127.0.0.1:18798/extension", + ); + if (!replacement) { + throw new Error("expected replacement relay socket"); + } + await harness.authenticate(replacement); + const replacementHello = replacement.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .find((frame) => frame.type === "hello"); + expect(replacementHello.tabs).toContainEqual(expect.objectContaining({ tabId: 45 })); + }); + + it("cancels a stale lifecycle connection across replacement pairing", async () => { + const harness = await loadBackground(); + const oldSocket = harness.relaySockets[0]; + if (!oldSocket) { + throw new Error("expected old relay socket"); + } + await harness.authenticate(oldSocket); + oldSocket.close(); + + const releaseConfigRead = harness.deferNextStorageGet(); + harness.alarmListener({ name: RELAY_WATCHDOG_ALARM }); + const pairing = sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${"b".repeat(64)}`, + accessMode: "all", + }); + releaseConfigRead(); + + await expect(pairing).resolves.toEqual({ ok: true }); + await vi.waitFor(() => { + expect( + harness.relaySockets.filter((socket) => socket.url === "ws://127.0.0.1:18798/extension"), + ).toHaveLength(1); + }); + expect( + harness.relaySockets.filter((socket) => socket.url === "ws://127.0.0.1:18797/extension"), + ).toHaveLength(1); + }); + + it("waits for access initialization before changing the stored mode", async () => { + const harness = await loadBackground({ + deferTabAccessInitialization: true, + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + }); + harness.storageSet.mockClear(); + const response = vi.fn(); + + harness.messageListener({ type: "setAccessMode", accessMode: "selected" }, {}, response); + await Promise.resolve(); + await Promise.resolve(); + + expect(response).not.toHaveBeenCalled(); + expect(harness.storageSet).not.toHaveBeenCalled(); + harness.releaseTabAccessInitialization(); + await vi.waitFor(() => { + expect(response).toHaveBeenCalledWith({ ok: true, accessMode: "selected" }); + }); + }); + + it("waits for access initialization before toggling an all-mode tab", async () => { + const harness = await loadBackground({ + deferTabAccessInitialization: true, + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 46, url: "https://example.com/pending-init", groupId: -1 }], + }); + harness.tabsGet.mockClear(); + const response = vi.fn(); + + harness.messageListener( + { type: "toggleTabAccess", tabId: 46, accessMode: "all", grant: false }, + {}, + response, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(response).not.toHaveBeenCalled(); + expect(harness.tabsGet).not.toHaveBeenCalled(); + expect(harness.tabsGroup).not.toHaveBeenCalled(); + harness.releaseTabAccessInitialization(); + await vi.waitFor(() => { + expect(response).toHaveBeenCalledWith({ ok: true, accessible: false, denied: true }); + }); + expect(harness.sessionStorageValues.deniedTabIdsV1).toEqual([46]); + expect(harness.tabsGroup).not.toHaveBeenCalled(); + }); + + it.each([undefined, null, Number.NaN, -1, 1.5, "41"])( + "rejects malformed getTabAccess tab id %s without querying Chrome", + async (tabId) => { + const harness = await loadBackground(); + harness.tabsGet.mockClear(); + + await expect(sendRuntimeMessage(harness, { type: "getTabAccess", tabId })).resolves.toEqual({ + accessMode: "selected", + accessible: false, + eligible: false, + denied: false, + }); + expect(harness.tabsGet).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + accessMode: "all", + label: "restricted", + tab: { id: 51, url: "chrome://settings", groupId: 7 }, + }, + { + accessMode: "selected", + label: "restricted", + tab: { id: 51, url: "chrome://settings", groupId: 7 }, + }, + { + accessMode: "all", + label: "incognito", + tab: { id: 52, url: "https://secret.example", incognito: true, groupId: 7 }, + }, + { + accessMode: "selected", + label: "incognito", + tab: { id: 52, url: "https://secret.example", incognito: true, groupId: 7 }, + }, + ])("rejects an $label tab in $accessMode mode", async ({ accessMode, tab }) => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode, + }, + initialTabs: [tab], + }); + harness.shareTab(tab.id); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 22, tabId: tab.id }); + await vi.waitFor(() => { + const frame = socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .find((candidate) => candidate.type === "error" && candidate.seq === 22); + expect(frame?.message).toMatch(/restricted|incognito/); + }); + expect(harness.debuggerAttach).not.toHaveBeenCalled(); + }); + + it("invalidates an in-flight CDP command when all mode downgrades to selected", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 61, url: "https://example.com/race", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 23, tabId: 61 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); + let releaseCommand = () => {}; + harness.debuggerSendCommand.mockImplementationOnce( + async () => + await new Promise>((resolve) => { + releaseCommand = () => resolve({}); + }), + ); + socket.receive({ type: "cdp", seq: 24, tabId: 61, method: "Runtime.evaluate" }); + await vi.waitFor(() => expect(harness.debuggerSendCommand).toHaveBeenCalled()); + + const changingMode = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + releaseCommand(); + + await expect(changingMode).resolves.toMatchObject({ ok: true, accessMode: "selected" }); + await vi.waitFor(() => { + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 61 }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 24, + message: "tab 61 access was revoked", + }); + }); + }); + + it("revokes all-mode authority before a queued downgrade reaches the mutation queue", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 204, url: "https://example.com/queued-downgrade", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 40, tabId: 204 }); + await vi.waitFor(() => + expect(harness.debuggerAttach).toHaveBeenCalledWith({ tabId: 204 }, "1.3"), + ); + + harness.storageSet.mockClear(); + socket.send.mockClear(); + const releaseOlderMutation = harness.deferNextStorageSet(); + const olderMutation = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "all", + }); + await vi.waitFor(() => expect(harness.storageSet).toHaveBeenCalledWith({ accessMode: "all" })); + + const downgrading = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + await expect( + sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 204 }), + ).resolves.toMatchObject({ accessible: false }); + harness.debuggerEventListener({ tabId: 204 }, "Runtime.consoleAPICalled", {}); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .some((frame) => frame.type === "cdpEvent"), + ).toBe(false); + + releaseOlderMutation(); + await expect(olderMutation).resolves.toEqual({ ok: true, accessMode: "all" }); + await expect(downgrading).resolves.toEqual({ ok: true, accessMode: "selected" }); + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 204 }); + }); + + it("rejects a stale tab action when a queued mode change executes first", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 205, url: "https://example.com/stale-action", groupId: -1 }], + }); + harness.storageSet.mockClear(); + const releaseModeStorage = harness.deferNextStorageSet(); + const changingMode = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith({ accessMode: "selected" }); + }); + + const staleToggle = sendRuntimeMessage(harness, { + type: "toggleTabAccess", + tabId: 205, + accessMode: "all", + grant: false, + }); + releaseModeStorage(); + + await expect(changingMode).resolves.toEqual({ ok: true, accessMode: "selected" }); + await expect(staleToggle).resolves.toEqual({ + ok: false, + error: "Browser access mode changed. Refresh and retry.", + }); + expect(harness.tabsGroup).not.toHaveBeenCalled(); + expect(harness.sessionStorageValues).not.toHaveProperty("deniedTabIdsV1"); + }); + + it("keeps a Selected barrier ahead of a queued All-mode widening", async () => { + let releaseConsent = () => {}; + const consent = new Promise((resolve) => { + releaseConsent = resolve; + }); + const onConsentChanged = vi.fn(async () => await consent); + const harness = await loadBackground({ + onConsentChanged, + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "selected", + }, + initialTabs: [{ id: 206, url: "https://example.com/queued-widening", groupId: -1 }], + }); + harness.storageSet.mockClear(); + const releaseWideningStorage = harness.deferNextStorageSet(); + const widening = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "all", + }); + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith({ accessMode: "all" }); + }); + + const restricting = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + releaseWideningStorage(); + await vi.waitFor(() => expect(onConsentChanged).toHaveBeenCalled()); + + await expect( + sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 206 }), + ).resolves.toMatchObject({ accessible: false }); + + releaseConsent(); + await expect(widening).resolves.toEqual({ ok: true, accessMode: "all" }); + await expect(restricting).resolves.toEqual({ ok: true, accessMode: "selected" }); + }); + + it("revalidates a selected survivor that leaves its group during downgrade cleanup", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [ + { id: 211, url: "https://example.com/selected", groupId: 7 }, + { id: 212, url: "https://example.com/unselected", groupId: -1 }, + ], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 41, tabId: 211 }); + socket.receive({ type: "attach", seq: 42, tabId: 212 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalledTimes(2)); + + let releaseUnselectedDetach = () => {}; + const unselectedDetach = new Promise((resolve) => { + releaseUnselectedDetach = resolve; + }); + harness.debuggerDetach.mockImplementation(async ({ tabId }: { tabId: number }) => { + if (tabId === 212) { + await unselectedDetach; + } + }); + socket.send.mockClear(); + const changingMode = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + await vi.waitFor(() => expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 212 })); + + harness.unshareTab(211); + harness.tabsUpdatedListener(211, { groupId: -1 }); + releaseUnselectedDetach(); + + await expect(changingMode).resolves.toEqual({ ok: true, accessMode: "selected" }); + await vi.waitFor(() => expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 211 })); + harness.debuggerEventListener({ tabId: 211 }, "Runtime.consoleAPICalled", {}); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .some((frame) => frame.type === "cdpEvent"), + ).toBe(false); + }); + + it("revokes an ungrouped attach already in flight during all-to-selected downgrade", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 62, url: "https://example.com/attach-race", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + let releaseAttach = () => {}; + harness.debuggerAttach.mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseAttach = () => resolve(undefined); + }), + ); + socket.receive({ type: "attach", seq: 25, tabId: 62 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); + + const changingMode = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + releaseAttach(); + + await expect(changingMode).resolves.toMatchObject({ ok: true, accessMode: "selected" }); + await vi.waitFor(() => { + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 62 }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 25, + message: "tab 62 access was revoked", + }); + }); + }); + + it("preserves the proven attach epoch across a deferred target lookup", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 63, url: "https://example.com/target-race", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + let releaseTargets = ( + _targets: Array<{ id?: string; tabId?: number; attached?: boolean }>, + ) => {}; + harness.debuggerGetTargets.mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseTargets = resolve; + }), + ); + socket.receive({ type: "attach", seq: 26, tabId: 63 }); + await vi.waitFor(() => { + expect(harness.debuggerAttach).toHaveBeenCalledWith({ tabId: 63 }, "1.3"); + expect(harness.debuggerGetTargets).toHaveBeenCalled(); + }); + + const releaseModeStorage = harness.deferNextStorageSet(); + releaseTargets([{ id: "target-63", tabId: 63, attached: true }]); + const changingMode = sendRuntimeMessage(harness, { + type: "setAccessMode", + accessMode: "selected", + }); + + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith({ accessMode: "selected" }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 26, + message: "tab 63 access was revoked", + }); + }); + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 63 }); + + harness.debuggerEventListener({ tabId: 63 }, "Runtime.consoleAPICalled", { value: 1 }); + const framesAfterEvent = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect( + framesAfterEvent.some( + (frame) => frame.type === "cdpEvent" && frame.method === "Runtime.consoleAPICalled", + ), + ).toBe(false); + + releaseModeStorage(); + await expect(changingMode).resolves.toEqual({ ok: true, accessMode: "selected" }); + }); + + it("does not mint an event epoch when a pending attach is selected again", async () => { + const harness = await loadBackground({ + initialTabs: [{ id: 64, url: "https://example.com/reselected", groupId: 7 }], + }); + harness.shareTab(64); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + let releaseTargets = ( + _targets: Array<{ id?: string; tabId?: number; attached?: boolean }>, + ) => {}; + harness.debuggerGetTargets.mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseTargets = resolve; + }), + ); + socket.receive({ type: "attach", seq: 27, tabId: 64 }); + await vi.waitFor(() => expect(harness.debuggerGetTargets).toHaveBeenCalled()); + + harness.unshareTab(64); + harness.tabsUpdatedListener(64, { groupId: -1 }); + harness.shareTab(64); + harness.tabsUpdatedListener(64, { groupId: 7 }); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + + harness.debuggerEventListener({ tabId: 64 }, "Runtime.consoleAPICalled", { value: 1 }); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .some((frame) => frame.type === "cdpEvent" && frame.method === "Runtime.consoleAPICalled"), + ).toBe(false); + + releaseTargets([{ id: "target-64", tabId: 64, attached: true }]); + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 27, + message: "tab 64 access was revoked", + }); + }); + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 64 }); + }); + + it.each(["all", "selected"] as const)( + "keeps an unrelated attachment live while toggling one tab in %s mode", + async (accessMode) => { + const groupId = accessMode === "selected" ? 7 : -1; + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode, + }, + initialTabs: [ + { id: 201, url: "https://example.com/attached", groupId }, + { id: 202, url: "https://example.com/toggle", groupId }, + ], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 28, tabId: 201 }); + socket.receive({ type: "attach", seq: 29, tabId: 202 }); + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "result", + seq: 28, + result: { targetId: "tab-201" }, + }); + expect(frames).toContainEqual({ + type: "result", + seq: 29, + result: { targetId: "tab-202" }, + }); + }); + + socket.send.mockClear(); + let releaseMutation = () => {}; + if (accessMode === "all") { + releaseMutation = harness.deferNextSessionStorageSet(); + harness.sessionStorageSet.mockClear(); + } else { + const pendingDetach = new Promise((resolve) => { + releaseMutation = resolve; + }); + harness.debuggerDetach.mockImplementation(async ({ tabId }: { tabId: number }) => { + if (tabId === 202) { + await pendingDetach; + } + }); + } + const toggling = sendRuntimeMessage(harness, { + type: "toggleTabAccess", + tabId: 202, + accessMode, + grant: false, + }); + if (accessMode === "all") { + await vi.waitFor(() => expect(harness.sessionStorageSet).toHaveBeenCalled()); + } else { + await vi.waitFor(() => expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 202 })); + } + + await expect( + sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 201 }), + ).resolves.toMatchObject({ accessible: true }); + await expect( + sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 202 }), + ).resolves.toMatchObject({ accessible: false }); + harness.debuggerEventListener({ tabId: 201 }, "Runtime.consoleAPICalled", { + phase: "during", + }); + harness.debuggerEventListener({ tabId: 202 }, "Runtime.consoleAPICalled", { + phase: "during", + }); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .filter((frame) => frame.type === "cdpEvent") + .map((frame) => frame.params?.phase), + ).toEqual(["during"]); + + releaseMutation(); + await expect(toggling).resolves.toEqual({ + ok: true, + accessible: false, + denied: accessMode === "all", + }); + + harness.debuggerEventListener({ tabId: 201 }, "Runtime.consoleAPICalled", { + phase: "after", + }); + harness.debuggerEventListener({ tabId: 202 }, "Runtime.consoleAPICalled", { + phase: "after", + }); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .filter((frame) => frame.type === "cdpEvent") + .map((frame) => frame.params?.phase), + ).toEqual(["during", "after"]); + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 202 }); + expect(harness.debuggerDetach).not.toHaveBeenCalledWith({ tabId: 201 }); + expect(harness.debuggerAttach).toHaveBeenCalledTimes(2); + }, + ); + + it.each(["all", "selected"] as const)( + "republishes restored tab access immediately in %s mode", + async (accessMode) => { + const tabId = 203; + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode, + }, + initialTabs: [ + { + id: tabId, + url: "https://example.com/restored", + groupId: accessMode === "selected" ? 7 : -1, + }, + ], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + + await expect( + sendRuntimeMessage(harness, { type: "toggleTabAccess", tabId, accessMode, grant: false }), + ).resolves.toMatchObject({ ok: true, accessible: false }); + await expect( + sendRuntimeMessage(harness, { type: "toggleTabAccess", tabId, accessMode, grant: true }), + ).resolves.toMatchObject({ ok: true, accessible: true, denied: false }); + + await vi.waitFor(() => { + const refreshes = socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .filter((frame) => frame.type === "tabs"); + expect(refreshes.at(-1)?.tabs).toContainEqual(expect.objectContaining({ tabId })); + }); + }, + ); + + it("refreshes targets on selected-to-all while keeping session-denied tabs hidden", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "selected", + }, + sessionConfig: { deniedTabIdsV1: [72] }, + initialTabs: [ + { id: 71, url: "https://example.com/available", groupId: -1 }, + { id: 72, url: "https://example.com/paused", groupId: -1 }, + ], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + await expect( + sendRuntimeMessage(harness, { type: "setAccessMode", accessMode: "all" }), + ).resolves.toMatchObject({ ok: true, accessMode: "all" }); + + await vi.waitFor(() => { + const refreshes = socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .filter((frame) => frame.type === "tabs"); + expect(refreshes.at(-1)?.tabs).toEqual([expect.objectContaining({ tabId: 71 })]); + }); + }); + + it("keeps detach available as the revocation cleanup command", async () => { + const harness = await loadBackground(); + const socket = harness.sockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + harness.unshareTab(41); + + socket.receive({ type: "detach", seq: 5, tabId: 41 }); + + await vi.waitFor(() => { + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 41 }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ type: "result", seq: 5, result: {} }); + }); + }); + + it("allows createTab and groups the new tab before reporting success", async () => { + const harness = await loadBackground(); + const socket = harness.sockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + harness.tabsCreate.mockResolvedValueOnce({ + id: 42, + url: "https://example.com", + active: true, + windowId: 1, + groupId: -1, + incognito: false, + }); + + socket.receive({ type: "createTab", seq: 6, url: "https://example.com" }); + + await vi.waitFor(() => { + expect(harness.tabsGroup).toHaveBeenCalledWith({ tabIds: [42] }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ type: "result", seq: 6, result: { tabId: 42 } }); + }); + }); + + it("invalidates an attach that was in flight when the tab left the group", async () => { + const harness = await loadBackground(); + const socket = harness.sockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + harness.shareTab(43); + let releaseAttach = () => {}; + harness.debuggerAttach.mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseAttach = () => resolve(undefined); + }), + ); + + socket.receive({ type: "attach", seq: 7, tabId: 43 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalledOnce()); + harness.unshareTab(43); + harness.tabsUpdatedListener(43, { groupId: -1 }); + await Promise.resolve(); + releaseAttach(); + + await vi.waitFor(() => { + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 43 }); + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 7, + message: "tab 43 access was revoked", + }); + }); + }); + + it("persists Cancel as an all-mode session deny, restores with Allow, and prunes on close", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 81, url: "https://example.com/cancel", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerDetachListener || !harness.tabsRemovedListener) { + throw new Error("expected relay and Chrome lifecycle listeners"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 30, tabId: 81 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); + + harness.debuggerDetachListener({ tabId: 81 }, "canceled_by_user"); + await vi.waitFor(() => { + expect(harness.sessionStorageValues.deniedTabIdsV1).toEqual([81]); + }); + socket.receive({ type: "attach", seq: 31, tabId: 81 }); + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 31, + message: "tab 81 is paused for OpenClaw", + }); + }); + + await expect( + sendRuntimeMessage(harness, { + type: "toggleTabAccess", + tabId: 81, + accessMode: "all", + grant: true, + }), + ).resolves.toMatchObject({ ok: true, accessible: true, denied: false }); + expect(harness.sessionStorageValues).not.toHaveProperty("deniedTabIdsV1"); + + harness.debuggerDetachListener({ tabId: 81 }, "canceled_by_user"); + await vi.waitFor(() => { + expect(harness.sessionStorageValues.deniedTabIdsV1).toEqual([81]); + }); + harness.tabsRemovedListener(81); + await vi.waitFor(() => { + expect(harness.sessionStorageValues).not.toHaveProperty("deniedTabIdsV1"); + }); + }); + + it("restores a validated Cancel deny after an MV3 worker restart", async () => { + const storedConfig = { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }; + const initialTabs = [{ id: 91, url: "https://example.com/reload", groupId: -1 }]; + const harness = await loadBackground({ + storedConfig, + sessionConfig: { deniedTabIdsV1: [91] }, + initialTabs, + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + const hello = socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .find((frame) => frame.type === "hello"); + expect(hello.tabs).toEqual([]); + socket.receive({ type: "attach", seq: 32, tabId: 91 }); + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "error", + seq: 32, + message: "tab 91 is paused for OpenClaw", + }); + }); + }); + + it.each(["all", "selected"] as const)( + "keeps agent-created tabs in the OpenClaw group in %s mode", + async (accessMode) => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode, + }, + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + harness.tabsCreate.mockResolvedValueOnce({ + id: 101, + url: "https://example.com/created", + active: true, + groupId: -1, + windowId: 1, + incognito: false, + }); + socket.receive({ type: "createTab", seq: 33, url: "https://example.com/created" }); + await vi.waitFor(() => { + expect(harness.tabsGroup).toHaveBeenCalledWith({ tabIds: [101] }); + }); + }, + ); + + it.each([ + { accessMode: "all" as const, detached: false }, + { accessMode: "selected" as const, detached: true }, + ])("revokes on group removal only in $accessMode mode", async ({ accessMode, detached }) => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode, + }, + initialTabs: [{ id: 111, url: "https://example.com/group", groupId: 7 }], + }); + harness.shareTab(111); + const socket = harness.relaySockets[0]; + if (!socket || !harness.tabGroupRemovedListener) { + throw new Error("expected relay and tab-group listener"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 34, tabId: 111 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); + harness.debuggerDetach.mockClear(); + + harness.unshareTab(111); + harness.tabGroupRemovedListener(); + + if (detached) { + await vi.waitFor(() => { + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 111 }); + }); + } else { + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(harness.debuggerDetach).not.toHaveBeenCalled(); + } + }); +}); diff --git a/extensions/browser/chrome-extension/background.js b/extensions/browser/chrome-extension/background.js index afe606735ce0..970ffc463d78 100644 --- a/extensions/browser/chrome-extension/background.js +++ b/extensions/browser/chrome-extension/background.js @@ -1,33 +1,28 @@ import { createCopilotController } from "./modules/copilot-background.js"; -import { - buildPageSharePayload, - capturePageShare, - waitForCondition, -} from "./modules/page-share-core.js"; +import { createPageShareController } from "./modules/page-share-background.js"; +import { waitForCondition } from "./modules/page-share-core.js"; import { createPageShareRelay } from "./modules/page-share-relay.js"; +import { createPopupMessageHandler } from "./modules/popup-background.js"; import { createRelayCommandHandler } from "./modules/relay-command-handler.js"; import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js"; // OpenClaw extension service worker. // // Thin transport between the OpenClaw extension relay (loopback WebSocket) and // chrome.debugger. All CDP target synthesis lives server-side in the relay -// bridge; this worker only attaches tabs, forwards frames, and keeps the -// OpenClaw tab group in sync. Membership in that group is the user-visible -// consent boundary: only grouped tabs are reported to (and driven by) OpenClaw. +// bridge; this worker owns tab eligibility/access and forwards allowed frames. +// The OpenClaw tab group is the ACL in selected mode and an ownership marker +// in all-tabs mode. import { + ACCESS_MODE_ALL, + ACCESS_MODE_SELECTED, OPENCLAW_TAB_GROUP_TITLE, createPairingConfigStore, - nearestGroupColor, - parsePairingString, reconnectDelayMs, toRelayTabInfo, } from "./modules/relay-core.js"; -import { - findOpenClawGroups, - isOpenClawGroupId, - listSharedTabs, - requireSharedTab, -} from "./modules/relay-tab-groups.js"; +import { findOpenClawGroups, isTabSelected } from "./modules/relay-tab-groups.js"; +import { registerTabAccessEvents } from "./modules/tab-access-events.js"; +import { createTabAccessPolicy } from "./modules/tab-access.js"; const BADGE = { off: { text: "", color: "#000000" }, @@ -56,21 +51,28 @@ let relayOpeningDeadlineTimer = null; let relayAuthenticatedSocket = null; let relayStatusHint = ""; let reconciledPairingInvalidationRevision = 0; +let relayConnectionGeneration = 0; +let relayConnectionsSuspended = false; /** Tab ids with an active chrome.debugger attachment. */ const attachedTabs = new Set(); +/** Access epoch proven for each attachment; debugger events use this synchronously. */ +const attachedAccessEpochs = new Map(); /** Tabs denied to every relay attach while copilot run cleanup is pending. */ const copilotDeniedTabs = new Set(); -/** Monotonic revocation epochs invalidate debugger attaches already in flight. */ -const tabAccessRevisions = new Map(); /** In-flight attach promises per tab id (coalesces concurrent attaches). */ const attachingTabs = new Map(); /** Latest revocation task per tab; restoration waits for its exact epoch. */ const copilotRevocations = new Map(); /** Debounce handle for tab-list refreshes. */ let tabsSyncTimer = null; -let pageShareBadgeTimer = null; +let accessMutationChain = Promise.resolve(); const pageShareRelay = createPageShareRelay(); const pairingConfigStore = createPairingConfigStore(chrome.storage.local); +const tabAccessPolicy = createTabAccessPolicy({ isSelectedTab: isTabSelected }); +const tabAccessReady = (async () => { + const config = await pairingConfigStore.read(); + await tabAccessPolicy.initialize(config.accessMode, Boolean(config.relayUrl)); +})(); function closeRelaySocket() { const socket = relayWs; @@ -87,14 +89,26 @@ function closeRelaySocket() { socket.close(); } +function suspendRelayConnections() { + relayConnectionsSuspended = true; + relayConnectionGeneration += 1; +} + +function resumeRelayConnections() { + relayConnectionsSuspended = false; + relayConnectionGeneration += 1; +} + async function reconcilePairingInvalidation() { if (reconciledPairingInvalidationRevision === pairingConfigStore.invalidationRevision) { return; } reconciledPairingInvalidationRevision = pairingConfigStore.invalidationRevision; clearRelayOpeningDeadline(); + await syncTabsToRelay(); closeRelaySocket(); setBadge("off"); + await detachAllDebuggerSessions(); await copilot?.refreshConfig(); } @@ -109,31 +123,26 @@ function setBadge(kind) { }); } -function flashPageShareBadge(ok) { - if (pageShareBadgeTimer) { - clearTimeout(pageShareBadgeTimer); - } - void chrome.action.setBadgeText({ text: ok ? "✓" : "!" }); - void chrome.action.setBadgeBackgroundColor({ color: ok ? "#0F9D58" : "#B91C1C" }); - pageShareBadgeTimer = setTimeout( - () => { - pageShareBadgeTimer = null; - setBadge(relayState); - }, - ok ? 2_000 : 3_000, - ); -} - async function getConfig() { const config = await pairingConfigStore.read(); + await tabAccessReady; + if (!config.relayUrl) { + tabAccessPolicy.setEnabled(false); + } if (config.pairingStatusHint) { relayStatusHint = config.pairingStatusHint; } return config; } +function runAccessMutation(task) { + const pending = accessMutationChain.then(task, task); + accessMutationChain = pending.catch(() => undefined); + return pending; +} + // --------------------------------------------------------------------------- -// Tab group management (the consent boundary) +// Tab group management (selected-mode ACL; all-mode ownership marker) // --------------------------------------------------------------------------- async function addTabToOpenClawGroup(tabId) { @@ -166,9 +175,9 @@ async function removeTabFromOpenClawGroup(tabId) { } } -async function isTabShared(tabId) { - const shared = await listSharedTabs(); - return shared.some((tab) => tab.id === tabId); +async function isTabAccessible(tabId) { + await tabAccessReady; + return (await tabAccessPolicy.inspectTab(tabId)).accessible; } function scheduleTabsSync() { @@ -185,16 +194,14 @@ async function syncTabsToRelay() { if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) { return; } - const shared = await listSharedTabs(); - // Detach tabs the user pulled out of the group; leaving the group revokes - // agent access immediately (and clears the per-tab debugger state). - const sharedIds = new Set(shared.map((tab) => tab.id)); + const accessible = await tabAccessPolicy.listAccessibleTabs(); + const accessibleIds = new Set(accessible.map((tab) => tab.id)); for (const tabId of attachedTabs) { - if (!sharedIds.has(tabId)) { + if (!accessibleIds.has(tabId)) { void detachDebugger(tabId); } } - send({ type: "tabs", tabs: shared.map(toRelayTabInfo) }); + send({ type: "tabs", tabs: accessible.map(toRelayTabInfo) }); } // --------------------------------------------------------------------------- @@ -203,21 +210,16 @@ async function syncTabsToRelay() { async function attachDebugger(tabId) { await copilotCustodyReady; - const accessRevision = tabAccessRevisions.get(tabId) ?? 0; + await tabAccessReady; + const accessEpoch = tabAccessPolicy.capture(tabId); const assertAccess = async () => { if (copilotDeniedTabs.has(tabId)) { throw new Error(`tab ${tabId} is blocked until its copilot run stops`); } - if ((tabAccessRevisions.get(tabId) ?? 0) !== accessRevision) { - throw new Error(`tab ${tabId} access was revoked`); - } - await requireSharedTab(tabId); + await tabAccessPolicy.requireTab(tabId, accessEpoch); if (copilotDeniedTabs.has(tabId)) { throw new Error(`tab ${tabId} is blocked until its copilot run stops`); } - if ((tabAccessRevisions.get(tabId) ?? 0) !== accessRevision) { - throw new Error(`tab ${tabId} access was revoked`); - } }; await assertAccess(); // Coalesce concurrent attaches for one tab. Two relay attach commands (or an @@ -262,6 +264,14 @@ async function attachDebugger(tabId) { throw error; } const target = targets.find((candidate) => candidate.tabId === tabId && candidate.attached); + // The attachment is authorized only by the epoch proven across the whole + // attach. Never replace it with a fresh post-await capture: that would let + // a revocation during async unwind authorize later debugger events. + if (copilotDeniedTabs.has(tabId) || !tabAccessPolicy.epochIsCurrent(tabId, accessEpoch)) { + await detachDebugger(tabId); + throw new Error(`tab ${tabId} access was revoked`); + } + attachedAccessEpochs.set(tabId, accessEpoch); return { targetId: target?.id ?? `tab-${tabId}` }; })(); attachingTabs.set(tabId, attach); @@ -276,6 +286,7 @@ async function detachDebugger(tabId) { // Always call Chrome: an attach can complete before attachedTabs records it. // The unconditional detach closes that revocation race. attachedTabs.delete(tabId); + attachedAccessEpochs.delete(tabId); try { await chrome.debugger.detach({ tabId }); } catch { @@ -283,8 +294,94 @@ async function detachDebugger(tabId) { } } +async function detachAllDebuggerSessions() { + const targets = await chrome.debugger.getTargets().catch(() => []); + const tabIds = new Set(attachedTabs); + for (const target of targets) { + if (target.attached && typeof target.tabId === "number") { + tabIds.add(target.tabId); + } + } + await Promise.allSettled(attachingTabs.values()); + for (const tabId of attachedTabs) { + tabIds.add(tabId); + } + await Promise.allSettled([...tabIds].map((tabId) => detachDebugger(tabId))); +} + +async function reconcileAccessMode(nextMode, { transitioning = false } = {}) { + await tabAccessReady; + const previousMode = tabAccessPolicy.mode; + const mode = tabAccessPolicy.setMode(nextMode); + if (mode === previousMode) { + if (transitioning) { + tabAccessPolicy.endTransition(); + } + return mode; + } + await Promise.allSettled(attachingTabs.values()); + if (mode === ACCESS_MODE_SELECTED) { + const selectedIds = new Set( + ( + await tabAccessPolicy.listAccessibleTabs({ + allowDuringTransition: transitioning, + }) + ).map((tab) => tab.id), + ); + await Promise.allSettled( + [...attachedTabs] + .filter((tabId) => !selectedIds.has(tabId)) + .map((tabId) => detachDebugger(tabId)), + ); + } + if (transitioning) { + tabAccessPolicy.endTransition(); + } + for (const tabId of attachedTabs) { + const epoch = tabAccessPolicy.capture(tabId); + const state = await tabAccessPolicy.inspectTab(tabId, epoch); + if (!tabAccessPolicy.epochIsCurrent(tabId, epoch)) { + // A post-transition tab event owns the newer revision. Keep this + // attachment fail-closed until that handler reconciles it. + continue; + } + if (!state.accessible) { + await detachDebugger(tabId); + } else if (attachedTabs.has(tabId)) { + attachedAccessEpochs.set(tabId, epoch); + } + } + await syncTabsToRelay(); + await copilot?.onConsentChanged(); + return mode; +} + +async function pauseTab(tabId) { + let storageError = null; + try { + await tabAccessPolicy.pause(tabId); + } catch (error) { + storageError = error; + } + await Promise.allSettled([attachingTabs.get(tabId)]); + await detachDebugger(tabId); + await syncTabsToRelay(); + await copilot?.onConsentChanged(tabId, { revoked: true }); + if (storageError) { + throw storageError instanceof Error + ? storageError + : new Error("Could not persist the tab pause."); + } +} + +async function allowTab(tabId) { + await tabAccessPolicy.allow(tabId); + await syncTabsToRelay(); + await copilot?.onConsentChanged(tabId); +} + async function revokeCopilotDebugger(tabId) { - tabAccessRevisions.set(tabId, (tabAccessRevisions.get(tabId) ?? 0) + 1); + tabAccessPolicy.invalidateTab(tabId); copilotDeniedTabs.add(tabId); const previous = copilotRevocations.get(tabId) ?? Promise.resolve(); const revocation = previous @@ -304,40 +401,13 @@ async function revokeCopilotDebugger(tabId) { } async function restoreCopilotDebugger(tabId) { - const accessRevision = tabAccessRevisions.get(tabId) ?? 0; + const accessEpoch = tabAccessPolicy.capture(tabId); await copilotRevocations.get(tabId); - if ((tabAccessRevisions.get(tabId) ?? 0) === accessRevision) { + if (tabAccessPolicy.epochIsCurrent(tabId, accessEpoch)) { copilotDeniedTabs.delete(tabId); } } -chrome.debugger.onEvent.addListener((source, method, params) => { - if (typeof source.tabId !== "number") { - return; - } - send({ - type: "cdpEvent", - tabId: source.tabId, - ...(source.sessionId ? { sessionId: source.sessionId } : {}), - method, - params, - }); -}); - -chrome.debugger.onDetach.addListener((source, reason) => { - if (typeof source.tabId !== "number") { - return; - } - attachedTabs.delete(source.tabId); - send({ type: "detached", tabId: source.tabId, reason }); - if (reason === "canceled_by_user") { - // The user hit "Cancel" on Chrome's debugging infobar: treat it as a - // revocation and pull the tab out of the shared group so the agent does - // not immediately re-attach. - void removeTabFromOpenClawGroup(source.tabId).then(scheduleTabsSync); - } -}); - // --------------------------------------------------------------------------- // Relay connection // --------------------------------------------------------------------------- @@ -386,23 +456,36 @@ const handleRelayCommand = createRelayCommandHandler({ addTabToOpenClawGroup, focusWindowForTab, scheduleTabsSync, + captureAccess: (tabId) => tabAccessPolicy.capture(tabId), + requireAccessibleTab: (tabId, epoch) => tabAccessPolicy.requireTab(tabId, epoch), }); async function sendHello() { - const shared = await listSharedTabs(); + const accessible = await tabAccessPolicy.listAccessibleTabs(); const uaMatch = /Chrom(?:e|ium)\/[\d.]+/.exec(navigator.userAgent); send({ type: "hello", userAgent: navigator.userAgent, browserVersion: uaMatch ? uaMatch[0] : "Chrome/unknown", extensionVersion: chrome.runtime.getManifest().version, - tabs: shared.map(toRelayTabInfo), + tabs: accessible.map(toRelayTabInfo), }); } -async function connectRelay() { +async function connectRelay(isConnectionAllowed = () => true) { + const connectionGeneration = relayConnectionGeneration; + const connectionIsCurrent = () => + !relayConnectionsSuspended && + connectionGeneration === relayConnectionGeneration && + isConnectionAllowed(); const { relayUrl, token } = await getConfig(); + if (!connectionIsCurrent()) { + return; + } await reconcilePairingInvalidation(); + if (!connectionIsCurrent()) { + return; + } if (!relayUrl || !token) { clearRelayOpeningDeadline(); setBadge("off"); @@ -414,6 +497,11 @@ async function connectRelay() { ) { return; } + // Pair revocation can race either awaited config step above. Keep the final + // cancellation check adjacent to socket creation so a stale pair cannot reconnect. + if (!connectionIsCurrent()) { + return; + } setBadge("connecting"); let ws; try { @@ -492,56 +580,23 @@ async function ensureRelayReady() { } } -async function sendPageToOpenClaw(tabId, note) { - await ensureRelayReady(); - const tab = await chrome.tabs.get(tabId); - const capture = await capturePageShare(tab); - const payload = buildPageSharePayload({ ...capture, note }); - if (!payload.content && !payload.selection) { - throw new Error("Nothing to send on this page."); - } - await sendPageShareRequest(payload); -} - -// Context-menu selections bind to the click-time document: the relay-connect -// delay can outlive a navigation, and recapture cannot see iframe selections, -// so the payload is built from the click snapshot without touching the tab. -async function sendSelectionSnapshot(tab, selection) { - await ensureRelayReady(); - const payload = buildPageSharePayload({ - url: tab.url ?? "", - title: tab.title ?? "", - content: "", - selection, - note: "", - }); - await sendPageShareRequest(payload); -} - -function withShareBadge(promise) { - return promise.then( - () => flashPageShareBadge(true), - () => flashPageShareBadge(false), - ); -} - -function sendPageFromChromeEntry(tabId) { - return withShareBadge(sendPageToOpenClaw(tabId, "")); -} - -async function installPageShareContextMenu() { - await chrome.contextMenus.removeAll(); - chrome.contextMenus.create({ - id: "openclaw-send-page", - title: "Send page to OpenClaw", - contexts: ["page", "selection"], - }); -} +const pageShare = createPageShareController({ + ensureRelayReady, + sendPageShareRequest, + restoreBadge: () => setBadge(relayState), +}); copilot = createCopilotController({ getConfig, - isTabShared, - addTabToOpenClawGroup, + isTabAccessible, + grantTabAccess: async (tabId) => { + if (tabAccessPolicy.mode === ACCESS_MODE_ALL) { + await allowTab(tabId); + } else { + await addTabToOpenClawGroup(tabId); + scheduleTabsSync(); + } + }, attachDebugger, detachDebugger, revokeDebugger: revokeCopilotDebugger, @@ -603,161 +658,53 @@ function scheduleReconnect() { // Popup messaging + lifecycle // --------------------------------------------------------------------------- -function sendErrorResponse(sendResponse, error) { - sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); -} +const handlePopupMessage = createPopupMessageHandler({ + pairingConfigStore, + policy: tabAccessPolicy, + accessReady: tabAccessReady, + getConfig, + getRelayState: () => relayState, + getRelayStatusHint: () => relayStatusHint, + resetRelayState: () => { + relayStatusHint = ""; + reconnectAttempt = 0; + }, + suspendRelayConnections, + resumeRelayConnections, + reconcilePairingInvalidation, + reconcileAccessMode, + runAccessMutation, + detachAllDebuggerSessions, + syncTabsToRelay, + clearRelayOpeningDeadline, + closeRelaySocket, + connectRelay, + setBadge, + getCopilot: () => copilot, + attachingTabs, + detachDebugger, + removeTabFromOpenClawGroup, + addTabToOpenClawGroup, + scheduleTabsSync, + pauseTab, + pageShare, +}); +chrome.runtime.onMessage.addListener((msg, _sender, reply) => handlePopupMessage(msg, reply)); -chrome.runtime.onMessage.addListener((msg, _sender, reply) => { - let settled = false; - const sendResponse = (response) => { - if (settled) { - return; - } - settled = true; - reply(response); - }; - void (async () => { - switch (msg?.type) { - case "getStatus": { - const { relayUrl } = await getConfig(); - await reconcilePairingInvalidation(); - const shared = await listSharedTabs(); - sendResponse({ - paired: Boolean(relayUrl), - state: relayState, - sharedTabCount: shared.length, - relayUrl: relayUrl ?? "", - ...(relayStatusHint ? { hint: relayStatusHint } : {}), - }); - return; - } - case "pair": { - const parsed = parsePairingString(msg.pairingString); - if (!parsed) { - sendResponse({ ok: false, error: "Invalid pairing string." }); - return; - } - await pairingConfigStore.save(parsed, nearestGroupColor(msg.groupColor)); - relayStatusHint = ""; - reconnectAttempt = 0; - clearRelayOpeningDeadline(); - closeRelaySocket(); - await connectRelay(); - await copilot.refreshConfig(); - sendResponse({ ok: true }); - return; - } - case "unpair": { - await pairingConfigStore.clear(); - relayStatusHint = ""; - clearRelayOpeningDeadline(); - closeRelaySocket(); - setBadge("off"); - await copilot.refreshConfig(); - sendResponse({ ok: true }); - return; - } - case "toggleShareTab": { - const tabId = msg.tabId; - if (typeof tabId !== "number") { - sendResponse({ ok: false, error: "No tab." }); - return; - } - const wasShared = await isTabShared(tabId); - if (wasShared) { - await detachDebugger(tabId); - await removeTabFromOpenClawGroup(tabId); - } else { - await addTabToOpenClawGroup(tabId); - } - scheduleTabsSync(); - await copilot.onConsentChanged(); - sendResponse({ ok: true, shared: !wasShared }); - return; - } - case "isTabShared": { - sendResponse({ shared: await isTabShared(msg.tabId) }); - return; - } - case "sendPageToOpenClaw": { - if (typeof msg.tabId !== "number") { - sendResponse({ ok: false, error: "No tab." }); - return; - } - try { - await sendPageToOpenClaw(msg.tabId, msg.note); - sendResponse({ ok: true }); - } catch (error) { - sendErrorResponse(sendResponse, error); - } - return; - } - case "prepareCopilotPanel": { - try { - const options = await copilot.preparePanel(msg.tabId); - sendResponse({ ok: true, ...options }); - } catch (error) { - sendErrorResponse(sendResponse, error); - } - return; - } - default: - sendResponse({ ok: false, error: "unknown message" }); - } - })().catch(sendErrorResponse.bind(null, sendResponse)); - return true; // keep sendResponse alive for the async path -}); - -chrome.tabs.onRemoved.addListener((tabId) => { - tabAccessRevisions.set(tabId, (tabAccessRevisions.get(tabId) ?? 0) + 1); - attachedTabs.delete(tabId); - copilotDeniedTabs.delete(tabId); - scheduleTabsSync(); - void copilot.onTabRemoved(tabId); -}); -chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { - scheduleTabsSync(); - if (typeof changeInfo.groupId !== "number") { - void copilot.onConsentChanged(tabId); - return; - } - // changeInfo.groupId is the event-time membership snapshot. Preserve a - // revocation even if a later event re-shares the tab before async cleanup. - void isOpenClawGroupId(changeInfo.groupId).then(async (shared) => { - if (!shared) { - tabAccessRevisions.set(tabId, (tabAccessRevisions.get(tabId) ?? 0) + 1); - await detachDebugger(tabId); - } - await copilot.onConsentChanged(tabId, { revoked: !shared }); - }); -}); -chrome.tabGroups.onUpdated.addListener(() => { - scheduleTabsSync(); - void copilot.onConsentChanged(); -}); -chrome.tabGroups.onRemoved.addListener(() => { - scheduleTabsSync(); - void copilot.onConsentChanged(); -}); - -chrome.commands.onCommand.addListener((command) => { - if (command !== "send-page") { - return; - } - void chrome.tabs - .query({ active: true, lastFocusedWindow: true }) - .then(([tab]) => (typeof tab?.id === "number" ? sendPageFromChromeEntry(tab.id) : undefined)); -}); -chrome.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId !== "openclaw-send-page" || typeof tab?.id !== "number") { - return; - } - const selection = info.selectionText?.trim() ?? ""; - if (selection) { - void withShareBadge(sendSelectionSnapshot(tab, selection)); - return; - } - void sendPageFromChromeEntry(tab.id); +registerTabAccessEvents({ + accessReady: tabAccessReady, + policy: tabAccessPolicy, + attachedTabs, + attachedAccessEpochs, + copilotDeniedTabs, + attachingTabs, + getCopilot: () => copilot, + send, + scheduleTabsSync, + detachDebugger, + pauseTab, + removeTabFromOpenClawGroup, + runAccessMutation, }); // Watchdog: MV3 can stop this worker; the alarm revives it and re-connects. @@ -774,7 +721,7 @@ chrome.alarms.onAlarm.addListener((alarm) => { }); chrome.runtime.onStartup.addListener(() => void connectRelay()); chrome.runtime.onInstalled.addListener(() => { - void installPageShareContextMenu(); + void pageShare.installContextMenu(); void connectRelay(); }); void [connectRelay(), copilotReady]; diff --git a/extensions/browser/chrome-extension/background.test-harness.ts b/extensions/browser/chrome-extension/background.test-harness.ts new file mode 100644 index 000000000000..2a290ec918e1 --- /dev/null +++ b/extensions/browser/chrome-extension/background.test-harness.ts @@ -0,0 +1,455 @@ +import { expect, vi } from "vitest"; +import { + AUTH_INSTANCE_ID, + AUTH_SERVER_NONCE, + AUTH_SESSION_ID, + configureFakeWebSockets, + FakeWebSocket, +} from "./background.test-support.js"; +import type { PageCaptureResult, RuntimeMessageListener } from "./background.test-support.js"; +import { computeRelayAuthProof } from "./modules/relay-auth-v2-crypto.js"; + +export const RELAY_SECRET = "a".repeat(64); +export const REPLACEMENT_RELAY_SECRET = "b".repeat(64); +const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; + +export async function loadBackground({ + deferTabAccessInitialization = false, + deferSocketClose = false, + onConsentChanged, + rejectStorageRemove = false, + relayNegotiatedProtocol, + sessionConfig, + storedConfig, + initialTabs = [], +}: { + deferTabAccessInitialization?: boolean; + deferSocketClose?: boolean; + onConsentChanged?: () => Promise; + rejectStorageRemove?: boolean; + relayNegotiatedProtocol?: string; + sessionConfig?: Record; + storedConfig?: Record; + initialTabs?: Array & { id: number }>; +} = {}) { + const sockets: FakeWebSocket[] = []; + let alarmListener: ((alarm: { name: string }) => void) | undefined; + let messageListener: RuntimeMessageListener | undefined; + let debuggerDetachListener: + | ((source: { tabId?: number }, reason: "target_closed" | "canceled_by_user") => void) + | undefined; + let debuggerEventListener: + | ((source: { tabId?: number; sessionId?: string }, method: string, params?: unknown) => void) + | undefined; + let tabsRemovedListener: ((tabId: number) => void) | undefined; + let tabsReplacedListener: ((addedTabId: number, removedTabId: number) => void) | undefined; + let tabGroupUpdatedListener: (() => void) | undefined; + let tabGroupRemovedListener: (() => void) | undefined; + let tabsUpdatedListener: ((tabId: number, changeInfo: { groupId?: number }) => void) | undefined; + let nextStorageGet: Promise | null = null; + let nextStorageRemove: Promise | null = null; + let nextStorageSet: Promise | null = null; + let nextSessionStorageSet: Promise | null = null; + let releaseTabAccessInitialization = () => {}; + const tabAccessInitialization = deferTabAccessInitialization + ? new Promise((resolve) => { + releaseTabAccessInitialization = resolve; + }) + : Promise.resolve(); + const sharedTabIds = new Set([1]); + const storageValues: Record = { + ...(storedConfig ?? { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "selected", + groupColor: "orange", + }), + }; + const sessionStorageValues: Record = { ...sessionConfig }; + const tabsById = new Map(initialTabs.map((tab) => [tab.id, tab])); + for (const tab of initialTabs) { + if (tab.groupId === 7) { + sharedTabIds.add(tab.id); + } + } + configureFakeWebSockets({ sockets, deferSocketClose, relayNegotiatedProtocol }); + + const addListener = vi.fn(); + const createAlarm = vi.fn(); + const clearAlarm = vi.fn(async () => true); + const setBadgeText = vi.fn(async () => undefined); + const setBadgeBackgroundColor = vi.fn(async () => undefined); + const storageGet = vi.fn(async (keys: string[]) => { + const pending = nextStorageGet; + nextStorageGet = null; + await pending; + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(storageValues, key)) + .map((key) => [key, storageValues[key]]), + ); + }); + const storageSet = vi.fn(async (values: Record) => { + const pending = nextStorageSet; + nextStorageSet = null; + await pending; + Object.assign(storageValues, values); + }); + const storageRemove = vi.fn(async (keys: string[]) => { + const pending = nextStorageRemove; + nextStorageRemove = null; + await pending; + if (rejectStorageRemove) { + throw new Error("Could not clear invalid browser pairing."); + } + for (const key of keys) { + delete storageValues[key]; + } + }); + const sessionStorageSet = vi.fn(async (values: Record) => { + const pending = nextSessionStorageSet; + nextSessionStorageSet = null; + await pending; + Object.assign(sessionStorageValues, values); + }); + const chromeMock = { + action: { setBadgeText, setBadgeBackgroundColor }, + commands: { onCommand: { addListener } }, + contextMenus: { + create: vi.fn(), + removeAll: vi.fn(async () => undefined), + onClicked: { addListener }, + }, + alarms: { + create: createAlarm, + clear: clearAlarm, + onAlarm: { + addListener: vi.fn((listener: (alarm: { name: string }) => void) => { + alarmListener = listener; + }), + }, + }, + debugger: { + onEvent: { + addListener: vi.fn( + ( + listener: ( + source: { tabId?: number; sessionId?: string }, + method: string, + params?: unknown, + ) => void, + ) => { + debuggerEventListener = listener; + }, + ), + }, + onDetach: { + addListener: vi.fn( + ( + listener: ( + source: { tabId?: number }, + reason: "target_closed" | "canceled_by_user", + ) => void, + ) => { + debuggerDetachListener = listener; + }, + ), + }, + attach: vi.fn(async () => undefined), + detach: vi.fn(async (_source: { tabId: number }) => undefined), + getTargets: vi.fn( + async (): Promise> => [], + ), + sendCommand: vi.fn(async () => ({})), + }, + runtime: { + getManifest: vi.fn(() => ({ version: "1.0.0" })), + onConnect: { addListener }, + onMessage: { + addListener: vi.fn((listener: RuntimeMessageListener) => { + messageListener = listener; + }), + }, + onStartup: { addListener }, + onInstalled: { addListener }, + }, + storage: { + local: { get: storageGet, set: storageSet, remove: storageRemove }, + session: { + get: vi.fn(async (keys: string[]) => { + await tabAccessInitialization; + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(sessionStorageValues, key)) + .map((key) => [key, sessionStorageValues[key]]), + ); + }), + set: sessionStorageSet, + remove: vi.fn(async (keys: string[]) => { + for (const key of keys) { + delete sessionStorageValues[key]; + } + }), + }, + }, + scripting: { + executeScript: vi.fn(async (): Promise> => []), + }, + tabGroups: { + query: vi.fn(async (): Promise> => []), + get: vi.fn(async (groupId: number) => ({ + id: groupId, + title: groupId === 7 ? "OpenClaw" : "Other", + windowId: 1, + })), + update: vi.fn(async () => undefined), + onUpdated: { + addListener: vi.fn((listener: () => void) => { + tabGroupUpdatedListener = listener; + }), + }, + onRemoved: { + addListener: vi.fn((listener: () => void) => { + tabGroupRemovedListener = listener; + }), + }, + }, + tabs: { + query: vi.fn(async () => + [...tabsById.values()].map((tab) => + Object.assign({}, tab, { groupId: sharedTabIds.has(tab.id) ? 7 : -1 }), + ), + ), + get: vi.fn(async (tabId: number) => ({ + id: tabId, + url: `https://example.com/tab/${tabId}`, + title: `Tab ${tabId}`, + incognito: false, + windowId: 1, + ...tabsById.get(tabId), + groupId: sharedTabIds.has(tabId) ? 7 : -1, + })), + group: vi.fn(async ({ tabIds }: { tabIds: number[] }) => { + for (const tabId of tabIds) { + sharedTabIds.add(tabId); + } + return 7; + }), + ungroup: vi.fn(async (tabIds: number[]) => { + for (const tabId of tabIds) { + sharedTabIds.delete(tabId); + } + }), + create: vi.fn(async ({ url, active }: { url: string; active: boolean }) => { + const id = Math.max(0, ...tabsById.keys()) + 1; + const tab = { id, url, active, windowId: 1, groupId: -1, incognito: false }; + tabsById.set(id, tab); + return tab; + }), + remove: vi.fn(async (tabId: number) => { + tabsById.delete(tabId); + }), + update: vi.fn(async () => undefined), + onRemoved: { + addListener: vi.fn((listener: (tabId: number) => void) => { + tabsRemovedListener = listener; + }), + }, + onReplaced: { + addListener: vi.fn((listener: (addedTabId: number, removedTabId: number) => void) => { + tabsReplacedListener = listener; + }), + }, + onUpdated: { + addListener: vi.fn( + (listener: (tabId: number, changeInfo: { groupId?: number }) => void) => { + tabsUpdatedListener = listener; + }, + ), + }, + }, + windows: { update: vi.fn(async () => undefined) }, + }; + + vi.stubGlobal("chrome", chromeMock); + vi.stubGlobal("navigator", { userAgent: "Chromium/125.0.0.0" }); + vi.stubGlobal("WebSocket", FakeWebSocket); + + if (onConsentChanged) { + const copilotModule = await import("./modules/copilot-background.js"); + const createCopilotController = copilotModule.createCopilotController; + vi.spyOn(copilotModule, "createCopilotController").mockImplementation((options) => ({ + ...createCopilotController(options), + onConsentChanged, + })); + } + + const backgroundModulePath = "./background.js"; + await import(backgroundModulePath); + await vi.waitFor(() => { + const pairingReads = storageGet.mock.calls.filter(([keys]) => + PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), + ); + expect(pairingReads.length).toBeGreaterThanOrEqual(2); + }); + if (!deferTabAccessInitialization) { + for (let attempt = 0; attempt < 20 && sockets.length === 0; attempt += 1) { + const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => + keys.includes("relayUrl"), + ); + if (pairingWasCleared) { + break; + } + await Promise.resolve(); + } + const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl")); + expect(sockets.length > 0 || pairingWasCleared).toBe(true); + } + + if (!alarmListener || !messageListener || !tabsUpdatedListener || !tabsReplacedListener) { + throw new Error("expected background worker lifecycle listeners"); + } + return { + alarmListener, + clearAlarm, + createAlarm, + executeScript: chromeMock.scripting.executeScript, + debuggerAttach: chromeMock.debugger.attach, + debuggerDetach: chromeMock.debugger.detach, + debuggerDetachListener, + debuggerEventListener, + debuggerGetTargets: chromeMock.debugger.getTargets, + debuggerSendCommand: chromeMock.debugger.sendCommand, + deferNextStorageGet: () => { + let release = () => {}; + nextStorageGet = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + deferNextStorageRemove: () => { + let release = () => {}; + nextStorageRemove = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + deferNextSessionStorageSet: () => { + let release = () => {}; + nextSessionStorageSet = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + deferNextStorageSet: () => { + let release = () => {}; + nextStorageSet = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + get gatewaySockets() { + return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay.v2")); + }, + messageListener, + releaseTabAccessInitialization, + get relaySockets() { + return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay.v2")); + }, + authenticate: async (socket: FakeWebSocket) => { + if (socket.readyState !== FakeWebSocket.OPEN) { + socket.open(); + } + await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); + const helloRaw = socket.send.mock.calls.find( + ([raw]) => JSON.parse(raw).type === "auth.hello", + )?.[0]; + if (typeof helloRaw !== "string") { + throw new Error("expected auth.hello"); + } + const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string }; + const issuedAtMs = Date.now(); + const fields = { + keyId: hello.keyId, + instanceId: AUTH_INSTANCE_ID, + sessionId: AUTH_SESSION_ID, + clientNonce: hello.clientNonce, + serverNonce: AUTH_SERVER_NONCE, + issuedAtMs, + expiresAtMs: issuedAtMs + 10_000, + role: "extension", + transport: "websocket", + method: "GET", + resource: new URL(socket.url).pathname + new URL(socket.url).search, + flow: "extension", + }; + socket.receive({ + type: "auth.challenge", + v: 2, + ...fields, + serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields), + }); + await vi.waitFor(() => { + expect( + socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"), + ).toBe(true); + }); + const responseRaw = socket.send.mock.calls.find( + ([raw]) => JSON.parse(raw).type === "auth.response", + )?.[0]; + if (typeof responseRaw !== "string") { + throw new Error("expected auth.response"); + } + const response = JSON.parse(responseRaw) as { clientProof: string }; + socket.receive({ + type: "auth.ok", + v: 2, + sessionId: AUTH_SESSION_ID, + acceptProof: await computeRelayAuthProof( + String(storageValues.token), + "accept", + fields, + response.clientProof, + ), + }); + await vi.waitFor(() => { + expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true); + }); + }, + setBadgeText, + sockets, + storageRemove, + storageSet, + storageValues, + sessionStorageValues, + sessionStorageSet, + shareTab: (tabId: number) => sharedTabIds.add(tabId), + unshareTab: (tabId: number) => sharedTabIds.delete(tabId), + tabGroupsQuery: chromeMock.tabGroups.query, + tabGroupUpdatedListener, + tabGroupRemovedListener, + tabsCreate: chromeMock.tabs.create, + tabsGet: chromeMock.tabs.get, + tabsGroup: chromeMock.tabs.group, + tabsQuery: chromeMock.tabs.query, + tabsRemove: chromeMock.tabs.remove, + tabsUngroup: chromeMock.tabs.ungroup, + tabsUpdate: chromeMock.tabs.update, + tabsUpdatedListener, + tabsRemovedListener, + tabsReplacedListener, + windowsUpdate: chromeMock.windows.update, + }; +} + +export async function sendRuntimeMessage( + harness: Awaited>, + message: { type: string } & Record, +) { + return await new Promise>((resolve) => { + harness.messageListener(message, {}, (response) => { + resolve(response as Record); + }); + }); +} diff --git a/extensions/browser/chrome-extension/background.test-support.ts b/extensions/browser/chrome-extension/background.test-support.ts index 4ca4395e9610..05fabacd078c 100644 --- a/extensions/browser/chrome-extension/background.test-support.ts +++ b/extensions/browser/chrome-extension/background.test-support.ts @@ -8,7 +8,14 @@ type SocketEvent = { data?: unknown }; type SocketListener = (event: SocketEvent) => void; export type RuntimeMessageListener = ( - message: { type: string; tabId?: number; note?: string; pairingString?: string }, + message: { + type: string; + tabId?: number; + note?: string; + pairingString?: string; + accessMode?: string; + grant?: boolean; + }, sender: unknown, sendResponse: (response: unknown) => void, ) => boolean; diff --git a/extensions/browser/chrome-extension/background.test.ts b/extensions/browser/chrome-extension/background.test.ts index 327ccc6b46e6..2ddf0d25be49 100644 --- a/extensions/browser/chrome-extension/background.test.ts +++ b/extensions/browser/chrome-extension/background.test.ts @@ -1,305 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - AUTH_INSTANCE_ID, - AUTH_SERVER_NONCE, - AUTH_SESSION_ID, - configureFakeWebSockets, - FakeWebSocket, -} from "./background.test-support.js"; -import type { PageCaptureResult, RuntimeMessageListener } from "./background.test-support.js"; -import { computeRelayAuthProof } from "./modules/relay-auth-v2-crypto.js"; + loadBackground, + RELAY_SECRET, + REPLACEMENT_RELAY_SECRET, + sendRuntimeMessage, +} from "./background.test-harness.js"; +import { AUTH_INSTANCE_ID, AUTH_SERVER_NONCE, AUTH_SESSION_ID } from "./background.test-support.js"; const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog"; const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline"; const START_TIME_MS = Date.parse("2026-07-16T08:00:00.000Z"); -const RELAY_SECRET = "a".repeat(64); -const REPLACEMENT_RELAY_SECRET = "b".repeat(64); -const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; - -async function loadBackground({ - deferSocketClose = false, - onConsentChanged, - rejectStorageRemove = false, - relayNegotiatedProtocol, - storedConfig, -}: { - deferSocketClose?: boolean; - onConsentChanged?: () => Promise; - rejectStorageRemove?: boolean; - relayNegotiatedProtocol?: string; - storedConfig?: Record; -} = {}) { - const sockets: FakeWebSocket[] = []; - let alarmListener: ((alarm: { name: string }) => void) | undefined; - let messageListener: RuntimeMessageListener | undefined; - let tabsUpdatedListener: ((tabId: number, changeInfo: { groupId?: number }) => void) | undefined; - let nextStorageRemove: Promise | null = null; - const sharedTabIds = new Set([1]); - const storageValues: Record = { - ...(storedConfig ?? { - relayUrl: "ws://127.0.0.1:18797/extension", - token: RELAY_SECRET, - authVersion: 2, - groupColor: "orange", - }), - }; - configureFakeWebSockets({ sockets, deferSocketClose, relayNegotiatedProtocol }); - - const addListener = vi.fn(); - const createAlarm = vi.fn(); - const clearAlarm = vi.fn(async () => true); - const setBadgeText = vi.fn(async () => undefined); - const setBadgeBackgroundColor = vi.fn(async () => undefined); - const storageGet = vi.fn(async (keys: string[]) => - Object.fromEntries( - keys - .filter((key) => Object.hasOwn(storageValues, key)) - .map((key) => [key, storageValues[key]]), - ), - ); - const storageSet = vi.fn(async (values: Record) => { - Object.assign(storageValues, values); - }); - const storageRemove = vi.fn(async (keys: string[]) => { - const pending = nextStorageRemove; - nextStorageRemove = null; - await pending; - if (rejectStorageRemove) { - throw new Error("Could not clear invalid browser pairing."); - } - for (const key of keys) { - delete storageValues[key]; - } - }); - const chromeMock = { - action: { setBadgeText, setBadgeBackgroundColor }, - commands: { onCommand: { addListener } }, - contextMenus: { - create: vi.fn(), - removeAll: vi.fn(async () => undefined), - onClicked: { addListener }, - }, - alarms: { - create: createAlarm, - clear: clearAlarm, - onAlarm: { - addListener: vi.fn((listener: (alarm: { name: string }) => void) => { - alarmListener = listener; - }), - }, - }, - debugger: { - onEvent: { addListener }, - onDetach: { addListener }, - attach: vi.fn(async () => undefined), - detach: vi.fn(async () => undefined), - getTargets: vi.fn(async () => []), - sendCommand: vi.fn(async () => ({})), - }, - runtime: { - getManifest: vi.fn(() => ({ version: "1.0.0" })), - onConnect: { addListener }, - onMessage: { - addListener: vi.fn((listener: RuntimeMessageListener) => { - messageListener = listener; - }), - }, - onStartup: { addListener }, - onInstalled: { addListener }, - }, - storage: { - local: { - get: storageGet, - set: storageSet, - remove: storageRemove, - }, - session: { - get: vi.fn(async () => ({})), - set: vi.fn(async () => undefined), - }, - }, - scripting: { - executeScript: vi.fn(async (): Promise> => []), - }, - tabGroups: { - query: vi.fn(async (): Promise> => []), - get: vi.fn(async (groupId: number) => ({ - id: groupId, - title: groupId === 7 ? "OpenClaw" : "Other", - windowId: 1, - })), - update: vi.fn(async () => undefined), - onUpdated: { addListener }, - onRemoved: { addListener }, - }, - tabs: { - query: vi.fn(async (): Promise> => []), - get: vi.fn(async (tabId: number) => ({ - id: tabId, - windowId: 1, - groupId: sharedTabIds.has(tabId) ? 7 : -1, - })), - group: vi.fn(async ({ tabIds }: { tabIds: number[] }) => { - for (const tabId of tabIds) { - sharedTabIds.add(tabId); - } - return 7; - }), - ungroup: vi.fn(async (tabIds: number[]) => { - for (const tabId of tabIds) { - sharedTabIds.delete(tabId); - } - }), - create: vi.fn(async () => ({ id: 1 })), - remove: vi.fn(async () => undefined), - update: vi.fn(async () => undefined), - onRemoved: { addListener }, - onUpdated: { - addListener: vi.fn( - (listener: (tabId: number, changeInfo: { groupId?: number }) => void) => { - tabsUpdatedListener = listener; - }, - ), - }, - }, - windows: { update: vi.fn(async () => undefined) }, - }; - - vi.stubGlobal("chrome", chromeMock); - vi.stubGlobal("navigator", { userAgent: "Chromium/125.0.0.0" }); - vi.stubGlobal("WebSocket", FakeWebSocket); - - if (onConsentChanged) { - const copilotModule = await import("./modules/copilot-background.js"); - const createCopilotController = copilotModule.createCopilotController; - vi.spyOn(copilotModule, "createCopilotController").mockImplementation((options) => ({ - ...createCopilotController(options), - onConsentChanged, - })); - } - - // The shipped MV3 worker is plain JS, so keep this a runtime-resolved import. - const backgroundModulePath = "./background.js"; - await import(backgroundModulePath); - await vi.waitFor(() => { - const pairingReads = storageGet.mock.calls.filter(([keys]) => - PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), - ); - expect(pairingReads.length).toBeGreaterThanOrEqual(2); - }); - - if (!alarmListener) { - throw new Error("expected background worker to register an alarm listener"); - } - if (!messageListener) { - throw new Error("expected background worker to register a message listener"); - } - if (!tabsUpdatedListener) { - throw new Error("expected background worker to register a tabs update listener"); - } - return { - alarmListener, - clearAlarm, - createAlarm, - executeScript: chromeMock.scripting.executeScript, - debuggerAttach: chromeMock.debugger.attach, - debuggerDetach: chromeMock.debugger.detach, - debuggerSendCommand: chromeMock.debugger.sendCommand, - deferNextStorageRemove: () => { - let release = () => {}; - nextStorageRemove = new Promise((resolve) => { - release = resolve; - }); - return release; - }, - get gatewaySockets() { - return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay.v2")); - }, - messageListener, - get relaySockets() { - return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay.v2")); - }, - authenticate: async (socket: FakeWebSocket) => { - if (socket.readyState !== FakeWebSocket.OPEN) { - socket.open(); - } - await vi.waitFor(() => { - expect(socket.send).toHaveBeenCalled(); - }); - const helloRaw = socket.send.mock.calls.find( - ([raw]) => JSON.parse(raw).type === "auth.hello", - )?.[0]; - if (typeof helloRaw !== "string") { - throw new Error("expected auth.hello"); - } - const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string }; - const issuedAtMs = Date.now(); - const fields = { - keyId: hello.keyId, - instanceId: AUTH_INSTANCE_ID, - sessionId: AUTH_SESSION_ID, - clientNonce: hello.clientNonce, - serverNonce: AUTH_SERVER_NONCE, - issuedAtMs, - expiresAtMs: issuedAtMs + 10_000, - role: "extension", - transport: "websocket", - method: "GET", - resource: new URL(socket.url).pathname + new URL(socket.url).search, - flow: "extension", - }; - socket.receive({ - type: "auth.challenge", - v: 2, - ...fields, - serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields), - }); - await vi.waitFor(() => { - expect( - socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"), - ).toBe(true); - }); - const responseRaw = socket.send.mock.calls.find( - ([raw]) => JSON.parse(raw).type === "auth.response", - )?.[0]; - if (typeof responseRaw !== "string") { - throw new Error("expected auth.response"); - } - const response = JSON.parse(responseRaw) as { clientProof: string }; - socket.receive({ - type: "auth.ok", - v: 2, - sessionId: AUTH_SESSION_ID, - acceptProof: await computeRelayAuthProof( - String(storageValues.token), - "accept", - fields, - response.clientProof, - ), - }); - await vi.waitFor(() => { - expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true); - }); - }, - setBadgeText, - sockets, - storageRemove, - storageSet, - storageValues, - shareTab: (tabId: number) => sharedTabIds.add(tabId), - unshareTab: (tabId: number) => sharedTabIds.delete(tabId), - tabGroupsQuery: chromeMock.tabGroups.query, - tabsCreate: chromeMock.tabs.create, - tabsGet: chromeMock.tabs.get, - tabsGroup: chromeMock.tabs.group, - tabsQuery: chromeMock.tabs.query, - tabsRemove: chromeMock.tabs.remove, - tabsUngroup: chromeMock.tabs.ungroup, - tabsUpdate: chromeMock.tabs.update, - tabsUpdatedListener, - windowsUpdate: chromeMock.windows.update, - }; -} describe("persisted relay pairing validation", () => { beforeEach(() => { @@ -344,7 +54,7 @@ describe("persisted relay pairing validation", () => { }, }); await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1)); - expect(harness.storageSet).toHaveBeenCalledWith({ authVersion: 2 }); + expect(harness.storageSet).toHaveBeenCalledWith({ authVersion: 2, accessMode: "selected" }); expect(harness.storageValues.authVersion).toBe(2); }); @@ -426,7 +136,8 @@ describe("persisted relay pairing validation", () => { expect.objectContaining({ paired: false, state: "off", - sharedTabCount: 0, + accessMode: "selected", + accessibleTabCount: 0, relayUrl: "", }), ); @@ -446,7 +157,8 @@ describe("persisted relay pairing validation", () => { expect(response).toHaveBeenCalledWith({ paired: false, state: "off", - sharedTabCount: 0, + accessMode: "selected", + accessibleTabCount: 0, relayUrl: "", }); }); @@ -532,6 +244,154 @@ describe("persisted relay pairing validation", () => { expect(replacement).toBeDefined(); expect(replacement?.close).not.toHaveBeenCalled(); }); + + it("unpair detaches every debugger session and clears session denies", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + sessionConfig: { deniedTabIdsV1: [122] }, + initialTabs: [ + { id: 121, url: "https://example.com/attached", groupId: -1 }, + { id: 122, url: "https://example.com/paused", groupId: -1 }, + ], + }); + const socket = harness.relaySockets[0]; + if (!socket) { + throw new Error("expected relay socket"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 35, tabId: 121 }); + await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalled()); + + await expect(sendRuntimeMessage(harness, { type: "unpair" })).resolves.toEqual({ ok: true }); + + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 121 }); + expect(harness.sessionStorageValues).not.toHaveProperty("deniedTabIdsV1"); + expect(harness.storageValues).not.toHaveProperty("accessMode"); + }); + + it("revokes immediately and supersedes an older pair stalled in storage", async () => { + const harness = await loadBackground({ + storedConfig: { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + authVersion: 2, + accessMode: "all", + }, + initialTabs: [{ id: 131, url: "https://example.com/paired", groupId: -1 }], + }); + const socket = harness.relaySockets[0]; + if (!socket || !harness.debuggerEventListener) { + throw new Error("expected relay and debugger event listener"); + } + await harness.authenticate(socket); + socket.receive({ type: "attach", seq: 36, tabId: 131 }); + await vi.waitFor(() => { + const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + expect(frames).toContainEqual({ + type: "result", + seq: 36, + result: { targetId: "tab-131" }, + }); + }); + + harness.storageSet.mockClear(); + const releasePairSave = harness.deferNextStorageSet(); + const pairing = sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, + accessMode: "all", + }); + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith( + expect.objectContaining({ + relayUrl: "ws://127.0.0.1:18798/extension", + token: REPLACEMENT_RELAY_SECRET, + }), + ); + }); + + const unpairing = sendRuntimeMessage(harness, { type: "unpair" }); + expect(socket.close).toHaveBeenCalledOnce(); + await expect( + sendRuntimeMessage(harness, { type: "getTabAccess", tabId: 131 }), + ).resolves.toEqual({ + accessMode: "all", + accessible: false, + eligible: false, + denied: false, + }); + harness.debuggerEventListener({ tabId: 131 }, "Runtime.consoleAPICalled", { value: 1 }); + expect( + socket.send.mock.calls + .map(([raw]) => JSON.parse(raw)) + .some((frame) => frame.type === "cdpEvent" && frame.method === "Runtime.consoleAPICalled"), + ).toBe(false); + + releasePairSave(); + await expect(pairing).resolves.toEqual({ + ok: false, + error: "Pairing was superseded by a newer request.", + }); + await expect(unpairing).resolves.toEqual({ ok: true }); + + expect(harness.relaySockets).toHaveLength(1); + expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 131 }); + expect(harness.storageValues).not.toHaveProperty("relayUrl"); + expect(harness.storageValues).not.toHaveProperty("token"); + expect(harness.storageValues).not.toHaveProperty("accessMode"); + }); + + it("lets the newest pair supersede an older pair stalled in storage", async () => { + const harness = await loadBackground(); + const original = harness.relaySockets[0]; + if (!original) { + throw new Error("expected original relay socket"); + } + await harness.authenticate(original); + + harness.storageSet.mockClear(); + const releaseFirstSave = harness.deferNextStorageSet(); + const firstPair = sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`, + accessMode: "all", + }); + await vi.waitFor(() => { + expect(harness.storageSet).toHaveBeenCalledWith( + expect.objectContaining({ relayUrl: "ws://127.0.0.1:18798/extension" }), + ); + }); + + const newestSecret = "c".repeat(64); + const secondPair = sendRuntimeMessage(harness, { + type: "pair", + pairingString: `ws://127.0.0.1:18799/extension#${newestSecret}`, + accessMode: "selected", + }); + releaseFirstSave(); + + await expect(firstPair).resolves.toEqual({ + ok: false, + error: "Pairing was superseded by a newer request.", + }); + await expect(secondPair).resolves.toEqual({ ok: true }); + expect(harness.storageValues).toMatchObject({ + relayUrl: "ws://127.0.0.1:18799/extension", + token: newestSecret, + accessMode: "selected", + }); + expect( + harness.relaySockets.some((socket) => socket.url === "ws://127.0.0.1:18798/extension"), + ).toBe(false); + expect( + harness.relaySockets.filter((socket) => socket.url === "ws://127.0.0.1:18799/extension"), + ).toHaveLength(1); + }); }); describe("relay authentication v2 transport", () => { @@ -661,11 +521,15 @@ describe("relay opening deadline", () => { expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_WATCHDOG_ALARM, { periodInMinutes: 0.5, }); - expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM, { - when: START_TIME_MS + 10_000, - }); + const openingDeadline = harness.createAlarm.mock.calls.find( + ([name]) => name === RELAY_OPENING_DEADLINE_ALARM, + )?.[1]?.when; + if (typeof openingDeadline !== "number") { + throw new Error("expected relay opening deadline alarm"); + } + expect(openingDeadline).toBe(Date.now() + 10_000); - vi.setSystemTime(START_TIME_MS + 10_000); + vi.setSystemTime(openingDeadline); harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM }); expect(harness.sockets[0]?.close).toHaveBeenCalledOnce(); @@ -675,7 +539,7 @@ describe("relay opening deadline", () => { await vi.advanceTimersByTimeAsync(1_000); expect(harness.sockets).toHaveLength(2); expect(harness.createAlarm).toHaveBeenLastCalledWith(RELAY_OPENING_DEADLINE_ALARM, { - when: START_TIME_MS + 21_000, + when: openingDeadline + 11_000, }); }); @@ -758,14 +622,18 @@ describe("popup message failure responses", () => { vi.unstubAllGlobals(); }); - it("responds exactly once when a shared tab closes before it can be grouped", async () => { + it("responds exactly once when a selected tab closes before it can be grouped", async () => { const harness = await loadBackground(); harness.tabsGet.mockRejectedValueOnce(new Error("No tab with id: 44.")); const sendResponse = vi.fn(); - expect(harness.messageListener({ type: "toggleShareTab", tabId: 44 }, {}, sendResponse)).toBe( - true, - ); + expect( + harness.messageListener( + { type: "toggleTabAccess", tabId: 44, accessMode: "selected", grant: true }, + {}, + sendResponse, + ), + ).toBe(true); await vi.waitFor(() => { expect(sendResponse).toHaveBeenCalledExactlyOnceWith({ @@ -788,14 +656,22 @@ describe("popup message failure responses", () => { }); const harness = await loadBackground({ onConsentChanged }); if (initiallyShared) { - harness.tabGroupsQuery.mockResolvedValueOnce([{ id: 7, windowId: 1 }]); - harness.tabsQuery.mockResolvedValueOnce([{ id: 44, windowId: 1 }]); + harness.shareTab(44); } const sendResponse = vi.fn(); - expect(harness.messageListener({ type: "toggleShareTab", tabId: 44 }, {}, sendResponse)).toBe( - true, - ); + expect( + harness.messageListener( + { + type: "toggleTabAccess", + tabId: 44, + accessMode: "selected", + grant: !initiallyShared, + }, + {}, + sendResponse, + ), + ).toBe(true); await vi.waitFor(() => { expect(onConsentChanged).toHaveBeenCalledOnce(); @@ -806,7 +682,11 @@ describe("popup message failure responses", () => { expect(harness.tabsGroup).toHaveBeenCalledWith({ tabIds: [44] }); } expect(sendResponse).toHaveBeenCalledExactlyOnceWith({ ok: false, error }); - expect(sendResponse).not.toHaveBeenCalledWith({ ok: true, shared: !initiallyShared }); + expect(sendResponse).not.toHaveBeenCalledWith({ + ok: true, + accessible: !initiallyShared, + denied: false, + }); }, ); @@ -984,114 +864,3 @@ describe("page-share relay request lifecycle", () => { expect(replacement.response).toHaveBeenCalledOnce(); }); }); - -describe("relay command authorization", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("rejects every authority-bearing command after tab-group revocation", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - await harness.authenticate(socket); - harness.shareTab(41); - harness.unshareTab(41); - - socket.receive({ type: "attach", seq: 1, tabId: 41 }); - socket.receive({ type: "cdp", seq: 2, tabId: 41, method: "Runtime.evaluate" }); - socket.receive({ type: "closeTab", seq: 3, tabId: 41 }); - socket.receive({ type: "activateTab", seq: 4, tabId: 41 }); - - await vi.waitFor(() => { - const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); - expect( - frames - .filter((frame) => frame.type === "error") - .map((frame) => frame.seq) - .toSorted((left, right) => left - right), - ).toEqual([1, 2, 3, 4]); - }); - expect(harness.debuggerAttach).not.toHaveBeenCalled(); - expect(harness.debuggerSendCommand).not.toHaveBeenCalled(); - expect(harness.tabsRemove).not.toHaveBeenCalled(); - expect(harness.tabsUpdate).not.toHaveBeenCalled(); - expect(harness.windowsUpdate).not.toHaveBeenCalled(); - }); - - it("keeps detach available as the revocation cleanup command", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - await harness.authenticate(socket); - harness.unshareTab(41); - - socket.receive({ type: "detach", seq: 5, tabId: 41 }); - - await vi.waitFor(() => { - expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 41 }); - const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); - expect(frames).toContainEqual({ type: "result", seq: 5, result: {} }); - }); - }); - - it("allows createTab and groups the new tab before reporting success", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - await harness.authenticate(socket); - harness.tabsCreate.mockResolvedValueOnce({ id: 42 }); - - socket.receive({ type: "createTab", seq: 6, url: "https://example.com" }); - - await vi.waitFor(() => { - expect(harness.tabsGroup).toHaveBeenCalledWith({ tabIds: [42] }); - const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); - expect(frames).toContainEqual({ type: "result", seq: 6, result: { tabId: 42 } }); - }); - }); - - it("invalidates an attach that was in flight when the tab left the group", async () => { - const harness = await loadBackground(); - const socket = harness.sockets[0]; - if (!socket) { - throw new Error("expected relay socket"); - } - await harness.authenticate(socket); - harness.shareTab(43); - let releaseAttach = () => {}; - harness.debuggerAttach.mockImplementationOnce( - async () => - await new Promise((resolve) => { - releaseAttach = () => resolve(undefined); - }), - ); - - socket.receive({ type: "attach", seq: 7, tabId: 43 }); - await vi.waitFor(() => expect(harness.debuggerAttach).toHaveBeenCalledOnce()); - harness.unshareTab(43); - harness.tabsUpdatedListener(43, { groupId: -1 }); - await Promise.resolve(); - releaseAttach(); - - await vi.waitFor(() => { - expect(harness.debuggerDetach).toHaveBeenCalledWith({ tabId: 43 }); - const frames = socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); - expect(frames).toContainEqual({ - type: "error", - seq: 7, - message: "tab 43 access was revoked", - }); - }); - }); -}); diff --git a/extensions/browser/chrome-extension/manifest.json b/extensions/browser/chrome-extension/manifest.json index 9e029b2a4a7f..df0fda0752fa 100644 --- a/extensions/browser/chrome-extension/manifest.json +++ b/extensions/browser/chrome-extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "OpenClaw", - "version": "2.1.0", - "description": "Let OpenClaw agents browse with you. Tabs shared with OpenClaw live in a colored tab group; drag a tab out to revoke access.", + "version": "2.2.0", + "description": "Let OpenClaw agents use eligible tabs in your signed-in Chrome, with All tabs or Selected tabs access and per-tab pause controls.", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts b/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts index 657af7514647..99dc000deaac 100644 --- a/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts +++ b/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts @@ -21,7 +21,7 @@ export function archiveCopilotSession( export function selectCopilotPanelState(options: { paired: boolean; - shared: boolean; + accessible: boolean; abortPending: boolean; gatewayState: string; }): string; diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.js b/extensions/browser/chrome-extension/modules/copilot-background-shared.js index 9d91c2a9389b..8e3bf60d052f 100644 --- a/extensions/browser/chrome-extension/modules/copilot-background-shared.js +++ b/extensions/browser/chrome-extension/modules/copilot-background-shared.js @@ -72,11 +72,11 @@ export async function archiveCopilotSession(gateway, entry) { await gateway.request("sessions.patch", { key: entry.sessionKey, archived: true }); } -export function selectCopilotPanelState({ paired, shared, abortPending, gatewayState }) { +export function selectCopilotPanelState({ paired, accessible, abortPending, gatewayState }) { if (!paired) { return "needs-pairing"; } - if (!shared) { + if (!accessible) { return "needs-sharing"; } return abortPending ? "reconciling" : gatewayState; diff --git a/extensions/browser/chrome-extension/modules/copilot-background.js b/extensions/browser/chrome-extension/modules/copilot-background.js index fe23cb4270c0..6ee107be94dd 100644 --- a/extensions/browser/chrome-extension/modules/copilot-background.js +++ b/extensions/browser/chrome-extension/modules/copilot-background.js @@ -19,8 +19,8 @@ const PANEL_PORT = "openclaw-copilot-panel"; export function createCopilotController({ chromeApi = chrome, getConfig, - isTabShared, - addTabToOpenClawGroup, + isTabAccessible, + grantTabAccess, attachDebugger, revokeDebugger, restoreDebugger, @@ -121,7 +121,7 @@ export function createCopilotController({ isConfigTransitioning: () => configTransitioning, currentReadyEpoch, readyEpochIsCurrent, - isTabShared, + isTabAccessible, attachDebugger, revokeDebugger, restoreDebuggerIfReleased, @@ -328,7 +328,12 @@ export function createCopilotController({ async function refreshPanelState( tabId, - { shared: knownShared, ensureSetup = false, hydrateHistory = false, suspended = false } = {}, + { + accessible: knownAccessible, + ensureSetup = false, + hydrateHistory = false, + suspended = false, + } = {}, ) { let tab; try { @@ -336,12 +341,13 @@ export function createCopilotController({ } catch { return; } - const shared = typeof knownShared === "boolean" ? knownShared : await isTabShared(tabId); + const accessible = + typeof knownAccessible === "boolean" ? knownAccessible : await isTabAccessible(tabId); const entry = registry.get(tabId, currentGatewayScope()); const panelStatus = currentPanelStatus(); const state = selectCopilotPanelState({ paired: Boolean(currentConfig?.relayUrl), - shared, + accessible, abortPending: Boolean(entry?.abortPending), gatewayState: panelStatus.state, }); @@ -350,7 +356,7 @@ export function createCopilotController({ state, label: state === "needs-sharing" - ? "Share this tab before the copilot can act" + ? "Allow OpenClaw on this tab before the copilot can act" : state === "reconciling" ? "Stopping the previous tab run" : panelStatus.label, @@ -362,7 +368,7 @@ export function createCopilotController({ }, sessionKey: entry?.sessionKey, }; - if (!shared) { + if (!accessible) { broadcastTab(tabId, panelState); if (!suspended) { await suspendTab(tabId, { detachInactive: true }); @@ -387,7 +393,7 @@ export function createCopilotController({ try { const prepared = await ensureSession(tabId, { hydrateHistory }); if (prepared) { - await refreshPanelState(tabId, { shared: await isTabShared(tabId) }); + await refreshPanelState(tabId, { accessible: await isTabAccessible(tabId) }); } } catch (error) { broadcastTab(tabId, { @@ -481,8 +487,8 @@ export function createCopilotController({ ); } - async function shareTab(tabId) { - await addTabToOpenClawGroup(tabId); + async function grantAccess(tabId) { + await grantTabAccess(tabId); scheduleTabsSync(); await refreshPanelState(tabId); } @@ -527,37 +533,37 @@ export function createCopilotController({ .catch(() => undefined) .then(async () => { // Event-time revocation is sticky even if a later update observes - // the tab re-shared. CDP must detach for the revoked interval. + // access restored. CDP must detach for the revoked interval. if (revoked) { await suspendTab(tabId, { detachInactive: true }); } if (consentRevisions.get(tabId) !== revision) { return; } - let shared = false; + let accessible = false; try { - shared = await isTabShared(tabId); + accessible = await isTabAccessible(tabId); } catch { // Missing tab state is treated as revoked consent. } - if (!shared) { + if (!accessible) { await suspendTab(tabId, { detachInactive: true }); } if (consentRevisions.get(tabId) !== revision) { return; } try { - shared = await isTabShared(tabId); + accessible = await isTabAccessible(tabId); } catch { - shared = false; + accessible = false; } if (consentRevisions.get(tabId) !== revision) { return; } - if (shared) { + if (accessible) { await restoreDebuggerIfReleased(tabId); } - await refreshPanelState(tabId, { shared, suspended: !shared }); + await refreshPanelState(tabId, { accessible, suspended: !accessible }); }) .finally(() => { if (consentByTab.get(tabId) === pending) { @@ -600,7 +606,7 @@ export function createCopilotController({ if (message?.type === "panel.send") { await sendMessage(tabId, port, portRevision, message.message); } else if (message?.type === "panel.share") { - await shareTab(tabId); + await grantAccess(tabId); } else if (message?.type === "panel.refresh") { await refreshPanelState(tabId); } diff --git a/extensions/browser/chrome-extension/modules/copilot-background.test.ts b/extensions/browser/chrome-extension/modules/copilot-background.test.ts index c94e1a77d2d1..aff86715f3dd 100644 --- a/extensions/browser/chrome-extension/modules/copilot-background.test.ts +++ b/extensions/browser/chrome-extension/modules/copilot-background.test.ts @@ -47,8 +47,8 @@ describe("browser copilot background", () => { storage, } as never, getConfig, - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(), @@ -133,8 +133,8 @@ describe("browser copilot background", () => { }, } as never, getConfig, - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(async () => undefined), @@ -192,8 +192,8 @@ describe("browser copilot background", () => { storage: { local: storageArea(), session: storageArea() }, } as never, getConfig, - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(async () => undefined), @@ -259,8 +259,8 @@ describe("browser copilot background", () => { storage: { local: storageArea(), session: storageArea() }, } as never, getConfig, - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(async () => undefined), @@ -312,8 +312,8 @@ describe("browser copilot background", () => { storage: { local: storageArea(), session: storageArea() }, } as never, getConfig, - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(async () => undefined), @@ -355,8 +355,8 @@ describe("browser copilot background", () => { relayUrl: "ws://127.0.0.1:18792/browser/extension", gatewayUrl: gatewayScope, })), - isTabShared: vi.fn(async () => true), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(async () => true), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger, @@ -382,7 +382,7 @@ describe("browser copilot background", () => { expect( selectCopilotPanelState({ paired: true, - shared: true, + accessible: true, abortPending: false, gatewayState: "ready", }), @@ -390,7 +390,7 @@ describe("browser copilot background", () => { expect( selectCopilotPanelState({ paired: true, - shared: true, + accessible: true, abortPending: true, gatewayState: "ready", }), @@ -419,8 +419,8 @@ describe("browser copilot background", () => { relayUrl: "ws://127.0.0.1:18792/browser/extension", gatewayUrl: "ws://127.0.0.1:18789", })), - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger, @@ -466,8 +466,8 @@ describe("browser copilot background", () => { relayUrl: "ws://127.0.0.1:18792/browser/extension", gatewayUrl: gatewayScope, })), - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger, @@ -525,8 +525,8 @@ describe("browser copilot background", () => { relayUrl: "ws://127.0.0.1:18792/browser/extension", gatewayUrl: "ws://127.0.0.1:18789", })), - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger, @@ -589,8 +589,8 @@ describe("browser copilot background", () => { relayUrl: "ws://127.0.0.1:28792/browser/extension", gatewayUrl: "ws://127.0.0.1:28789", })), - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(async () => undefined), @@ -672,8 +672,8 @@ describe("browser copilot background", () => { const controller = createCopilotController({ chromeApi: chromeApi as never, getConfig: vi.fn(), - isTabShared: vi.fn(), - addTabToOpenClawGroup: vi.fn(), + isTabAccessible: vi.fn(), + grantTabAccess: vi.fn(), attachDebugger: vi.fn(), detachDebugger: vi.fn(), revokeDebugger: vi.fn(), diff --git a/extensions/browser/chrome-extension/modules/copilot-session.js b/extensions/browser/chrome-extension/modules/copilot-session.js index 48222d6ebdf0..e5ef4ab97769 100644 --- a/extensions/browser/chrome-extension/modules/copilot-session.js +++ b/extensions/browser/chrome-extension/modules/copilot-session.js @@ -22,7 +22,7 @@ export function createCopilotSessionController({ isConfigTransitioning, currentReadyEpoch, readyEpochIsCurrent, - isTabShared, + isTabAccessible, attachDebugger, revokeDebugger, restoreDebuggerIfReleased, @@ -51,9 +51,9 @@ export function createCopilotSessionController({ return false; } try { - const shared = await isTabShared(tabId); + const accessible = await isTabAccessible(tabId); return ( - shared && + accessible && portsByTab.has(tabId) && sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) ); @@ -63,13 +63,13 @@ export function createCopilotSessionController({ } async function suspendUnauthorizedSetup(tabId) { - let shared = false; + let accessible = false; try { - shared = await isTabShared(tabId); + accessible = await isTabAccessible(tabId); } catch { // Missing or unreadable tab state is not authorized to retain CDP access. } - await suspendTab(tabId, { detachInactive: !shared }); + await suspendTab(tabId, { detachInactive: !accessible }); if (portsByTab.has(tabId)) { void refreshPanelState(tabId); } @@ -82,7 +82,7 @@ export function createCopilotSessionController({ gatewayScope, hydrateHistory, ) { - if (!gateway.ready || !(await isTabShared(tabId))) { + if (!gateway.ready || !(await isTabAccessible(tabId))) { return null; } const staleActiveSession = registry @@ -246,8 +246,8 @@ export function createCopilotSessionController({ if (!readyEpoch) { throw new Error("Gateway is still reconciling this tab."); } - if (!(await isTabShared(tabId))) { - throw new Error("This tab is not shared with OpenClaw."); + if (!(await isTabAccessible(tabId))) { + throw new Error("This tab is not available to OpenClaw."); } const entry = await ensureSession(tabId, { hydrateHistory: false }); if (!entry) { @@ -271,16 +271,16 @@ export function createCopilotSessionController({ } let submitted = false; try { - const stillShared = await isTabShared(tabId); + const stillAccessible = await isTabAccessible(tabId); const stillOwnsPanel = panelOwnsSend(tabId, port, portRevision); const stillOwnsGateway = readyEpochIsCurrent(readyEpoch); - if (!stillShared || !stillOwnsPanel || !stillOwnsGateway) { - if (!stillShared || !stillOwnsPanel) { - await suspendTab(tabId, { detachInactive: !stillShared }); + if (!stillAccessible || !stillOwnsPanel || !stillOwnsGateway) { + if (!stillAccessible || !stillOwnsPanel) { + await suspendTab(tabId, { detachInactive: !stillAccessible }); } throw new Error( - !stillShared - ? "This tab is not shared with OpenClaw." + !stillAccessible + ? "This tab is not available to OpenClaw." : !stillOwnsPanel ? "This panel is no longer attached to the tab." : "Gateway connection changed while preparing this tab.", diff --git a/extensions/browser/chrome-extension/modules/copilot-session.test.ts b/extensions/browser/chrome-extension/modules/copilot-session.test.ts new file mode 100644 index 000000000000..3d4f76915be4 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { createCopilotSessionController } from "./copilot-session.js"; + +function createHarness(accessible: boolean) { + const gatewayScope = "ws://127.0.0.1:18789/"; + const entry = { + tabId: 41, + gatewayScope, + sessionKey: "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111", + sessionId: "session-id", + provisional: false, + binding: undefined as Record | undefined, + }; + let present = false; + const registry = { + list: vi.fn(() => (present ? [entry] : [])), + get: vi.fn(() => (present ? entry : null)), + put: vi.fn(async (_tabId: number, value: Record) => { + present = true; + Object.assign(entry, value, { tabId: 41 }); + return entry; + }), + updateBinding: vi.fn( + async (_tabId: number, _scope: string, binding: Record) => { + entry.binding = binding; + }, + ), + markSessionCreationPending: vi.fn(async () => entry), + confirmSession: vi.fn(async () => { + entry.provisional = false; + return entry; + }), + closeTab: vi.fn(), + }; + const request = vi.fn(async (method: string) => + method === "sessions.create" ? { sessionId: "session-id" } : {}, + ); + const gateway = { + ready: true, + hello: { snapshot: { sessionDefaults: { mainSessionKey: "agent:main:main" } } }, + request, + }; + const attachDebugger = vi.fn(async () => ({ targetId: "target-41" })); + const portsByTab = new Map([[41, new Set([{}])]]); + const controller = createCopilotSessionController({ + chromeApi: { tabs: { get: vi.fn(async () => ({ id: 41 })) } }, + gateway, + registry: registry as never, + ensureByTab: new Map(), + tabRevisions: new Map(), + portsByTab, + portRevisions: new Map(), + sendsByTab: new Set(), + currentGatewayScope: () => gatewayScope, + getGatewayRevision: () => 1, + getCurrentConfig: () => ({ + relayUrl: "ws://127.0.0.1:18797/extension", + gatewayUrl: gatewayScope, + }), + isConfigTransitioning: () => false, + currentReadyEpoch: () => ({ gatewayScope, configRevision: 1, statusRevision: 1 }), + readyEpochIsCurrent: () => true, + isTabAccessible: vi.fn(async () => accessible), + attachDebugger, + revokeDebugger: vi.fn(), + restoreDebuggerIfReleased: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribeTab: vi.fn(), + suspendTab: vi.fn(), + hydrate: vi.fn(), + refreshPanelState: vi.fn(), + drainArchives: vi.fn(), + scheduleAbortRetry: vi.fn(), + }); + return { attachDebugger, controller, request }; +} + +describe("tab copilot access policy", () => { + it("prepares a session for an access-authorized ungrouped all-mode tab", async () => { + const harness = createHarness(true); + await expect(harness.controller.ensureSession(41)).resolves.toMatchObject({ + tabId: 41, + binding: expect.objectContaining({ kind: "tab", tabId: 41, targetId: "target-41" }), + }); + expect(harness.attachDebugger).toHaveBeenCalledWith(41); + expect(harness.request).toHaveBeenCalledWith( + "sessions.create", + expect.objectContaining({ key: expect.stringContaining("browser-copilot") }), + ); + }); + + it.each(["paused", "restricted"])("does not attach a %s tab", async () => { + const harness = createHarness(false); + await expect(harness.controller.ensureSession(41)).resolves.toBeNull(); + expect(harness.attachDebugger).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/page-share-background.js b/extensions/browser/chrome-extension/modules/page-share-background.js new file mode 100644 index 000000000000..17da48d69b59 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/page-share-background.js @@ -0,0 +1,84 @@ +import { buildPageSharePayload, capturePageShare } from "./page-share-core.js"; + +/** Own popup/command/context-menu page sharing and its transient badge. */ +export function createPageShareController({ + chromeApi = chrome, + ensureRelayReady, + sendPageShareRequest, + restoreBadge, +}) { + let badgeTimer = null; + + function flashBadge(ok) { + if (badgeTimer) { + clearTimeout(badgeTimer); + } + void chromeApi.action.setBadgeText({ text: ok ? "✓" : "!" }); + void chromeApi.action.setBadgeBackgroundColor({ color: ok ? "#0F9D58" : "#B91C1C" }); + badgeTimer = setTimeout( + () => { + badgeTimer = null; + restoreBadge(); + }, + ok ? 2_000 : 3_000, + ); + } + + async function sendPage(tabId, note) { + await ensureRelayReady(); + const tab = await chromeApi.tabs.get(tabId); + const capture = await capturePageShare(tab); + const payload = buildPageSharePayload({ ...capture, note }); + if (!payload.content && !payload.selection) { + throw new Error("Nothing to send on this page."); + } + await sendPageShareRequest(payload); + } + + async function sendSelectionSnapshot(tab, selection) { + await ensureRelayReady(); + await sendPageShareRequest( + buildPageSharePayload({ + url: tab.url ?? "", + title: tab.title ?? "", + content: "", + selection, + note: "", + }), + ); + } + + function withBadge(promise) { + return promise.then( + () => flashBadge(true), + () => flashBadge(false), + ); + } + + async function installContextMenu() { + await chromeApi.contextMenus.removeAll(); + chromeApi.contextMenus.create({ + id: "openclaw-send-page", + title: "Send page to OpenClaw", + contexts: ["page", "selection"], + }); + } + + chromeApi.commands.onCommand.addListener((command) => { + if (command !== "send-page") { + return; + } + void chromeApi.tabs + .query({ active: true, lastFocusedWindow: true }) + .then(([tab]) => (typeof tab?.id === "number" ? withBadge(sendPage(tab.id, "")) : undefined)); + }); + chromeApi.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId !== "openclaw-send-page" || typeof tab?.id !== "number") { + return; + } + const selection = info.selectionText?.trim() ?? ""; + void withBadge(selection ? sendSelectionSnapshot(tab, selection) : sendPage(tab.id, "")); + }); + + return { installContextMenu, sendPage }; +} diff --git a/extensions/browser/chrome-extension/modules/popup-background.js b/extensions/browser/chrome-extension/modules/popup-background.js new file mode 100644 index 000000000000..dcdeebd37be9 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/popup-background.js @@ -0,0 +1,301 @@ +import { + ACCESS_MODE_ALL, + ACCESS_MODE_SELECTED, + nearestGroupColor, + parsePairingString, +} from "./relay-core.js"; +import { isTabSelected } from "./relay-tab-groups.js"; + +function isValidTabId(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function errorResponse(sendResponse, error) { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); +} + +/** Own pairing/settings/popup messages; authority stays in the injected access policy. */ +export function createPopupMessageHandler({ + chromeApi = chrome, + pairingConfigStore, + policy, + accessReady, + getConfig, + getRelayState, + getRelayStatusHint, + resetRelayState, + suspendRelayConnections, + resumeRelayConnections, + reconcilePairingInvalidation, + reconcileAccessMode, + runAccessMutation, + detachAllDebuggerSessions, + syncTabsToRelay, + clearRelayOpeningDeadline, + closeRelaySocket, + connectRelay, + setBadge, + getCopilot, + attachingTabs, + detachDebugger, + removeTabFromOpenClawGroup, + addTabToOpenClawGroup, + scheduleTabsSync, + pauseTab, + pageShare, +}) { + let pairingGeneration = 0; + + const assertPairingCurrent = (generation) => { + if (generation !== pairingGeneration) { + throw new Error("Pairing was superseded by a newer request."); + } + }; + + return (msg, reply) => { + let settled = false; + const sendResponse = (response) => { + if (!settled) { + settled = true; + reply(response); + } + }; + void (async () => { + try { + switch (msg?.type) { + case "getStatus": { + await accessReady; + const { relayUrl, accessMode } = await getConfig(); + await reconcilePairingInvalidation(); + const accessible = await policy.listAccessibleTabs(); + const hint = getRelayStatusHint(); + sendResponse({ + paired: Boolean(relayUrl), + state: getRelayState(), + accessMode, + accessibleTabCount: accessible.length, + relayUrl: relayUrl ?? "", + ...(hint ? { hint } : {}), + }); + return; + } + case "pair": { + const parsed = parsePairingString(msg.pairingString); + if (!parsed) { + sendResponse({ ok: false, error: "Invalid pairing string." }); + return; + } + const generation = ++pairingGeneration; + suspendRelayConnections(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + await accessReady; + assertPairingCurrent(generation); + await runAccessMutation(async () => { + assertPairingCurrent(generation); + // A newer request may have waited behind an older save. Reassert + // transport custody when this generation reaches the queue head. + suspendRelayConnections(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + // A replacement pairing must never inherit a later, wider policy. + // Retire its authenticated socket before storage or mode can yield. + const accessMode = + msg.accessMode === ACCESS_MODE_SELECTED ? ACCESS_MODE_SELECTED : ACCESS_MODE_ALL; + const downgrading = + policy.mode === ACCESS_MODE_ALL && accessMode === ACCESS_MODE_SELECTED; + if (downgrading) { + policy.beginTransition(); + } + try { + await pairingConfigStore.save( + parsed, + nearestGroupColor(msg.groupColor), + accessMode, + ); + assertPairingCurrent(generation); + await reconcileAccessMode(accessMode, { transitioning: downgrading }); + assertPairingCurrent(generation); + policy.setEnabled(true); + } catch (error) { + if (downgrading) { + policy.endTransition(); + } + throw error; + } + resetRelayState(); + await getCopilot().refreshConfig(); + assertPairingCurrent(generation); + resumeRelayConnections(); + await connectRelay(() => generation === pairingGeneration); + if (generation !== pairingGeneration) { + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + assertPairingCurrent(generation); + } + }); + sendResponse({ ok: true }); + return; + } + case "unpair": { + pairingGeneration += 1; + // Revocation is synchronous. Queued storage and debugger cleanup + // must not leave the old authority or relay alive in the meantime. + policy.setEnabled(false); + policy.invalidateAll(); + suspendRelayConnections(); + resetRelayState(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + await accessReady; + // Initialization can finish after the synchronous revoke above. + // Reassert it before waiting on any older queued bookkeeping. + policy.setEnabled(false); + policy.invalidateAll(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + await runAccessMutation(async () => { + // Initialization or an older mutation may have completed while + // this request was waiting for the queue; keep revocation sticky. + policy.setEnabled(false); + const detaching = detachAllDebuggerSessions(); + await syncTabsToRelay(); + await pairingConfigStore.clear(); + await policy.clearDenied(); + await detaching; + resetRelayState(); + clearRelayOpeningDeadline(); + closeRelaySocket(); + setBadge("off"); + await getCopilot().refreshConfig(); + }); + sendResponse({ ok: true }); + return; + } + case "setAccessMode": { + if (msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) { + sendResponse({ ok: false, error: "Invalid access mode." }); + return; + } + const restricting = msg.accessMode === ACCESS_MODE_SELECTED; + if (restricting) { + // A queued widening may not have updated policy.mode yet. Every + // Selected request revokes before older mutations can hold the queue. + policy.beginTransition(); + } + let accessMode; + try { + await accessReady; + accessMode = await runAccessMutation(async () => { + const storedMode = await pairingConfigStore.setAccessMode(msg.accessMode); + await reconcileAccessMode(storedMode, { transitioning: restricting }); + return storedMode; + }); + } catch (error) { + if (restricting) { + policy.endTransition(); + } + throw error; + } + sendResponse({ ok: true, accessMode }); + return; + } + case "toggleTabAccess": { + const tabId = msg.tabId; + if (!isValidTabId(tabId)) { + sendResponse({ ok: false, error: "No tab." }); + return; + } + if ( + (msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) || + typeof msg.grant !== "boolean" + ) { + sendResponse({ ok: false, error: "Invalid tab access action." }); + return; + } + await accessReady; + if (policy.mode !== msg.accessMode) { + sendResponse({ ok: false, error: "Browser access mode changed. Refresh and retry." }); + return; + } + const revocation = policy.beginRevocation(tabId); + let restoredAccess = false; + try { + await runAccessMutation(async () => { + if (policy.mode !== msg.accessMode) { + throw new Error("Browser access mode changed. Refresh and retry."); + } + if (policy.mode === ACCESS_MODE_ALL) { + const denied = policy.isDenied(tabId); + if (msg.grant && denied) { + await policy.allow(tabId); + restoredAccess = true; + } else if (!msg.grant && !denied) { + await pauseTab(tabId); + } + } else { + const wasSelected = await isTabSelected(await chromeApi.tabs.get(tabId)); + if (!msg.grant && wasSelected) { + policy.invalidateTab(tabId); + await Promise.allSettled([attachingTabs.get(tabId)]); + await detachDebugger(tabId); + await removeTabFromOpenClawGroup(tabId); + scheduleTabsSync(); + await getCopilot().onConsentChanged(tabId, { revoked: true }); + } else if (msg.grant && !wasSelected) { + policy.invalidateTab(tabId); + await addTabToOpenClawGroup(tabId); + restoredAccess = true; + } + } + }); + } finally { + policy.endRevocation(revocation); + } + if (restoredAccess) { + scheduleTabsSync(); + await syncTabsToRelay(); + await getCopilot().onConsentChanged(tabId, { revoked: false }); + } + const state = await policy.inspectTab(tabId); + sendResponse({ ok: true, accessible: state.accessible, denied: state.denied }); + return; + } + case "getTabAccess": { + await accessReady; + const state = await policy.inspectTab(msg.tabId); + sendResponse({ + accessMode: policy.mode, + accessible: state.accessible, + eligible: state.eligible, + denied: state.denied, + }); + return; + } + case "sendPageToOpenClaw": { + if (typeof msg.tabId !== "number") { + sendResponse({ ok: false, error: "No tab." }); + return; + } + await pageShare.sendPage(msg.tabId, msg.note); + sendResponse({ ok: true }); + return; + } + case "prepareCopilotPanel": { + const options = await getCopilot().preparePanel(msg.tabId); + sendResponse({ ok: true, ...options }); + return; + } + default: + sendResponse({ ok: false, error: "unknown message" }); + } + } catch (error) { + errorResponse(sendResponse, error); + } + })(); + return true; + }; +} diff --git a/extensions/browser/chrome-extension/modules/relay-command-handler.d.ts b/extensions/browser/chrome-extension/modules/relay-command-handler.d.ts index 330878341a11..307b42d7afce 100644 --- a/extensions/browser/chrome-extension/modules/relay-command-handler.d.ts +++ b/extensions/browser/chrome-extension/modules/relay-command-handler.d.ts @@ -1,8 +1,16 @@ +import type { TabAccessEpoch } from "./tab-access.js"; +import type { AccessibleBrowserTabSnapshot, BrowserTabSnapshot } from "./tab-eligibility.js"; + export function createRelayCommandHandler(params: { send: (message: Record) => void; attachDebugger: (tabId: number) => Promise; detachDebugger: (tabId: number) => Promise; addTabToOpenClawGroup: (tabId: number) => Promise; - focusWindowForTab: (tab: chrome.tabs.Tab) => Promise; + focusWindowForTab: (tab: BrowserTabSnapshot) => Promise; scheduleTabsSync: () => void; + captureAccess: (tabId: number) => TabAccessEpoch; + requireAccessibleTab: ( + tabId: number, + epoch: TabAccessEpoch, + ) => Promise; }): (message: Record) => Promise; diff --git a/extensions/browser/chrome-extension/modules/relay-command-handler.js b/extensions/browser/chrome-extension/modules/relay-command-handler.js index 87a086d311be..c7da2cb49429 100644 --- a/extensions/browser/chrome-extension/modules/relay-command-handler.js +++ b/extensions/browser/chrome-extension/modules/relay-command-handler.js @@ -1,5 +1,3 @@ -import { requireSharedTab } from "./relay-tab-groups.js"; - /** Build the authenticated application-command dispatcher for the relay socket. */ export function createRelayCommandHandler({ send, @@ -8,6 +6,8 @@ export function createRelayCommandHandler({ addTabToOpenClawGroup, focusWindowForTab, scheduleTabsSync, + captureAccess, + requireAccessibleTab, }) { return async (message) => { const { seq } = message; @@ -24,7 +24,8 @@ export function createRelayCommandHandler({ send({ type: "result", seq, result: {} }); return; case "cdp": { - await requireSharedTab(message.tabId); + const epoch = captureAccess(message.tabId); + await requireAccessibleTab(message.tabId, epoch); const target = message.sessionId ? { tabId: message.tabId, sessionId: message.sessionId } : { tabId: message.tabId }; @@ -33,6 +34,7 @@ export function createRelayCommandHandler({ message.method, message.params ?? {}, ); + await requireAccessibleTab(message.tabId, epoch); send({ type: "result", seq, result: result ?? {} }); return; } @@ -49,18 +51,22 @@ export function createRelayCommandHandler({ send({ type: "result", seq, result: { tabId: tab.id } }); return; } - case "closeTab": - await requireSharedTab(message.tabId); + case "closeTab": { + const epoch = captureAccess(message.tabId); + await requireAccessibleTab(message.tabId, epoch); await detachDebugger(message.tabId); - await requireSharedTab(message.tabId); + await requireAccessibleTab(message.tabId, epoch); await chrome.tabs.remove(message.tabId); send({ type: "result", seq, result: {} }); return; + } case "activateTab": { - const tab = await requireSharedTab(message.tabId); + const epoch = captureAccess(message.tabId); + const tab = await requireAccessibleTab(message.tabId, epoch); await chrome.tabs.update(message.tabId, { active: true }); - await requireSharedTab(message.tabId); + await requireAccessibleTab(message.tabId, epoch); await focusWindowForTab(tab); + await requireAccessibleTab(message.tabId, epoch); send({ type: "result", seq, result: {} }); return; } diff --git a/extensions/browser/chrome-extension/modules/relay-command-handler.test.ts b/extensions/browser/chrome-extension/modules/relay-command-handler.test.ts new file mode 100644 index 000000000000..297671525d38 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/relay-command-handler.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRelayCommandHandler } from "./relay-command-handler.js"; + +function createHarness() { + const send = vi.fn(); + const epoch = { revision: 1, tabRevision: 2 }; + const requireAccessibleTab = vi.fn(async () => ({ id: 7, windowId: 3 })); + const focusWindowForTab = vi.fn(async () => undefined); + const chromeMock = { + debugger: { sendCommand: vi.fn(async () => ({ value: 1 })) }, + tabs: { + create: vi.fn(), + remove: vi.fn(async () => undefined), + update: vi.fn(async () => undefined), + }, + }; + vi.stubGlobal("chrome", chromeMock); + const handler = createRelayCommandHandler({ + send, + attachDebugger: vi.fn(), + detachDebugger: vi.fn(async () => undefined), + addTabToOpenClawGroup: vi.fn(), + focusWindowForTab, + scheduleTabsSync: vi.fn(), + captureAccess: vi.fn(() => epoch), + requireAccessibleTab, + }); + return { chromeMock, epoch, focusWindowForTab, handler, requireAccessibleTab, send }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("relay authority rechecks", () => { + it("checks access before and after an async CDP command", async () => { + const harness = createHarness(); + await harness.handler({ type: "cdp", seq: 1, tabId: 7, method: "Runtime.evaluate" }); + expect(harness.requireAccessibleTab.mock.calls).toEqual([ + [7, harness.epoch], + [7, harness.epoch], + ]); + expect(harness.send).toHaveBeenCalledWith({ type: "result", seq: 1, result: { value: 1 } }); + }); + + it("checks access around tab activation and window focus", async () => { + const harness = createHarness(); + await harness.handler({ type: "activateTab", seq: 2, tabId: 7 }); + expect(harness.requireAccessibleTab).toHaveBeenCalledTimes(3); + expect(harness.chromeMock.tabs.update).toHaveBeenCalledWith(7, { active: true }); + expect(harness.focusWindowForTab).toHaveBeenCalled(); + }); + + it("checks access immediately before close and reports the successful removal", async () => { + const harness = createHarness(); + await harness.handler({ type: "closeTab", seq: 3, tabId: 7 }); + expect(harness.requireAccessibleTab.mock.calls).toEqual([ + [7, harness.epoch], + [7, harness.epoch], + ]); + expect(harness.chromeMock.tabs.remove).toHaveBeenCalledWith(7); + expect(harness.send).toHaveBeenCalledWith({ type: "result", seq: 3, result: {} }); + }); + + it("does not report a post-operation result when access changes during CDP", async () => { + const harness = createHarness(); + harness.requireAccessibleTab + .mockResolvedValueOnce({ id: 7, windowId: 3 }) + .mockRejectedValueOnce(new Error("tab 7 access was revoked")); + await harness.handler({ type: "cdp", seq: 4, tabId: 7, method: "Runtime.evaluate" }); + expect(harness.send).toHaveBeenCalledWith({ + type: "error", + seq: 4, + message: "tab 7 access was revoked", + }); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/relay-core.d.ts b/extensions/browser/chrome-extension/modules/relay-core.d.ts index 5a17b934fc9a..a58d09031364 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.d.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.d.ts @@ -2,6 +2,8 @@ // it can load unbundled in Chrome). Kept in sync with relay-core.js. export const OPENCLAW_TAB_GROUP_TITLE: string; +export const ACCESS_MODE_ALL: "all"; +export const ACCESS_MODE_SELECTED: "selected"; export function parsePairingString(raw: unknown): { relayUrl: string; token: string; @@ -18,13 +20,16 @@ export function createPairingConfigStore(storage: { token: string; gatewayUrl: string; authVersion?: 2; + accessMode: "all" | "selected"; groupColor: string; pairingStatusHint: string; }>; save( pairing: { relayUrl: string; token: string; gatewayUrl?: string }, groupColor: string, + accessMode?: "all" | "selected", ): Promise; + setAccessMode(accessMode: unknown): Promise<"all" | "selected">; clear(): Promise; }; diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js index fcd57c603b10..fb7d503b05ab 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.js +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -2,11 +2,14 @@ // backoff, and Chrome tab-group color mapping. No chrome.* usage here so the // repo's vitest suite can exercise the logic directly. -/** Tab group shown to the user; membership == what the agent may touch. */ +/** Tab group shown to the user; an ACL in selected mode and an ownership marker in all mode. */ export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw"; +export const ACCESS_MODE_ALL = "all"; +export const ACCESS_MODE_SELECTED = "selected"; const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay.v2"; const RELAY_SECRET_PATTERN = /^[0-9a-f]{64}$/; const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token", "authVersion"]; +const ACCESS_MODE_KEY = "accessMode"; const PAIRING_STATUS_KEY = "pairingStatus"; const UNSUPPORTED_PROXY_PREFIX_STATUS = "proxy-prefix-unsupported"; const UNSUPPORTED_PROXY_PREFIX_HINT = @@ -225,6 +228,7 @@ export function createPairingConfigStore(storage) { run(async () => { const stored = await storage.get([ ...PAIRING_STORAGE_KEYS, + ACCESS_MODE_KEY, PAIRING_STATUS_KEY, "groupColor", ]); @@ -250,8 +254,22 @@ export function createPairingConfigStore(storage) { } } else { invalidObserved = false; - if (pairing && stored.authVersion === undefined) { - await storage.set({ authVersion: 2 }); + if (pairing) { + const repairs = {}; + if (stored.authVersion === undefined) { + repairs.authVersion = 2; + } + // Pairings created before access modes promised group-only access. + // Unknown future/corrupt values fail closed without discarding the key. + if ( + stored[ACCESS_MODE_KEY] !== ACCESS_MODE_ALL && + stored[ACCESS_MODE_KEY] !== ACCESS_MODE_SELECTED + ) { + repairs[ACCESS_MODE_KEY] = ACCESS_MODE_SELECTED; + } + if (Object.keys(repairs).length > 0) { + await storage.set(repairs); + } } if (pairing && pairingStatus) { pairingStatus = ""; @@ -263,23 +281,40 @@ export function createPairingConfigStore(storage) { token: pairing?.token ?? "", gatewayUrl: pairing?.gatewayUrl ?? "", authVersion: pairing ? 2 : undefined, + accessMode: pairing + ? stored[ACCESS_MODE_KEY] === ACCESS_MODE_ALL + ? ACCESS_MODE_ALL + : ACCESS_MODE_SELECTED + : ACCESS_MODE_SELECTED, groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange", pairingStatusHint: pairingStatus === UNSUPPORTED_PROXY_PREFIX_STATUS ? UNSUPPORTED_PROXY_PREFIX_HINT : "", }; }), - save: (pairing, groupColor) => + save: (pairing, groupColor, accessMode = ACCESS_MODE_ALL) => run(async () => { await storage.set({ relayUrl: pairing.relayUrl, token: pairing.token, gatewayUrl: pairing.gatewayUrl ?? "", authVersion: 2, + accessMode: accessMode === ACCESS_MODE_SELECTED ? ACCESS_MODE_SELECTED : ACCESS_MODE_ALL, groupColor, }); await storage.remove([PAIRING_STATUS_KEY]); }), - clear: () => run(() => storage.remove([...PAIRING_STORAGE_KEYS, PAIRING_STATUS_KEY])), + setAccessMode: (accessMode) => + run(async () => { + const stored = await storage.get(PAIRING_STORAGE_KEYS); + if (!parseStoredPairing(stored)) { + throw new Error("Pair the extension first."); + } + const normalized = accessMode === ACCESS_MODE_ALL ? ACCESS_MODE_ALL : ACCESS_MODE_SELECTED; + await storage.set({ [ACCESS_MODE_KEY]: normalized }); + return normalized; + }), + clear: () => + run(() => storage.remove([...PAIRING_STORAGE_KEYS, ACCESS_MODE_KEY, PAIRING_STATUS_KEY])), }; } diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts index de1ec2f63861..38e08efeca0a 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.test.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -165,14 +165,85 @@ describe("persisted pairing storage", () => { set, remove: async () => undefined, }).read(); - expect(set).toHaveBeenCalledWith({ authVersion: 2 }); + expect(set).toHaveBeenCalledWith({ authVersion: 2, accessMode: "selected" }); expect(config).toMatchObject({ relayUrl: "ws://127.0.0.1:18797/extension", token: RELAY_SECRET, authVersion: 2, + accessMode: "selected", }); }); + it("defaults a newly saved pairing to all tabs", async () => { + const stored: Record = {}; + const set = vi.fn(async (values: Record) => { + Object.assign(stored, values); + }); + const store = createPairingConfigStore({ + get: async () => stored, + set, + remove: async () => undefined, + }); + + await store.save({ relayUrl: "ws://127.0.0.1:18797/extension", token: RELAY_SECRET }, "orange"); + + expect(stored.accessMode).toBe("all"); + await expect(store.read()).resolves.toMatchObject({ accessMode: "all" }); + }); + + it("persists an explicitly selected-tabs pairing", async () => { + const stored: Record = {}; + const store = createPairingConfigStore({ + get: async () => stored, + set: async (values) => { + Object.assign(stored, values); + }, + remove: async () => undefined, + }); + await store.save( + { relayUrl: "ws://127.0.0.1:18797/extension", token: RELAY_SECRET }, + "orange", + "selected", + ); + await expect(store.read()).resolves.toMatchObject({ accessMode: "selected" }); + }); + + it("repairs a malformed access mode without invalidating the pairing", async () => { + const stored: Record = { + relayUrl: "ws://127.0.0.1:18797/extension", + token: RELAY_SECRET, + gatewayUrl: "", + authVersion: 2, + accessMode: "future-mode", + }; + const remove = vi.fn(async () => undefined); + const set = vi.fn(async (values: Record) => { + Object.assign(stored, values); + }); + const config = await createPairingConfigStore({ get: async () => stored, set, remove }).read(); + expect(config).toMatchObject({ accessMode: "selected", relayUrl: stored.relayUrl }); + expect(set).toHaveBeenCalledWith({ accessMode: "selected" }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("clears the access mode when unpairing", async () => { + const remove = vi.fn(async () => undefined); + const store = createPairingConfigStore({ + get: async () => ({}), + set: async () => undefined, + remove, + }); + await store.clear(); + expect(remove).toHaveBeenCalledWith([ + "relayUrl", + "gatewayUrl", + "token", + "authVersion", + "accessMode", + "pairingStatus", + ]); + }); + it("rejects and clears an unsupported stored auth version", async () => { const remove = vi.fn(async () => undefined); const config = await createPairingConfigStore({ diff --git a/extensions/browser/chrome-extension/modules/relay-tab-groups.js b/extensions/browser/chrome-extension/modules/relay-tab-groups.js index c10e2e4baf8c..184e9e3935ac 100644 --- a/extensions/browser/chrome-extension/modules/relay-tab-groups.js +++ b/extensions/browser/chrome-extension/modules/relay-tab-groups.js @@ -8,17 +8,7 @@ export async function findOpenClawGroups() { } } -export async function listSharedTabs() { - const groups = await findOpenClawGroups(); - const tabs = []; - for (const group of groups) { - const groupTabs = await chrome.tabs.query({ groupId: group.id }); - tabs.push(...groupTabs); - } - return tabs.filter((tab) => typeof tab.id === "number"); -} - -export async function isOpenClawGroupId(groupId) { +async function isOpenClawGroupId(groupId) { if (!Number.isInteger(groupId) || groupId < 0) { return false; } @@ -30,10 +20,6 @@ export async function isOpenClawGroupId(groupId) { } } -export async function requireSharedTab(tabId) { - const tab = await chrome.tabs.get(tabId); - if (!(await isOpenClawGroupId(tab.groupId))) { - throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`); - } - return tab; +export async function isTabSelected(tab) { + return await isOpenClawGroupId(tab?.groupId); } diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.d.ts b/extensions/browser/chrome-extension/modules/tab-access-events.d.ts new file mode 100644 index 000000000000..7dc1e26411ba --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access-events.d.ts @@ -0,0 +1,59 @@ +import type { TabAccessEpoch, TabAccessMode } from "./tab-access.js"; + +type ChromeEvent = { + addListener(listener: Listener): void; +}; + +export type TabAccessEventsChromeApi = { + debugger: { + onEvent: ChromeEvent< + (source: { tabId?: number; sessionId?: string }, method: string, params: unknown) => void + >; + onDetach: ChromeEvent<(source: { tabId?: number }, reason: string) => void>; + }; + tabs: { + onRemoved: ChromeEvent<(tabId: number) => void>; + onReplaced: ChromeEvent<(addedTabId: number, removedTabId: number) => void>; + onUpdated: ChromeEvent<(tabId: number, changeInfo: { groupId?: number; url?: string }) => void>; + }; + tabGroups: { + onUpdated: ChromeEvent<() => void>; + onRemoved: ChromeEvent<() => void>; + }; +}; + +export type TabAccessEventPolicy = { + readonly mode: TabAccessMode; + beginRevocation(tabId: number): symbol; + endRevocation(token: symbol): void; + capture(tabId: number): TabAccessEpoch; + epochIsCurrent(tabId: number, epoch: TabAccessEpoch): boolean; + invalidateTab(tabId: number): void; + invalidateAll(): void; + inspectTab(tabId: number, epoch: TabAccessEpoch): Promise<{ accessible: boolean }>; + listAccessibleTabs(): Promise>; + forgetTab(tabId: number): Promise; + replaceTab(addedTabId: number, removedTabId: number): Promise; +}; + +export type TabAccessEventCopilot = { + onConsentChanged(tabId?: number, options?: { revoked?: boolean }): void | Promise; + onTabRemoved(tabId: number): void | Promise; +}; + +export function registerTabAccessEvents(options: { + chromeApi?: TabAccessEventsChromeApi; + accessReady: Promise; + policy: TabAccessEventPolicy; + attachedTabs: Set; + attachedAccessEpochs: Map; + copilotDeniedTabs: Set; + attachingTabs: Map>; + getCopilot(): TabAccessEventCopilot; + send(message: Record): void; + scheduleTabsSync(): void; + detachDebugger(tabId: number): Promise; + pauseTab(tabId: number): void | Promise; + removeTabFromOpenClawGroup(tabId: number): void | Promise; + runAccessMutation(task: () => void | Promise): Promise; +}): void; diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.js b/extensions/browser/chrome-extension/modules/tab-access-events.js new file mode 100644 index 000000000000..d923ea9c14b4 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access-events.js @@ -0,0 +1,213 @@ +import { ACCESS_MODE_ALL, ACCESS_MODE_SELECTED } from "./relay-core.js"; + +/** Register Chrome lifecycle events that can grant, revoke, or project tab access. */ +export function registerTabAccessEvents({ + chromeApi = chrome, + accessReady, + policy, + attachedTabs, + attachedAccessEpochs, + copilotDeniedTabs, + attachingTabs, + getCopilot, + send, + scheduleTabsSync, + detachDebugger, + pauseTab, + removeTabFromOpenClawGroup, + runAccessMutation, +}) { + let groupEventRevision = 0; + + chromeApi.debugger.onEvent.addListener((source, method, params) => { + if (typeof source.tabId !== "number") { + return; + } + const accessEpoch = attachedAccessEpochs.get(source.tabId); + if (!accessEpoch || !policy.epochIsCurrent(source.tabId, accessEpoch)) { + return; + } + send({ + type: "cdpEvent", + tabId: source.tabId, + ...(source.sessionId ? { sessionId: source.sessionId } : {}), + method, + params, + }); + }); + + chromeApi.debugger.onDetach.addListener((source, reason) => { + if (typeof source.tabId !== "number") { + return; + } + attachedTabs.delete(source.tabId); + attachedAccessEpochs.delete(source.tabId); + send({ type: "detached", tabId: source.tabId, reason }); + if (reason !== "canceled_by_user") { + return; + } + const revocation = policy.beginRevocation(source.tabId); + void runAccessMutation(async () => { + try { + await accessReady; + if (policy.mode === ACCESS_MODE_ALL) { + await pauseTab(source.tabId); + } else { + policy.invalidateTab(source.tabId); + await removeTabFromOpenClawGroup(source.tabId); + scheduleTabsSync(); + await getCopilot()?.onConsentChanged(source.tabId, { revoked: true }); + } + } finally { + policy.endRevocation(revocation); + } + }).catch(() => undefined); + }); + + chromeApi.tabs.onRemoved.addListener((tabId) => { + void (async () => { + await accessReady; + policy.invalidateTab(tabId); + attachedTabs.delete(tabId); + attachedAccessEpochs.delete(tabId); + copilotDeniedTabs.delete(tabId); + scheduleTabsSync(); + await policy.forgetTab(tabId).catch(() => undefined); + await getCopilot().onTabRemoved(tabId); + })(); + }); + + chromeApi.tabs.onReplaced.addListener((addedTabId, removedTabId) => { + const revocation = policy.beginRevocation(addedTabId); + policy.invalidateTab(removedTabId); + attachedTabs.delete(removedTabId); + attachedAccessEpochs.delete(removedTabId); + copilotDeniedTabs.delete(removedTabId); + scheduleTabsSync(); + void (async () => { + try { + await accessReady; + const pauseTransferred = await policy.replaceTab(addedTabId, removedTabId); + await Promise.allSettled([attachingTabs.get(removedTabId), attachingTabs.get(addedTabId)]); + await Promise.allSettled([detachDebugger(removedTabId), detachDebugger(addedTabId)]); + await getCopilot().onTabRemoved(removedTabId); + if (pauseTransferred) { + await getCopilot().onConsentChanged(addedTabId, { revoked: true }); + } + } finally { + policy.endRevocation(revocation); + scheduleTabsSync(); + } + })().catch(() => undefined); + }); + + chromeApi.tabs.onUpdated.addListener((tabId, changeInfo) => { + scheduleTabsSync(); + if ( + typeof changeInfo.url === "string" || + (policy.mode === ACCESS_MODE_SELECTED && typeof changeInfo.groupId === "number") + ) { + // Security contract: every URL change retires synchronous CDP authority. + // Pre-proof events intentionally drop; replay could cross a restricted destination. + policy.invalidateTab(tabId); + } + const eventEpoch = policy.capture(tabId); + void (async () => { + await accessReady; + const eventIsCurrent = () => policy.epochIsCurrent(tabId, eventEpoch); + if (!eventIsCurrent()) { + return; + } + const state = await policy.inspectTab(tabId, eventEpoch); + if (!eventIsCurrent()) { + return; + } + if (!state.accessible) { + await Promise.allSettled([attachingTabs.get(tabId)]); + if (!eventIsCurrent()) { + return; + } + await detachDebugger(tabId); + if (!eventIsCurrent()) { + return; + } + await getCopilot().onConsentChanged(tabId, { revoked: true }); + return; + } + if (attachedTabs.has(tabId) && attachedAccessEpochs.has(tabId)) { + attachedAccessEpochs.set(tabId, eventEpoch); + } + await getCopilot().onConsentChanged(tabId, { revoked: false }); + })(); + }); + + const onGroupChanged = () => { + const eventRevision = ++groupEventRevision; + scheduleTabsSync(); + if (policy.mode !== ACCESS_MODE_SELECTED) { + return; + } + // Group title/removal changes mutate the selected-mode ACL. Retire every + // attachment epoch synchronously before any readiness or Chrome lookup. + policy.invalidateAll(); + void accessReady.then(async () => { + if (eventRevision !== groupEventRevision || policy.mode !== ACCESS_MODE_SELECTED) { + return; + } + const epochs = new Map( + [...attachedAccessEpochs.keys()] + .filter((tabId) => attachedTabs.has(tabId)) + .map((tabId) => [tabId, policy.capture(tabId)]), + ); + await Promise.allSettled(attachingTabs.values()); + if (eventRevision !== groupEventRevision) { + return; + } + const selected = new Set((await policy.listAccessibleTabs()).map((tab) => tab.id)); + if (eventRevision !== groupEventRevision) { + return; + } + await Promise.allSettled( + [...attachedTabs] + .filter((tabId) => !selected.has(tabId)) + .map((tabId) => detachDebugger(tabId)), + ); + if (eventRevision !== groupEventRevision) { + return; + } + let newerTabEventOwnsAccess = false; + for (const [tabId, epoch] of epochs) { + if (!selected.has(tabId) || !attachedTabs.has(tabId)) { + continue; + } + const state = await policy.inspectTab(tabId, epoch); + if (eventRevision !== groupEventRevision) { + return; + } + if (!policy.epochIsCurrent(tabId, epoch)) { + // A newer tab event owns this attachment's revision. + newerTabEventOwnsAccess = true; + continue; + } + if (state.accessible) { + attachedAccessEpochs.set(tabId, epoch); + } else { + await detachDebugger(tabId); + if (eventRevision !== groupEventRevision) { + return; + } + } + } + if (eventRevision !== groupEventRevision) { + return; + } + if (newerTabEventOwnsAccess) { + onGroupChanged(); + return; + } + await getCopilot().onConsentChanged(); + }); + }; + chromeApi.tabGroups.onUpdated.addListener(onGroupChanged); + chromeApi.tabGroups.onRemoved.addListener(onGroupChanged); +} diff --git a/extensions/browser/chrome-extension/modules/tab-access-events.test.ts b/extensions/browser/chrome-extension/modules/tab-access-events.test.ts new file mode 100644 index 000000000000..0f95f0a7bfb5 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access-events.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerTabAccessEvents } from "./tab-access-events.js"; + +function deferred() { + let resolve = (_value: T) => {}; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createHarness( + mode: "all" | "selected" = "selected", + accessReady: Promise = Promise.resolve(), +) { + let debuggerEventListener: + | ((source: { tabId?: number }, method: string, params: unknown) => void) + | undefined; + let debuggerDetachListener: ((source: { tabId?: number }, reason: string) => void) | undefined; + let tabsUpdatedListener: + | ((tabId: number, changeInfo: { groupId?: number; url?: string }) => void) + | undefined; + let tabsReplacedListener: ((addedTabId: number, removedTabId: number) => void) | undefined; + let groupUpdatedListener: (() => void) | undefined; + let revision = 0; + let accessible = true; + const attachedTabs = new Set([7]); + const attachedAccessEpochs = new Map([[7, { revision: 0, tabRevision: 0 }]]); + const attachingTabs = new Map>(); + const send = vi.fn(); + const onConsentChanged = vi.fn(async () => undefined); + const onTabRemoved = vi.fn(async () => undefined); + const policy = { + mode, + beginRevocation: vi.fn(() => Symbol("revocation")), + endRevocation: vi.fn(), + capture: vi.fn(() => ({ revision, tabRevision: 0 })), + epochIsCurrent: vi.fn( + (_tabId: number, epoch: { revision: number }) => epoch.revision === revision, + ), + invalidateTab: vi.fn(() => { + revision += 1; + }), + invalidateAll: vi.fn(() => { + revision += 1; + }), + inspectTab: vi.fn(async (_tabId: number, epoch: { revision: number }) => ({ + accessible: accessible && epoch.revision === revision, + })), + listAccessibleTabs: vi.fn(async () => (accessible ? [{ id: 7 }] : [])), + forgetTab: vi.fn(async () => undefined), + replaceTab: vi.fn(async () => false), + }; + const detachDebugger = vi.fn(async (tabId: number) => { + attachedTabs.delete(tabId); + attachedAccessEpochs.delete(tabId); + }); + const pauseTab = vi.fn(async () => undefined); + const removeTabFromOpenClawGroup = vi.fn(async () => undefined); + const chromeApi = { + debugger: { + onEvent: { + addListener: (listener: typeof debuggerEventListener) => { + debuggerEventListener = listener; + }, + }, + onDetach: { + addListener: (listener: typeof debuggerDetachListener) => { + debuggerDetachListener = listener; + }, + }, + }, + tabs: { + onRemoved: { addListener: vi.fn() }, + onReplaced: { + addListener: (listener: typeof tabsReplacedListener) => { + tabsReplacedListener = listener; + }, + }, + onUpdated: { + addListener: (listener: typeof tabsUpdatedListener) => { + tabsUpdatedListener = listener; + }, + }, + }, + tabGroups: { + onUpdated: { + addListener: (listener: () => void) => { + groupUpdatedListener = listener; + }, + }, + onRemoved: { addListener: vi.fn() }, + }, + }; + + registerTabAccessEvents({ + chromeApi, + accessReady, + policy, + attachedTabs, + attachedAccessEpochs, + copilotDeniedTabs: new Set(), + attachingTabs, + getCopilot: () => ({ onConsentChanged, onTabRemoved }), + send, + scheduleTabsSync: vi.fn(), + detachDebugger, + pauseTab, + removeTabFromOpenClawGroup, + runAccessMutation: vi.fn(async (task) => await task()), + }); + if ( + !debuggerEventListener || + !debuggerDetachListener || + !tabsUpdatedListener || + !tabsReplacedListener || + !groupUpdatedListener + ) { + throw new Error("expected tab access event listeners"); + } + return { + attachedAccessEpochs, + attachingTabs, + detachDebugger, + debuggerDetachListener, + debuggerEventListener, + groupUpdatedListener, + onConsentChanged, + onTabRemoved, + policy, + pauseTab, + removeTabFromOpenClawGroup, + send, + setAccessible: (next: boolean) => { + accessible = next; + }, + tabsUpdatedListener, + tabsReplacedListener, + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("tab access event epochs", () => { + it("waits for stored access mode before handling Chrome's cancel revocation", async () => { + const ready = deferred(); + const harness = createHarness("selected", ready.promise); + + harness.debuggerDetachListener({ tabId: 7 }, "canceled_by_user"); + expect(harness.policy.beginRevocation).toHaveBeenCalledWith(7); + expect(harness.pauseTab).not.toHaveBeenCalled(); + expect(harness.removeTabFromOpenClawGroup).not.toHaveBeenCalled(); + + harness.policy.mode = "all"; + ready.resolve(); + await vi.waitFor(() => expect(harness.pauseTab).toHaveBeenCalledWith(7)); + + expect(harness.removeTabFromOpenClawGroup).not.toHaveBeenCalled(); + expect(harness.policy.endRevocation).toHaveBeenCalledOnce(); + }); + + it.each([ + { + label: "all-mode URL", + mode: "all", + firstChange: { url: "https://one.example" }, + secondChange: { url: "https://two.example" }, + }, + { + label: "selected-mode URL", + mode: "selected", + firstChange: { url: "https://one.example" }, + secondChange: { url: "https://two.example" }, + }, + { + label: "selected-mode group", + mode: "selected", + firstChange: { groupId: 7 }, + secondChange: { groupId: 7 }, + }, + ] as const)( + "ignores a stale $label revocation after a newer eligible update", + async ({ mode, firstChange, secondChange }) => { + const harness = createHarness(mode); + const firstInspection = deferred<{ accessible: boolean }>(); + let firstInspectionResumed = false; + harness.policy.inspectTab + .mockImplementationOnce(async () => { + const state = await firstInspection.promise; + firstInspectionResumed = true; + return state; + }) + .mockResolvedValueOnce({ accessible: true }); + + harness.tabsUpdatedListener(7, firstChange); + await vi.waitFor(() => expect(harness.policy.inspectTab).toHaveBeenCalledTimes(1)); + harness.tabsUpdatedListener(7, secondChange); + await vi.waitFor(() => { + expect(harness.attachedAccessEpochs.get(7)).toEqual({ revision: 2, tabRevision: 0 }); + }); + + firstInspection.resolve({ accessible: false }); + await vi.waitFor(() => expect(firstInspectionResumed).toBe(true)); + await Promise.resolve(); + + expect(harness.detachDebugger).not.toHaveBeenCalled(); + expect(harness.onConsentChanged).not.toHaveBeenCalledWith(7, { revoked: true }); + expect(harness.onConsentChanged).toHaveBeenCalledWith(7, { revoked: false }); + harness.debuggerEventListener({ tabId: 7 }, "Runtime.consoleAPICalled", {}); + expect(harness.send).toHaveBeenCalledWith( + expect.objectContaining({ type: "cdpEvent", tabId: 7 }), + ); + }, + ); + + it.each([ + { + label: "all-mode URL", + mode: "all", + firstChange: { url: "https://one.example" }, + secondChange: { url: "chrome://settings" }, + }, + { + label: "selected-mode URL", + mode: "selected", + firstChange: { url: "https://one.example" }, + secondChange: { url: "chrome://settings" }, + }, + { + label: "selected-mode group", + mode: "selected", + firstChange: { groupId: 7 }, + secondChange: { groupId: -1 }, + }, + ] as const)( + "lets the current restricted $label update revoke exactly once when an older update resumes", + async ({ mode, firstChange, secondChange }) => { + const harness = createHarness(mode); + const firstInspection = deferred<{ accessible: boolean }>(); + let firstInspectionResumed = false; + harness.policy.inspectTab + .mockImplementationOnce(async () => { + const state = await firstInspection.promise; + firstInspectionResumed = true; + return state; + }) + .mockResolvedValueOnce({ accessible: false }); + + harness.tabsUpdatedListener(7, firstChange); + await vi.waitFor(() => expect(harness.policy.inspectTab).toHaveBeenCalledTimes(1)); + harness.tabsUpdatedListener(7, secondChange); + await vi.waitFor(() => { + expect(harness.detachDebugger).toHaveBeenCalledTimes(1); + expect(harness.onConsentChanged).toHaveBeenCalledWith(7, { revoked: true }); + }); + + firstInspection.resolve({ accessible: false }); + await vi.waitFor(() => expect(firstInspectionResumed).toBe(true)); + await Promise.resolve(); + + expect(harness.detachDebugger).toHaveBeenCalledTimes(1); + expect(harness.onConsentChanged).toHaveBeenCalledTimes(1); + }, + ); + + it("cleans up both tab identities after Chrome replaces a paused tab", async () => { + const harness = createHarness("all"); + harness.policy.replaceTab.mockResolvedValueOnce(true); + + harness.tabsReplacedListener(8, 7); + + await vi.waitFor(() => { + expect(harness.policy.replaceTab).toHaveBeenCalledWith(8, 7); + expect(harness.detachDebugger).toHaveBeenCalledWith(7); + expect(harness.detachDebugger).toHaveBeenCalledWith(8); + expect(harness.onTabRemoved).toHaveBeenCalledWith(7); + expect(harness.onConsentChanged).toHaveBeenCalledWith(8, { revoked: true }); + }); + }); + + it("lets a newer eligible tab event own stale group-wide reconciliation", async () => { + const harness = createHarness("selected"); + const groupInspection = deferred<{ accessible: boolean }>(); + harness.policy.inspectTab + .mockImplementationOnce(async () => await groupInspection.promise) + .mockResolvedValueOnce({ accessible: true }); + + harness.groupUpdatedListener(); + harness.debuggerEventListener({ tabId: 7 }, "Page.frameNavigated", {}); + expect(harness.send).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(harness.policy.inspectTab).toHaveBeenCalledTimes(1)); + + harness.tabsUpdatedListener(7, { url: "https://two.example" }); + await vi.waitFor(() => { + expect(harness.attachedAccessEpochs.get(7)).toEqual({ revision: 2, tabRevision: 0 }); + }); + groupInspection.resolve({ accessible: false }); + await Promise.resolve(); + + expect(harness.detachDebugger).not.toHaveBeenCalled(); + }); + + it("does not refresh epochs from a stale group-wide access snapshot", async () => { + vi.stubGlobal("chrome", { tabGroups: { get: vi.fn() } }); + const harness = createHarness(); + const firstList = deferred>(); + harness.policy.listAccessibleTabs + .mockImplementationOnce(async () => await firstList.promise) + .mockResolvedValueOnce([{ id: 7 }]); + + harness.groupUpdatedListener(); + await vi.waitFor(() => expect(harness.policy.listAccessibleTabs).toHaveBeenCalledTimes(1)); + harness.groupUpdatedListener(); + await vi.waitFor(() => expect(harness.onConsentChanged).toHaveBeenCalledTimes(1)); + harness.setAccessible(false); + harness.policy.invalidateTab(); + firstList.resolve([{ id: 7 }]); + await Promise.resolve(); + + harness.debuggerEventListener({ tabId: 7 }, "Network.requestWillBeSent", {}); + expect(harness.send).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/tab-access.d.ts b/extensions/browser/chrome-extension/modules/tab-access.d.ts new file mode 100644 index 000000000000..a91221351a5a --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access.d.ts @@ -0,0 +1,70 @@ +import type { + AccessibleBrowserTabSnapshot, + BrowserTabSnapshot, + TabEligibilityReason, +} from "./tab-eligibility.js"; + +export type TabAccessMode = "all" | "selected"; + +export type TabAccessEpoch = Readonly<{ + revision: number; + tabRevision: number; +}>; + +export type TabAccessReason = TabEligibilityReason | "revoked" | "paused" | "not-selected" | null; + +export type TabAccessState = { + accessible: boolean; + eligible: boolean; + denied: boolean; + reason: TabAccessReason; + tab: BrowserTabSnapshot | null; +}; + +export type TabAccessStorageArea = { + get(keys: string[]): Promise>; + set(values: Record): Promise; + remove(keys: string[]): Promise; +}; + +export type TabAccessChromeApi = { + extension?: { + isAllowedFileSchemeAccess?: () => boolean | Promise; + }; + storage: { session: TabAccessStorageArea }; + tabs: { + get(tabId: number): Promise; + query(queryInfo: Record): Promise; + }; +}; + +export type TabAccessPolicy = { + initialize(initialMode?: TabAccessMode, initialEnabled?: boolean): Promise; + readonly mode: TabAccessMode; + setMode(nextMode: TabAccessMode): TabAccessMode; + setEnabled(nextEnabled: boolean): void; + beginTransition(): void; + endTransition(): void; + beginRevocation(tabId: number): symbol; + endRevocation(token: symbol): void; + capture(tabId: number): TabAccessEpoch; + epochIsCurrent(tabId: number, epoch: TabAccessEpoch): boolean; + invalidateTab(tabId: number): void; + invalidateAll(): void; + inspectTab(tabId: number, epoch?: TabAccessEpoch): Promise; + requireTab(tabId: number, epoch?: TabAccessEpoch): Promise; + listAccessibleTabs(options?: { + allowDuringTransition?: boolean; + }): Promise; + pause(tabId: number): Promise; + allow(tabId: number): Promise; + forgetTab(tabId: number): Promise; + replaceTab(addedTabId: number, removedTabId: number): Promise; + clearDenied(): Promise; + isDenied(tabId: number): boolean; +}; + +export function createTabAccessPolicy(options: { + chromeApi?: TabAccessChromeApi; + isSelectedTab(tab: BrowserTabSnapshot): boolean | Promise; +}): TabAccessPolicy; diff --git a/extensions/browser/chrome-extension/modules/tab-access.js b/extensions/browser/chrome-extension/modules/tab-access.js new file mode 100644 index 000000000000..f640bc89bb1a --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access.js @@ -0,0 +1,404 @@ +import { ACCESS_MODE_ALL, ACCESS_MODE_SELECTED } from "./relay-core.js"; +import { effectiveTabUrl, tabEligibility } from "./tab-eligibility.js"; + +const DENIED_TAB_IDS_KEY = "deniedTabIdsV1"; + +function isValidTabId(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +/** + * Owns access mode, durable browser-session pauses, and revocation epochs. + * Every authority-bearing caller captures an epoch and checks through here. + */ +export function createTabAccessPolicy({ chromeApi = chrome, isSelectedTab }) { + const deniedTabIds = new Set(); + const tabRevisions = new Map(); + let mode = ACCESS_MODE_SELECTED; + let enabled = false; + let transitioning = false; + // Single-tab mutations fail closed without retiring unrelated attachment epochs. + const revocationBarriers = new Map(); + let revision = 0; + let discoveryRevision = 0; + let initialized = null; + let storageChain = Promise.resolve(); + + const mutateStorage = (task) => { + const pending = storageChain.then(task, task); + storageChain = pending.catch(() => undefined); + return pending; + }; + + const persistedIds = () => [...deniedTabIds].toSorted((left, right) => left - right); + + async function fileAccessAllowed() { + try { + return (await chromeApi.extension?.isAllowedFileSchemeAccess?.()) === true; + } catch { + return false; + } + } + + async function tabIsEligible(tab) { + return tabEligibility(tab, { + fileAccessAllowed: + tab?.url?.startsWith("file:") || tab?.pendingUrl?.startsWith("file:") + ? await fileAccessAllowed() + : true, + }).eligible; + } + + async function persistDeniedIds() { + const ids = persistedIds(); + if (ids.length === 0) { + await chromeApi.storage.session.remove([DENIED_TAB_IDS_KEY]); + return; + } + await chromeApi.storage.session.set({ [DENIED_TAB_IDS_KEY]: ids }); + } + + function invalidateTab(tabId) { + tabRevisions.set(tabId, (tabRevisions.get(tabId) ?? 0) + 1); + discoveryRevision += 1; + } + + function capture(tabId) { + return { revision, tabRevision: tabRevisions.get(tabId) ?? 0 }; + } + + function tabIsRevoking(tabId) { + for (const revokedTabId of revocationBarriers.values()) { + if (revokedTabId === tabId) { + return true; + } + } + return false; + } + + function epochIsCurrent(tabId, epoch) { + return ( + enabled && + !transitioning && + !tabIsRevoking(tabId) && + epoch.revision === revision && + epoch.tabRevision === (tabRevisions.get(tabId) ?? 0) + ); + } + + async function initialize(initialMode = ACCESS_MODE_SELECTED, initialEnabled = false) { + if (initialized) { + return await initialized; + } + mode = initialMode === ACCESS_MODE_ALL ? ACCESS_MODE_ALL : ACCESS_MODE_SELECTED; + enabled = initialEnabled; + initialized = (async () => { + const [stored, tabs] = await Promise.all([ + chromeApi.storage.session.get([DENIED_TAB_IDS_KEY]), + chromeApi.tabs.query({}), + ]); + const existingIds = new Set(); + for (const tab of tabs) { + if (isValidTabId(tab.id)) { + existingIds.add(tab.id); + } + } + const raw = stored[DENIED_TAB_IDS_KEY]; + if (Array.isArray(raw)) { + for (const tabId of raw) { + if (isValidTabId(tabId) && existingIds.has(tabId)) { + deniedTabIds.add(tabId); + } + } + } + const normalized = persistedIds(); + if ( + !Array.isArray(raw) || + raw.length !== normalized.length || + raw.some((tabId, index) => tabId !== normalized[index]) + ) { + await persistDeniedIds(); + } + })(); + return await initialized; + } + + function setMode(nextMode) { + const normalized = nextMode === ACCESS_MODE_ALL ? ACCESS_MODE_ALL : ACCESS_MODE_SELECTED; + if (normalized !== mode) { + mode = normalized; + revision += 1; + discoveryRevision += 1; + } + return mode; + } + + function setEnabled(nextEnabled) { + const normalized = nextEnabled === true; + if (normalized !== enabled) { + enabled = normalized; + revision += 1; + discoveryRevision += 1; + } + } + + function beginTransition() { + if (!transitioning) { + transitioning = true; + revision += 1; + discoveryRevision += 1; + } + } + + function endTransition() { + if (transitioning) { + transitioning = false; + revision += 1; + discoveryRevision += 1; + } + } + + function beginRevocation(tabId) { + const token = Symbol("tab-access-revocation"); + revocationBarriers.set(token, tabId); + invalidateTab(tabId); + return token; + } + + function endRevocation(token) { + const tabId = revocationBarriers.get(token); + if (tabId === undefined) { + return; + } + revocationBarriers.delete(token); + // An epoch captured behind the barrier must not become valid when it opens. + invalidateTab(tabId); + } + + async function inspectTab(tabId, epoch = capture(tabId)) { + if (!isValidTabId(tabId)) { + return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; + } + if (!enabled || transitioning || tabIsRevoking(tabId)) { + return { accessible: false, eligible: false, denied: false, reason: "revoked", tab: null }; + } + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied: false, reason: "revoked", tab: null }; + } + let tab; + try { + tab = await chromeApi.tabs.get(tabId); + } catch { + return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; + } + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied: false, reason: "revoked", tab }; + } + let allowedFileAccess = true; + if (tab?.url?.startsWith("file:") || tab?.pendingUrl?.startsWith("file:")) { + allowedFileAccess = await fileAccessAllowed(); + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied: false, reason: "revoked", tab }; + } + } + const eligibility = tabEligibility(tab, { + fileAccessAllowed: allowedFileAccess, + }); + if (!eligibility.eligible) { + return { accessible: false, eligible: false, denied: false, reason: eligibility.reason, tab }; + } + const denied = mode === ACCESS_MODE_ALL && deniedTabIds.has(tabId); + const selected = mode === ACCESS_MODE_SELECTED ? await isSelectedTab(tab) : true; + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: true, denied, reason: "revoked", tab }; + } + if (mode === ACCESS_MODE_SELECTED && selected) { + let current; + try { + current = await chromeApi.tabs.get(tabId); + } catch { + return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; + } + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; + } + const currentEligible = await tabIsEligible(current); + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; + } + const currentSelected = await isSelectedTab(current); + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; + } + if ( + current.groupId !== tab.groupId || + effectiveTabUrl(current) !== effectiveTabUrl(tab) || + current.incognito !== tab.incognito || + !currentEligible || + !currentSelected + ) { + return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; + } + } + if (!epochIsCurrent(tabId, epoch)) { + return { accessible: false, eligible: true, denied, reason: "revoked", tab }; + } + return { + accessible: !denied && selected, + eligible: true, + denied, + reason: denied ? "paused" : selected ? null : "not-selected", + tab, + }; + } + + async function requireTab(tabId, epoch = capture(tabId)) { + const state = await inspectTab(tabId, epoch); + if (state.accessible) { + return state.tab; + } + if (state.reason === "revoked") { + throw new Error(`tab ${tabId} access was revoked`); + } + if (state.reason === "paused") { + throw new Error(`tab ${tabId} is paused for OpenClaw`); + } + if (state.reason === "not-selected") { + throw new Error(`tab ${tabId} is not in the OpenClaw tab group`); + } + if (state.reason === "incognito") { + throw new Error(`tab ${tabId} is incognito and unavailable to OpenClaw`); + } + throw new Error(`tab ${tabId} is restricted or unavailable to OpenClaw`); + } + + async function listAccessibleTabs({ allowDuringTransition = false } = {}) { + await initialize(mode); + for (;;) { + const listRevision = discoveryRevision; + if (!enabled || (transitioning && !allowDuringTransition)) { + return []; + } + const tabs = await chromeApi.tabs.query({}); + const accessible = []; + for (const tab of tabs) { + if (tabIsRevoking(tab.id)) { + continue; + } + if (!(await tabIsEligible(tab))) { + continue; + } + if (mode === ACCESS_MODE_ALL) { + if (!deniedTabIds.has(tab.id)) { + accessible.push(tab); + } + } else if (await isSelectedTab(tab)) { + accessible.push(tab); + } + } + if (listRevision === discoveryRevision) { + return accessible; + } + } + } + + async function pause(tabId) { + // Revoke synchronously: Chrome lookup and session persistence may yield, + // but newly arriving authority must already fail closed. + invalidateTab(tabId); + deniedTabIds.add(tabId); + let tab; + try { + tab = await chromeApi.tabs.get(tabId); + } catch (error) { + deniedTabIds.delete(tabId); + invalidateTab(tabId); + throw error; + } + if (!(await tabIsEligible(tab))) { + deniedTabIds.delete(tabId); + invalidateTab(tabId); + throw new Error(`tab ${tabId} is restricted or unavailable to OpenClaw`); + } + await mutateStorage(persistDeniedIds); + } + + async function allow(tabId) { + if (!deniedTabIds.has(tabId)) { + return; + } + invalidateTab(tabId); + await mutateStorage(async () => { + deniedTabIds.delete(tabId); + try { + await persistDeniedIds(); + } catch (error) { + deniedTabIds.add(tabId); + throw error; + } + }); + invalidateTab(tabId); + } + + async function forgetTab(tabId) { + invalidateTab(tabId); + if (!deniedTabIds.delete(tabId)) { + return; + } + await mutateStorage(persistDeniedIds); + } + + async function replaceTab(addedTabId, removedTabId) { + invalidateTab(removedTabId); + invalidateTab(addedTabId); + if (!deniedTabIds.delete(removedTabId)) { + return false; + } + deniedTabIds.add(addedTabId); + try { + await mutateStorage(persistDeniedIds); + } catch (error) { + // Keep both identities denied in memory when persistence fails; widening + // access is worse than retaining a harmless stale ID until restart. + deniedTabIds.add(removedTabId); + throw error; + } + return true; + } + + async function clearDenied() { + revision += 1; + discoveryRevision += 1; + deniedTabIds.clear(); + await mutateStorage(persistDeniedIds); + } + + return { + initialize, + get mode() { + return mode; + }, + setMode, + setEnabled, + beginTransition, + endTransition, + beginRevocation, + endRevocation, + capture, + epochIsCurrent, + invalidateTab, + invalidateAll: () => { + revision += 1; + discoveryRevision += 1; + }, + inspectTab, + requireTab, + listAccessibleTabs, + pause, + allow, + forgetTab, + replaceTab, + clearDenied, + isDenied: (tabId) => deniedTabIds.has(tabId), + }; +} diff --git a/extensions/browser/chrome-extension/modules/tab-access.test.ts b/extensions/browser/chrome-extension/modules/tab-access.test.ts new file mode 100644 index 000000000000..8c125ff7ed92 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-access.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTabAccessPolicy } from "./tab-access.js"; +import { tabEligibility } from "./tab-eligibility.js"; + +function storageArea(seed: Record = {}) { + const values = { ...seed }; + return { + values, + get: vi.fn(async (keys: string[]) => + Object.fromEntries( + keys.filter((key) => Object.hasOwn(values, key)).map((key) => [key, values[key]]), + ), + ), + set: vi.fn(async (next: Record) => { + Object.assign(values, next); + }), + remove: vi.fn(async (keys: string[]) => { + for (const key of keys) { + delete values[key]; + } + }), + }; +} + +function createHarness({ + tabs, + denied = [], +}: { + tabs: Array<{ + id: number; + url?: string; + pendingUrl?: string; + groupId?: number; + incognito?: boolean; + [key: string]: unknown; + }>; + denied?: unknown[]; +}) { + const session = storageArea({ deniedTabIdsV1: denied }); + const current = new Map(tabs.map((tab) => [tab.id, tab])); + const chromeApi = { + storage: { session }, + tabs: { + get: vi.fn(async (tabId: number) => { + const tab = current.get(tabId); + if (!tab) { + throw new Error(`No tab with id: ${tabId}`); + } + return tab; + }), + query: vi.fn(async () => [...current.values()]), + }, + }; + const policy = createTabAccessPolicy({ + chromeApi, + isSelectedTab: async (tab) => tab.groupId === 7, + }); + return { chromeApi, current, policy, session }; +} + +describe("tab eligibility", () => { + it.each([ + "http://example.com", + "https://example.com/path", + "data:text/html,fixture", + "blob:https://example.com/1234", + "file:///tmp/openclaw-fixture.html", + ])("allows ordinary document URL %s", (url) => { + expect(tabEligibility({ id: 1, url, incognito: false }).eligible).toBe(true); + }); + + it("rejects file documents when Chrome has not granted file URL access", () => { + expect( + tabEligibility({ id: 1, url: "file:///tmp/private.html" }, { fileAccessAllowed: false }), + ).toEqual({ + eligible: false, + reason: "restricted", + }); + }); + + it.each([ + "chrome://settings", + "chrome-extension://abcdefghijklmnop/popup.html", + "devtools://devtools/bundled/inspector.html", + "view-source:https://example.com", + "about:settings", + "about:blank", + "about:blank#ready", + "blob:chrome-extension://abcdefghijklmnop/private", + "blob:null/private", + ])("rejects restricted URL %s", (url) => { + expect(tabEligibility({ id: 1, url })).toEqual({ eligible: false, reason: "restricted" }); + }); + + it("rejects missing ids, missing URLs, malformed URLs, and incognito tabs", () => { + expect(tabEligibility({ url: "https://example.com" })).toEqual({ + eligible: false, + reason: "missing", + }); + expect(tabEligibility({ id: 1 })).toEqual({ eligible: false, reason: "missing" }); + expect(tabEligibility({ id: 1, url: "not a URL" })).toEqual({ + eligible: false, + reason: "restricted", + }); + expect(tabEligibility({ id: 1, url: "https://example.com", incognito: true })).toEqual({ + eligible: false, + reason: "incognito", + }); + }); + + it("treats a pending destination as an additional eligibility restriction", () => { + expect( + tabEligibility({ + id: 1, + url: "https://example.com/ordinary", + pendingUrl: "chrome://settings", + }), + ).toEqual({ eligible: false, reason: "restricted" }); + expect(tabEligibility({ id: 1, pendingUrl: "https://example.com/pending" })).toEqual({ + eligible: true, + reason: null, + }); + expect( + tabEligibility({ + id: 1, + url: "chrome://settings", + pendingUrl: "https://example.com/pending", + }), + ).toEqual({ eligible: false, reason: "restricted" }); + expect( + tabEligibility({ + id: 1, + url: "https://example.com/stale", + pendingUrl: "not a URL", + }), + ).toEqual({ eligible: false, reason: "restricted" }); + expect( + tabEligibility( + { + id: 1, + url: "file:///tmp/private.html", + pendingUrl: "https://example.com/pending", + }, + { fileAccessAllowed: false }, + ), + ).toEqual({ eligible: false, reason: "restricted" }); + }); +}); + +describe("tab access policy", () => { + it("allows ungrouped eligible tabs in all mode and only grouped tabs in selected mode", async () => { + const harness = createHarness({ + tabs: [ + { id: 1, url: "https://one.example", groupId: -1 }, + { id: 2, url: "https://two.example", groupId: 7 }, + ], + }); + await harness.policy.initialize("all", true); + await expect(harness.policy.requireTab(1)).resolves.toMatchObject({ id: 1 }); + expect((await harness.policy.listAccessibleTabs()).map((tab) => tab.id)).toEqual([1, 2]); + + harness.policy.setMode("selected"); + await expect(harness.policy.requireTab(1)).rejects.toThrow("not in the OpenClaw tab group"); + await expect(harness.policy.requireTab(2)).resolves.toMatchObject({ id: 2 }); + }); + + it("rejects restricted and incognito tabs in both modes", async () => { + const harness = createHarness({ + tabs: [ + { id: 1, url: "chrome://settings", groupId: 7 }, + { id: 2, url: "https://secret.example", incognito: true, groupId: 7 }, + ], + }); + await harness.policy.initialize("all", true); + await expect(harness.policy.requireTab(1)).rejects.toThrow("restricted or unavailable"); + await expect(harness.policy.requireTab(2)).rejects.toThrow("incognito"); + harness.policy.setMode("selected"); + await expect(harness.policy.requireTab(1)).rejects.toThrow("restricted or unavailable"); + await expect(harness.policy.requireTab(2)).rejects.toThrow("incognito"); + }); + + it("invalidates captured authority across mode and per-tab deny changes", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: -1 }], + }); + await harness.policy.initialize("all", true); + const beforeMode = harness.policy.capture(1); + harness.policy.setMode("selected"); + await expect(harness.policy.requireTab(1, beforeMode)).rejects.toThrow("access was revoked"); + + harness.policy.setMode("all"); + const beforePause = harness.policy.capture(1); + await harness.policy.pause(1); + await expect(harness.policy.requireTab(1, beforePause)).rejects.toThrow("access was revoked"); + await expect(harness.policy.requireTab(1)).rejects.toThrow("paused for OpenClaw"); + }); + + it("blocks new authority before the asynchronous pause lookup completes", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: -1 }], + }); + await harness.policy.initialize("all", true); + let releaseLookup = () => {}; + harness.chromeApi.tabs.get.mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseLookup = () => resolve({ id: 1, url: "https://one.example", groupId: -1 }); + }), + ); + + const pausing = harness.policy.pause(1); + expect(harness.policy.isDenied(1)).toBe(true); + await expect(harness.policy.requireTab(1)).rejects.toThrow("paused for OpenClaw"); + releaseLookup(); + await expect(pausing).resolves.toBeUndefined(); + }); + + it("blocks newly arriving authority throughout a mode transition", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: -1 }], + }); + await harness.policy.initialize("all", true); + harness.policy.beginTransition(); + await expect(harness.policy.requireTab(1)).rejects.toThrow("access was revoked"); + await expect(harness.policy.listAccessibleTabs()).resolves.toEqual([]); + harness.policy.setMode("selected"); + harness.policy.endTransition(); + await expect(harness.policy.requireTab(1)).rejects.toThrow("not in the OpenClaw tab group"); + }); + + it("scopes revocation barriers to one tab while keeping captured authority fail closed", async () => { + const harness = createHarness({ + tabs: [ + { id: 1, url: "https://one.example", groupId: -1 }, + { id: 2, url: "https://two.example", groupId: -1 }, + ], + }); + await harness.policy.initialize("all", true); + const tabOneEpoch = harness.policy.capture(1); + const tabTwoEpoch = harness.policy.capture(2); + expect(harness.policy.epochIsCurrent(1, tabOneEpoch)).toBe(true); + expect(harness.policy.epochIsCurrent(2, tabTwoEpoch)).toBe(true); + + harness.policy.setEnabled(false); + expect(harness.policy.epochIsCurrent(1, harness.policy.capture(1))).toBe(false); + + harness.policy.setEnabled(true); + harness.policy.beginTransition(); + expect(harness.policy.epochIsCurrent(1, harness.policy.capture(1))).toBe(false); + harness.policy.endTransition(); + expect(harness.policy.epochIsCurrent(1, harness.policy.capture(1))).toBe(true); + + const tabTwoRevocationEpoch = harness.policy.capture(2); + const revocation = harness.policy.beginRevocation(1); + const duringRevocation = harness.policy.capture(1); + expect(harness.policy.epochIsCurrent(1, duringRevocation)).toBe(false); + expect(harness.policy.epochIsCurrent(2, tabTwoRevocationEpoch)).toBe(true); + await expect(harness.policy.listAccessibleTabs()).resolves.toEqual([ + expect.objectContaining({ id: 2 }), + ]); + harness.policy.endRevocation(revocation); + expect(harness.policy.epochIsCurrent(1, duringRevocation)).toBe(false); + expect(harness.policy.epochIsCurrent(1, harness.policy.capture(1))).toBe(true); + expect(harness.policy.epochIsCurrent(2, tabTwoRevocationEpoch)).toBe(true); + }); + + it("fails closed when selected-mode membership changes during an async policy check", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: 7 }], + }); + await harness.policy.initialize("selected", true); + harness.chromeApi.tabs.get + .mockResolvedValueOnce({ id: 1, url: "https://one.example", groupId: 7 }) + .mockResolvedValueOnce({ id: 1, url: "https://one.example", groupId: -1 }); + await expect(harness.policy.requireTab(1)).rejects.toThrow("access was revoked"); + }); + + it("revalidates selected group authority when the group title changes in place", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: 7 }], + }); + const isSelectedTab = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const policy = createTabAccessPolicy({ + chromeApi: harness.chromeApi, + isSelectedTab, + }); + await policy.initialize("selected", true); + + await expect(policy.requireTab(1)).rejects.toThrow("access was revoked"); + expect(isSelectedTab).toHaveBeenCalledTimes(2); + }); + + it("rejects an epoch revoked during the final selected-group lookup", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: 7 }], + }); + let releaseFinalLookup = () => {}; + const finalLookup = new Promise((resolve) => { + releaseFinalLookup = () => resolve(true); + }); + const isSelectedTab = vi.fn().mockResolvedValueOnce(true).mockReturnValueOnce(finalLookup); + const policy = createTabAccessPolicy({ + chromeApi: harness.chromeApi, + isSelectedTab, + }); + await policy.initialize("selected", true); + + const requiring = policy.requireTab(1); + await vi.waitFor(() => expect(isSelectedTab).toHaveBeenCalledTimes(2)); + policy.invalidateTab(1); + releaseFinalLookup(); + + await expect(requiring).rejects.toThrow("access was revoked"); + }); + + it("revokes selected authority when a pending navigation appears between reads", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example", groupId: 7 }], + }); + await harness.policy.initialize("selected", true); + harness.chromeApi.tabs.get + .mockResolvedValueOnce({ id: 1, url: "https://one.example", groupId: 7 }) + .mockResolvedValueOnce({ + id: 1, + url: "https://one.example", + pendingUrl: "https://two.example", + groupId: 7, + }); + + await expect(harness.policy.requireTab(1)).rejects.toThrow("access was revoked"); + }); + + it("restores existing-tab session denies across worker instances and prunes closed ids", async () => { + const harness = createHarness({ + tabs: [ + { id: 1, url: "https://one.example" }, + { id: 2, url: "chrome://settings" }, + ], + denied: [1, 1, 2, -1, 999, "1"], + }); + await harness.policy.initialize("all", true); + expect(harness.session.values.deniedTabIdsV1).toEqual([1, 2]); + await expect(harness.policy.requireTab(1)).rejects.toThrow("paused for OpenClaw"); + harness.current.set(2, { id: 2, url: "https://two.example" }); + await expect(harness.policy.requireTab(2)).rejects.toThrow("paused for OpenClaw"); + + const reloaded = createTabAccessPolicy({ + chromeApi: harness.chromeApi, + isSelectedTab: async () => false, + }); + await reloaded.initialize("all", true); + await expect(reloaded.requireTab(1)).rejects.toThrow("paused for OpenClaw"); + await reloaded.allow(1); + await expect(reloaded.requireTab(1)).resolves.toMatchObject({ id: 1 }); + expect(harness.session.values.deniedTabIdsV1).toEqual([2]); + await reloaded.allow(2); + expect(harness.session.values).not.toHaveProperty("deniedTabIdsV1"); + }); + + it("prunes a denied id when its tab closes", async () => { + const harness = createHarness({ + tabs: [{ id: 1, url: "https://one.example" }], + denied: [1], + }); + await harness.policy.initialize("all", true); + harness.current.delete(1); + await harness.policy.forgetTab(1); + expect(harness.session.values).not.toHaveProperty("deniedTabIdsV1"); + }); + + it("moves a pause to Chrome's replacement tab id", async () => { + const harness = createHarness({ + tabs: [ + { id: 1, url: "https://one.example" }, + { id: 2, url: "https://two.example" }, + ], + denied: [1], + }); + await harness.policy.initialize("all", true); + + await expect(harness.policy.replaceTab(2, 1)).resolves.toBe(true); + + expect(harness.policy.isDenied(1)).toBe(false); + expect(harness.policy.isDenied(2)).toBe(true); + expect(harness.session.values.deniedTabIdsV1).toEqual([2]); + await expect(harness.policy.requireTab(2)).rejects.toThrow("paused for OpenClaw"); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/tab-eligibility.d.ts b/extensions/browser/chrome-extension/modules/tab-eligibility.d.ts new file mode 100644 index 000000000000..2e4c5d84378c --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-eligibility.d.ts @@ -0,0 +1,25 @@ +export type BrowserTabSnapshot = { + id?: number; + url?: string; + pendingUrl?: string; + title?: string; + active?: boolean; + incognito?: boolean; + groupId?: number; + windowId?: number; +}; + +export type AccessibleBrowserTabSnapshot = BrowserTabSnapshot & { id: number }; + +export type TabEligibilityReason = "missing" | "incognito" | "restricted"; + +export type TabEligibilityResult = + | { eligible: true; reason: null } + | { eligible: false; reason: TabEligibilityReason }; + +export function effectiveTabUrl(tab: BrowserTabSnapshot | null | undefined): string | undefined; + +export function tabEligibility( + tab: BrowserTabSnapshot | null | undefined, + options?: { fileAccessAllowed?: boolean }, +): TabEligibilityResult; diff --git a/extensions/browser/chrome-extension/modules/tab-eligibility.js b/extensions/browser/chrome-extension/modules/tab-eligibility.js new file mode 100644 index 000000000000..819af7537c30 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/tab-eligibility.js @@ -0,0 +1,39 @@ +function isValidTabId(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +export function effectiveTabUrl(tab) { + return tab?.pendingUrl ?? tab?.url; +} + +function ordinaryDocumentUrl(rawUrl, fileAccessAllowed) { + let url; + try { + url = new URL(rawUrl); + } catch { + return false; + } + return ( + url.protocol === "http:" || + url.protocol === "https:" || + url.protocol === "data:" || + (url.protocol === "blob:" && /^https?:\/\//u.test(url.origin)) || + (url.protocol === "file:" && fileAccessAllowed) + ); +} + +/** Pure owner of the ordinary-document eligibility boundary. */ +export function tabEligibility(tab, { fileAccessAllowed = true } = {}) { + const urls = [tab?.url, tab?.pendingUrl].filter( + (url) => typeof url === "string" && url.length > 0, + ); + if (!tab || !isValidTabId(tab.id) || urls.length === 0) { + return { eligible: false, reason: "missing" }; + } + if (tab.incognito === true) { + return { eligible: false, reason: "incognito" }; + } + return urls.every((url) => ordinaryDocumentUrl(url, fileAccessAllowed)) + ? { eligible: true, reason: null } + : { eligible: false, reason: "restricted" }; +} diff --git a/extensions/browser/chrome-extension/page-share.e2e.test.ts b/extensions/browser/chrome-extension/page-share.e2e.test.ts index 01a652aca007..c5ed46e89aaa 100644 --- a/extensions/browser/chrome-extension/page-share.e2e.test.ts +++ b/extensions/browser/chrome-extension/page-share.e2e.test.ts @@ -22,6 +22,9 @@ import { declare const chrome: { runtime: { sendMessage(message: Record): Promise<{ + accessMode?: "all" | "selected"; + accessible?: boolean; + denied?: boolean; ok?: boolean; error?: string; }>; @@ -301,7 +304,70 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => { expect(relay.connectionCount).toBe(0); }, 60_000); - it("enforces pairing and current tab-group consent at the extension edge", async () => { + it("migrates an existing pairing to selected access after a browser restart", async () => { + const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET); + cleanups.push(relay.close); + const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs); + const userDataDir = tempDirs.make("openclaw-extension-selected-migration-profile-"); + const launchOptions: Parameters[1] = { + channel: "chromium", + headless: true, + ignoreDefaultArgs: ["--disable-extensions"], + args: [ + "--enable-unsafe-extension-debugging", + `--disable-extensions-except=${unpackedExtension}`, + `--load-extension=${unpackedExtension}`, + ], + }; + const initialContext = await chromium.launchPersistentContext(userDataDir, launchOptions); + cleanups.push(async () => await initialContext.close()); + const initialExtensionId = await waitForContextExtensionId(initialContext, unpackedExtension); + const initialLauncher = initialContext.pages()[0] ?? (await initialContext.newPage()); + await initialLauncher.goto(`chrome-extension://${initialExtensionId}/e2e-launcher.html`); + await initialLauncher.evaluate( + async ({ relayPort, token }) => + await chrome.storage.local.set({ + relayUrl: `ws://127.0.0.1:${relayPort}/extension`, + token, + gatewayUrl: "", + groupColor: "orange", + }), + { relayPort: relay.port, token: PAGE_SHARE_RELAY_SECRET }, + ); + await initialContext.close(); + + const context = await chromium.launchPersistentContext(userDataDir, launchOptions); + cleanups.push(async () => await context.close()); + const extensionId = await waitForContextExtensionId(context, unpackedExtension); + const launcher = context.pages()[0] ?? (await context.newPage()); + await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); + await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); + await expect + .poll( + async () => + await launcher.evaluate( + async () => await chrome.storage.local.get(["authVersion", "accessMode"]), + ), + { timeout: 10_000 }, + ) + .toEqual({ authVersion: 2, accessMode: "selected" }); + + const ordinary = await context.newPage(); + await ordinary.goto("data:text/html,Selected migration fixture"); + const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); + const tabId = await worker.evaluate(async (expectedUrl) => { + const tab = (await chrome.tabs.query({})).find((candidate) => candidate.url === expectedUrl); + if (typeof tab?.id !== "number") { + throw new Error("Chromium did not expose the migration fixture tab"); + } + return tab.id; + }, ordinary.url()); + await expect(relay.command({ type: "attach", tabId })).rejects.toThrow( + `tab ${tabId} is not in the OpenClaw tab group`, + ); + }, 60_000); + + it("controls and pauses an ungrouped ordinary tab in new all-tabs mode", async () => { const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET); cleanups.push(relay.close); const fixture = createServer((_request, response) => { @@ -343,45 +409,96 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => { expect(relay.connectionCount).toBe(0); const validPairing = await launcher.evaluate( - async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }), + async (pairingString) => + await chrome.runtime.sendMessage({ type: "pair", pairingString, accessMode: "all" }), `ws://127.0.0.1:${relay.port}/extension#${PAGE_SHARE_RELAY_SECRET}`, ); expect(validPairing).toEqual({ ok: true }); await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); - const created = (await relay.command({ - type: "createTab", - url: `http://127.0.0.1:${fixturePort}/authorization`, - background: true, - })) as { tabId?: number }; - if (typeof created.tabId !== "number") { - throw new Error("extension did not return a created tab id"); - } - const tabId = created.tabId; - const sharedTab = await worker.evaluate(async (targetTabId) => { - const tab = await chrome.tabs.get(targetTabId); - const group = await chrome.tabGroups.get(tab.groupId ?? -1); - return { active: tab.active, title: group.title }; - }, tabId); - expect(sharedTab).toEqual({ active: false, title: "OpenClaw" }); + const ordinary = await context.newPage(); + await ordinary.goto(`http://127.0.0.1:${fixturePort}/authorization`); + const tabId = await worker.evaluate(async (expectedUrl) => { + const tab = (await chrome.tabs.query({})).find((candidate) => candidate.url === expectedUrl); + if (typeof tab?.id !== "number") { + throw new Error("Chromium did not expose the all-tabs fixture tab"); + } + return tab.id; + }, ordinary.url()); + expect( + (await worker.evaluate(async (targetTabId) => await chrome.tabs.get(targetTabId), tabId)) + .groupId, + ).toBe(-1); + await expect + .poll( + () => + relay.tabRefreshes.some( + (refresh) => + Array.isArray(refresh.tabs) && + refresh.tabs.some( + (target) => + typeof target === "object" && + target !== null && + (target as { tabId?: unknown }).tabId === tabId, + ), + ), + { timeout: 10_000 }, + ) + .toBe(true); await relay.command({ type: "attach", tabId }); + await expect( + relay.command({ + type: "cdp", + tabId, + method: "Runtime.evaluate", + params: { expression: "document.title", returnByValue: true }, + }), + ).resolves.toMatchObject({ result: { value: "Authorization fixture" } }); - await worker.evaluate(async (targetTabId) => await chrome.tabs.ungroup([targetTabId]), tabId); + expect( + await launcher.evaluate( + async (targetTabId) => + await chrome.runtime.sendMessage({ + type: "toggleTabAccess", + tabId: targetTabId, + accessMode: "all", + grant: false, + }), + tabId, + ), + ).toMatchObject({ ok: true, accessible: false, denied: true }); + await expect + .poll( + () => { + const latest = relay.tabRefreshes.at(-1); + return ( + Array.isArray(latest?.tabs) && + latest.tabs.some( + (target) => + typeof target === "object" && + target !== null && + (target as { tabId?: unknown }).tabId === tabId, + ) + ); + }, + { timeout: 10_000 }, + ) + .toBe(false); await expect( relay.command({ type: "cdp", tabId, method: "Runtime.evaluate", params: {} }), - ).rejects.toThrow(`tab ${tabId} is not in the OpenClaw tab group`); + ).rejects.toThrow(`tab ${tabId} is paused for OpenClaw`); await expect(relay.command({ type: "activateTab", tabId })).rejects.toThrow( - `tab ${tabId} is not in the OpenClaw tab group`, + `tab ${tabId} is paused for OpenClaw`, ); await expect(relay.command({ type: "closeTab", tabId })).rejects.toThrow( - `tab ${tabId} is not in the OpenClaw tab group`, + `tab ${tabId} is paused for OpenClaw`, ); expect( await worker.evaluate(async (targetTabId) => await chrome.tabs.get(targetTabId), tabId), - ).toMatchObject({ active: false, id: tabId }); + ).toMatchObject({ id: tabId }); await expect(relay.command({ type: "detach", tabId })).resolves.toEqual({}); - await worker.evaluate(async (targetTabId) => await chrome.tabs.remove(targetTabId), tabId); + await ordinary.close(); }, 60_000); }); @@ -731,7 +848,6 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay" { timeout: 10_000 }, ) .toContain("Connected"); - await evaluateToolbarPopup( browserCdp, attached.sessionId, @@ -743,6 +859,8 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay" new MutationObserver(() => { window.__openclawPopupRefreshes += 1; }) .observe(relayValue, { childList: true }); button.dataset.tabId = ${JSON.stringify(String(missingTabId))}; + button.dataset.accessMode = "all"; + button.dataset.grant = "false"; button.classList.remove("hidden"); button.disabled = false; button.click(); diff --git a/extensions/browser/chrome-extension/popup-errors.test.ts b/extensions/browser/chrome-extension/popup-errors.test.ts index 54b1721ece48..f7243821a8e2 100644 --- a/extensions/browser/chrome-extension/popup-errors.test.ts +++ b/extensions/browser/chrome-extension/popup-errors.test.ts @@ -8,13 +8,20 @@ type PopupMessage = { type: string; tabId?: number; pairingString?: string; + accessMode?: string; + grant?: boolean; }; type PopupState = { paired?: boolean; shared?: boolean; + denied?: boolean; + eligible?: boolean; + accessMode?: "all" | "selected"; statusHint?: string; - failures: Partial>; + failures: Partial< + Record<"getStatus" | "pair" | "unpair" | "toggleTabAccess" | "setAccessMode", string> + >; onFailure?: (message: PopupMessage) => void; }; @@ -38,14 +45,31 @@ async function loadPopup(params: PopupState) { return { paired: params.paired !== false, state: "on", - sharedTabCount: params.shared ? 1 : 0, + accessMode: params.accessMode ?? "selected", + accessibleTabCount: params.shared ? 1 : 0, relayUrl: "ws://127.0.0.1:18797/extension", ...(params.statusHint ? { hint: params.statusHint } : {}), }; case "prepareCopilotPanel": return { ok: true, path: "sidepanel.html?binding=fixture" }; - case "isTabShared": - return { shared: params.shared === true }; + case "getTabAccess": + return { + accessMode: params.accessMode ?? "selected", + accessible: params.shared === true, + denied: params.denied === true, + eligible: params.eligible !== false, + }; + case "setAccessMode": + params.accessMode = message.accessMode === "selected" ? "selected" : "all"; + return { ok: true, accessMode: params.accessMode }; + case "toggleTabAccess": + if ((params.accessMode ?? "selected") === "all") { + params.denied = message.grant !== true; + params.shared = message.grant === true; + } else { + params.shared = message.grant === true; + } + return { ok: true }; default: return { ok: true }; } @@ -70,7 +94,7 @@ async function loadPopup(params: PopupState) { expect(sendMessage).toHaveBeenCalledWith({ type: "getStatus" }); return; } - expect(sendMessage).toHaveBeenCalledWith({ type: "isTabShared", tabId: 44 }); + expect(sendMessage).toHaveBeenCalledWith({ type: "getTabAccess", tabId: 44 }); }); return { sendMessage }; @@ -161,15 +185,20 @@ describe("Chrome extension popup action errors", () => { it("shows a rejected share-toggle error in the visible connected popup", async () => { const error = "No tab with id: 44."; - const failures: Partial> = { - toggleShareTab: error, + const failures: Partial> = { + toggleTabAccess: error, }; const { sendMessage } = await loadPopup({ failures }); popupElement("shareButton").click(); await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ type: "toggleShareTab", tabId: 44 }); + expect(sendMessage).toHaveBeenCalledWith({ + type: "toggleTabAccess", + tabId: 44, + accessMode: "selected", + grant: true, + }); expect(popupElement("statusLine").textContent).toBe(error); expect(popupElement("statusLine").closest(".hidden")).toBeNull(); expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); @@ -185,15 +214,63 @@ describe("Chrome extension popup action errors", () => { delete failures.getStatus; await expectVisibleErrorAfterStatusRefresh(error, sendMessage); - delete failures.toggleShareTab; + delete failures.toggleTabAccess; popupElement("shareButton").click(); await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared"); + expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab shared"); expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); }); }); + it("shows all-mode availability and toggles the tab between Pause and Allow", async () => { + const popup: PopupState = { accessMode: "all", shared: true, failures: {} }; + const { sendMessage } = await loadPopup(popup); + expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab available"); + expect(popupElement("shareButton").textContent).toBe("Pause OpenClaw on this tab"); + + popupElement("shareButton").click(); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith({ + type: "toggleTabAccess", + tabId: 44, + accessMode: "all", + grant: false, + }); + expect(popupElement("shareButton").textContent).toBe("Allow OpenClaw on this tab"); + }); + }); + + it.each([ + { accessMode: "all" as const, denied: true, label: "paused all-mode" }, + { accessMode: "selected" as const, denied: false, label: "unselected selected-mode" }, + ])("disables Copilot for an inaccessible $label tab", async ({ accessMode, denied }) => { + await loadPopup({ accessMode, denied, shared: false, failures: {} }); + + await vi.waitFor(() => { + expect(popupElement("shareButton").dataset.tabId).toBe("44"); + }); + expect((popupElement("copilotButton") as HTMLButtonElement).disabled).toBe(true); + }); + + it("changes Access immediately in settings and shows the all-tabs warning", async () => { + const popup: PopupState = { accessMode: "selected", failures: {} }; + const { sendMessage } = await loadPopup(popup); + popupElement("settingsButton").click(); + await vi.waitFor(() => { + expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false); + }); + const select = popupElement("accessModeSelect") as HTMLSelectElement; + select.value = "all"; + select.dispatchEvent(new Event("change")); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith({ type: "setAccessMode", accessMode: "all" }); + expect(popupElement("accessWarning").classList.contains("hidden")).toBe(false); + }); + }); + it.each([ { action: "share", initiallyShared: false, nextLabel: "Stop sharing this tab" }, { action: "unshare", initiallyShared: true, nextLabel: "Share this tab with OpenClaw" }, @@ -203,7 +280,7 @@ describe("Chrome extension popup action errors", () => { const error = "Could not reconcile browser tab consent."; const popup: PopupState = { shared: initiallyShared, - failures: { toggleShareTab: error }, + failures: { toggleTabAccess: error }, }; popup.onFailure = () => { popup.shared = !popup.shared; @@ -258,7 +335,11 @@ describe("Chrome extension popup action errors", () => { popup.paired = true; popupElement("pairButton").click(); await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledWith({ type: "pair", pairingString: "" }); + expect(sendMessage).toHaveBeenCalledWith({ + type: "pair", + pairingString: "", + accessMode: "all", + }); expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared"); expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); }); @@ -283,6 +364,7 @@ describe("Chrome extension popup action errors", () => { expect(sendMessage).toHaveBeenCalledWith({ type: "pair", pairingString: pairingInput.value, + accessMode: "all", }); expect(popupElement("error").textContent).toBe(error); expect(popupElement("pairSection").classList.contains("hidden")).toBe(true); @@ -295,7 +377,7 @@ describe("Chrome extension popup action errors", () => { popupElement("shareButton").click(); await vi.waitFor(() => { - expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared"); + expect(popupElement("statusLine").textContent).toBe("Connected · 1 tab shared"); expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false); }); }); @@ -343,6 +425,7 @@ describe("Chrome extension popup action errors", () => { expect(sendMessage).toHaveBeenCalledWith({ type: "pair", pairingString: pairingInput.value, + accessMode: "all", }); expect(popupElement("error").textContent).toBe(error); expect(popupElement("error").closest(".hidden")).toBeNull(); diff --git a/extensions/browser/chrome-extension/popup.html b/extensions/browser/chrome-extension/popup.html index ffa049646453..8acaef392944 100644 --- a/extensions/browser/chrome-extension/popup.html +++ b/extensions/browser/chrome-extension/popup.html @@ -31,7 +31,8 @@ } button, - textarea { + textarea, + select { font: inherit; } @@ -184,6 +185,77 @@ color: #797168; } + .access-choice { + margin: 10px 0 0; + padding: 0; + border: 0; + } + + .access-choice legend { + margin-bottom: 6px; + color: var(--muted); + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.12em; + } + + .mode-option { + display: grid; + grid-template-columns: 16px 1fr; + gap: 1px 7px; + padding: 7px 8px; + border: 1px solid #332f2b; + background: var(--panel); + cursor: pointer; + } + + .mode-option:first-of-type { + border-radius: 7px 7px 0 0; + } + + .mode-option:last-of-type { + margin-top: -1px; + border-radius: 0 0 7px 7px; + } + + .mode-option:has(input:checked) { + position: relative; + border-color: #86503b; + background: var(--orange-soft); + } + + .mode-option input { + grid-row: 1 / 3; + align-self: center; + margin: 0; + accent-color: var(--orange); + } + + .mode-title { + font-size: 11px; + font-weight: 700; + } + + .mode-detail { + color: var(--muted); + font-size: 10px; + line-height: 1.35; + } + + .recommended { + color: #ffad8f; + } + + select { + max-width: 150px; + padding: 5px 24px 5px 7px; + border: 1px solid #3b3631; + border-radius: 6px; + background: var(--panel-raised); + color: var(--ink); + font-size: 11px; + } + #pairingString { font-family: var(--mono); font-size: 11px; @@ -317,6 +389,19 @@ string:

+
+ ACCESS + + +
@@ -352,7 +437,20 @@
Relay
+
+
Access
+
+ +
+
+

+ All tabs includes signed-in sites in this Chrome profile. Pause individual tabs from the + toolbar popup when needed. +

Unpairing disconnects the gateway and forgets the relay token. diff --git a/extensions/browser/chrome-extension/popup.js b/extensions/browser/chrome-extension/popup.js index d1e6dac0d272..867deeb54bb3 100644 --- a/extensions/browser/chrome-extension/popup.js +++ b/extensions/browser/chrome-extension/popup.js @@ -1,4 +1,4 @@ -// Popup: pairing, connection status, per-tab share toggle, and settings. +// Popup: pairing, connection status, access mode, per-tab control, and settings. const statusDot = document.getElementById("statusDot"); const pairSection = document.getElementById("pairSection"); @@ -19,6 +19,8 @@ const versionValue = document.getElementById("versionValue"); const statusHint = document.getElementById("statusHint"); const unpairNote = document.getElementById("unpairNote"); const relayValue = document.getElementById("relayValue"); +const accessModeSelect = document.getElementById("accessModeSelect"); +const accessWarning = document.getElementById("accessWarning"); let sendingPage = false; let settingsOpen = false; // Preserve rejected actions across status polls until a successful retry. @@ -58,6 +60,9 @@ async function refresh() { settingsSection.classList.toggle("hidden", !settingsOpen); settingsButton.classList.toggle("active", settingsOpen); relayValue.textContent = status.paired ? relayHost(status.relayUrl) : "—"; + accessModeSelect.value = status.accessMode === "selected" ? "selected" : "all"; + accessModeSelect.disabled = !status.paired; + accessWarning.classList.toggle("hidden", status.accessMode !== "all" || !status.paired); unpairButton.classList.toggle("hidden", !status.paired); unpairNote.classList.toggle("hidden", !status.paired); if (!status.paired) { @@ -67,7 +72,7 @@ async function refresh() { const label = STATE_LABEL[status.state] ?? STATE_LABEL.off; statusLine.textContent = actionError ?? - `${label} · ${status.sharedTabCount} tab${status.sharedTabCount === 1 ? "" : "s"} shared`; + `${label} · ${status.accessibleTabCount} tab${status.accessibleTabCount === 1 ? "" : "s"} ${status.accessMode === "all" ? "available" : "shared"}`; statusHint.textContent = status.hint || "Relay unreachable — is the OpenClaw gateway running and up to date?"; statusHint.classList.toggle("hidden", status.state !== "error"); @@ -77,6 +82,8 @@ async function refresh() { copilotButton.disabled = true; sendPageButton.disabled = true; delete sendPageButton.dataset.tabId; + delete shareButton.dataset.accessMode; + delete shareButton.dataset.grant; return; } sendPageButton.dataset.tabId = String(tab.id); @@ -85,10 +92,20 @@ async function refresh() { copilotButton.disabled = !panel?.ok; copilotButton.dataset.tabId = String(tab.id); copilotButton.dataset.path = panel?.path ?? ""; - const { shared } = await chrome.runtime.sendMessage({ type: "isTabShared", tabId: tab.id }); - shareButton.classList.remove("hidden"); - shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw"; + const tabAccess = await chrome.runtime.sendMessage({ type: "getTabAccess", tabId: tab.id }); + shareButton.classList.toggle("hidden", !tabAccess.eligible); + copilotButton.disabled = !panel?.ok || !tabAccess.accessible; + shareButton.textContent = + status.accessMode === "all" + ? tabAccess.denied + ? "Allow OpenClaw on this tab" + : "Pause OpenClaw on this tab" + : tabAccess.accessible + ? "Stop sharing this tab" + : "Share this tab with OpenClaw"; shareButton.dataset.tabId = String(tab.id); + shareButton.dataset.accessMode = status.accessMode === "selected" ? "selected" : "all"; + shareButton.dataset.grant = String(!tabAccess.accessible); } async function onSendPage() { @@ -125,6 +142,10 @@ async function onPair() { const result = await chrome.runtime.sendMessage({ type: "pair", pairingString: pairingInput.value, + accessMode: + document.querySelector('input[name="pairAccessMode"]:checked')?.value === "selected" + ? "selected" + : "all", }); if (!result.ok) { actionError = result.error ?? "Pairing failed."; @@ -151,10 +172,17 @@ async function onUnpair() { await refresh(); } -async function onToggleShare() { +async function onToggleTabAccess() { const tabId = Number.parseInt(shareButton.dataset.tabId ?? "", 10); - if (Number.isFinite(tabId)) { - const result = await chrome.runtime.sendMessage({ type: "toggleShareTab", tabId }); + const accessMode = shareButton.dataset.accessMode; + const grant = shareButton.dataset.grant === "true"; + if (Number.isFinite(tabId) && (accessMode === "all" || accessMode === "selected")) { + const result = await chrome.runtime.sendMessage({ + type: "toggleTabAccess", + tabId, + accessMode, + grant, + }); if (result?.ok === false) { actionError = result.error ?? "Could not update browser tab sharing."; statusLine.textContent = actionError; @@ -166,6 +194,20 @@ async function onToggleShare() { await refresh(); } +async function onAccessModeChange() { + const result = await chrome.runtime.sendMessage({ + type: "setAccessMode", + accessMode: accessModeSelect.value, + }); + if (!result?.ok) { + actionError = result?.error ?? "Could not update browser access."; + statusLine.textContent = actionError; + } else { + actionError = null; + } + await refresh(); +} + async function onOpenCopilot() { const tabId = Number.parseInt(copilotButton.dataset.tabId ?? "", 10); const path = copilotButton.dataset.path; @@ -183,9 +225,10 @@ settingsButton.addEventListener("click", () => { }); pairButton.addEventListener("click", () => void onPair()); unpairButton.addEventListener("click", () => void onUnpair()); -shareButton.addEventListener("click", () => void onToggleShare()); +shareButton.addEventListener("click", () => void onToggleTabAccess()); copilotButton.addEventListener("click", () => void onOpenCopilot()); sendPageButton.addEventListener("click", () => void onSendPage()); +accessModeSelect.addEventListener("change", () => void onAccessModeChange()); void refresh(); setInterval(() => void refresh(), 2000); diff --git a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts b/extensions/browser/chrome-extension/sidepanel.e2e-support.ts index ead646a9dfc8..99c3f49650c8 100644 --- a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts +++ b/extensions/browser/chrome-extension/sidepanel.e2e-support.ts @@ -67,6 +67,7 @@ export function rawDataText(data: RawData): string { type RelayHarness = { readonly connectionCount: number; hellos: Array>; + tabRefreshes: Array>; port: number; close: () => Promise; command: (body: Record) => Promise; @@ -90,6 +91,7 @@ export async function createRelayHarness(token = "a".repeat(64)): Promise> = []; + const tabRefreshes: Array> = []; const pendingCommands = new Map< number, { reject: (error: Error) => void; resolve: (result: unknown) => void } @@ -192,6 +194,10 @@ export async function createRelayHarness(token = "a".repeat(64)): Promise { const client = [...authenticated].find((candidate) => candidate.readyState === 1); @@ -560,7 +567,10 @@ export async function copyCopilotSidepanelExtension(tempDirs: { const target = tempDirs.make("openclaw-copilot-extension-"); await fs.cp(extensionDir, target, { recursive: true, - filter: (source) => !source.endsWith(".test.ts"), + filter: (source) => + !source.endsWith(".test.ts") && + !source.endsWith(".test-support.ts") && + !source.endsWith(".test-harness.ts"), }); await fs.writeFile( path.join(target, "e2e-launcher.html"), diff --git a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts index ddcaa9f66b58..1f3e274a7594 100644 --- a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts +++ b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts @@ -407,6 +407,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { type: "pair", pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`, groupColor: "#ff7020", + accessMode: "selected", }), { gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET }, ); @@ -555,7 +556,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { }); }); - it("isolates two tab sessions, enforces bindings, denies unshared use, and archives on close", async () => { + it("isolates two tab sessions, enforces bindings, denies revoked access, and archives on close", async () => { const gateway = await createGatewayHarness(); cleanups.push(gateway.close); const relay = await createRelayHarness(); @@ -592,6 +593,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { type: "pair", pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`, groupColor: "#ff7020", + accessMode: "selected", }), { gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET }, ); @@ -642,11 +644,11 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { ) .toEqual({ detail: - "Sharing adds this tab to the OpenClaw group. The copilot can act here, but nowhere else.", - title: "Keep the boundary visible", + "Use the current access mode to allow OpenClaw here. Restricted and incognito tabs remain unavailable.", + title: "Allow this tab", }); expect(await alphaPanel.disabled("#message-input")).toBe(true); - await alphaPanel.screenshot(path.join(artifactDir, "before-unshared.png")); + await alphaPanel.screenshot(path.join(artifactDir, "before-access.png")); await alphaPanel.click("#gate-action"); await expect .poll(async () => !(await alphaPanel.disabled("#message-input")), { @@ -686,9 +688,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { throw new Error("Chrome did not expose the beta tab id"); } await betaTab.goto(`${fixture.baseUrl}/beta`); - await expect - .poll(async () => await betaPanel.text("#gate-title")) - .toBe("Keep the boundary visible"); + await expect.poll(async () => await betaPanel.text("#gate-title")).toBe("Allow this tab"); await betaPanel.click("#gate-action"); await expect .poll(async () => !(await betaPanel.disabled("#message-input")), { @@ -816,7 +816,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => { releaseConsentSubscription(); await expect .poll(async () => await reopenedBetaPanel.text("#gate-title"), { timeout: 10_000 }) - .toBe("Keep the boundary visible"); + .toBe("Allow this tab"); expect(gateway.chatSends).toHaveLength(2); await reopenedBetaPanel.click("#gate-action"); await expect diff --git a/extensions/browser/chrome-extension/sidepanel.html b/extensions/browser/chrome-extension/sidepanel.html index fd9c4562fdf6..ce4193070e67 100644 --- a/extensions/browser/chrome-extension/sidepanel.html +++ b/extensions/browser/chrome-extension/sidepanel.html @@ -27,13 +27,15 @@

Preparing this tab

Chrome is proving which tab owns this panel.

- +