From 9914b48c578262ded2b2318493fd7e2fc46dd8ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 18:47:48 +0000 Subject: [PATCH] fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) --- CHANGELOG.md | 1 + src/browser/browser-utils.test.ts | 18 +++- src/browser/cdp.helpers.ts | 22 ++++ src/browser/pw-session.ts | 30 ++---- .../server-context.loopback-direct-ws.test.ts | 100 ++++++++++++++++++ src/browser/server-context.selection.ts | 8 +- src/browser/server-context.tab-ops.ts | 10 +- 7 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 src/browser/server-context.loopback-direct-ws.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c26849508..904126a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. +- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. ## 2026.3.7 diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index 80ad76c65..d5c081668 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; +import { + appendCdpPath, + getHeadersWithAuth, + normalizeCdpHttpBaseForJsonEndpoints, +} from "./cdp.helpers.js"; import { __test } from "./client-fetch.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { shouldRejectBrowserMutation } from "./csrf.js"; @@ -155,6 +159,18 @@ describe("cdp.helpers", () => { expect(url).toBe("https://example.com/chrome/json/list?token=abc"); }); + it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints( + "wss://connect.example.com/devtools/browser/ABC?token=abc", + ); + expect(url).toBe("https://connect.example.com/?token=abc"); + }); + + it("strips a trailing /cdp suffix when normalizing HTTP bases", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc"); + expect(url).toBe("http://127.0.0.1:9222/?token=abc"); + }); + it("adds basic auth headers when credentials are present", () => { const headers = getHeadersWithAuth("https://user:pass@example.com"); expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 6e360e802..5749a591f 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -67,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string { return url.toString(); } +export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { + try { + const url = new URL(cdpUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); + url.pathname = url.pathname.replace(/\/cdp$/, ""); + return url.toString().replace(/\/$/, ""); + } catch { + // Best-effort fallback for non-URL-ish inputs. + return cdpUrl + .replace(/^ws:/, "http:") + .replace(/^wss:/, "https:") + .replace(/\/devtools\/browser\/.*$/, "") + .replace(/\/cdp$/, "") + .replace(/\/$/, ""); + } +} + function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map(); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index b657bb2e2..55d41c60c 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -10,7 +10,13 @@ import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; -import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; +import { + appendCdpPath, + fetchJson, + getHeadersWithAuth, + normalizeCdpHttpBaseForJsonEndpoints, + withCdpSocket, +} from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; import { @@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise { await cur.browser.close().catch(() => {}); } -function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { - try { - const url = new URL(cdpUrl); - if (url.protocol === "ws:") { - url.protocol = "http:"; - } else if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); - url.pathname = url.pathname.replace(/\/cdp$/, ""); - return url.toString().replace(/\/$/, ""); - } catch { - // Best-effort fallback for non-URL-ish inputs. - return cdpUrl - .replace(/^ws:/, "http:") - .replace(/^wss:/, "https:") - .replace(/\/devtools\/browser\/.*$/, "") - .replace(/\/cdp$/, "") - .replace(/\/$/, ""); - } -} - function cdpSocketNeedsAttach(wsUrl: string): boolean { try { const pathname = new URL(wsUrl).pathname; diff --git a/src/browser/server-context.loopback-direct-ws.test.ts b/src/browser/server-context.loopback-direct-ws.test.ts new file mode 100644 index 000000000..9f6512fab --- /dev/null +++ b/src/browser/server-context.loopback-direct-ws.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import * as cdpModule from "./cdp.js"; +import { createBrowserRouteContext } from "./server-context.js"; +import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js"; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("browser server-context loopback direct WebSocket profiles", () => { + it("uses an HTTP /json/list base when opening tabs", async () => { + const createTargetViaCdp = vi + .spyOn(cdpModule, "createTargetViaCdp") + .mockResolvedValue({ targetId: "CREATED" }); + + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + expect(u).toBe("http://127.0.0.1:18800/json/list?token=abc"); + return { + ok: true, + json: async () => [ + { + id: "CREATED", + title: "New Tab", + url: "http://127.0.0.1:8080", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", + type: "page", + }, + ], + } as unknown as Response; + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const opened = await openclaw.openTab("http://127.0.0.1:8080"); + expect(opened.targetId).toBe("CREATED"); + expect(createTargetViaCdp).toHaveBeenCalledWith({ + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + url: "http://127.0.0.1:8080", + ssrfPolicy: { allowPrivateNetwork: true }, + }); + }); + + it("uses an HTTP /json base for focus and close", async () => { + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (u === "http://127.0.0.1:18800/json/list?token=abc") { + return { + ok: true, + json: async () => [ + { + id: "T1", + title: "Tab 1", + url: "https://example.com", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/T1", + type: "page", + }, + ], + } as unknown as Response; + } + if (u === "http://127.0.0.1:18800/json/activate/T1?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + if (u === "http://127.0.0.1:18800/json/close/T1?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + throw new Error(`unexpected fetch: ${u}`); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + await openclaw.focusTab("T1"); + await openclaw.closeTab("T1"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:18800/json/activate/T1?token=abc", + expect.any(Object), + ); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:18800/json/close/T1?token=abc", + expect.any(Object), + ); + }); +}); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index e1c78426e..740a99db2 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -1,4 +1,4 @@ -import { fetchOk } from "./cdp.helpers.js"; +import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import type { PwAiModule } from "./pw-ai-module.js"; @@ -27,6 +27,8 @@ export function createProfileSelectionOps({ listTabs, openTab, }: SelectionDeps): SelectionOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); @@ -122,7 +124,7 @@ export function createProfileSelectionOps({ } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`)); + await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`)); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; }; @@ -144,7 +146,7 @@ export function createProfileSelectionOps({ } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`)); + await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`)); }; return { diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index cf026d658..fcf0d66eb 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -1,5 +1,5 @@ import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { fetchJson, fetchOk } from "./cdp.helpers.js"; +import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { @@ -58,6 +58,8 @@ export function createProfileTabOps({ state, getProfileState, }: TabOpsDeps): ProfileTabOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const listTabs = async (): Promise => { // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions if (!profile.cdpIsLoopback) { @@ -82,7 +84,7 @@ export function createProfileTabOps({ webSocketDebuggerUrl?: string; type?: string; }> - >(appendCdpPath(profile.cdpUrl, "/json/list")); + >(appendCdpPath(cdpHttpBase, "/json/list")); return raw .map((t) => ({ targetId: t.id ?? "", @@ -115,7 +117,7 @@ export function createProfileTabOps({ const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; for (const tab of candidates.slice(0, excessCount)) { - void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { + void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => { // best-effort cleanup only }); } @@ -180,7 +182,7 @@ export function createProfileTabOps({ } const encoded = encodeURIComponent(url); - const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new")); + const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new")); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const endpoint = endpointUrl.search ? (() => {