Browser: reuse extension relay when relay port is already occupied (#20035)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b310666d39
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano 2026-02-18 13:13:04 +00:00 committed by GitHub
parent f4db58a5fd
commit 39881a318a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 161 additions and 8 deletions

View File

@ -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 (`<chatId>:topic:<threadId>`) 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.

View File

@ -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<void>((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<void>((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<void>((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<void>((resolve) => blocker.close(() => resolve()));
});
});

View File

@ -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<number, ChromeExtensionRelayServer>();
const relayAuthByPort = new Map<number, string>();
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<boolean> {
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<WebSocket>();
const connectedTargets = new Map<string, ConnectedTarget>();
@ -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<void>((resolve, reject) => {
server.listen(info.port, info.host, () => resolve());
server.once("error", reject);
});
try {
await new Promise<void>((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;