diff --git a/CHANGELOG.md b/CHANGELOG.md index 37be5f25fea..7aff7b56dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky. - Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. - iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. - Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 50ffffd4134..f0d1858b060 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1,3 +1,4 @@ +import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; import { @@ -152,6 +153,23 @@ describe("chrome extension relay server", () => { ext.close(); }); + it("derives relay auth headers from gateway token for loopback URLs", async () => { + const port = await getFreePort(); + const prev = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; + try { + 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 () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; @@ -349,4 +367,57 @@ describe("chrome extension relay server", () => { cdp.close(); ext.close(); }); + + it("reuses an already-bound relay port when another process owns it", async () => { + const port = await getFreePort(); + const fakeRelay = createServer((req, res) => { + if (req.url?.startsWith("/extension/status")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ connected: false })); + return; + } + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("OK"); + }); + await new Promise((resolve, reject) => { + fakeRelay.listen(port, "127.0.0.1", () => resolve()); + fakeRelay.once("error", reject); + }); + + const prev = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; + try { + cdpUrl = `http://127.0.0.1:${port}`; + const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); + expect(relay.port).toBe(port); + const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as { + connected?: boolean; + }; + expect(status.connected).toBe(false); + } finally { + if (prev === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prev; + } + await new Promise((resolve) => fakeRelay.close(() => resolve())); + } + }); + + it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => { + const port = await getFreePort(); + const blocker = createServer((_, res) => { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("not-relay"); + }); + await new Promise((resolve, reject) => { + blocker.listen(port, "127.0.0.1", () => resolve()); + blocker.once("error", reject); + }); + const blockedUrl = `http://127.0.0.1:${port}`; + await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow( + /EADDRINUSE/i, + ); + await new Promise((resolve) => blocker.close(() => resolve())); + }); }); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 09ac1ff8a64..53a38e3ac73 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -1,9 +1,10 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import type { IncomingMessage } from "node:http"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; import WebSocket, { WebSocketServer } from "ws"; +import { loadConfig } from "../config/config.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; @@ -145,6 +146,66 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { const serversByPort = new Map(); const relayAuthByPort = new Map(); +function resolveGatewayAuthToken(): string | null { + const envToken = + process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); + if (envToken) { + return envToken; + } + try { + const cfg = loadConfig(); + const configToken = cfg.gateway?.auth?.token?.trim(); + if (configToken) { + return configToken; + } + } catch { + // ignore config read failures; caller can fallback to per-process random token + } + return null; +} + +function deriveDeterministicRelayAuthToken(port: number): string | null { + const gatewayToken = resolveGatewayAuthToken(); + if (!gatewayToken) { + return null; + } + return createHash("sha256") + .update(`openclaw-relay:${port}:`) + .update(gatewayToken) + .digest("base64url"); +} + +function resolveRelayAuthToken(port: number): string { + return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url"); +} + +function isAddrInUseError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "EADDRINUSE" + ); +} + +async function looksLikeOpenClawRelay(baseUrl: string): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 500); + try { + const statusUrl = new URL("/extension/status", `${baseUrl}/`).toString(); + const res = await fetch(statusUrl, { signal: ctrl.signal }); + if (!res.ok) { + return false; + } + const body = (await res.json()) as { connected?: unknown }; + return typeof body.connected === "boolean"; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + function relayAuthTokenForUrl(url: string): string | null { try { const parsed = new URL(url); @@ -160,7 +221,7 @@ function relayAuthTokenForUrl(url: string): string | null { if (!Number.isFinite(port)) { return null; } - return relayAuthByPort.get(port) ?? null; + return relayAuthByPort.get(port) ?? deriveDeterministicRelayAuthToken(port); } catch { return null; } @@ -187,6 +248,8 @@ export async function ensureChromeExtensionRelayServer(opts: { return existing; } + const relayAuthToken = resolveRelayAuthToken(info.port); + let extensionWs: WebSocket | null = null; const cdpClients = new Set(); const connectedTargets = new Map(); @@ -326,8 +389,6 @@ export async function ensureChromeExtensionRelayServer(opts: { } }; - const relayAuthToken = randomBytes(32).toString("base64url"); - const server = createServer((req, res) => { const url = new URL(req.url ?? "/", info.baseUrl); const path = url.pathname; @@ -703,10 +764,30 @@ export async function ensureChromeExtensionRelayServer(opts: { }); }); - await new Promise((resolve, reject) => { - server.listen(info.port, info.host, () => resolve()); - server.once("error", reject); - }); + try { + await new Promise((resolve, reject) => { + server.listen(info.port, info.host, () => resolve()); + server.once("error", reject); + }); + } catch (err) { + if (isAddrInUseError(err) && (await looksLikeOpenClawRelay(info.baseUrl))) { + const existingRelay: ChromeExtensionRelayServer = { + host: info.host, + port: info.port, + baseUrl: info.baseUrl, + cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, + extensionConnected: () => false, + stop: async () => { + serversByPort.delete(info.port); + relayAuthByPort.delete(info.port); + }, + }; + relayAuthByPort.set(info.port, relayAuthToken); + serversByPort.set(info.port, existingRelay); + return existingRelay; + } + throw err; + } const addr = server.address() as AddressInfo | null; const port = addr?.port ?? info.port;