mirror of https://github.com/openclaw/openclaw.git
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:
parent
f4db58a5fd
commit
39881a318a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue