Skip to content

Commit 9914b48

Browse files
committed
fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150)
1 parent 4d904e7 commit 9914b48

File tree

7 files changed

+158
-31
lines changed

7 files changed

+158
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- 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.
2828
- 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.
2929
- 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.
30+
- 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.
3031

3132
## 2026.3.7
3233

src/browser/browser-utils.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
2+
import {
3+
appendCdpPath,
4+
getHeadersWithAuth,
5+
normalizeCdpHttpBaseForJsonEndpoints,
6+
} from "./cdp.helpers.js";
37
import { __test } from "./client-fetch.js";
48
import { resolveBrowserConfig, resolveProfile } from "./config.js";
59
import { shouldRejectBrowserMutation } from "./csrf.js";
@@ -155,6 +159,18 @@ describe("cdp.helpers", () => {
155159
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
156160
});
157161

162+
it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => {
163+
const url = normalizeCdpHttpBaseForJsonEndpoints(
164+
"wss://connect.example.com/devtools/browser/ABC?token=abc",
165+
);
166+
expect(url).toBe("https://connect.example.com/?token=abc");
167+
});
168+
169+
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
170+
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
171+
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
172+
});
173+
158174
it("adds basic auth headers when credentials are present", () => {
159175
const headers = getHeadersWithAuth("https://user:[email protected]");
160176
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);

src/browser/cdp.helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string {
6767
return url.toString();
6868
}
6969

70+
export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
71+
try {
72+
const url = new URL(cdpUrl);
73+
if (url.protocol === "ws:") {
74+
url.protocol = "http:";
75+
} else if (url.protocol === "wss:") {
76+
url.protocol = "https:";
77+
}
78+
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
79+
url.pathname = url.pathname.replace(/\/cdp$/, "");
80+
return url.toString().replace(/\/$/, "");
81+
} catch {
82+
// Best-effort fallback for non-URL-ish inputs.
83+
return cdpUrl
84+
.replace(/^ws:/, "http:")
85+
.replace(/^wss:/, "https:")
86+
.replace(/\/devtools\/browser\/.*$/, "")
87+
.replace(/\/cdp$/, "")
88+
.replace(/\/$/, "");
89+
}
90+
}
91+
7092
function createCdpSender(ws: WebSocket) {
7193
let nextId = 1;
7294
const pending = new Map<number, Pending>();

src/browser/pw-session.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import { chromium } from "playwright-core";
1010
import { formatErrorMessage } from "../infra/errors.js";
1111
import type { SsrFPolicy } from "../infra/net/ssrf.js";
1212
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
13-
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
13+
import {
14+
appendCdpPath,
15+
fetchJson,
16+
getHeadersWithAuth,
17+
normalizeCdpHttpBaseForJsonEndpoints,
18+
withCdpSocket,
19+
} from "./cdp.helpers.js";
1420
import { normalizeCdpWsUrl } from "./cdp.js";
1521
import { getChromeWebSocketUrl } from "./chrome.js";
1622
import {
@@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise<void> {
546552
await cur.browser.close().catch(() => {});
547553
}
548554

549-
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
550-
try {
551-
const url = new URL(cdpUrl);
552-
if (url.protocol === "ws:") {
553-
url.protocol = "http:";
554-
} else if (url.protocol === "wss:") {
555-
url.protocol = "https:";
556-
}
557-
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
558-
url.pathname = url.pathname.replace(/\/cdp$/, "");
559-
return url.toString().replace(/\/$/, "");
560-
} catch {
561-
// Best-effort fallback for non-URL-ish inputs.
562-
return cdpUrl
563-
.replace(/^ws:/, "http:")
564-
.replace(/^wss:/, "https:")
565-
.replace(/\/devtools\/browser\/.*$/, "")
566-
.replace(/\/cdp$/, "")
567-
.replace(/\/$/, "");
568-
}
569-
}
570-
571555
function cdpSocketNeedsAttach(wsUrl: string): boolean {
572556
try {
573557
const pathname = new URL(wsUrl).pathname;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
3+
import * as cdpModule from "./cdp.js";
4+
import { createBrowserRouteContext } from "./server-context.js";
5+
import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js";
6+
7+
afterEach(() => {
8+
globalThis.fetch = originalFetch;
9+
vi.restoreAllMocks();
10+
});
11+
12+
describe("browser server-context loopback direct WebSocket profiles", () => {
13+
it("uses an HTTP /json/list base when opening tabs", async () => {
14+
const createTargetViaCdp = vi
15+
.spyOn(cdpModule, "createTargetViaCdp")
16+
.mockResolvedValue({ targetId: "CREATED" });
17+
18+
const fetchMock = vi.fn(async (url: unknown) => {
19+
const u = String(url);
20+
expect(u).toBe("http://127.0.0.1:18800/json/list?token=abc");
21+
return {
22+
ok: true,
23+
json: async () => [
24+
{
25+
id: "CREATED",
26+
title: "New Tab",
27+
url: "http://127.0.0.1:8080",
28+
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
29+
type: "page",
30+
},
31+
],
32+
} as unknown as Response;
33+
});
34+
35+
global.fetch = withFetchPreconnect(fetchMock);
36+
const state = makeState("openclaw");
37+
state.resolved.profiles.openclaw = {
38+
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
39+
color: "#FF4500",
40+
};
41+
const ctx = createBrowserRouteContext({ getState: () => state });
42+
const openclaw = ctx.forProfile("openclaw");
43+
44+
const opened = await openclaw.openTab("http://127.0.0.1:8080");
45+
expect(opened.targetId).toBe("CREATED");
46+
expect(createTargetViaCdp).toHaveBeenCalledWith({
47+
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
48+
url: "http://127.0.0.1:8080",
49+
ssrfPolicy: { allowPrivateNetwork: true },
50+
});
51+
});
52+
53+
it("uses an HTTP /json base for focus and close", async () => {
54+
const fetchMock = vi.fn(async (url: unknown) => {
55+
const u = String(url);
56+
if (u === "http://127.0.0.1:18800/json/list?token=abc") {
57+
return {
58+
ok: true,
59+
json: async () => [
60+
{
61+
id: "T1",
62+
title: "Tab 1",
63+
url: "https://example.com",
64+
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/T1",
65+
type: "page",
66+
},
67+
],
68+
} as unknown as Response;
69+
}
70+
if (u === "http://127.0.0.1:18800/json/activate/T1?token=abc") {
71+
return { ok: true, json: async () => ({}) } as unknown as Response;
72+
}
73+
if (u === "http://127.0.0.1:18800/json/close/T1?token=abc") {
74+
return { ok: true, json: async () => ({}) } as unknown as Response;
75+
}
76+
throw new Error(`unexpected fetch: ${u}`);
77+
});
78+
79+
global.fetch = withFetchPreconnect(fetchMock);
80+
const state = makeState("openclaw");
81+
state.resolved.profiles.openclaw = {
82+
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
83+
color: "#FF4500",
84+
};
85+
const ctx = createBrowserRouteContext({ getState: () => state });
86+
const openclaw = ctx.forProfile("openclaw");
87+
88+
await openclaw.focusTab("T1");
89+
await openclaw.closeTab("T1");
90+
91+
expect(fetchMock).toHaveBeenCalledWith(
92+
"http://127.0.0.1:18800/json/activate/T1?token=abc",
93+
expect.any(Object),
94+
);
95+
expect(fetchMock).toHaveBeenCalledWith(
96+
"http://127.0.0.1:18800/json/close/T1?token=abc",
97+
expect.any(Object),
98+
);
99+
});
100+
});

src/browser/server-context.selection.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchOk } from "./cdp.helpers.js";
1+
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
22
import { appendCdpPath } from "./cdp.js";
33
import type { ResolvedBrowserProfile } from "./config.js";
44
import type { PwAiModule } from "./pw-ai-module.js";
@@ -27,6 +27,8 @@ export function createProfileSelectionOps({
2727
listTabs,
2828
openTab,
2929
}: SelectionDeps): SelectionOps {
30+
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
31+
3032
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
3133
await ensureBrowserAvailable();
3234
const profileState = getProfileState();
@@ -122,7 +124,7 @@ export function createProfileSelectionOps({
122124
}
123125
}
124126

125-
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`));
127+
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
126128
const profileState = getProfileState();
127129
profileState.lastTargetId = resolvedTargetId;
128130
};
@@ -144,7 +146,7 @@ export function createProfileSelectionOps({
144146
}
145147
}
146148

147-
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`));
149+
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
148150
};
149151

