Files
openclaw/extensions/browser/chrome-extension/modules/relay-core.js
Peter Steinberger 5350b92dc8 fix(browser): wake extension relay through gateway (#122537)
* fix(browser): wake extension relay through gateway

* fix(browser): preserve manual extension relay pairing
2026-08-12 02:13:03 -07:00

364 lines
12 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; 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 =
"Stored proxy-prefixed browser relay pairing is no longer supported. Re-run `openclaw browser extension pair` with a Gateway URL that has no path prefix.";
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],
};
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) {
if (relay.pathname !== "/browser/extension") {
return null;
}
const gateway = new URL(relay.toString());
gateway.pathname = "/";
gateway.search = "";
return gateway.toString();
}
function isUnsupportedProxyPrefix(raw) {
if (typeof raw !== "string") {
return false;
}
try {
const relay = new URL(raw);
return (
isAllowedWebSocketUrl(relay) &&
relay.pathname !== "/browser/extension" &&
relay.pathname.endsWith("/browser/extension")
);
} catch {
return false;
}
}
function normalizeRelayQuery(relay) {
const query = [...relay.searchParams];
if (
query.some(([key, value]) => key !== "profile" || !/^[a-z0-9-]+$/.test(value)) ||
query.filter(([key]) => key === "profile").length > 1
) {
return false;
}
relay.searchParams.sort();
return true;
}
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;
}
const supportedPath =
(isLoopbackHost(relay.hostname) && relay.pathname === "/extension") ||
relay.pathname === "/browser/extension";
if (
!isAllowedWebSocketUrl(relay) ||
!supportedPath ||
!normalizeRelayQuery(relay) ||
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`.
* Native local and direct-remote pairings use the Gateway route; local manual,
* browser-node, and legacy local pairings use the host relay route.
* The additive gateway hint is not a credential; old extensions safely pass
* it through to the relay while new extensions remove it before connecting.
*/
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);
let parsed;
try {
parsed = new URL(relayUrl);
} catch {
return null;
}
const query = [...parsed.searchParams];
const gatewayEntries = query.filter(([key]) => key === "gateway");
const profileEntries = query.filter(([key]) => key === "profile");
if (
gatewayEntries.length > 1 ||
profileEntries.length > 1 ||
query.some(([key]) => key !== "gateway" && key !== "profile")
) {
return null;
}
const gatewayUrl = gatewayEntries[0]?.[1];
if ((gatewayEntries.length === 1 && !gatewayUrl?.trim()) || profileEntries[0]?.[1] === "") {
return null;
}
parsed.searchParams.delete("gateway");
parsed.searchParams.sort();
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 ||
(stored.authVersion !== undefined && stored.authVersion !== 2) ||
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 {
get invalidationRevision() {
return invalidationRevision;
},
read: () =>
run(async () => {
const stored = await storage.get([
...PAIRING_STORAGE_KEYS,
ACCESS_MODE_KEY,
PAIRING_STATUS_KEY,
"groupColor",
]);
const hasPairing = PAIRING_STORAGE_KEYS.some((key) => Object.hasOwn(stored, key));
const pairing = hasPairing ? parseStoredPairing(stored) : null;
let pairingStatus =
stored[PAIRING_STATUS_KEY] === UNSUPPORTED_PROXY_PREFIX_STATUS
? UNSUPPORTED_PROXY_PREFIX_STATUS
: "";
if (hasPairing && !pairing) {
if (!invalidObserved) {
invalidationRevision += 1;
}
invalidObserved = true;
pairingStatus = isUnsupportedProxyPrefix(stored.relayUrl)
? UNSUPPORTED_PROXY_PREFIX_STATUS
: "";
await storage.remove(PAIRING_STORAGE_KEYS).catch(() => undefined);
if (pairingStatus) {
await storage.set({ [PAIRING_STATUS_KEY]: pairingStatus }).catch(() => undefined);
} else if (Object.hasOwn(stored, PAIRING_STATUS_KEY)) {
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
}
} else {
invalidObserved = false;
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 = "";
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
}
}
return {
relayUrl: pairing?.relayUrl ?? "",
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, 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]);
}),
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])),
};
}
/** Build the v2 WebSocket subprotocol list; credentials stay in WebCrypto only. */
export function buildRelayWsProtocols() {
return [EXTENSION_RELAY_PROTOCOL];
}
/** 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,
};
}