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:
+
@@ -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.
-
+