mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(browser): harden extension relay authorization (#120390)
* fix(browser): harden extension relay authorization * fix(browser): validate persisted relay pairing
This commit is contained in:
committed by
GitHub
parent
e55c3703a7
commit
947bca5608
@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Browser extension relay security:** require canonical 64-character relay secrets and safe WebSocket pairing URLs, and recheck OpenClaw tab-group consent at the extension edge before every authority-bearing existing-tab command.
|
||||
- **Control UI debug diagnostics:** keep last-good status, health, model, and heartbeat snapshots visible when refreshes fail, show the failure inside Snapshots, isolate it from Manual RPC state, and prevent older manual calls from overwriting newer ones. Thanks @shakkernerd.
|
||||
- **Control UI read-only preferences:** keep personal preference edits browser-local without attempting unauthorized config writes or claiming server sync, preserve offline intent for a later authorized reconnect, and restore the current server value on local reset. Thanks @shakkernerd.
|
||||
- **Control UI owner handoff:** give browsers opened by host-issued dashboard and graphical onboarding links durable administrator access, including same-browser recovery from a limited credential, while keeping generic, Telegram, mobile, and ordinary scope-upgrade paths bounded. Thanks @shakkernerd.
|
||||
|
||||
@@ -29,16 +29,19 @@ Three parts:
|
||||
|
||||
- **Browser control service** (Gateway or node host): the API the `browser`
|
||||
tool calls.
|
||||
- **Extension relay** (loopback WebSocket): a small server the control service
|
||||
starts on `127.0.0.1`. It presents a Chrome DevTools Protocol endpoint to
|
||||
OpenClaw and speaks to the extension. Both sides authenticate with a
|
||||
host-local token (see below).
|
||||
- **Extension relay**: a small server the control service exposes on loopback
|
||||
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 only sees and controls tabs that are in the **OpenClaw tab group**. The
|
||||
group is the consent boundary: drag a tab in to share it, drag it out (or click
|
||||
the toolbar button) to revoke access instantly.
|
||||
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.
|
||||
|
||||
## Install and pair
|
||||
|
||||
@@ -60,12 +63,17 @@ the toolbar button) to revoke access instantly.
|
||||
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.
|
||||
|
||||
The pairing token is a **host-local secret** created on first use and stored
|
||||
The pairing token is a **per-host secret** created on first use and stored
|
||||
under `credentials/` in the state directory (mode `0600`). Each machine that
|
||||
runs a browser — the Gateway host and every browser node host — owns its own
|
||||
token, so no credential has to travel between machines. To rotate it, delete the
|
||||
`browser-extension-relay.secret` file and pair again.
|
||||
|
||||
The secret stays in the pairing string fragment rather than the WebSocket URL
|
||||
sent to the server. During connection, the extension presents it as a
|
||||
WebSocket subprotocol credential. This keeps it out of normal proxy request
|
||||
URLs and access logs; still treat the complete pairing string as a password.
|
||||
|
||||
## Use it
|
||||
|
||||
Select the built-in `chrome` profile in a `browser` tool call, or make it the
|
||||
@@ -91,12 +99,12 @@ openclaw config set browser.defaultProfile chrome
|
||||
- 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.
|
||||
|
||||
### External CDP clients (chrome-devtools-mcp, Puppeteer)
|
||||
### Authenticated external CDP clients
|
||||
|
||||
The relay is a standard CDP browser endpoint, so tools other than OpenClaw can
|
||||
drive the paired Chrome through it — same consent model (shared tabs only),
|
||||
same host-local token, and still no "Allow remote debugging?" prompt. Print the
|
||||
endpoint and auth header:
|
||||
The relay supports authenticated external CDP clients such as mcporter,
|
||||
chrome-devtools-mcp, and Puppeteer. They use the same paired Chrome and the same
|
||||
tab-group consent boundary, without Chrome's "Allow remote debugging?" prompt.
|
||||
Print the endpoint and bearer-auth header:
|
||||
|
||||
```bash
|
||||
openclaw browser extension cdp
|
||||
@@ -111,12 +119,15 @@ npx chrome-devtools-mcp --wsEndpoint ws://127.0.0.1:18799/cdp \
|
||||
```
|
||||
|
||||
`openclaw browser extension cdp --json` emits `{ browserUrl, wsEndpoint,
|
||||
headers }` for scripting. The token is the same host-local relay secret the
|
||||
pairing string carries: treat it as private, and rotate it by deleting
|
||||
headers }` for scripting. External CDP clients authenticate to the local CDP
|
||||
endpoint with this header; the extension authenticates its separate relay
|
||||
WebSocket with the subprotocol credential described above. Both derive from
|
||||
the same per-host relay secret. Treat it as private, and rotate it by deleting
|
||||
`credentials/browser-extension-relay.secret` and pairing again.
|
||||
|
||||
[mcporter](https://github.com/openclaw/mcporter) needs no wiring at all: when a
|
||||
paired relay answers on this host, it transparently rewrites
|
||||
[mcporter](https://github.com/openclaw/mcporter) is a supported external CDP
|
||||
client and needs no extension-side wiring: when a paired relay answers on this
|
||||
host, it transparently rewrites
|
||||
`chrome-devtools-mcp --autoConnect` server commands to the relay endpoint, so
|
||||
agents calling Chrome DevTools through mcporter skip the remote-debugging
|
||||
prompt automatically (set `MCPORTER_DISABLE_CHROME_DEVTOOLS_RELAY=1` there to
|
||||
@@ -235,12 +246,15 @@ extension popup shows **Connected**.
|
||||
|
||||
## Security model
|
||||
|
||||
- The relay binds loopback only; both WebSocket sides are authenticated with the
|
||||
derived token, and the extension side is origin-checked to `chrome-extension://`.
|
||||
- Same-host and browser-node relays bind loopback; direct remote pairing uses
|
||||
the Gateway's `wss://` route. Both WebSocket sides authenticate with the
|
||||
per-host secret, and the extension side is origin-checked to
|
||||
`chrome-extension://`.
|
||||
- Direct Gateway pairing does not accept the relay token in the request URL;
|
||||
the bundled extension carries it in the WebSocket subprotocol list instead.
|
||||
- The agent can only see and drive tabs in the **OpenClaw tab group**. Your
|
||||
other tabs stay private.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -15,11 +15,18 @@ import { createPageShareRelay } from "./modules/page-share-relay.js";
|
||||
import {
|
||||
OPENCLAW_TAB_GROUP_TITLE,
|
||||
buildRelayWsProtocols,
|
||||
createPairingConfigStore,
|
||||
nearestGroupColor,
|
||||
parsePairingString,
|
||||
reconnectDelayMs,
|
||||
toRelayTabInfo,
|
||||
} from "./modules/relay-core.js";
|
||||
import {
|
||||
findOpenClawGroups,
|
||||
isOpenClawGroupId,
|
||||
listSharedTabs,
|
||||
requireSharedTab,
|
||||
} from "./modules/relay-tab-groups.js";
|
||||
|
||||
const BADGE = {
|
||||
off: { text: "", color: "#000000" },
|
||||
@@ -44,12 +51,13 @@ let copilot = null;
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer = null;
|
||||
let relayOpeningDeadlineAt = 0;
|
||||
let reconciledPairingInvalidationRevision = 0;
|
||||
/** Tab ids with an active chrome.debugger attachment. */
|
||||
const attachedTabs = new Set();
|
||||
/** Tabs denied to every relay attach while copilot run cleanup is pending. */
|
||||
const copilotDeniedTabs = new Set();
|
||||
/** Monotonic revocation epochs invalidate attaches already in flight. */
|
||||
const copilotAccessRevisions = new Map();
|
||||
/** 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. */
|
||||
@@ -58,18 +66,31 @@ const copilotRevocations = new Map();
|
||||
let tabsSyncTimer = null;
|
||||
let pageShareBadgeTimer = null;
|
||||
const pageShareRelay = createPageShareRelay();
|
||||
const pairingConfigStore = createPairingConfigStore(chrome.storage.local);
|
||||
|
||||
function closeRelaySocket() {
|
||||
const socket = relayWs;
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
relayWs = null;
|
||||
// Chrome completes close asynchronously; fail pending requests before the
|
||||
// handshake so pairing and unpairing never leave a popup stuck on Sending.
|
||||
pageShareRelay.rejectSocket(socket);
|
||||
socket.close();
|
||||
}
|
||||
|
||||
async function reconcilePairingInvalidation() {
|
||||
if (reconciledPairingInvalidationRevision === pairingConfigStore.invalidationRevision) {
|
||||
return;
|
||||
}
|
||||
reconciledPairingInvalidationRevision = pairingConfigStore.invalidationRevision;
|
||||
clearRelayOpeningDeadline();
|
||||
closeRelaySocket();
|
||||
setBadge("off");
|
||||
await copilot?.refreshConfig();
|
||||
}
|
||||
|
||||
function setBadge(kind) {
|
||||
relayState = kind;
|
||||
const cfg = BADGE[kind] ?? BADGE.off;
|
||||
@@ -97,45 +118,13 @@ function flashPageShareBadge(ok) {
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
const stored = await chrome.storage.local.get(["relayUrl", "token", "groupColor"]);
|
||||
return {
|
||||
relayUrl: typeof stored.relayUrl === "string" ? stored.relayUrl : "",
|
||||
token: typeof stored.token === "string" ? stored.token : "",
|
||||
groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange",
|
||||
};
|
||||
}
|
||||
|
||||
async function getCopilotConfig() {
|
||||
const config = await getConfig();
|
||||
const stored = await chrome.storage.local.get(["gatewayUrl"]);
|
||||
return {
|
||||
...config,
|
||||
gatewayUrl: typeof stored.gatewayUrl === "string" ? stored.gatewayUrl : "",
|
||||
};
|
||||
return await pairingConfigStore.read();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab group management (the consent boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function findOpenClawGroups() {
|
||||
try {
|
||||
return await chrome.tabGroups.query({ title: OPENCLAW_TAB_GROUP_TITLE });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function addTabToOpenClawGroup(tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const groups = await findOpenClawGroups();
|
||||
@@ -171,18 +160,6 @@ async function isTabShared(tabId) {
|
||||
return shared.some((tab) => tab.id === tabId);
|
||||
}
|
||||
|
||||
async function isOpenClawGroupId(groupId) {
|
||||
if (!Number.isInteger(groupId) || groupId < 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(groupId);
|
||||
return group.title === OPENCLAW_TAB_GROUP_TITLE;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTabsSync() {
|
||||
if (tabsSyncTimer) {
|
||||
return;
|
||||
@@ -215,29 +192,40 @@ async function syncTabsToRelay() {
|
||||
|
||||
async function attachDebugger(tabId) {
|
||||
await copilotCustodyReady;
|
||||
const accessRevision = tabAccessRevisions.get(tabId) ?? 0;
|
||||
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);
|
||||
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
|
||||
// auto-attach racing an explicit share) would otherwise both call
|
||||
// chrome.debugger.attach and the second throws "Another debugger is already
|
||||
// attached". The bridge and this worker can also disagree after an MV3 restart.
|
||||
const inFlight = attachingTabs.get(tabId);
|
||||
if (inFlight) {
|
||||
return await inFlight;
|
||||
const result = await inFlight;
|
||||
try {
|
||||
await assertAccess();
|
||||
} catch (error) {
|
||||
await detachDebugger(tabId);
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const accessRevision = copilotAccessRevisions.get(tabId) ?? 0;
|
||||
const assertAccess = () => {
|
||||
if (
|
||||
copilotDeniedTabs.has(tabId) ||
|
||||
(copilotAccessRevisions.get(tabId) ?? 0) !== accessRevision
|
||||
) {
|
||||
throw new Error(`tab ${tabId} is blocked until its copilot run stops`);
|
||||
}
|
||||
};
|
||||
const attach = (async () => {
|
||||
assertAccess();
|
||||
if (!(await isTabShared(tabId))) {
|
||||
throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`);
|
||||
}
|
||||
assertAccess();
|
||||
await assertAccess();
|
||||
if (!attachedTabs.has(tabId)) {
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, "1.3");
|
||||
@@ -248,7 +236,7 @@ async function attachDebugger(tabId) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertAccess();
|
||||
await assertAccess();
|
||||
} catch (error) {
|
||||
await detachDebugger(tabId);
|
||||
throw error;
|
||||
@@ -257,7 +245,7 @@ async function attachDebugger(tabId) {
|
||||
}
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
try {
|
||||
assertAccess();
|
||||
await assertAccess();
|
||||
} catch (error) {
|
||||
await detachDebugger(tabId);
|
||||
throw error;
|
||||
@@ -285,7 +273,7 @@ async function detachDebugger(tabId) {
|
||||
}
|
||||
|
||||
async function revokeCopilotDebugger(tabId) {
|
||||
copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
tabAccessRevisions.set(tabId, (tabAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
copilotDeniedTabs.add(tabId);
|
||||
const previous = copilotRevocations.get(tabId) ?? Promise.resolve();
|
||||
const revocation = previous
|
||||
@@ -305,9 +293,9 @@ async function revokeCopilotDebugger(tabId) {
|
||||
}
|
||||
|
||||
async function restoreCopilotDebugger(tabId) {
|
||||
const accessRevision = copilotAccessRevisions.get(tabId) ?? 0;
|
||||
const accessRevision = tabAccessRevisions.get(tabId) ?? 0;
|
||||
await copilotRevocations.get(tabId);
|
||||
if ((copilotAccessRevisions.get(tabId) ?? 0) === accessRevision) {
|
||||
if ((tabAccessRevisions.get(tabId) ?? 0) === accessRevision) {
|
||||
copilotDeniedTabs.delete(tabId);
|
||||
}
|
||||
}
|
||||
@@ -372,11 +360,14 @@ async function handleRelayCommand(msg) {
|
||||
return;
|
||||
}
|
||||
case "detach": {
|
||||
// Detach is the cleanup primitive after consent is revoked, so it must
|
||||
// remain available when the tab is no longer shared.
|
||||
await detachDebugger(msg.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
case "cdp": {
|
||||
await requireSharedTab(msg.tabId);
|
||||
const target = msg.sessionId
|
||||
? { tabId: msg.tabId, sessionId: msg.sessionId }
|
||||
: { tabId: msg.tabId };
|
||||
@@ -395,14 +386,17 @@ async function handleRelayCommand(msg) {
|
||||
return;
|
||||
}
|
||||
case "closeTab": {
|
||||
await requireSharedTab(msg.tabId);
|
||||
await detachDebugger(msg.tabId);
|
||||
await requireSharedTab(msg.tabId);
|
||||
await chrome.tabs.remove(msg.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
case "activateTab": {
|
||||
const tab = await chrome.tabs.get(msg.tabId);
|
||||
const tab = await requireSharedTab(msg.tabId);
|
||||
await chrome.tabs.update(msg.tabId, { active: true });
|
||||
await requireSharedTab(msg.tabId);
|
||||
await focusWindowForTab(tab);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
@@ -433,6 +427,7 @@ async function sendHello() {
|
||||
|
||||
async function connectRelay() {
|
||||
const { relayUrl, token } = await getConfig();
|
||||
await reconcilePairingInvalidation();
|
||||
if (!relayUrl || !token) {
|
||||
clearRelayOpeningDeadline();
|
||||
setBadge("off");
|
||||
@@ -500,6 +495,7 @@ async function sendPageShareRequest(payload) {
|
||||
|
||||
async function ensureRelayReady() {
|
||||
const config = await getConfig();
|
||||
await reconcilePairingInvalidation();
|
||||
if (!config.relayUrl || !config.token) {
|
||||
throw new Error("Pair the extension first.");
|
||||
}
|
||||
@@ -558,7 +554,7 @@ async function installPageShareContextMenu() {
|
||||
}
|
||||
|
||||
copilot = createCopilotController({
|
||||
getConfig: getCopilotConfig,
|
||||
getConfig,
|
||||
isTabShared,
|
||||
addTabToOpenClawGroup,
|
||||
attachDebugger,
|
||||
@@ -635,6 +631,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
switch (msg?.type) {
|
||||
case "getStatus": {
|
||||
const { relayUrl } = await getConfig();
|
||||
await reconcilePairingInvalidation();
|
||||
const shared = await listSharedTabs();
|
||||
sendResponse({
|
||||
paired: Boolean(relayUrl),
|
||||
@@ -650,26 +647,19 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
sendResponse({ ok: false, error: "Invalid pairing string." });
|
||||
return;
|
||||
}
|
||||
await chrome.storage.local.set({
|
||||
relayUrl: parsed.relayUrl,
|
||||
token: parsed.token,
|
||||
groupColor: nearestGroupColor(msg.groupColor),
|
||||
});
|
||||
await pairingConfigStore.save(parsed, nearestGroupColor(msg.groupColor));
|
||||
reconnectAttempt = 0;
|
||||
clearRelayOpeningDeadline();
|
||||
closeRelaySocket();
|
||||
relayWs = null;
|
||||
await chrome.storage.local.set({ gatewayUrl: parsed.gatewayUrl ?? "" });
|
||||
await connectRelay();
|
||||
await copilot.refreshConfig();
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
case "unpair": {
|
||||
await chrome.storage.local.remove(["relayUrl", "gatewayUrl", "token"]);
|
||||
await pairingConfigStore.clear();
|
||||
clearRelayOpeningDeadline();
|
||||
closeRelaySocket();
|
||||
relayWs = null;
|
||||
setBadge("off");
|
||||
await copilot.refreshConfig();
|
||||
sendResponse({ ok: true });
|
||||
@@ -727,7 +717,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
tabAccessRevisions.set(tabId, (tabAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
attachedTabs.delete(tabId);
|
||||
copilotDeniedTabs.delete(tabId);
|
||||
scheduleTabsSync();
|
||||
@@ -741,9 +731,13 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
}
|
||||
// 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((shared) =>
|
||||
copilot.onConsentChanged(tabId, { revoked: !shared }),
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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", "gatewayUrl", "token", "groupColor"];
|
||||
|
||||
type SocketEvent = { data?: unknown };
|
||||
type SocketListener = (event: SocketEvent) => void;
|
||||
@@ -21,13 +24,27 @@ type PageCaptureResult = {
|
||||
async function loadBackground({
|
||||
deferSocketClose = false,
|
||||
onConsentChanged,
|
||||
rejectStorageRemove = false,
|
||||
storedConfig,
|
||||
}: {
|
||||
deferSocketClose?: boolean;
|
||||
onConsentChanged?: () => Promise<void>;
|
||||
rejectStorageRemove?: boolean;
|
||||
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,
|
||||
groupColor: "orange",
|
||||
}),
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
@@ -49,7 +66,7 @@ async function loadBackground({
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly protocols: string[],
|
||||
readonly protocols: string[] = [],
|
||||
) {
|
||||
sockets.push(this);
|
||||
}
|
||||
@@ -81,6 +98,27 @@ async function loadBackground({
|
||||
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 } },
|
||||
@@ -119,13 +157,9 @@ async function loadBackground({
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn(async () => ({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: "test-token-placeholder",
|
||||
groupColor: "orange",
|
||||
})),
|
||||
set: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
get: storageGet,
|
||||
set: storageSet,
|
||||
remove: storageRemove,
|
||||
},
|
||||
session: {
|
||||
get: vi.fn(async () => ({})),
|
||||
@@ -137,20 +171,44 @@ async function loadBackground({
|
||||
},
|
||||
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 () => ({ id: 1, windowId: 1 })),
|
||||
group: vi.fn(async () => 1),
|
||||
ungroup: vi.fn(async () => undefined),
|
||||
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 },
|
||||
onUpdated: {
|
||||
addListener: vi.fn(
|
||||
(listener: (tabId: number, changeInfo: { groupId?: number }) => void) => {
|
||||
tabsUpdatedListener = listener;
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
windows: { update: vi.fn(async () => undefined) },
|
||||
};
|
||||
@@ -171,8 +229,14 @@ async function loadBackground({
|
||||
// The shipped MV3 worker is plain JS, so keep this a runtime-resolved import.
|
||||
const backgroundModulePath = "./background.js";
|
||||
await import(backgroundModulePath);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.waitFor(() => {
|
||||
const pairingReads = storageGet.mock.calls.filter(
|
||||
([keys]) =>
|
||||
keys.length === PAIRING_CONFIG_KEYS.length &&
|
||||
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");
|
||||
@@ -180,24 +244,253 @@ async function loadBackground({
|
||||
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"));
|
||||
},
|
||||
messageListener,
|
||||
get relaySockets() {
|
||||
return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay"));
|
||||
},
|
||||
setBadgeText,
|
||||
sockets,
|
||||
storageRemove: chromeMock.storage.local.remove,
|
||||
storageSet: chromeMock.storage.local.set,
|
||||
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(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens the canonical persisted pairing on startup", async () => {
|
||||
const harness = await loadBackground({
|
||||
storedConfig: {
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com/base",
|
||||
groupColor: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.relaySockets).toHaveLength(1);
|
||||
expect(harness.gatewaySockets).toHaveLength(1);
|
||||
});
|
||||
expect(harness.relaySockets[0]).toMatchObject({
|
||||
url: "wss://gateway.example.com/base/browser/extension",
|
||||
protocols: ["openclaw-extension-relay", `openclaw-extension-token.${RELAY_SECRET}`],
|
||||
});
|
||||
expect(harness.storageRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
|
||||
[
|
||||
"an unsafe remote relay URL",
|
||||
{ relayUrl: "ws://gateway.example.com/extension", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"URL credentials",
|
||||
{ relayUrl: "wss://user:pass@gateway.example.com/extension", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"an unsafe remote Gateway URL",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "ws://gateway.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Gateway URL credentials",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://user:pass@gateway.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"a Gateway URL query",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com?token=nope",
|
||||
},
|
||||
],
|
||||
[
|
||||
"a Gateway URL fragment",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com#fragment",
|
||||
},
|
||||
],
|
||||
["a malformed URL", { relayUrl: "not a URL", token: RELAY_SECRET }],
|
||||
[
|
||||
"an unknown query",
|
||||
{ relayUrl: "ws://127.0.0.1:18797/extension?unknown=1", token: RELAY_SECRET },
|
||||
],
|
||||
["partial state", { relayUrl: "ws://127.0.0.1:18797/extension", groupColor: "orange" }],
|
||||
[
|
||||
"mismatched direct state",
|
||||
{
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://other.example.com/base",
|
||||
},
|
||||
],
|
||||
])("clears %s before startup can open a socket", async (_label, storedConfig) => {
|
||||
const harness = await loadBackground({ storedConfig });
|
||||
|
||||
expect(harness.relaySockets).toHaveLength(0);
|
||||
expect(harness.gatewaySockets).toHaveLength(0);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
|
||||
const response = vi.fn();
|
||||
harness.messageListener({ type: "getStatus" }, {}, response);
|
||||
await vi.waitFor(() => {
|
||||
expect(response).toHaveBeenCalledWith({
|
||||
paired: false,
|
||||
state: "off",
|
||||
sharedTabCount: 0,
|
||||
relayUrl: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("stays unpaired when clearing invalid persisted state fails", async () => {
|
||||
const harness = await loadBackground({
|
||||
rejectStorageRemove: true,
|
||||
storedConfig: { relayUrl: "ws://gateway.example.com/extension", token: RELAY_SECRET },
|
||||
});
|
||||
|
||||
const response = vi.fn();
|
||||
harness.messageListener({ type: "getStatus" }, {}, response);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(response).toHaveBeenCalledWith({
|
||||
paired: false,
|
||||
state: "off",
|
||||
sharedTabCount: 0,
|
||||
relayUrl: "",
|
||||
});
|
||||
});
|
||||
expect(harness.relaySockets).toHaveLength(0);
|
||||
expect(harness.gatewaySockets).toHaveLength(0);
|
||||
expect(harness.storageRemove).toHaveBeenCalled();
|
||||
expect(harness.storageValues).toMatchObject({ token: RELAY_SECRET });
|
||||
});
|
||||
|
||||
it("revalidates persisted state before a reconnect", async () => {
|
||||
const harness = await loadBackground();
|
||||
const socket = harness.sockets[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected initial relay socket");
|
||||
}
|
||||
harness.storageValues.token = "invalid-after-startup";
|
||||
|
||||
socket.close();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(harness.sockets).toHaveLength(1);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
|
||||
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" });
|
||||
});
|
||||
|
||||
it("disconnects both live consumers when the watchdog observes invalid state", async () => {
|
||||
const harness = await loadBackground({
|
||||
storedConfig: {
|
||||
relayUrl: "wss://gateway.example.com/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.relaySockets).toHaveLength(1);
|
||||
expect(harness.gatewaySockets).toHaveLength(1);
|
||||
});
|
||||
harness.storageValues.token = "invalid-after-startup";
|
||||
|
||||
harness.alarmListener({ name: RELAY_WATCHDOG_ALARM });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.relaySockets[0]?.close).toHaveBeenCalled();
|
||||
expect(harness.gatewaySockets[0]?.close).toHaveBeenCalled();
|
||||
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" });
|
||||
});
|
||||
expect(harness.sockets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not let stale invalid cleanup erase a concurrently saved pairing", async () => {
|
||||
const harness = await loadBackground();
|
||||
harness.storageValues.token = "invalid-after-startup";
|
||||
const releaseRemove = harness.deferNextStorageRemove();
|
||||
const statusResponse = vi.fn();
|
||||
harness.messageListener({ type: "getStatus" }, {}, statusResponse);
|
||||
await vi.waitFor(() => expect(harness.storageRemove).toHaveBeenCalled());
|
||||
const pairResponse = vi.fn();
|
||||
harness.messageListener(
|
||||
{
|
||||
type: "pair",
|
||||
pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`,
|
||||
},
|
||||
{},
|
||||
pairResponse,
|
||||
);
|
||||
|
||||
releaseRemove();
|
||||
|
||||
await vi.waitFor(() => expect(pairResponse).toHaveBeenCalledWith({ ok: true }));
|
||||
expect(harness.storageValues).toMatchObject({
|
||||
relayUrl: "ws://127.0.0.1:18798/extension",
|
||||
token: REPLACEMENT_RELAY_SECRET,
|
||||
gatewayUrl: "",
|
||||
});
|
||||
const replacement = harness.relaySockets.find(
|
||||
(socket) => socket.url === "ws://127.0.0.1:18798/extension",
|
||||
);
|
||||
expect(replacement).toBeDefined();
|
||||
expect(replacement?.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function startPendingPageShare(
|
||||
harness: Awaited<ReturnType<typeof loadBackground>>,
|
||||
socket = harness.sockets.at(-1),
|
||||
@@ -396,7 +689,7 @@ describe("popup message failure responses", () => {
|
||||
{
|
||||
message: {
|
||||
type: "pair" as const,
|
||||
pairingString: "ws://127.0.0.1:18798/extension#replacement-token-placeholder",
|
||||
pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`,
|
||||
},
|
||||
operation: "set" as const,
|
||||
error: "Could not save browser pairing.",
|
||||
@@ -477,7 +770,7 @@ describe("page-share relay request lifecycle", () => {
|
||||
harness.messageListener(
|
||||
{
|
||||
type: "pair",
|
||||
pairingString: "ws://127.0.0.1:18798/extension#replacement-token-placeholder",
|
||||
pairingString: `ws://127.0.0.1:18798/extension#${REPLACEMENT_RELAY_SECRET}`,
|
||||
},
|
||||
{},
|
||||
pairResponse,
|
||||
@@ -566,3 +859,114 @@ 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");
|
||||
}
|
||||
socket.open();
|
||||
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");
|
||||
}
|
||||
socket.open();
|
||||
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");
|
||||
}
|
||||
socket.open();
|
||||
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");
|
||||
}
|
||||
socket.open();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,24 @@ export function parsePairingString(raw: unknown): {
|
||||
token: string;
|
||||
gatewayUrl?: string;
|
||||
} | null;
|
||||
export function createPairingConfigStore(storage: {
|
||||
get(keys: string[]): Promise<Record<string, unknown>>;
|
||||
set(values: Record<string, unknown>): Promise<void>;
|
||||
remove(keys: string[]): Promise<void>;
|
||||
}): {
|
||||
readonly invalidationRevision: number;
|
||||
read(): Promise<{
|
||||
relayUrl: string;
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
groupColor: string;
|
||||
}>;
|
||||
save(
|
||||
pairing: { relayUrl: string; token: string; gatewayUrl?: string },
|
||||
groupColor: string,
|
||||
): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export function buildRelayWsProtocols(token: string): string[];
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw";
|
||||
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
|
||||
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
|
||||
const RELAY_SECRET_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token"];
|
||||
|
||||
const CHROME_GROUP_COLORS = {
|
||||
grey: [128, 128, 128],
|
||||
@@ -19,6 +21,101 @@ const CHROME_GROUP_COLORS = {
|
||||
orange: [255, 112, 32],
|
||||
};
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const normalized = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.replace(/\.+$/, "");
|
||||
if (normalized === "localhost" || normalized === "::1") {
|
||||
return true;
|
||||
}
|
||||
const ipv4 = /^(\d{1,3})(?:\.\d{1,3}){3}$/.exec(normalized);
|
||||
if (ipv4?.[1] === "127") {
|
||||
return true;
|
||||
}
|
||||
// URL canonicalizes mapped loopback addresses to ::ffff:7fxx:xxxx.
|
||||
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
|
||||
return mapped ? Number.parseInt(mapped[1], 16) >> 8 === 0x7f : false;
|
||||
}
|
||||
|
||||
function isAllowedWebSocketUrl(url) {
|
||||
if (url.username || url.password) {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === "wss:" || (url.protocol === "ws:" && isLoopbackHost(url.hostname));
|
||||
}
|
||||
|
||||
function parseGatewayHint(raw) {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
let gateway;
|
||||
try {
|
||||
gateway = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isAllowedWebSocketUrl(gateway) || gateway.search || gateway.hash) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function directGatewayUrlFromRelay(relay) {
|
||||
const suffix = "/browser/extension";
|
||||
if (!relay.pathname.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
const gateway = new URL(relay.toString());
|
||||
gateway.pathname = gateway.pathname.slice(0, -suffix.length) || "/";
|
||||
return gateway.toString();
|
||||
}
|
||||
|
||||
function validatePairingFields(relayUrl, token, gatewayUrl) {
|
||||
if (typeof relayUrl !== "string" || typeof token !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (!RELAY_SECRET_PATTERN.test(token)) {
|
||||
return null;
|
||||
}
|
||||
let relay;
|
||||
try {
|
||||
relay = new URL(relayUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isAllowedWebSocketUrl(relay) ||
|
||||
!relay.pathname.endsWith("/extension") ||
|
||||
relay.search ||
|
||||
relay.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const hasGateway = gatewayUrl !== undefined && gatewayUrl !== "";
|
||||
const parsedGateway = hasGateway ? parseGatewayHint(gatewayUrl) : undefined;
|
||||
if (hasGateway && !parsedGateway) {
|
||||
return null;
|
||||
}
|
||||
const directGateway = directGatewayUrlFromRelay(relay);
|
||||
if (directGateway && parsedGateway) {
|
||||
const normalizedGateway = new URL(parsedGateway);
|
||||
normalizedGateway.pathname = normalizedGateway.pathname.replace(/\/+$/, "") || "/";
|
||||
if (normalizedGateway.toString() !== directGateway) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
relayUrl: relay.toString(),
|
||||
token,
|
||||
...(parsedGateway ? { gatewayUrl: parsedGateway } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pairing string printed by `openclaw browser extension pair`.
|
||||
* Shape: ws://127.0.0.1:<port>/extension?gateway=<url>#<token>
|
||||
@@ -32,31 +129,87 @@ export function parsePairingString(raw) {
|
||||
return null;
|
||||
}
|
||||
const relayUrl = trimmed.slice(0, hashIndex);
|
||||
const token = trimmed.slice(hashIndex + 1).trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const token = trimmed.slice(hashIndex + 1);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(relayUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
const query = [...parsed.searchParams];
|
||||
if (query.length > 1 || (query.length === 1 && query[0]?.[0] !== "gateway")) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.pathname.endsWith("/extension")) {
|
||||
const gatewayUrl = query.length === 1 ? query[0]?.[1] : undefined;
|
||||
if (query.length === 1 && !gatewayUrl?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const gatewayUrl = parsed.searchParams.get("gateway")?.trim() || undefined;
|
||||
parsed.searchParams.delete("gateway");
|
||||
if ([...parsed.searchParams].length > 0) {
|
||||
parsed.search = "";
|
||||
return validatePairingFields(parsed.toString(), token, gatewayUrl);
|
||||
}
|
||||
|
||||
/** Validate the canonical tuple persisted in chrome.storage.local. */
|
||||
function parseStoredPairing(stored) {
|
||||
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = validatePairingFields(stored.relayUrl, stored.token, stored.gatewayUrl);
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.relayUrl !== stored.relayUrl ||
|
||||
parsed.token !== stored.token ||
|
||||
(parsed.gatewayUrl ?? "") !== (stored.gatewayUrl ?? "")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Own serialized validation and mutation at the extension pairing storage boundary. */
|
||||
export function createPairingConfigStore(storage) {
|
||||
let chain = Promise.resolve();
|
||||
let invalidObserved = false;
|
||||
let invalidationRevision = 0;
|
||||
const run = (task) => {
|
||||
const pending = chain.then(task, task);
|
||||
chain = pending.catch(() => undefined);
|
||||
return pending;
|
||||
};
|
||||
return {
|
||||
relayUrl: parsed.toString(),
|
||||
token,
|
||||
...(gatewayUrl ? { gatewayUrl } : {}),
|
||||
get invalidationRevision() {
|
||||
return invalidationRevision;
|
||||
},
|
||||
read: () =>
|
||||
run(async () => {
|
||||
const stored = await storage.get([...PAIRING_STORAGE_KEYS, "groupColor"]);
|
||||
const hasPairing = PAIRING_STORAGE_KEYS.some((key) => Object.hasOwn(stored, key));
|
||||
const pairing = hasPairing ? parseStoredPairing(stored) : null;
|
||||
if (hasPairing && !pairing) {
|
||||
if (!invalidObserved) {
|
||||
invalidationRevision += 1;
|
||||
}
|
||||
invalidObserved = true;
|
||||
await storage.remove(PAIRING_STORAGE_KEYS).catch(() => undefined);
|
||||
} else {
|
||||
invalidObserved = false;
|
||||
}
|
||||
return {
|
||||
relayUrl: pairing?.relayUrl ?? "",
|
||||
token: pairing?.token ?? "",
|
||||
gatewayUrl: pairing?.gatewayUrl ?? "",
|
||||
groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange",
|
||||
};
|
||||
}),
|
||||
save: (pairing, groupColor) =>
|
||||
run(() =>
|
||||
storage.set({
|
||||
relayUrl: pairing.relayUrl,
|
||||
token: pairing.token,
|
||||
gatewayUrl: pairing.gatewayUrl ?? "",
|
||||
groupColor,
|
||||
}),
|
||||
),
|
||||
clear: () => run(() => storage.remove(PAIRING_STORAGE_KEYS)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,23 +3,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRelayWsProtocols,
|
||||
createPairingConfigStore,
|
||||
nearestGroupColor,
|
||||
parsePairingString,
|
||||
reconnectDelayMs,
|
||||
} from "./relay-core.js";
|
||||
|
||||
const RELAY_SECRET = "a".repeat(64);
|
||||
|
||||
describe("parsePairingString", () => {
|
||||
it("parses a valid pairing string the CLI emits", () => {
|
||||
const parsed = parsePairingString("ws://127.0.0.1:18797/extension#deadbeefcafe");
|
||||
const parsed = parsePairingString(`ws://127.0.0.1:18797/extension#${RELAY_SECRET}`);
|
||||
expect(parsed).toEqual({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: "deadbeefcafe",
|
||||
token: RELAY_SECRET,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips with the CLI pairing format", () => {
|
||||
const port = 18797;
|
||||
const token = "abc123";
|
||||
const token = RELAY_SECRET;
|
||||
const pairing = `ws://127.0.0.1:${port}/extension#${token}`;
|
||||
const parsed = parsePairingString(pairing);
|
||||
if (!parsed) {
|
||||
@@ -32,23 +35,165 @@ describe("parsePairingString", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed strings", () => {
|
||||
expect(parsePairingString("")).toBeNull();
|
||||
expect(parsePairingString("http://127.0.0.1/extension#tok")).toBeNull();
|
||||
expect(parsePairingString("ws://127.0.0.1/other#tok")).toBeNull();
|
||||
expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull();
|
||||
expect(parsePairingString("ws://127.0.0.1/extension")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts the additive direct Gateway hint without passing it to the relay", () => {
|
||||
const gatewayUrl = "wss://gateway.example.com/base";
|
||||
const pairing = `ws://127.0.0.1:18797/extension?gateway=${encodeURIComponent(gatewayUrl)}#tok`;
|
||||
const pairing = `ws://127.0.0.1:18797/extension?gateway=${encodeURIComponent(gatewayUrl)}#${RELAY_SECRET}`;
|
||||
expect(parsePairingString(pairing)).toEqual({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: "tok",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ws://localhost.:18797/extension",
|
||||
"ws://127.25.0.1:18797/extension",
|
||||
"ws://[::1]:18797/extension",
|
||||
"ws://[::ffff:127.0.0.1]:18797/extension",
|
||||
"wss://gateway.example.com/browser/extension",
|
||||
])("accepts the supported relay transport %s", (relayUrl) => {
|
||||
expect(parsePairingString(`${relayUrl}#${RELAY_SECRET}`)?.token).toBe(RELAY_SECRET);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty string", ""],
|
||||
["an HTTP URL", `http://127.0.0.1/extension#${RELAY_SECRET}`],
|
||||
["a non-loopback plaintext URL", `ws://gateway.example.com/extension#${RELAY_SECRET}`],
|
||||
["relay credentials", `wss://user:pass@gateway.example.com/extension#${RELAY_SECRET}`],
|
||||
["the wrong path", `ws://127.0.0.1/other#${RELAY_SECRET}`],
|
||||
["a missing secret", "ws://127.0.0.1/extension#"],
|
||||
["a short secret", "ws://127.0.0.1/extension#abc123"],
|
||||
["an uppercase secret", `ws://127.0.0.1/extension#${"A".repeat(64)}`],
|
||||
["an unknown query parameter", `ws://127.0.0.1/extension?token=nope#${RELAY_SECRET}`],
|
||||
[
|
||||
"duplicate Gateway hints",
|
||||
`ws://127.0.0.1/extension?gateway=wss%3A%2F%2Fone.example&gateway=wss%3A%2F%2Ftwo.example#${RELAY_SECRET}`,
|
||||
],
|
||||
["an empty Gateway hint", `ws://127.0.0.1/extension?gateway=#${RELAY_SECRET}`],
|
||||
[
|
||||
"a credentialed Gateway hint",
|
||||
`ws://127.0.0.1/extension?gateway=${encodeURIComponent("wss://user:pass@gateway.example.com")}#${RELAY_SECRET}`,
|
||||
],
|
||||
[
|
||||
"an insecure remote Gateway hint",
|
||||
`ws://127.0.0.1/extension?gateway=${encodeURIComponent("ws://gateway.example.com")}#${RELAY_SECRET}`,
|
||||
],
|
||||
])("rejects %s", (_label, pairing) => {
|
||||
expect(parsePairingString(pairing)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function readStoredPairing(stored: Record<string, unknown>) {
|
||||
const config = await createPairingConfigStore({
|
||||
get: async () => stored,
|
||||
set: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
}).read();
|
||||
if (!config.relayUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
relayUrl: config.relayUrl,
|
||||
token: config.token,
|
||||
...(config.gatewayUrl ? { gatewayUrl: config.gatewayUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("persisted pairing storage", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "a loopback relay without a Gateway hint",
|
||||
stored: {
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "a loopback relay with an independent Gateway hint",
|
||||
stored: {
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com/base",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "a direct relay with its matching trailing-slash Gateway hint",
|
||||
stored: {
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com/base/",
|
||||
},
|
||||
},
|
||||
])("accepts $label", async ({ stored }) => {
|
||||
expect(await readStoredPairing(stored)).toEqual(stored);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
|
||||
[
|
||||
"an unsafe remote relay",
|
||||
{ relayUrl: "ws://gateway.example.com/extension", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"relay URL credentials",
|
||||
{ relayUrl: "wss://user:pass@gateway.example.com/extension", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"an unsafe remote Gateway hint",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "ws://gateway.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Gateway URL credentials",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://user:pass@gateway.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"a Gateway URL query",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com?token=nope",
|
||||
},
|
||||
],
|
||||
[
|
||||
"a Gateway URL fragment",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com#fragment",
|
||||
},
|
||||
],
|
||||
["a malformed relay URL", { relayUrl: "not a URL", token: RELAY_SECRET }],
|
||||
[
|
||||
"an unknown relay query",
|
||||
{ relayUrl: "ws://127.0.0.1:18797/extension?token=nope", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"duplicate relay queries",
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18797/extension?gateway=one&gateway=two",
|
||||
token: RELAY_SECRET,
|
||||
},
|
||||
],
|
||||
["partial state", { relayUrl: "ws://127.0.0.1:18797/extension" }],
|
||||
[
|
||||
"a mismatched direct Gateway hint",
|
||||
{
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://other.example.com/base",
|
||||
},
|
||||
],
|
||||
])("rejects %s", async (_label, stored) => {
|
||||
expect(await readStoredPairing(stored)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnectDelayMs", () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { OPENCLAW_TAB_GROUP_TITLE } from "./relay-core.js";
|
||||
|
||||
export async function findOpenClawGroups() {
|
||||
try {
|
||||
return await chrome.tabGroups.query({ title: OPENCLAW_TAB_GROUP_TITLE });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!Number.isInteger(groupId) || groupId < 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(groupId);
|
||||
return group.title === OPENCLAW_TAB_GROUP_TITLE;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { useAutoCleanupTempDirTracker } from "../test-support.js";
|
||||
import {
|
||||
copyCopilotSidepanelExtension,
|
||||
createRelayHarness,
|
||||
waitForContextExtensionId,
|
||||
waitForLoadedExtensionId,
|
||||
} from "./sidepanel.e2e-support.js";
|
||||
|
||||
@@ -18,9 +20,26 @@ declare const chrome: {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
storage: {
|
||||
local: {
|
||||
get(keys: string[]): Promise<Record<string, unknown>>;
|
||||
set(values: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
};
|
||||
tabGroups: {
|
||||
get(groupId: number): Promise<{ title?: string }>;
|
||||
};
|
||||
tabs: {
|
||||
get(tabId: number): Promise<{ id?: number; url?: string; windowId?: number }>;
|
||||
get(tabId: number): Promise<{
|
||||
active?: boolean;
|
||||
groupId?: number;
|
||||
id?: number;
|
||||
url?: string;
|
||||
windowId?: number;
|
||||
}>;
|
||||
query(query: Record<string, unknown>): Promise<Array<{ id?: number; url?: string }>>;
|
||||
remove(tabId: number): Promise<void>;
|
||||
ungroup(tabIds: number[]): Promise<void>;
|
||||
update(tabId: number, update: { active: boolean }): Promise<unknown>;
|
||||
};
|
||||
windows: {
|
||||
@@ -29,6 +48,7 @@ declare const chrome: {
|
||||
};
|
||||
|
||||
const runE2E = process.env.OPENCLAW_BROWSER_COPILOT_E2E === "1";
|
||||
const PAGE_SHARE_RELAY_SECRET = "c".repeat(64);
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
let nextPopupCommandId = 0;
|
||||
@@ -104,6 +124,148 @@ async function evaluateToolbarPopup<T>(
|
||||
}
|
||||
}
|
||||
|
||||
describe.runIf(runE2E)("Chrome extension relay authorization", () => {
|
||||
it("clears an invalid persisted pairing before reconnecting after restart", async () => {
|
||||
const relay = await createRelayHarness();
|
||||
cleanups.push(relay.close);
|
||||
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
|
||||
const userDataDir = tempDirs.make("openclaw-extension-persisted-auth-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 }) =>
|
||||
await chrome.storage.local.set({
|
||||
relayUrl: `ws://127.0.0.1:${relayPort}/extension`,
|
||||
token: "legacy-unsafe-token",
|
||||
gatewayUrl: "",
|
||||
groupColor: "orange",
|
||||
}),
|
||||
{ relayPort: relay.port },
|
||||
);
|
||||
await initialContext.close();
|
||||
|
||||
const reloadedContext = await chromium.launchPersistentContext(userDataDir, launchOptions);
|
||||
cleanups.push(async () => await reloadedContext.close());
|
||||
const extensionId = await waitForContextExtensionId(reloadedContext, unpackedExtension);
|
||||
expect(extensionId).toBe(initialExtensionId);
|
||||
const launcher = reloadedContext.pages()[0] ?? (await reloadedContext.newPage());
|
||||
await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await launcher.evaluate(
|
||||
async () => await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token"]),
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({});
|
||||
expect(
|
||||
await launcher.evaluate(async () => await chrome.runtime.sendMessage({ type: "getStatus" })),
|
||||
).toMatchObject({ paired: false, relayUrl: "", state: "off" });
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1_500);
|
||||
});
|
||||
expect(relay.connectionCount).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it("enforces pairing and current tab-group consent at the extension edge", async () => {
|
||||
const relay = await createRelayHarness();
|
||||
cleanups.push(relay.close);
|
||||
const fixture = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<!doctype html><title>Authorization fixture</title>");
|
||||
});
|
||||
const fixturePort = await listen(fixture);
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fixture.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
|
||||
const context = await chromium.launchPersistentContext(
|
||||
tempDirs.make("openclaw-extension-auth-profile-"),
|
||||
{
|
||||
channel: "chromium",
|
||||
headless: true,
|
||||
ignoreDefaultArgs: ["--disable-extensions"],
|
||||
args: [
|
||||
"--enable-unsafe-extension-debugging",
|
||||
`--disable-extensions-except=${unpackedExtension}`,
|
||||
`--load-extension=${unpackedExtension}`,
|
||||
],
|
||||
},
|
||||
);
|
||||
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`);
|
||||
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
|
||||
|
||||
const invalidPairing = await launcher.evaluate(
|
||||
async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }),
|
||||
`ws://gateway.example.com/extension#${PAGE_SHARE_RELAY_SECRET}`,
|
||||
);
|
||||
expect(invalidPairing).toEqual({ ok: false, error: "Invalid pairing string." });
|
||||
expect(relay.connectionCount).toBe(0);
|
||||
|
||||
const validPairing = await launcher.evaluate(
|
||||
async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }),
|
||||
`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" });
|
||||
await relay.command({ type: "attach", tabId });
|
||||
|
||||
await worker.evaluate(async (targetTabId) => await chrome.tabs.ungroup([targetTabId]), tabId);
|
||||
await expect(
|
||||
relay.command({ type: "cdp", tabId, method: "Runtime.evaluate", params: {} }),
|
||||
).rejects.toThrow(`tab ${tabId} is not in the OpenClaw tab group`);
|
||||
await expect(relay.command({ type: "activateTab", tabId })).rejects.toThrow(
|
||||
`tab ${tabId} is not in the OpenClaw tab group`,
|
||||
);
|
||||
await expect(relay.command({ type: "closeTab", tabId })).rejects.toThrow(
|
||||
`tab ${tabId} is not in the OpenClaw tab group`,
|
||||
);
|
||||
expect(
|
||||
await worker.evaluate(async (targetTabId) => await chrome.tabs.get(targetTabId), tabId),
|
||||
).toMatchObject({ active: false, id: tabId });
|
||||
|
||||
await expect(relay.command({ type: "detach", tabId })).resolves.toEqual({});
|
||||
await worker.evaluate(async (targetTabId) => await chrome.tabs.remove(targetTabId), tabId);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay", () => {
|
||||
it.each([
|
||||
{ label: "relay disconnection", unpair: false },
|
||||
@@ -116,7 +278,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
|
||||
});
|
||||
const relay = await startExtensionRelayServer({
|
||||
port: 0,
|
||||
token: "openclaw-autoqa-page-share-relay-placeholder",
|
||||
token: PAGE_SHARE_RELAY_SECRET,
|
||||
onPageShare: async (payload) => {
|
||||
receivedShares.push({ url: payload.url, content: payload.content });
|
||||
await delivery;
|
||||
@@ -340,7 +502,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
|
||||
it("keeps a real stale-tab sharing error visible across the popup status poll", async () => {
|
||||
const relay = await startExtensionRelayServer({
|
||||
port: 0,
|
||||
token: "openclaw-autoqa-popup-consent-relay-placeholder",
|
||||
token: PAGE_SHARE_RELAY_SECRET,
|
||||
});
|
||||
cleanups.push(async () => await relay.close());
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { BrowserContext, CDPSession, Page } from "playwright-core";
|
||||
import type { expect as VitestExpect } from "vitest";
|
||||
import type { RawData } from "ws";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
|
||||
type CopilotTurnIsolationGateway = {
|
||||
chatSends: Array<Record<string, unknown>>;
|
||||
@@ -57,6 +58,115 @@ export function rawDataText(data: RawData): string {
|
||||
: data.toString("utf8");
|
||||
}
|
||||
|
||||
type RelayHarness = {
|
||||
readonly connectionCount: number;
|
||||
hellos: Array<Record<string, unknown>>;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
command: (body: Record<string, unknown>) => Promise<unknown>;
|
||||
setAvailable: (available: boolean) => void;
|
||||
};
|
||||
|
||||
export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("extension relay test server did not bind a TCP port");
|
||||
}
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 1_000_000,
|
||||
handleProtocols: (protocols) => protocols.values().next().value ?? false,
|
||||
});
|
||||
const hellos: Array<Record<string, unknown>> = [];
|
||||
const pendingCommands = new Map<
|
||||
number,
|
||||
{ reject: (error: Error) => void; resolve: (result: unknown) => void }
|
||||
>();
|
||||
let available = true;
|
||||
let connectionCount = 0;
|
||||
let nextCommandSeq = 0;
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!available) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (client) => {
|
||||
wss.emit("connection", client, request);
|
||||
});
|
||||
});
|
||||
wss.on("connection", (socket) => {
|
||||
connectionCount += 1;
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
|
||||
if (message.type === "hello") {
|
||||
hellos.push(message);
|
||||
return;
|
||||
}
|
||||
const seq = typeof message.seq === "number" ? message.seq : undefined;
|
||||
if (seq === undefined || (message.type !== "result" && message.type !== "error")) {
|
||||
return;
|
||||
}
|
||||
const pending = pendingCommands.get(seq);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pendingCommands.delete(seq);
|
||||
if (message.type === "error") {
|
||||
pending.reject(new Error(textValue(message.message) || "extension relay command failed"));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
get connectionCount() {
|
||||
return connectionCount;
|
||||
},
|
||||
hellos,
|
||||
port: address.port,
|
||||
command: async (body) => {
|
||||
const client = [...wss.clients].find((candidate) => candidate.readyState === 1);
|
||||
if (!client) {
|
||||
throw new Error("extension relay client is not connected");
|
||||
}
|
||||
const seq = ++nextCommandSeq;
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
pendingCommands.set(seq, { resolve, reject });
|
||||
});
|
||||
client.send(JSON.stringify({ ...body, seq }));
|
||||
return await result;
|
||||
},
|
||||
setAvailable: (nextAvailable) => {
|
||||
available = nextAvailable;
|
||||
if (!available) {
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
},
|
||||
close: async () => {
|
||||
for (const pending of pendingCommands.values()) {
|
||||
pending.reject(new Error("extension relay harness closed"));
|
||||
}
|
||||
pendingCommands.clear();
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertCopilotStaleRunIsolation(params: {
|
||||
expect: typeof VitestExpect;
|
||||
gateway: CopilotTurnIsolationGateway;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
assertCopilotStaleRunIsolation,
|
||||
countCopilotHistoryRequests,
|
||||
copyCopilotSidepanelExtension,
|
||||
createRelayHarness,
|
||||
openTabPanel,
|
||||
rawDataText,
|
||||
resolveChromiumExecutableOverride,
|
||||
@@ -49,12 +50,24 @@ declare const chrome: {
|
||||
setOptions(options: { tabId: number; enabled: boolean }): Promise<void>;
|
||||
};
|
||||
tabs: {
|
||||
get(tabId: number): Promise<{
|
||||
active?: boolean;
|
||||
groupId?: number;
|
||||
id?: number;
|
||||
url?: string;
|
||||
windowId?: number;
|
||||
}>;
|
||||
getCurrent(): Promise<{ id?: number }>;
|
||||
remove(tabId: number): Promise<void>;
|
||||
ungroup(tabIds: number[]): Promise<void>;
|
||||
};
|
||||
tabGroups: {
|
||||
get(groupId: number): Promise<{ title?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
const runE2E = process.env.OPENCLAW_BROWSER_COPILOT_E2E === "1";
|
||||
const RELAY_SECRET = "a".repeat(64);
|
||||
|
||||
type RequestFrame = {
|
||||
id: string;
|
||||
@@ -78,14 +91,6 @@ type GatewayHarness = {
|
||||
holdNextSubscription: () => () => void;
|
||||
};
|
||||
|
||||
type RelayHarness = {
|
||||
readonly connectionCount: number;
|
||||
hellos: Array<Record<string, unknown>>;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
setAvailable: (available: boolean) => void;
|
||||
};
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
@@ -127,63 +132,6 @@ function sendError(
|
||||
);
|
||||
}
|
||||
|
||||
async function createRelayHarness(): Promise<RelayHarness> {
|
||||
const server = createServer();
|
||||
const port = await listen(server);
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 1_000_000,
|
||||
handleProtocols: (protocols) => protocols.values().next().value ?? false,
|
||||
});
|
||||
const hellos: Array<Record<string, unknown>> = [];
|
||||
let available = true;
|
||||
let connectionCount = 0;
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!available) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (client) => {
|
||||
wss.emit("connection", client, request);
|
||||
});
|
||||
});
|
||||
wss.on("connection", (socket) => {
|
||||
connectionCount += 1;
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
|
||||
if (message.type === "hello") {
|
||||
hellos.push(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
get connectionCount() {
|
||||
return connectionCount;
|
||||
},
|
||||
hellos,
|
||||
port,
|
||||
setAvailable: (nextAvailable) => {
|
||||
available = nextAvailable;
|
||||
if (!available) {
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
},
|
||||
close: async () => {
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGatewayHarness(): Promise<GatewayHarness> {
|
||||
const server = createServer();
|
||||
const port = await listen(server);
|
||||
@@ -454,13 +402,13 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
|
||||
const launcher = initialContext.pages()[0] ?? (await initialContext.newPage());
|
||||
await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
|
||||
await launcher.evaluate(
|
||||
async ({ gatewayPort, relayPort }) =>
|
||||
async ({ gatewayPort, relayPort, relaySecret }) =>
|
||||
await chrome.runtime.sendMessage({
|
||||
type: "pair",
|
||||
pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#relay-e2e-token`,
|
||||
pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`,
|
||||
groupColor: "#ff7020",
|
||||
}),
|
||||
{ gatewayPort: gateway.port, relayPort: relay.port },
|
||||
{ gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET },
|
||||
);
|
||||
await expect.poll(() => gateway.connectParams.length, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
@@ -639,13 +587,13 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
|
||||
await alphaTab.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
|
||||
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
|
||||
await alphaTab.evaluate(
|
||||
async ({ gatewayPort, relayPort }) =>
|
||||
async ({ gatewayPort, relayPort, relaySecret }) =>
|
||||
await chrome.runtime.sendMessage({
|
||||
type: "pair",
|
||||
pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#relay-e2e-token`,
|
||||
pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#${relaySecret}`,
|
||||
groupColor: "#ff7020",
|
||||
}),
|
||||
{ gatewayPort: gateway.port, relayPort: relay.port },
|
||||
{ gatewayPort: gateway.port, relayPort: relay.port, relaySecret: RELAY_SECRET },
|
||||
);
|
||||
await expect.poll(() => gateway.connectParams.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createCliRuntimeCapture } from "../../test-support.js";
|
||||
import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js";
|
||||
import * as cliCoreApiModule from "./core-api.js";
|
||||
|
||||
const relayMocks = vi.hoisted(() => ({ ensureExtensionRelayToken: vi.fn(() => "pair-token") }));
|
||||
const relayMocks = vi.hoisted(() => ({ ensureExtensionRelayToken: vi.fn(() => "a".repeat(64)) }));
|
||||
|
||||
vi.mock("../browser/extension-relay/relay-auth.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../browser/extension-relay/relay-auth.js")>()),
|
||||
@@ -52,7 +52,7 @@ describe("browser extension pairing Gateway URL", () => {
|
||||
await program.parseAsync(["browser", "extension", "pair", "--json"], { from: "user" });
|
||||
|
||||
expect(writeJsonSpy).toHaveBeenCalledWith({
|
||||
pairingString: expect.stringContaining("#pair-token"),
|
||||
pairingString: expect.stringContaining(`#${"a".repeat(64)}`),
|
||||
relayPort: 18799,
|
||||
remote: false,
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe("browser extension pairing Gateway URL", () => {
|
||||
expect(writeJsonSpy).toHaveBeenCalledWith({
|
||||
browserUrl: "http://127.0.0.1:18799",
|
||||
wsEndpoint: "ws://127.0.0.1:18799/cdp",
|
||||
headers: { Authorization: "Bearer pair-token" },
|
||||
headers: { Authorization: `Bearer ${"a".repeat(64)}` },
|
||||
});
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user