diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 94956571101..60f50d6551e 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,4 @@ -import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' const DEFAULT_PORT = 18792 diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 00000000000..53e2cd55014 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 96b87768dae..aa6fcc4901f 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,17 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -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) { const status = document.getElementById('status') if (!status) return @@ -47,46 +39,12 @@ async function checkRelayReachable(port, token) { 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}`) - - // Validate that this is a CDP relay /json/version payload, not gateway HTML. - const contentType = String(res.contentType || '') - const data = res.json - if (!contentType.includes('application/json')) { - setStatus( - 'error', - 'Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - if (!data || typeof data !== 'object' || !('Browser' in data) || !('Protocol-Version' in data)) { - setStatus( - 'error', - 'Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) } catch (err) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - setStatus( - 'error', - 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - } else { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts new file mode 100644 index 00000000000..23aa6d1ce06 --- /dev/null +++ b/src/browser/chrome-extension-options-validation.test.ts @@ -0,0 +1,113 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +type RelayCheckResponse = { + status?: number; + ok?: boolean; + error?: string; + contentType?: string; + json?: unknown; +}; + +type RelayCheckStatus = + | { action: "throw"; error: string } + | { action: "status"; kind: "ok" | "error"; message: string }; + +type RelayCheckExceptionStatus = { kind: "error"; message: string }; + +type OptionsValidationModule = { + classifyRelayCheckResponse: ( + res: RelayCheckResponse | null | undefined, + port: number, + ) => RelayCheckStatus; + classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus; +}; + +const require = createRequire(import.meta.url); +const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js"; + +async function loadOptionsValidation(): Promise { + try { + return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Unexpected token 'export'")) { + throw error; + } + return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule; + } +} + +const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation(); + +describe("chrome extension options validation", () => { + it("maps 401 response to token rejected error", () => { + const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792); + expect(result).toEqual({ + action: "status", + kind: "error", + message: "Gateway token rejected. Check token and save again.", + }); + }); + + it("maps non-json 200 response to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps json response without CDP keys to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "application/json", json: { ok: true } }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps valid relay json response to success", () => { + const result = classifyRelayCheckResponse( + { + status: 200, + ok: true, + contentType: "application/json", + json: { Browser: "Chrome/136", "Protocol-Version": "1.3" }, + }, + 19004, + ); + expect(result).toEqual({ + action: "status", + kind: "ok", + message: "Relay reachable and authenticated at http://127.0.0.1:19004/", + }); + }); + + it("maps syntax/json exceptions to wrong-endpoint error", () => { + const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps generic exceptions to relay unreachable error", () => { + const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.", + }); + }); +});