diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 8725c3f33e8..6a65e592c22 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -392,6 +392,53 @@ describe("chrome extension relay server", () => { ext2.close(); }); + it("keeps /json/version websocket endpoint during short extension disconnects", async () => { + const { port, ext } = await startRelayWithExtension(); + ext.send( + JSON.stringify({ + method: "forwardCDPEvent", + params: { + method: "Target.attachedToTarget", + params: { + sessionId: "cb-tab-disconnect", + targetInfo: { + targetId: "t-disconnect", + type: "page", + title: "Disconnect test", + url: "https://example.com", + }, + waitingForDebugger: false, + }, + }, + }), + ); + + await waitForListMatch( + async () => + (await fetch(`${cdpUrl}/json/list`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as Array<{ id?: string }>, + (list) => list.some((entry) => entry.id === "t-disconnect"), + ); + + const extClosed = waitForClose(ext, 2_000); + ext.close(); + await extClosed; + + const version = (await fetch(`${cdpUrl}/json/version`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as { + webSocketDebuggerUrl?: string; + }; + expect(String(version.webSocketDebuggerUrl ?? "")).toContain("/cdp"); + + const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), + }); + await waitForOpen(cdp); + cdp.close(); + }); + it("waits briefly for extension reconnect before failing CDP commands", async () => { const { port, ext: ext1 } = await startRelayWithExtension(); const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 3f5697f1d56..a6f14091f6e 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -82,7 +82,7 @@ type ConnectedTarget = { }; const RELAY_AUTH_HEADER = "x-openclaw-relay-token"; -const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000; +const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 20_000; const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000; function headerValue(value: string | string[] | undefined): string | undefined { @@ -256,6 +256,7 @@ export async function ensureChromeExtensionRelayServer(opts: { const cdpClients = new Set(); const connectedTargets = new Map(); const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN; + const hasConnectedTargets = () => connectedTargets.size > 0; let extensionDisconnectCleanupTimer: NodeJS.Timeout | null = null; const extensionReconnectWaiters = new Set<(connected: boolean) => void>(); @@ -534,8 +535,9 @@ export async function ensureChromeExtensionRelayServer(opts: { Browser: "OpenClaw/extension-relay", "Protocol-Version": "1.3", }; - // Only advertise the WS URL if a real extension is connected. - if (extensionConnected()) { + // Keep reporting CDP WS while attached targets are cached, so callers can + // reconnect through brief MV3 worker disconnects. + if (extensionConnected() || hasConnectedTargets()) { payload.webSocketDebuggerUrl = cdpWsUrl; } res.writeHead(200, { "Content-Type": "application/json" }); @@ -658,10 +660,8 @@ export async function ensureChromeExtensionRelayServer(opts: { rejectUpgrade(socket, 401, "Unauthorized"); return; } - if (!extensionConnected()) { - rejectUpgrade(socket, 503, "Extension not connected"); - return; - } + // Allow CDP clients to connect even during brief extension worker drops. + // Individual commands already wait briefly for extension reconnect. wssCdp.handleUpgrade(req, socket, head, (ws) => { wssCdp.emit("connection", ws, req); });