fix(browser): unify extension relay auth on gateway token

This commit is contained in:
Peter Steinberger
2026-02-19 08:39:34 +01:00
parent 781b1c1e09
commit 7e54b6c96f
8 changed files with 146 additions and 70 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. - Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras.
- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting.
- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr.
- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus.
- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. - Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos.

View File

@@ -20,3 +20,4 @@ Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate i
## Options ## Options
- `Relay port`: defaults to `18792`. - `Relay port`: defaults to `18792`.
- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).

View File

@@ -42,6 +42,12 @@ async function getRelayPort() {
return n return n
} }
async function getGatewayToken() {
const stored = await chrome.storage.local.get(['gatewayToken'])
const token = String(stored.gatewayToken || '').trim()
return token || ''
}
function setBadge(tabId, kind) { function setBadge(tabId, kind) {
const cfg = BADGE[kind] const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text }) void chrome.action.setBadgeText({ tabId, text: cfg.text })
@@ -55,8 +61,11 @@ async function ensureRelayConnection() {
relayConnectPromise = (async () => { relayConnectPromise = (async () => {
const port = await getRelayPort() const port = await getRelayPort()
const gatewayToken = await getGatewayToken()
const httpBase = `http://127.0.0.1:${port}` const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension` const wsUrl = gatewayToken
? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}`
: `ws://127.0.0.1:${port}/extension`
// Fast preflight: is the relay server up? // Fast preflight: is the relay server up?
try { try {
@@ -65,6 +74,12 @@ async function ensureRelayConnection() {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
} }
if (!gatewayToken) {
throw new Error(
'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)',
)
}
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
relayWs = ws relayWs = ws

View File

@@ -176,15 +176,19 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Relay port</h2> <h2>Relay connection</h2>
<label for="port">Port</label> <label for="port">Port</label>
<div class="row"> <div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" /> <input id="port" inputmode="numeric" pattern="[0-9]*" />
</div>
<label for="token" style="margin-top: 10px">Gateway token</label>
<div class="row">
<input id="token" type="password" autocomplete="off" style="width: min(520px, 100%)" />
<button id="save" type="button">Save</button> <button id="save" type="button">Save</button>
</div> </div>
<div class="hint"> <div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>. Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your OpenClaw profile uses a different <code>cdpUrl</code> port. Gateway token must match <code>gateway.auth.token</code> (or <code>OPENCLAW_GATEWAY_TOKEN</code>).
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
</div> </div>

View File

@@ -13,6 +13,12 @@ function updateRelayUrl(port) {
el.textContent = `http://127.0.0.1:${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 }
}
function setStatus(kind, message) { function setStatus(kind, message) {
const status = document.getElementById('status') const status = document.getElementById('status')
if (!status) return if (!status) return
@@ -20,18 +26,31 @@ function setStatus(kind, message) {
status.textContent = message || '' status.textContent = message || ''
} }
async function checkRelayReachable(port) { async function checkRelayReachable(port, token) {
const url = `http://127.0.0.1:${port}/` const url = `http://127.0.0.1:${port}/json/version`
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Gateway token required. Save your gateway token to connect.')
return
}
const ctrl = new AbortController() const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900) const t = setTimeout(() => ctrl.abort(), 1200)
try { try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }) const res = await fetch(url, {
method: 'GET',
headers: relayHeaders(trimmedToken),
signal: ctrl.signal,
})
if (res.status === 401) {
setStatus('error', 'Gateway token rejected. Check token and save again.')
return
}
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`) setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`)
} catch { } catch {
setStatus( setStatus(
'error', 'error',
`Relay not reachable at ${url}. Start OpenClaws browser relay on this machine, then click the toolbar button again.`, `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`,
) )
} finally { } finally {
clearTimeout(t) clearTimeout(t)
@@ -39,20 +58,25 @@ async function checkRelayReachable(port) {
} }
async function load() { async function load() {
const stored = await chrome.storage.local.get(['relayPort']) const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken'])
const port = clampPort(stored.relayPort) const port = clampPort(stored.relayPort)
const token = String(stored.gatewayToken || '').trim()
document.getElementById('port').value = String(port) document.getElementById('port').value = String(port)
document.getElementById('token').value = token
updateRelayUrl(port) updateRelayUrl(port)
await checkRelayReachable(port) await checkRelayReachable(port, token)
} }
async function save() { async function save() {
const input = document.getElementById('port') const portInput = document.getElementById('port')
const port = clampPort(input.value) const tokenInput = document.getElementById('token')
await chrome.storage.local.set({ relayPort: port }) const port = clampPort(portInput.value)
input.value = String(port) const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayPort: port, gatewayToken: token })
portInput.value = String(port)
tokenInput.value = token
updateRelayUrl(port) updateRelayUrl(port)
await checkRelayReachable(port) await checkRelayReachable(port, token)
} }
document.getElementById('save').addEventListener('click', () => void save()) document.getElementById('save').addEventListener('click', () => void save())

