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
This commit is contained in:
Peter Steinberger
2026-08-09 03:15:54 -07:00
committed by GitHub
parent a9d3980ed1
commit 8362f74c99
44 changed files with 4764 additions and 925 deletions
+6
View File
@@ -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)
+57 -23
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+234 -287
View File
@@ -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];
@@ -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<void>;
rejectStorageRemove?: boolean;
relayNegotiatedProtocol?: string;
sessionConfig?: Record<string, unknown>;
storedConfig?: Record<string, unknown>;
initialTabs?: Array<Record<string, unknown> & { 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<void> | null = null;
let nextStorageRemove: Promise<void> | null = null;
let nextStorageSet: Promise<void> | null = null;
let nextSessionStorageSet: Promise<void> | null = null;
let releaseTabAccessInitialization = () => {};
const tabAccessInitialization = deferTabAccessInitialization
? new Promise<void>((resolve) => {
releaseTabAccessInitialization = resolve;
})
: Promise.resolve();
const sharedTabIds = new Set<number>([1]);
const storageValues: Record<string, unknown> = {
...(storedConfig ?? {
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
authVersion: 2,
accessMode: "selected",
groupColor: "orange",
}),
};
const sessionStorageValues: Record<string, unknown> = { ...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<string, unknown>) => {
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<string, unknown>) => {
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<Array<{ id?: string; tabId?: number; attached?: boolean }>> => [],
),
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<Array<{ result: PageCaptureResult }>> => []),
},
tabGroups: {
query: vi.fn(async (): Promise<Array<{ id: number; windowId: number }>> => []),
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<void>((resolve) => {
release = resolve;
});
return release;
},
deferNextStorageRemove: () => {
let release = () => {};
nextStorageRemove = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
deferNextSessionStorageSet: () => {
let release = () => {};
nextSessionStorageSet = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
deferNextStorageSet: () => {
let release = () => {};
nextStorageSet = new Promise<void>((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<ReturnType<typeof loadBackground>>,
message: { type: string } & Record<string, unknown>,
) {
return await new Promise<Record<string, unknown>>((resolve) => {
harness.messageListener(message, {}, (response) => {
resolve(response as Record<string, unknown>);
});
});
}
@@ -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;
@@ -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<void>;
rejectStorageRemove?: boolean;
relayNegotiatedProtocol?: string;
storedConfig?: Record<string, unknown>;
} = {}) {
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<void> | null = null;
const sharedTabIds = new Set<number>([1]);
const storageValues: Record<string, unknown> = {
...(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<string, unknown>) => {
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<Array<{ result: PageCaptureResult }>> => []),
},
tabGroups: {
query: vi.fn(async (): Promise<Array<{ id: number; windowId: number }>> => []),
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<Array<{ id: number; windowId: number }>> => []),
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<void>((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<undefined>((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",
});
});
});
});
@@ -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",
@@ -21,7 +21,7 @@ export function archiveCopilotSession(
export function selectCopilotPanelState(options: {
paired: boolean;
shared: boolean;
accessible: boolean;
abortPending: boolean;
gatewayState: string;
}): string;
@@ -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;
@@ -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);
}
@@ -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(),
@@ -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.",
@@ -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<string, unknown> | 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<string, unknown>) => {
present = true;
Object.assign(entry, value, { tabId: 41 });
return entry;
}),
updateBinding: vi.fn(
async (_tabId: number, _scope: string, binding: Record<string, unknown>) => {
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();
});
});
@@ -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 };
}
@@ -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;
};
}
@@ -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<string, unknown>) => void;
attachDebugger: (tabId: number) => Promise<unknown>;
detachDebugger: (tabId: number) => Promise<void>;
addTabToOpenClawGroup: (tabId: number) => Promise<void>;
focusWindowForTab: (tab: chrome.tabs.Tab) => Promise<void>;
focusWindowForTab: (tab: BrowserTabSnapshot) => Promise<void>;
scheduleTabsSync: () => void;
captureAccess: (tabId: number) => TabAccessEpoch;
requireAccessibleTab: (
tabId: number,
epoch: TabAccessEpoch,
) => Promise<AccessibleBrowserTabSnapshot>;
}): (message: Record<string, unknown>) => Promise<void>;
@@ -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;
}
@@ -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",
});
});
});
@@ -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<void>;
setAccessMode(accessMode: unknown): Promise<"all" | "selected">;
clear(): Promise<void>;
};
@@ -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])),
};
}
@@ -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<string, unknown> = {};
const set = vi.fn(async (values: Record<string, unknown>) => {
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown>) => {
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({
@@ -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);
}
@@ -0,0 +1,59 @@
import type { TabAccessEpoch, TabAccessMode } from "./tab-access.js";
type ChromeEvent<Listener> = {
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<Array<{ id: number }>>;
forgetTab(tabId: number): Promise<void>;
replaceTab(addedTabId: number, removedTabId: number): Promise<boolean>;
};
export type TabAccessEventCopilot = {
onConsentChanged(tabId?: number, options?: { revoked?: boolean }): void | Promise<void>;
onTabRemoved(tabId: number): void | Promise<void>;
};
export function registerTabAccessEvents(options: {
chromeApi?: TabAccessEventsChromeApi;
accessReady: Promise<unknown>;
policy: TabAccessEventPolicy;
attachedTabs: Set<number>;
attachedAccessEpochs: Map<number, TabAccessEpoch>;
copilotDeniedTabs: Set<number>;
attachingTabs: Map<number, Promise<unknown>>;
getCopilot(): TabAccessEventCopilot;
send(message: Record<string, unknown>): void;
scheduleTabsSync(): void;
detachDebugger(tabId: number): Promise<void>;
pauseTab(tabId: number): void | Promise<void>;
removeTabFromOpenClawGroup(tabId: number): void | Promise<void>;
runAccessMutation(task: () => void | Promise<void>): Promise<void>;
}): void;
@@ -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);
}
@@ -0,0 +1,323 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerTabAccessEvents } from "./tab-access-events.js";
function deferred<T>() {
let resolve = (_value: T) => {};
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function createHarness(
mode: "all" | "selected" = "selected",
accessReady: Promise<unknown> = 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<number, Promise<unknown>>();
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<void>();
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<Array<{ id: number }>>();
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();
});
});
@@ -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<Record<string, unknown>>;
set(values: Record<string, unknown>): Promise<void>;
remove(keys: string[]): Promise<void>;
};
export type TabAccessChromeApi = {
extension?: {
isAllowedFileSchemeAccess?: () => boolean | Promise<boolean>;
};
storage: { session: TabAccessStorageArea };
tabs: {
get(tabId: number): Promise<BrowserTabSnapshot>;
query(queryInfo: Record<string, unknown>): Promise<BrowserTabSnapshot[]>;
};
};
export type TabAccessPolicy = {
initialize(initialMode?: TabAccessMode, initialEnabled?: boolean): Promise<void>;
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<TabAccessState>;
requireTab(tabId: number, epoch?: TabAccessEpoch): Promise<AccessibleBrowserTabSnapshot>;
listAccessibleTabs(options?: {
allowDuringTransition?: boolean;
}): Promise<AccessibleBrowserTabSnapshot[]>;
pause(tabId: number): Promise<void>;
allow(tabId: number): Promise<void>;
forgetTab(tabId: number): Promise<void>;
replaceTab(addedTabId: number, removedTabId: number): Promise<boolean>;
clearDenied(): Promise<void>;
isDenied(tabId: number): boolean;
};
export function createTabAccessPolicy(options: {
chromeApi?: TabAccessChromeApi;
isSelectedTab(tab: BrowserTabSnapshot): boolean | Promise<boolean>;
}): TabAccessPolicy;
@@ -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),
};
}
@@ -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<string, unknown> = {}) {
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<string, unknown>) => {
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,<title>fixture</title>",
"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<boolean>((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");
});
});
@@ -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;
@@ -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" };
}
@@ -22,6 +22,9 @@ import {
declare const chrome: {
runtime: {
sendMessage(message: Record<string, unknown>): 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<typeof chromium.launchPersistentContext>[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,<title>Selected migration fixture</title>");
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<void>(
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();
@@ -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<Record<"getStatus" | "pair" | "unpair" | "toggleShareTab", string>>;
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<Record<"getStatus" | "toggleShareTab", string>> = {
toggleShareTab: error,
const failures: Partial<Record<"getStatus" | "toggleTabAccess", string>> = {
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();
+99 -1
View File
@@ -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:
</p>
<textarea id="pairingString" placeholder="ws://127.0.0.1:18797/extension#…"></textarea>
<fieldset class="access-choice">
<legend>ACCESS</legend>
<label class="mode-option">
<input type="radio" name="pairAccessMode" value="all" checked />
<span class="mode-title">All tabs</span>
<span class="mode-detail recommended">Recommended for a personal browser</span>
</label>
<label class="mode-option">
<input type="radio" name="pairAccessMode" value="selected" />
<span class="mode-title">Selected tabs</span>
<span class="mode-detail">Only tabs in the OpenClaw group</span>
</label>
</fieldset>
<p id="error" class="hidden"></p>
<button id="pairButton" class="action primary" type="button">Pair</button>
</section>
@@ -352,7 +437,20 @@
<dt>Relay</dt>
<dd id="relayValue"></dd>
</div>
<div class="settings-row">
<dt>Access</dt>
<dd>
<select id="accessModeSelect" aria-label="Browser tab access">
<option value="all">All tabs</option>
<option value="selected">Selected tabs</option>
</select>
</dd>
</div>
</dl>
<p id="accessWarning" class="lede" style="margin: 8px 2px 2px">
All tabs includes signed-in sites in this Chrome profile. Pause individual tabs from the
toolbar popup when needed.
</p>
<button id="unpairButton" class="action danger" type="button">Unpair this browser</button>
<p id="unpairNote" class="lede" style="margin: 10px 2px 0">
Unpairing disconnects the gateway and forgets the relay token.
+52 -9
View File
@@ -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);
@@ -67,6 +67,7 @@ export function rawDataText(data: RawData): string {
type RelayHarness = {
readonly connectionCount: number;
hellos: Array<Record<string, unknown>>;
tabRefreshes: Array<Record<string, unknown>>;
port: number;
close: () => Promise<void>;
command: (body: Record<string, unknown>) => Promise<unknown>;
@@ -90,6 +91,7 @@ export async function createRelayHarness(token = "a".repeat(64)): Promise<RelayH
protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false,
});
const hellos: Array<Record<string, unknown>> = [];
const tabRefreshes: Array<Record<string, unknown>> = [];
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<RelayH
hellos.push(message);
return;
}
if (message.type === "tabs") {
tabRefreshes.push(message);
return;
}
const seq = typeof message.seq === "number" ? message.seq : undefined;
if (seq === undefined || (message.type !== "result" && message.type !== "error")) {
return;
@@ -217,6 +223,7 @@ export async function createRelayHarness(token = "a".repeat(64)): Promise<RelayH
return connectionCount;
},
hellos,
tabRefreshes,
port: address.port,
command: async (body) => {
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"),
@@ -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
@@ -27,13 +27,15 @@
<h1 id="gate-title">Preparing this tab</h1>
<p id="gate-detail">Chrome is proving which tab owns this panel.</p>
<code id="request-id" class="request-id hidden"></code>
<button id="gate-action" class="primary hidden" type="button">Share this tab</button>
<button id="gate-action" class="primary hidden" type="button">Allow this tab</button>
</section>
<section id="messages" class="messages hidden" aria-label="Conversation"></section>
</main>
<footer class="composer-shell">
<div id="session-note" class="session-note">No session until this tab is shared.</div>
<div id="session-note" class="session-note">
No session until OpenClaw can access this tab.
</div>
<div class="composer">
<textarea
id="message-input"
@@ -99,7 +99,7 @@ function handleChatEvent(payload) {
addBubble("system", payload.errorMessage || "The run failed.");
}
if (payload.state === "aborted") {
addBubble("system", "Run stopped because this tab was closed or unshared.");
addBubble("system", "Run stopped because this tab was closed or access was revoked.");
}
if (payload.state === "final" || payload.state === "error" || payload.state === "aborted") {
finalizeStream();
@@ -126,12 +126,12 @@ function updateState(state) {
setComposerEnabled(true);
break;
case "needs-sharing":
sessionNote.textContent = "No session until this tab is shared.";
sessionNote.textContent = "No session until OpenClaw can access this tab.";
setGate({
action: "share",
title: "Keep the boundary visible",
title: "Allow this tab",
detail:
"Sharing adds this tab to the OpenClaw group. The copilot can act here, but nowhere else.",
"Use the current access mode to allow OpenClaw here. Restricted and incognito tabs remain unavailable.",
});
break;
case "needs-pairing":
@@ -40,7 +40,11 @@ async function main() {
// Ship only the runtime extension; colocated *.test.ts and *.d.ts stay out.
await fs.cp(srcDir, outDir, {
recursive: true,
filter: (source) => !source.endsWith(".test.ts") && !source.endsWith(".d.ts"),
filter: (source) =>
!source.endsWith(".test.ts") &&
!source.endsWith(".test-support.ts") &&
!source.endsWith(".test-harness.ts") &&
!source.endsWith(".d.ts"),
});
}
@@ -230,7 +230,7 @@ describe("ExtensionRelayBridge", () => {
});
});
it("attaches shared tabs and announces targets on Target.setAutoAttach", async () => {
it("attaches accessible tabs and announces targets on Target.setAutoAttach", async () => {
const bridge = new ExtensionRelayBridge();
const { handlers } = wireExtension(bridge);
sendHello(handlers);
@@ -287,7 +287,7 @@ describe("ExtensionRelayBridge", () => {
expect(response?.result).toMatchObject({ ok: true });
});
it("multiplexes Playwright page CDP sessions over the shared tab attachment", async () => {
it("multiplexes Playwright page CDP sessions over the accessible tab attachment", async () => {
const bridge = new ExtensionRelayBridge();
const { socket: extSocket, handlers } = wireExtension(bridge);
sendHello(handlers);
@@ -455,7 +455,7 @@ describe("ExtensionRelayBridge", () => {
},
);
it("emits Target.detachedFromTarget when a shared tab leaves the group", async () => {
it("emits Target.detachedFromTarget when a tab becomes unavailable", async () => {
const bridge = new ExtensionRelayBridge();
const { handlers } = wireExtension(bridge);
sendHello(handlers);
@@ -467,13 +467,13 @@ describe("ExtensionRelayBridge", () => {
);
await flush();
// Tab 1 removed from the shared set.
// Tab 1 removed from the accessible set.
handlers.onMessage(JSON.stringify({ type: "tabs", tabs: [] }));
await flush();
const detached = client.frames().find((frame) => frame.method === "Target.detachedFromTarget");
expect(detached).toBeTruthy();
expect(bridge.sharedTabs()).toHaveLength(0);
expect(bridge.accessibleTabs()).toHaveLength(0);
});
it("rejects isolated browser contexts (real profile only)", async () => {
@@ -539,7 +539,7 @@ describe("ExtensionRelayBridge", () => {
]);
});
it("reaps child sessions when a tab leaves the group (no stale routing)", async () => {
it("reaps child sessions when a tab becomes unavailable (no stale routing)", async () => {
const bridge = new ExtensionRelayBridge();
const { handlers } = wireExtension(bridge);
sendHello(handlers);
@@ -563,7 +563,7 @@ describe("ExtensionRelayBridge", () => {
);
await flush();
// Tab 1 leaves the OpenClaw group.
// Tab 1 disappears from the extension's accessible set.
handlers.onMessage(JSON.stringify({ type: "tabs", tabs: [] }));
await flush();
@@ -752,13 +752,13 @@ describe("ExtensionRelayBridge", () => {
closeReason: "replaced by newer extension connection",
});
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
expect(bridge.sharedTabs()).toEqual([
expect(bridge.accessibleTabs()).toEqual([
{ tabId: 2, url: "https://candidate.example", title: "Candidate", active: true },
]);
active.handlers.onClose();
expect(bridge.extensionConnected).toBe(true);
expect(bridge.sharedTabs()).toHaveLength(1);
expect(bridge.accessibleTabs()).toHaveLength(1);
});
it("rejects an older candidate when a newer candidate promotes first", () => {
@@ -843,7 +843,7 @@ describe("ExtensionRelayBridge", () => {
expect(contexts?.result).toEqual({ browserContextIds: [] });
});
it("lists shared tabs as DevTools-style target descriptors", async () => {
it("lists accessible tabs as DevTools-style target descriptors", async () => {
const bridge = new ExtensionRelayBridge();
const { handlers } = wireExtension(bridge);
sendHello(handlers);
@@ -137,8 +137,8 @@ export class ExtensionRelayBridge {
return this.extension?.identity ?? null;
}
/** Tabs currently shared with OpenClaw (the extension's tab group). */
sharedTabs(): RelayTabInfo[] {
/** Tabs currently reported as accessible by the extension. */
accessibleTabs(): RelayTabInfo[] {
return [...this.tabs.values()].map((tab) => tab.info);
}
@@ -432,15 +432,15 @@ export class ExtensionRelayBridge {
existing.info = info;
} else {
this.tabs.set(info.tabId, { info });
// Newly shared tab: expose it to auto-attach clients right away so an
// agent mid-session sees tabs the user shares via the toolbar action.
// Newly accessible tabs must reach auto-attach clients immediately;
// an access-mode or pause change may happen mid-session.
if ([...this.clients].some((client) => client.autoAttach)) {
void this.ensureTabAttached(info.tabId)
.then(({ targetId, sessionId }) => {
this.announceAttachedTab(info.tabId, targetId, sessionId, { onlyAutoAttach: true });
})
.catch((err: unknown) => {
log.warn(`auto-attach of shared tab ${info.tabId} failed: ${String(err)}`);
log.warn(`auto-attach of accessible tab ${info.tabId} failed: ${String(err)}`);
});
}
}
@@ -450,7 +450,7 @@ export class ExtensionRelayBridge {
private async ensureTabAttached(tabId: number): Promise<{ targetId: string; sessionId: string }> {
const tab = this.tabs.get(tabId);
if (!tab) {
throw new Error(`tab ${tabId} is not shared with OpenClaw`);
throw new Error(`tab ${tabId} is not available to OpenClaw`);
}
if (tab.attached) {
return tab.attached;
@@ -465,8 +465,8 @@ export class ExtensionRelayBridge {
const targetId = typeof result?.targetId === "string" ? result.targetId : `tab-${tabId}`;
const sessionId = `openclaw-tab-${tabId}-${this.nextSessionOrdinal++}`;
const attached = { targetId, sessionId };
// Identity check, not just presence: the tab could have left the group and
// rejoined under the same tabId while this attach was in flight, replacing
// Identity check, not just presence: the tab could have lost and regained
// access under the same tabId while this attach was in flight, replacing
// the TabState. Writing onto the new TabState would bind stale attach data.
const current = this.tabs.get(tabId);
if (current !== tab) {
@@ -540,7 +540,7 @@ export class ExtensionRelayBridge {
}
}
// Playwright's page-scoped CDP sessions listen on their synthetic parent
// browser session, so detach those aliases there when the shared tab goes.
// browser session, so detach those aliases there when tab access is revoked.
for (const [auxiliarySessionId, auxiliary] of this.auxiliaryTabSessions) {
if (auxiliary.tabId !== tabId) {
continue;
@@ -870,7 +870,7 @@ export class ExtensionRelayBridge {
case "Target.attachToTarget": {
const targetId = request.params?.targetId as string | undefined;
const found = targetId ? this.tabByTargetId(targetId) : null;
// Also allow attach by tab that is shared but not yet debugger-attached.
// Also allow attach by tab that is accessible but not yet debugger-attached.
if (!found && targetId) {
this.respondError(client, request, `No target with given id found: ${targetId}`, -32602);
return;
@@ -1,11 +1,11 @@
/**
* Wire protocol between the extension relay server and the OpenClaw Chrome
* extension. The extension stays a dumb transport: it attaches chrome.debugger,
* forwards CDP traffic, and manages the OpenClaw tab group. All CDP target
* semantics (Target.* synthesis for Playwright) live server-side in the bridge.
* extension. The extension owns tab eligibility/access, attaches chrome.debugger,
* and forwards CDP traffic. All CDP target semantics (Target.* synthesis for
* Playwright) live server-side in the bridge.
*/
/** Tab snapshot reported by the extension for tabs shared with OpenClaw. */
/** Tab snapshot reported by the extension for tabs currently accessible to OpenClaw. */
export type RelayTabInfo = {
tabId: number;
url: string;
@@ -39,7 +39,7 @@ type ExtensionHelloMessage = {
tabs: RelayTabInfo[];
};
/** Full refresh of shared tabs; sent on any group membership or tab change. */
/** Full refresh of accessible tabs; sent on any access-policy or tab change. */
type ExtensionTabsMessage = {
type: "tabs";
tabs: RelayTabInfo[];
@@ -103,15 +103,15 @@ export type ExtensionToRelayMessage =
export type RelayCommandBody =
/** Forward a CDP command into an attached tab (or one of its child sessions). */
| { type: "cdp"; tabId: number; sessionId?: string; method: string; params?: unknown }
/** Attach chrome.debugger to a shared tab. Result: { targetId: string }. */
/** Attach chrome.debugger to an accessible tab. Result: { targetId: string }. */
| { type: "attach"; tabId: number }
/** Detach chrome.debugger from a tab (tab left the group or client detached). */
/** Detach chrome.debugger from a tab (access revoked or client detached). */
| { type: "detach"; tabId: number }
/** Open a new tab inside the OpenClaw tab group. Result: { tabId: number }. */
| { type: "createTab"; url: string; background?: boolean; focus?: boolean }
/** Close a shared tab. Result: {}. */
/** Close an accessible tab. Result: {}. */
| { type: "closeTab"; tabId: number }
/** Focus a shared tab (window + tab activation). Result: {}. */
/** Focus an accessible tab (window + tab activation). Result: {}. */
| { type: "activateTab"; tabId: number };
/** Keepalive probe; the extension answers with pong. */
@@ -720,7 +720,9 @@ describe.sequential("extension relay HTTP auth v2", () => {
tabs: [{ tabId: 1, url: "https://example.test", title: `beat-${index}`, active: true }],
}),
);
await vi.waitFor(() => expect(handle?.bridge.sharedTabs()[0]?.title).toBe(`beat-${index}`));
await vi.waitFor(() =>
expect(handle?.bridge.accessibleTabs()[0]?.title).toBe(`beat-${index}`),
);
}
expect(extension.readyState).toBe(WebSocket.OPEN);