150152
return {

src/browser/server-context.tab-ops.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
2-
import { fetchJson, fetchOk } from "./cdp.helpers.js";
2+
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
33
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
44
import type { ResolvedBrowserProfile } from "./config.js";
55
import {
@@ -58,6 +58,8 @@ export function createProfileTabOps({
5858
state,
5959
getProfileState,
6060
}: TabOpsDeps): ProfileTabOps {
61+
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
62+
6163
const listTabs = async (): Promise<BrowserTab[]> => {
6264
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
6365
if (!profile.cdpIsLoopback) {
@@ -82,7 +84,7 @@ export function createProfileTabOps({
8284
webSocketDebuggerUrl?: string;
8385
type?: string;
8486
}>
85-
>(appendCdpPath(profile.cdpUrl, "/json/list"));
87+
>(appendCdpPath(cdpHttpBase, "/json/list"));
8688
return raw
8789
.map((t) => ({
8890
targetId: t.id ?? "",
@@ -115,7 +117,7 @@ export function createProfileTabOps({
115117
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
116118
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
117119
for (const tab of candidates.slice(0, excessCount)) {
118-
void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => {
120+
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
119121
// best-effort cleanup only
120122
});
121123
}
@@ -180,7 +182,7 @@ export function createProfileTabOps({
180182
}
181183

182184
const encoded = encodeURIComponent(url);
183-
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
185+
const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));
184186
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
185187
const endpoint = endpointUrl.search
186188
? (() => {

0 commit comments

Comments
 (0)