diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js index 183e35f9c4a..fe32d2c0616 100644 --- a/assets/chrome-extension/background-utils.js +++ b/assets/chrome-extension/background-utils.js @@ -11,14 +11,32 @@ export function reconnectDelayMs( return backoff + Math.max(0, jitterMs) * random(); } -export function buildRelayWsUrl(port, gatewayToken) { +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`openclaw-extension-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { const token = String(gatewayToken || "").trim(); if (!token) { throw new Error( "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", ); } - return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`; + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; } export function isRetryableReconnectError(err) { diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 5de9027bfcd..b149f8745dc 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,4 @@ -import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' const DEFAULT_PORT = 18792 @@ -128,7 +128,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = buildRelayWsUrl(port, gatewayToken) + const wsUrl = await buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -798,3 +798,16 @@ async function whenReady(fn) { await initPromise return fn() } + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then((res) => sendResponse({ status: res.status, ok: res.ok })) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index e4252ccae4c..7a47a5d947e 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -13,10 +13,15 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -function relayHeaders(token) { - const t = String(token || '').trim() - if (!t) return {} - return { 'x-openclaw-relay-token': t } +async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', enc.encode(gatewayToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], + ) + const sig = await crypto.subtle.sign( + 'HMAC', key, enc.encode(`openclaw-extension-relay-v1:${port}`), + ) + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('') } function setStatus(kind, message) { @@ -33,18 +38,21 @@ async function checkRelayReachable(port, token) { setStatus('error', 'Gateway token required. Save your gateway token to connect.') return } - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { - method: 'GET', - headers: relayHeaders(trimmedToken), - signal: ctrl.signal, + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, }) + if (!res) throw new Error('No response from service worker') if (res.status === 401) { setStatus('error', 'Gateway token rejected. Check token and save again.') return } + if (res.error) throw new Error(res.error) if (!res.ok) throw new Error(`HTTP ${res.status}`) setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) } catch { @@ -52,8 +60,6 @@ async function checkRelayReachable(port, token) { 'error', `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) - } finally { - clearTimeout(t) } } diff --git a/src/browser/chrome-extension-background-utils.test.ts b/src/browser/chrome-extension-background-utils.test.ts index 75cf9af5590..74b767cb269 100644 --- a/src/browser/chrome-extension-background-utils.test.ts +++ b/src/browser/chrome-extension-background-utils.test.ts @@ -2,7 +2,8 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; type BackgroundUtilsModule = { - buildRelayWsUrl: (port: number, gatewayToken: string) => string; + buildRelayWsUrl: (port: number, gatewayToken: string) => Promise; + deriveRelayToken: (gatewayToken: string, port: number) => Promise; isRetryableReconnectError: (err: unknown) => boolean; reconnectDelayMs: ( attempt: number, @@ -25,18 +26,27 @@ async function loadBackgroundUtils(): Promise { } } -const { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } = +const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } = await loadBackgroundUtils(); describe("chrome extension background utils", () => { - it("builds websocket url with encoded gateway token", () => { - const url = buildRelayWsUrl(18792, "abc/+= token"); - expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token"); + it("derives relay token as HMAC-SHA256 of gateway token and port", async () => { + const relayToken = await deriveRelayToken("test-gateway-token", 18792); + expect(relayToken).toMatch(/^[0-9a-f]{64}$/); + const relayToken2 = await deriveRelayToken("test-gateway-token", 18792); + expect(relayToken).toBe(relayToken2); + const differentPort = await deriveRelayToken("test-gateway-token", 9999); + expect(relayToken).not.toBe(differentPort); }); - it("throws when gateway token is missing", () => { - expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/); - expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/); + it("builds websocket url with derived relay token", async () => { + const url = await buildRelayWsUrl(18792, "test-token"); + expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:18792\/extension\?token=[0-9a-f]{64}$/); + }); + + it("throws when gateway token is missing", async () => { + await expect(buildRelayWsUrl(18792, "")).rejects.toThrow(/Missing gatewayToken/); + await expect(buildRelayWsUrl(18792, " ")).rejects.toThrow(/Missing gatewayToken/); }); it("uses exponential backoff from attempt index", () => {