diff --git a/CHANGELOG.md b/CHANGELOG.md index dc34e8ec9..f650fc558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928) - Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng. - Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553) +- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688) - Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker. - Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) - iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 8c920b351..45d17b669 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -208,6 +208,17 @@ describe("chrome extension relay server", () => { expect(err.message).toContain("401"); }); + it("deduplicates concurrent relay starts for the same requested port", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + const [first, second] = await Promise.all([ + ensureChromeExtensionRelayServer({ cdpUrl }), + ensureChromeExtensionRelayServer({ cdpUrl }), + ]); + expect(first).toBe(second); + expect(first.port).toBe(port); + }); + it("allows CORS preflight from chrome-extension origins", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 514988a62..aa51a115a 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -172,6 +172,7 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { } const relayRuntimeByPort = new Map(); +const relayInitByPort = new Map>(); function isAddrInUseError(err: unknown): boolean { return ( @@ -219,634 +220,654 @@ export async function ensureChromeExtensionRelayServer(opts: { return existing.server; } - const relayAuthToken = resolveRelayAuthTokenForPort(info.port); - const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port)); + const inFlight = relayInitByPort.get(info.port); + if (inFlight) { + return await inFlight; + } - let extensionWs: WebSocket | null = null; - const cdpClients = new Set(); - const connectedTargets = new Map(); - const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN; + const initPromise = (async (): Promise => { + const relayAuthToken = resolveRelayAuthTokenForPort(info.port); + const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port)); - const pendingExtension = new Map< - number, - { - resolve: (v: unknown) => void; - reject: (e: Error) => void; - timer: NodeJS.Timeout; - } - >(); - let nextExtensionId = 1; + let extensionWs: WebSocket | null = null; + const cdpClients = new Set(); + const connectedTargets = new Map(); + const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN; - const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise => { - const ws = extensionWs; - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Chrome extension not connected"); - } - ws.send(JSON.stringify(payload)); - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingExtension.delete(payload.id); - reject(new Error(`extension request timeout: ${payload.params.method}`)); - }, 30_000); - pendingExtension.set(payload.id, { resolve, reject, timer }); - }); - }; - - const broadcastToCdpClients = (evt: CdpEvent) => { - const msg = JSON.stringify(evt); - for (const ws of cdpClients) { - if (ws.readyState !== WebSocket.OPEN) { - continue; + const pendingExtension = new Map< + number, + { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; } - ws.send(msg); - } - }; + >(); + let nextExtensionId = 1; - const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(JSON.stringify(res)); - }; - - const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => { - for (const target of connectedTargets.values()) { - if (mode === "autoAttach") { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); - } else { - ws.send( - JSON.stringify({ - method: "Target.targetCreated", - params: { targetInfo: { ...target.targetInfo, attached: true } }, - } satisfies CdpEvent), - ); + const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise => { + const ws = extensionWs; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("Chrome extension not connected"); } - } - }; - - const routeCdpCommand = async (cmd: CdpCommand): Promise => { - switch (cmd.method) { - case "Browser.getVersion": - return { - protocolVersion: "1.3", - product: "Chrome/OpenClaw-Extension-Relay", - revision: "0", - userAgent: "OpenClaw-Extension-Relay", - jsVersion: "V8", - }; - case "Browser.setDownloadBehavior": - return {}; - case "Target.setAutoAttach": - case "Target.setDiscoverTargets": - return {}; - case "Target.getTargets": - return { - targetInfos: Array.from(connectedTargets.values()).map((t) => ({ - ...t.targetInfo, - attached: true, - })), - }; - case "Target.getTargetInfo": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (targetId) { - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { targetInfo: t.targetInfo }; - } - } - } - if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) { - const t = connectedTargets.get(cmd.sessionId); - if (t) { - return { targetInfo: t.targetInfo }; - } - } - const first = Array.from(connectedTargets.values())[0]; - return { targetInfo: first?.targetInfo }; - } - case "Target.attachToTarget": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (!targetId) { - throw new Error("targetId required"); - } - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { sessionId: t.sessionId }; - } - } - throw new Error("target not found"); - } - default: { - const id = nextExtensionId++; - return await sendToExtension({ - id, - method: "forwardCDPCommand", - params: { - method: cmd.method, - sessionId: cmd.sessionId, - params: cmd.params, - }, - }); - } - } - }; - - const server = createServer((req, res) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const path = url.pathname; - const origin = getHeader(req, "origin"); - const isChromeExtensionOrigin = - typeof origin === "string" && origin.startsWith("chrome-extension://"); - - if (isChromeExtensionOrigin && origin) { - // Let extension pages call relay HTTP endpoints cross-origin. - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Vary", "Origin"); - } - - // Handle CORS preflight requests from the browser extension. - if (req.method === "OPTIONS") { - if (origin && !isChromeExtensionOrigin) { - res.writeHead(403); - res.end("Forbidden"); - return; - } - const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "") - .split(",") - .map((header) => header.trim().toLowerCase()) - .filter((header) => header.length > 0); - const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]); - res.writeHead(204, { - "Access-Control-Allow-Origin": origin ?? "*", - "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", - "Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "), - "Access-Control-Max-Age": "86400", - Vary: "Origin, Access-Control-Request-Headers", + ws.send(JSON.stringify(payload)); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingExtension.delete(payload.id); + reject(new Error(`extension request timeout: ${payload.params.method}`)); + }, 30_000); + pendingExtension.set(payload.id, { resolve, reject, timer }); }); - res.end(); - return; - } - - if (path.startsWith("/json")) { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - res.writeHead(401); - res.end("Unauthorized"); - return; - } - } - - if (req.method === "HEAD" && path === "/") { - res.writeHead(200); - res.end(); - return; - } - - if (path === "/") { - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("OK"); - return; - } - - if (path === "/extension/status") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ connected: extensionConnected() })); - return; - } - - const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`; - const wsHost = `ws://${hostHeader}`; - const cdpWsUrl = `${wsHost}/cdp`; - - if ( - (path === "/json/version" || path === "/json/version/") && - (req.method === "GET" || req.method === "PUT") - ) { - const payload: Record = { - Browser: "OpenClaw/extension-relay", - "Protocol-Version": "1.3", - }; - // Only advertise the WS URL if a real extension is connected. - if (extensionConnected()) { - payload.webSocketDebuggerUrl = cdpWsUrl; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(payload)); - return; - } - - const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]); - if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) { - const list = Array.from(connectedTargets.values()).map((t) => ({ - id: t.targetId, - type: t.targetInfo.type ?? "page", - title: t.targetInfo.title ?? "", - description: t.targetInfo.title ?? "", - url: t.targetInfo.url ?? "", - webSocketDebuggerUrl: cdpWsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`, - })); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(list)); - return; - } - - const handleTargetActionRoute = ( - match: RegExpMatchArray | null, - cdpMethod: "Target.activateTarget" | "Target.closeTarget", - ): boolean => { - if (!match || (req.method !== "GET" && req.method !== "PUT")) { - return false; - } - const targetId = decodeURIComponent(match[1] ?? "").trim(); - if (!targetId) { - res.writeHead(400); - res.end("targetId required"); - return true; - } - void (async () => { - try { - await sendToExtension({ - id: nextExtensionId++, - method: "forwardCDPCommand", - params: { method: cdpMethod, params: { targetId } }, - }); - } catch { - // ignore - } - })(); - res.writeHead(200); - res.end("OK"); - return true; }; - if (handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget")) { - return; - } - if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) { - return; - } - - res.writeHead(404); - res.end("not found"); - }); - - const wssExtension = new WebSocketServer({ noServer: true }); - const wssCdp = new WebSocketServer({ noServer: true }); - - server.on("upgrade", (req, socket, head) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const pathname = url.pathname; - const remote = req.socket.remoteAddress; - - if (!isLoopbackAddress(remote)) { - rejectUpgrade(socket, 403, "Forbidden"); - return; - } - - const origin = headerValue(req.headers.origin); - if (origin && !origin.startsWith("chrome-extension://")) { - rejectUpgrade(socket, 403, "Forbidden: invalid origin"); - return; - } - - if (pathname === "/extension") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - if (extensionConnected()) { - rejectUpgrade(socket, 409, "Extension already connected"); - return; - } - // MV3 worker reconnect races can leave a stale non-OPEN socket reference. - if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) { - try { - extensionWs.terminate(); - } catch { - // ignore + const broadcastToCdpClients = (evt: CdpEvent) => { + const msg = JSON.stringify(evt); + for (const ws of cdpClients) { + if (ws.readyState !== WebSocket.OPEN) { + continue; } - extensionWs = null; + ws.send(msg); } - wssExtension.handleUpgrade(req, socket, head, (ws) => { - wssExtension.emit("connection", ws, req); - }); - return; - } + }; - if (pathname === "/cdp") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - if (!extensionConnected()) { - rejectUpgrade(socket, 503, "Extension not connected"); - return; - } - wssCdp.handleUpgrade(req, socket, head, (ws) => { - wssCdp.emit("connection", ws, req); - }); - return; - } - - rejectUpgrade(socket, 404, "Not Found"); - }); - - wssExtension.on("connection", (ws) => { - extensionWs = ws; - - const ping = setInterval(() => { + const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => { if (ws.readyState !== WebSocket.OPEN) { return; } - ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage)); - }, 5000); + ws.send(JSON.stringify(res)); + }; - ws.on("message", (data) => { - if (extensionWs !== ws) { - return; - } - let parsed: ExtensionMessage | null = null; - try { - parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage; - } catch { - return; - } - - if (parsed && typeof parsed === "object" && "id" in parsed && typeof parsed.id === "number") { - const pending = pendingExtension.get(parsed.id); - if (!pending) { - return; - } - pendingExtension.delete(parsed.id); - clearTimeout(pending.timer); - if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) { - pending.reject(new Error(parsed.error)); + const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => { + for (const target of connectedTargets.values()) { + if (mode === "autoAttach") { + ws.send( + JSON.stringify({ + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); } else { - pending.resolve(parsed.result); - } - return; - } - - if (parsed && typeof parsed === "object" && "method" in parsed) { - if ((parsed as ExtensionPongMessage).method === "pong") { - return; - } - if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") { - return; - } - const evt = parsed as ExtensionForwardEventMessage; - const method = evt.params?.method; - const params = evt.params?.params; - const sessionId = evt.params?.sessionId; - if (!method || typeof method !== "string") { - return; - } - - if (method === "Target.attachedToTarget") { - const attached = (params ?? {}) as AttachedToTargetEvent; - const targetType = attached?.targetInfo?.type ?? "page"; - if (targetType !== "page") { - return; - } - if (attached?.sessionId && attached?.targetInfo?.targetId) { - const prev = connectedTargets.get(attached.sessionId); - const nextTargetId = attached.targetInfo.targetId; - const prevTargetId = prev?.targetId; - const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId); - connectedTargets.set(attached.sessionId, { - sessionId: attached.sessionId, - targetId: nextTargetId, - targetInfo: attached.targetInfo, - }); - if (changedTarget && prevTargetId) { - broadcastToCdpClients({ - method: "Target.detachedFromTarget", - params: { sessionId: attached.sessionId, targetId: prevTargetId }, - sessionId: attached.sessionId, - }); - } - if (!prev || changedTarget) { - broadcastToCdpClients({ method, params, sessionId }); - } - return; - } - } - - if (method === "Target.detachedFromTarget") { - const detached = (params ?? {}) as DetachedFromTargetEvent; - if (detached?.sessionId) { - connectedTargets.delete(detached.sessionId); - } - broadcastToCdpClients({ method, params, sessionId }); - return; - } - - // Keep cached tab metadata fresh for /json/list. - // After navigation, Chrome updates URL/title via Target.targetInfoChanged. - if (method === "Target.targetInfoChanged") { - const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } }; - const targetInfo = changed?.targetInfo; - const targetId = targetInfo?.targetId; - if (targetId && (targetInfo?.type ?? "page") === "page") { - for (const [sid, target] of connectedTargets) { - if (target.targetId !== targetId) { - continue; - } - connectedTargets.set(sid, { - ...target, - targetInfo: { ...target.targetInfo, ...(targetInfo as object) }, - }); - } - } - } - - broadcastToCdpClients({ method, params, sessionId }); - } - }); - - ws.on("close", () => { - clearInterval(ping); - if (extensionWs !== ws) { - return; - } - extensionWs = null; - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("extension disconnected")); - } - pendingExtension.clear(); - connectedTargets.clear(); - - for (const client of cdpClients) { - try { - client.close(1011, "extension disconnected"); - } catch { - // ignore + ws.send( + JSON.stringify({ + method: "Target.targetCreated", + params: { targetInfo: { ...target.targetInfo, attached: true } }, + } satisfies CdpEvent), + ); } } - cdpClients.clear(); - }); - }); + }; - wssCdp.on("connection", (ws) => { - cdpClients.add(ws); - - ws.on("message", async (data) => { - let cmd: CdpCommand | null = null; - try { - cmd = JSON.parse(rawDataToString(data)) as CdpCommand; - } catch { - return; - } - if (!cmd || typeof cmd !== "object") { - return; - } - if (typeof cmd.id !== "number" || typeof cmd.method !== "string") { - return; - } - - if (!extensionConnected()) { - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: "Extension not connected" }, - }); - return; - } - - try { - const result = await routeCdpCommand(cmd); - - if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) { - ensureTargetEventsForClient(ws, "autoAttach"); - } - if (cmd.method === "Target.setDiscoverTargets") { - const discover = (cmd.params ?? {}) as { discover?: boolean }; - if (discover.discover === true) { - ensureTargetEventsForClient(ws, "discover"); - } - } - if (cmd.method === "Target.attachToTarget") { + const routeCdpCommand = async (cmd: CdpCommand): Promise => { + switch (cmd.method) { + case "Browser.getVersion": + return { + protocolVersion: "1.3", + product: "Chrome/OpenClaw-Extension-Relay", + revision: "0", + userAgent: "OpenClaw-Extension-Relay", + jsVersion: "V8", + }; + case "Browser.setDownloadBehavior": + return {}; + case "Target.setAutoAttach": + case "Target.setDiscoverTargets": + return {}; + case "Target.getTargets": + return { + targetInfos: Array.from(connectedTargets.values()).map((t) => ({ + ...t.targetInfo, + attached: true, + })), + }; + case "Target.getTargetInfo": { const params = (cmd.params ?? {}) as { targetId?: string }; const targetId = typeof params.targetId === "string" ? params.targetId : undefined; if (targetId) { - const target = Array.from(connectedTargets.values()).find( - (t) => t.targetId === targetId, - ); - if (target) { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); + for (const t of connectedTargets.values()) { + if (t.targetId === targetId) { + return { targetInfo: t.targetInfo }; + } } } + if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) { + const t = connectedTargets.get(cmd.sessionId); + if (t) { + return { targetInfo: t.targetInfo }; + } + } + const first = Array.from(connectedTargets.values())[0]; + return { targetInfo: first?.targetInfo }; + } + case "Target.attachToTarget": { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = typeof params.targetId === "string" ? params.targetId : undefined; + if (!targetId) { + throw new Error("targetId required"); + } + for (const t of connectedTargets.values()) { + if (t.targetId === targetId) { + return { sessionId: t.sessionId }; + } + } + throw new Error("target not found"); + } + default: { + const id = nextExtensionId++; + return await sendToExtension({ + id, + method: "forwardCDPCommand", + params: { + method: cmd.method, + sessionId: cmd.sessionId, + params: cmd.params, + }, + }); + } + } + }; + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", info.baseUrl); + const path = url.pathname; + const origin = getHeader(req, "origin"); + const isChromeExtensionOrigin = + typeof origin === "string" && origin.startsWith("chrome-extension://"); + + if (isChromeExtensionOrigin && origin) { + // Let extension pages call relay HTTP endpoints cross-origin. + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + + // Handle CORS preflight requests from the browser extension. + if (req.method === "OPTIONS") { + if (origin && !isChromeExtensionOrigin) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "") + .split(",") + .map((header) => header.trim().toLowerCase()) + .filter((header) => header.length > 0); + const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]); + res.writeHead(204, { + "Access-Control-Allow-Origin": origin ?? "*", + "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", + "Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "), + "Access-Control-Max-Age": "86400", + Vary: "Origin, Access-Control-Request-Headers", + }); + res.end(); + return; + } + + if (path.startsWith("/json")) { + const token = getRelayAuthTokenFromRequest(req, url); + if (!token || !relayAuthTokens.has(token)) { + res.writeHead(401); + res.end("Unauthorized"); + return; + } + } + + if (req.method === "HEAD" && path === "/") { + res.writeHead(200); + res.end(); + return; + } + + if (path === "/") { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("OK"); + return; + } + + if (path === "/extension/status") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ connected: extensionConnected() })); + return; + } + + const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`; + const wsHost = `ws://${hostHeader}`; + const cdpWsUrl = `${wsHost}/cdp`; + + if ( + (path === "/json/version" || path === "/json/version/") && + (req.method === "GET" || req.method === "PUT") + ) { + const payload: Record = { + Browser: "OpenClaw/extension-relay", + "Protocol-Version": "1.3", + }; + // Only advertise the WS URL if a real extension is connected. + if (extensionConnected()) { + payload.webSocketDebuggerUrl = cdpWsUrl; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + return; + } + + const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]); + if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) { + const list = Array.from(connectedTargets.values()).map((t) => ({ + id: t.targetId, + type: t.targetInfo.type ?? "page", + title: t.targetInfo.title ?? "", + description: t.targetInfo.title ?? "", + url: t.targetInfo.url ?? "", + webSocketDebuggerUrl: cdpWsUrl, + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`, + })); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(list)); + return; + } + + const handleTargetActionRoute = ( + match: RegExpMatchArray | null, + cdpMethod: "Target.activateTarget" | "Target.closeTarget", + ): boolean => { + if (!match || (req.method !== "GET" && req.method !== "PUT")) { + return false; + } + const targetId = decodeURIComponent(match[1] ?? "").trim(); + if (!targetId) { + res.writeHead(400); + res.end("targetId required"); + return true; + } + void (async () => { + try { + await sendToExtension({ + id: nextExtensionId++, + method: "forwardCDPCommand", + params: { method: cdpMethod, params: { targetId } }, + }); + } catch { + // ignore + } + })(); + res.writeHead(200); + res.end("OK"); + return true; + }; + + if ( + handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget") + ) { + return; + } + if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) { + return; + } + + res.writeHead(404); + res.end("not found"); + }); + + const wssExtension = new WebSocketServer({ noServer: true }); + const wssCdp = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url ?? "/", info.baseUrl); + const pathname = url.pathname; + const remote = req.socket.remoteAddress; + + if (!isLoopbackAddress(remote)) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + + const origin = headerValue(req.headers.origin); + if (origin && !origin.startsWith("chrome-extension://")) { + rejectUpgrade(socket, 403, "Forbidden: invalid origin"); + return; + } + + if (pathname === "/extension") { + const token = getRelayAuthTokenFromRequest(req, url); + if (!token || !relayAuthTokens.has(token)) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } + if (extensionConnected()) { + rejectUpgrade(socket, 409, "Extension already connected"); + return; + } + // MV3 worker reconnect races can leave a stale non-OPEN socket reference. + if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) { + try { + extensionWs.terminate(); + } catch { + // ignore + } + extensionWs = null; + } + wssExtension.handleUpgrade(req, socket, head, (ws) => { + wssExtension.emit("connection", ws, req); + }); + return; + } + + if (pathname === "/cdp") { + const token = getRelayAuthTokenFromRequest(req, url); + if (!token || !relayAuthTokens.has(token)) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } + if (!extensionConnected()) { + rejectUpgrade(socket, 503, "Extension not connected"); + return; + } + wssCdp.handleUpgrade(req, socket, head, (ws) => { + wssCdp.emit("connection", ws, req); + }); + return; + } + + rejectUpgrade(socket, 404, "Not Found"); + }); + + wssExtension.on("connection", (ws) => { + extensionWs = ws; + + const ping = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) { + return; + } + ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage)); + }, 5000); + + ws.on("message", (data) => { + if (extensionWs !== ws) { + return; + } + let parsed: ExtensionMessage | null = null; + try { + parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage; + } catch { + return; } - sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); - } catch (err) { - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: err instanceof Error ? err.message : String(err) }, - }); - } + if ( + parsed && + typeof parsed === "object" && + "id" in parsed && + typeof parsed.id === "number" + ) { + const pending = pendingExtension.get(parsed.id); + if (!pending) { + return; + } + pendingExtension.delete(parsed.id); + clearTimeout(pending.timer); + if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) { + pending.reject(new Error(parsed.error)); + } else { + pending.resolve(parsed.result); + } + return; + } + + if (parsed && typeof parsed === "object" && "method" in parsed) { + if ((parsed as ExtensionPongMessage).method === "pong") { + return; + } + if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") { + return; + } + const evt = parsed as ExtensionForwardEventMessage; + const method = evt.params?.method; + const params = evt.params?.params; + const sessionId = evt.params?.sessionId; + if (!method || typeof method !== "string") { + return; + } + + if (method === "Target.attachedToTarget") { + const attached = (params ?? {}) as AttachedToTargetEvent; + const targetType = attached?.targetInfo?.type ?? "page"; + if (targetType !== "page") { + return; + } + if (attached?.sessionId && attached?.targetInfo?.targetId) { + const prev = connectedTargets.get(attached.sessionId); + const nextTargetId = attached.targetInfo.targetId; + const prevTargetId = prev?.targetId; + const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId); + connectedTargets.set(attached.sessionId, { + sessionId: attached.sessionId, + targetId: nextTargetId, + targetInfo: attached.targetInfo, + }); + if (changedTarget && prevTargetId) { + broadcastToCdpClients({ + method: "Target.detachedFromTarget", + params: { sessionId: attached.sessionId, targetId: prevTargetId }, + sessionId: attached.sessionId, + }); + } + if (!prev || changedTarget) { + broadcastToCdpClients({ method, params, sessionId }); + } + return; + } + } + + if (method === "Target.detachedFromTarget") { + const detached = (params ?? {}) as DetachedFromTargetEvent; + if (detached?.sessionId) { + connectedTargets.delete(detached.sessionId); + } + broadcastToCdpClients({ method, params, sessionId }); + return; + } + + // Keep cached tab metadata fresh for /json/list. + // After navigation, Chrome updates URL/title via Target.targetInfoChanged. + if (method === "Target.targetInfoChanged") { + const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } }; + const targetInfo = changed?.targetInfo; + const targetId = targetInfo?.targetId; + if (targetId && (targetInfo?.type ?? "page") === "page") { + for (const [sid, target] of connectedTargets) { + if (target.targetId !== targetId) { + continue; + } + connectedTargets.set(sid, { + ...target, + targetInfo: { ...target.targetInfo, ...(targetInfo as object) }, + }); + } + } + } + + broadcastToCdpClients({ method, params, sessionId }); + } + }); + + ws.on("close", () => { + clearInterval(ping); + if (extensionWs !== ws) { + return; + } + extensionWs = null; + for (const [, pending] of pendingExtension) { + clearTimeout(pending.timer); + pending.reject(new Error("extension disconnected")); + } + pendingExtension.clear(); + connectedTargets.clear(); + + for (const client of cdpClients) { + try { + client.close(1011, "extension disconnected"); + } catch { + // ignore + } + } + cdpClients.clear(); + }); }); - ws.on("close", () => { - cdpClients.delete(ws); - }); - }); + wssCdp.on("connection", (ws) => { + cdpClients.add(ws); - try { - await new Promise((resolve, reject) => { - server.listen(info.port, info.host, () => resolve()); - server.once("error", reject); - }); - } catch (err) { - if ( - isAddrInUseError(err) && - (await probeAuthenticatedOpenClawRelay({ - baseUrl: info.baseUrl, - relayAuthHeader: RELAY_AUTH_HEADER, - relayAuthToken, - })) - ) { - const existingRelay: ChromeExtensionRelayServer = { - host: info.host, - port: info.port, - baseUrl: info.baseUrl, - cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, - extensionConnected: () => false, - stop: async () => { - relayRuntimeByPort.delete(info.port); - }, - }; - relayRuntimeByPort.set(info.port, { server: existingRelay, relayAuthToken }); - return existingRelay; - } - throw err; - } - - const addr = server.address() as AddressInfo | null; - const port = addr?.port ?? info.port; - const host = info.host; - const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; - - const relay: ChromeExtensionRelayServer = { - host, - port, - baseUrl, - cdpWsUrl: `ws://${host}:${port}/cdp`, - extensionConnected, - stop: async () => { - relayRuntimeByPort.delete(port); - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("server stopping")); - } - pendingExtension.clear(); - try { - extensionWs?.close(1001, "server stopping"); - } catch { - // ignore - } - for (const ws of cdpClients) { + ws.on("message", async (data) => { + let cmd: CdpCommand | null = null; try { - ws.close(1001, "server stopping"); + cmd = JSON.parse(rawDataToString(data)) as CdpCommand; + } catch { + return; + } + if (!cmd || typeof cmd !== "object") { + return; + } + if (typeof cmd.id !== "number" || typeof cmd.method !== "string") { + return; + } + + if (!extensionConnected()) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: "Extension not connected" }, + }); + return; + } + + try { + const result = await routeCdpCommand(cmd); + + if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) { + ensureTargetEventsForClient(ws, "autoAttach"); + } + if (cmd.method === "Target.setDiscoverTargets") { + const discover = (cmd.params ?? {}) as { discover?: boolean }; + if (discover.discover === true) { + ensureTargetEventsForClient(ws, "discover"); + } + } + if (cmd.method === "Target.attachToTarget") { + const params = (cmd.params ?? {}) as { targetId?: string }; + const targetId = typeof params.targetId === "string" ? params.targetId : undefined; + if (targetId) { + const target = Array.from(connectedTargets.values()).find( + (t) => t.targetId === targetId, + ); + if (target) { + ws.send( + JSON.stringify({ + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false, + }, + } satisfies CdpEvent), + ); + } + } + } + + sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); + } catch (err) { + sendResponseToCdp(ws, { + id: cmd.id, + sessionId: cmd.sessionId, + error: { message: err instanceof Error ? err.message : String(err) }, + }); + } + }); + + ws.on("close", () => { + cdpClients.delete(ws); + }); + }); + + try { + await new Promise((resolve, reject) => { + server.listen(info.port, info.host, () => resolve()); + server.once("error", reject); + }); + } catch (err) { + if ( + isAddrInUseError(err) && + (await probeAuthenticatedOpenClawRelay({ + baseUrl: info.baseUrl, + relayAuthHeader: RELAY_AUTH_HEADER, + relayAuthToken, + })) + ) { + const existingRelay: ChromeExtensionRelayServer = { + host: info.host, + port: info.port, + baseUrl: info.baseUrl, + cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, + extensionConnected: () => false, + stop: async () => { + relayRuntimeByPort.delete(info.port); + }, + }; + relayRuntimeByPort.set(info.port, { server: existingRelay, relayAuthToken }); + return existingRelay; + } + throw err; + } + + const addr = server.address() as AddressInfo | null; + const port = addr?.port ?? info.port; + const host = info.host; + const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; + + const relay: ChromeExtensionRelayServer = { + host, + port, + baseUrl, + cdpWsUrl: `ws://${host}:${port}/cdp`, + extensionConnected, + stop: async () => { + relayRuntimeByPort.delete(port); + for (const [, pending] of pendingExtension) { + clearTimeout(pending.timer); + pending.reject(new Error("server stopping")); + } + pendingExtension.clear(); + try { + extensionWs?.close(1001, "server stopping"); } catch { // ignore } - } - await new Promise((resolve) => { - server.close(() => resolve()); - }); - wssExtension.close(); - wssCdp.close(); - }, - }; + for (const ws of cdpClients) { + try { + ws.close(1001, "server stopping"); + } catch { + // ignore + } + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + wssExtension.close(); + wssCdp.close(); + }, + }; - relayRuntimeByPort.set(port, { server: relay, relayAuthToken }); - return relay; + relayRuntimeByPort.set(port, { server: relay, relayAuthToken }); + return relay; + })(); + relayInitByPort.set(info.port, initPromise); + try { + return await initPromise; + } finally { + relayInitByPort.delete(info.port); + } } export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise {