Files
openclaw/extensions/browser/chrome-extension/modules/relay-core.js
Peter Steinberger 654029892d feat(browser): pair the Chrome extension directly to a remote gateway (#101127)
* feat(browser): direct extension→gateway relay path for remote Chrome (#53599)

Let the OpenClaw Chrome extension pair directly to a remote gateway over
wss:// with no OpenClaw node host on the browser machine — the managed-hosting
path from #53599 (extension is the only thing installed on the laptop).

- Gateway route /browser/extension registered by the browser plugin with
  auth:"plugin" + no nodeCapability, so the gateway does not pre-enforce token
  auth (browser WebSockets cannot send an Authorization header). The upgrade
  handler self-validates the host-local relay secret from ?token=, origin-checks
  chrome-extension://, resolves the extension profile, then attaches the socket
  to the same ExtensionRelayBridge the loopback relay uses. All CDP synthesis,
  tab-group scoping, and the in-process Playwright /cdp client are unchanged.
- `openclaw browser extension pair --gateway-url wss://host` prints a
  wss://host/browser/extension#<secret> string; the path ends in /extension so
  the extension's existing pairing parser accepts it with zero extension code
  changes.
- relay-server: extract attachExtensionWebSocket + export requestToken /
  isAllowedExtensionOrigin / EXTENSION_RELAY_MAX_PAYLOAD_BYTES so loopback and
  gateway paths share one bind + one frame cap.
- runtime-lifecycle: dispose the shared gateway WebSocketServer on shutdown.
- docs: three remote topologies (same host / direct-to-gateway / via node host).

Coverage: 6 unit tests for the handler's path/503/403/404/401/attach branches.
The full extension→bridge→CDP→Chrome loop over /browser/extension was live-proven
with a real Chrome + the built extension. The real gateway upgrade→handleUpgrade
dispatch for an auth:"plugin" unprotected route is verified against core
(server-http.ts, plugins-http.ts, route-auth.ts).

* fix(browser): harden remote extension pairing
2026-07-07 04:31:52 +01:00

95 lines
2.8 KiB
JavaScript

// Pure helpers for the OpenClaw extension: pairing-string parsing, reconnect
// 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. */
export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw";
export const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
const CHROME_GROUP_COLORS = {
grey: [128, 128, 128],
blue: [66, 133, 244],
red: [219, 68, 55],
yellow: [244, 180, 0],
green: [15, 157, 88],
pink: [233, 30, 99],
purple: [156, 39, 176],
cyan: [0, 188, 212],
orange: [255, 112, 32],
};
/**
* Parse a pairing string printed by `openclaw browser extension pair`.
* Shape: ws://127.0.0.1:<port>/extension#<token>
* Returns { relayUrl, token } or null when malformed.
*/
export function parsePairingString(raw) {
const trimmed = String(raw ?? "").trim();
const hashIndex = trimmed.indexOf("#");
if (hashIndex <= 0) {
return null;
}
const relayUrl = trimmed.slice(0, hashIndex);
const token = trimmed.slice(hashIndex + 1).trim();
if (!token) {
return null;
}
let parsed;
try {
parsed = new URL(relayUrl);
} catch {
return null;
}
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return null;
}
if (!parsed.pathname.endsWith("/extension")) {
return null;
}
return { relayUrl, token };
}
/** Build WebSocket subprotocols without putting the relay secret in the request URL. */
export function buildRelayWsProtocols(token) {
return [EXTENSION_RELAY_PROTOCOL, `${EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX}${token}`];
}
/** Exponential reconnect backoff: 1s, 2s, 4s ... capped at 30s. */
export function reconnectDelayMs(attempt) {
const capped = Math.min(Math.max(0, attempt), 5);
return Math.min(1000 * 2 ** capped, 30_000);
}
/** Map a hex color to the closest Chrome tab-group color name. */
export function nearestGroupColor(hex) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? "").trim());
if (!match) {
return "orange";
}
const value = Number.parseInt(match[1], 16);
const r = (value >> 16) & 0xff;
const g = (value >> 8) & 0xff;
const b = value & 0xff;
let best = "orange";
let bestDistance = Number.POSITIVE_INFINITY;
for (const [name, [cr, cg, cb]] of Object.entries(CHROME_GROUP_COLORS)) {
const distance = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
if (distance < bestDistance) {
bestDistance = distance;
best = name;
}
}
return best;
}
/** Normalize a chrome.tabs.Tab into the relay's tab info shape. */
export function toRelayTabInfo(tab) {
return {
tabId: tab.id,
url: tab.url ?? "",
title: tab.title ?? "",
active: tab.active === true,
};
}