View File

@@ -53,10 +53,15 @@ After upgrading OpenClaw:
- Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory. - Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory.
- Chrome → `chrome://extensions` → click “Reload” on the extension. - Chrome → `chrome://extensions` → click “Reload” on the extension.
## Use it (no extra config) ## Use it (set gateway token once)
OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port.
Before first attach, open extension Options and set:
- `Port` (default `18792`)
- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`)
Use it: Use it:
- CLI: `openclaw browser --browser-profile chrome tabs` - CLI: `openclaw browser --browser-profile chrome tabs`
@@ -89,12 +94,12 @@ openclaw browser create-profile \
- `ON`: attached; OpenClaw can drive that tab. - `ON`: attached; OpenClaw can drive that tab.
- `…`: connecting to the local relay. - `…`: connecting to the local relay.
- `!`: relay not reachable (most common: browser relay server isnt running on this machine). - `!`: relay not reachable/authenticated (most common: relay server not running, or gateway token missing/wrong).
If you see `!`: If you see `!`:
- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere. - Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
- Open the extension Options page; it shows whether the relay is reachable. - Open the extension Options page; it validates relay reachability + gateway-token auth.
## Remote Gateway (use a node host) ## Remote Gateway (use a node host)
@@ -169,7 +174,7 @@ Recommendations:
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage. - Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing. - Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public). - Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
- The relay blocks non-extension origins and requires an internal auth token for CDP clients. - The relay blocks non-extension origins and requires gateway-token auth for both `/cdp` and `/extension`.
Related: Related:

View File

@@ -1,5 +1,5 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import WebSocket from "ws"; import WebSocket from "ws";
import { import {
ensureChromeExtensionRelayServer, ensureChromeExtensionRelayServer,
@@ -122,13 +122,25 @@ async function waitForListMatch<T>(
} }
describe("chrome extension relay server", () => { describe("chrome extension relay server", () => {
const TEST_GATEWAY_TOKEN = "test-gateway-token";
let cdpUrl = ""; let cdpUrl = "";
let previousGatewayToken: string | undefined;
beforeEach(() => {
previousGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
});
afterEach(async () => { afterEach(async () => {
if (cdpUrl) { if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = ""; cdpUrl = "";
} }
if (previousGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayToken;
}
}); });
it("advertises CDP WS only when extension is connected", async () => { it("advertises CDP WS only when extension is connected", async () => {
@@ -143,7 +155,9 @@ describe("chrome extension relay server", () => {
}; };
expect(v1.webSocketDebuggerUrl).toBeUndefined(); expect(v1.webSocketDebuggerUrl).toBeUndefined();
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext); await waitForOpen(ext);
const v2 = (await fetch(`${cdpUrl}/json/version`, { const v2 = (await fetch(`${cdpUrl}/json/version`, {
@@ -156,21 +170,11 @@ describe("chrome extension relay server", () => {
ext.close(); ext.close();
}); });
it("derives relay auth headers from gateway token for loopback URLs", async () => { it("uses gateway token for relay auth headers on loopback URLs", async () => {
const port = await getFreePort(); const port = await getFreePort();
const prev = process.env.OPENCLAW_GATEWAY_TOKEN; const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
try { expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN);
const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
expect((headers["x-openclaw-relay-token"] ?? "").length).toBeGreaterThan(20);
} finally {
if (prev === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prev;
}
}
}); });
it("rejects CDP access without relay auth token", async () => { it("rejects CDP access without relay auth token", async () => {
@@ -186,12 +190,36 @@ describe("chrome extension relay server", () => {
expect(err.message).toContain("401"); expect(err.message).toContain("401");
}); });
it("tracks attached page targets and exposes them via CDP + /json/list", async () => { it("rejects extension websocket access without relay auth token", async () => {
const port = await getFreePort(); const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`; cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl }); await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
const err = await waitForError(ext);
expect(err.message).toContain("401");
});
it("accepts extension websocket access with gateway token query param", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
);
await waitForOpen(ext);
ext.close();
});
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext); await waitForOpen(ext);
// Simulate a tab attach coming from the extension. // Simulate a tab attach coming from the extension.
@@ -307,7 +335,9 @@ describe("chrome extension relay server", () => {
cdpUrl = `http://127.0.0.1:${port}`; cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl }); await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext); await waitForOpen(ext);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {

View File

@@ -1,8 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import { createServer } from "node:http";
import WebSocket, { WebSocketServer } from "ws"; import WebSocket, { WebSocketServer } from "ws";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
@@ -94,6 +93,18 @@ function getHeader(req: IncomingMessage, name: string): string | undefined {
return headerValue(req.headers[name.toLowerCase()]); return headerValue(req.headers[name.toLowerCase()]);
} }
function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined {
const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim();
if (headerToken) {
return headerToken;
}
const queryToken = url?.searchParams.get("token")?.trim();
if (queryToken) {
return queryToken;
}
return undefined;
}
export type ChromeExtensionRelayServer = { export type ChromeExtensionRelayServer = {
host: string; host: string;
port: number; port: number;
@@ -144,7 +155,6 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
} }
const serversByPort = new Map<number, ChromeExtensionRelayServer>(); const serversByPort = new Map<number, ChromeExtensionRelayServer>();
const relayAuthByPort = new Map<number, string>();
function resolveGatewayAuthToken(): string | null { function resolveGatewayAuthToken(): string | null {
const envToken = const envToken =
@@ -164,19 +174,14 @@ function resolveGatewayAuthToken(): string | null {
return null; return null;
} }
function deriveDeterministicRelayAuthToken(port: number): string | null { function resolveRelayAuthToken(): string {
const gatewayToken = resolveGatewayAuthToken(); const gatewayToken = resolveGatewayAuthToken();
if (!gatewayToken) { if (gatewayToken) {
return null; return gatewayToken;
} }
return createHash("sha256") throw new Error(
.update(`openclaw-relay:${port}:`) "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
.update(gatewayToken) );
.digest("base64url");
}
function resolveRelayAuthToken(port: number): string {
return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url");
} }
function isAddrInUseError(err: unknown): boolean { function isAddrInUseError(err: unknown): boolean {
@@ -212,16 +217,7 @@ function relayAuthTokenForUrl(url: string): string | null {
if (!isLoopbackHost(parsed.hostname)) { if (!isLoopbackHost(parsed.hostname)) {
return null; return null;
} }
const port = return resolveGatewayAuthToken();
parsed.port?.trim() !== ""
? Number(parsed.port)
: parsed.protocol === "https:" || parsed.protocol === "wss:"
? 443
: 80;
if (!Number.isFinite(port)) {
return null;
}
return relayAuthByPort.get(port) ?? deriveDeterministicRelayAuthToken(port);
} catch { } catch {
return null; return null;
} }
@@ -248,7 +244,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
return existing; return existing;
} }
const relayAuthToken = resolveRelayAuthToken(info.port); const relayAuthToken = resolveRelayAuthToken();
let extensionWs: WebSocket | null = null; let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>(); const cdpClients = new Set<WebSocket>();
@@ -529,6 +525,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
} }
if (pathname === "/extension") { if (pathname === "/extension") {
const token = getRelayAuthTokenFromRequest(req, url);
if (!token || token !== relayAuthToken) {
rejectUpgrade(socket, 401, "Unauthorized");
return;
}
if (extensionWs) { if (extensionWs) {
rejectUpgrade(socket, 409, "Extension already connected"); rejectUpgrade(socket, 409, "Extension already connected");
return; return;
@@ -540,7 +541,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
} }
if (pathname === "/cdp") { if (pathname === "/cdp") {
const token = getHeader(req, RELAY_AUTH_HEADER); const token = getRelayAuthTokenFromRequest(req, url);
if (!token || token !== relayAuthToken) { if (!token || token !== relayAuthToken) {
rejectUpgrade(socket, 401, "Unauthorized"); rejectUpgrade(socket, 401, "Unauthorized");
return; return;
@@ -779,10 +780,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
extensionConnected: () => false, extensionConnected: () => false,
stop: async () => { stop: async () => {
serversByPort.delete(info.port); serversByPort.delete(info.port);
relayAuthByPort.delete(info.port);
}, },
}; };
relayAuthByPort.set(info.port, relayAuthToken);
serversByPort.set(info.port, existingRelay); serversByPort.set(info.port, existingRelay);
return existingRelay; return existingRelay;
} }
@@ -802,7 +801,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
extensionConnected: () => Boolean(extensionWs), extensionConnected: () => Boolean(extensionWs),
stop: async () => { stop: async () => {
serversByPort.delete(port); serversByPort.delete(port);
relayAuthByPort.delete(port);
try { try {
extensionWs?.close(1001, "server stopping"); extensionWs?.close(1001, "server stopping");
} catch { } catch {
@@ -823,7 +821,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
}, },
}; };
relayAuthByPort.set(port, relayAuthToken);
serversByPort.set(port, relay); serversByPort.set(port, relay);
return relay; return relay;
} }
@@ -835,6 +832,5 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }):
return false; return false;
} }
await existing.stop(); await existing.stop();
relayAuthByPort.delete(info.port);
return true; return true;
} }