mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 07:33:46 -06:00
5350b92dc8
* fix(browser): wake extension relay through gateway * fix(browser): preserve manual extension relay pairing
364 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|