mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
a9d3980ed1
commit
8362f74c99
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user