mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:48:38 +00:00
fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150)
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.3.7
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 { __test } from "./client-fetch.js";
|
||||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||||
import { shouldRejectBrowserMutation } from "./csrf.js";
|
import { shouldRejectBrowserMutation } from "./csrf.js";
|
||||||
@@ -155,6 +159,18 @@ describe("cdp.helpers", () => {
|
|||||||
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
|
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", () => {
|
it("adds basic auth headers when credentials are present", () => {
|
||||||
const headers = getHeadersWithAuth("https://user:pass@example.com");
|
const headers = getHeadersWithAuth("https://user:pass@example.com");
|
||||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string {
|
|||||||
return url.toString();
|
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) {
|
function createCdpSender(ws: WebSocket) {
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const pending = new Map<number, Pending>();
|
const pending = new Map<number, Pending>();
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import { chromium } from "playwright-core";
|
|||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.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 { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||||
import {
|
import {
|
||||||
@@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise<void> {
|
|||||||
await cur.browser.close().catch(() => {});
|
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 {
|
function cdpSocketNeedsAttach(wsUrl: string): boolean {
|
||||||
try {
|
try {
|
||||||
const pathname = new URL(wsUrl).pathname;
|
const pathname = new URL(wsUrl).pathname;
|
||||||
|
|||||||
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal file
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchOk } from "./cdp.helpers.js";
|
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||||
import { appendCdpPath } from "./cdp.js";
|
import { appendCdpPath } from "./cdp.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import type { PwAiModule } from "./pw-ai-module.js";
|
import type { PwAiModule } from "./pw-ai-module.js";
|
||||||
@@ -27,6 +27,8 @@ export function createProfileSelectionOps({
|
|||||||
listTabs,
|
listTabs,
|
||||||
openTab,
|
openTab,
|
||||||
}: SelectionDeps): SelectionOps {
|
}: SelectionDeps): SelectionOps {
|
||||||
|
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||||
|
|
||||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||||
await ensureBrowserAvailable();
|
await ensureBrowserAvailable();
|
||||||
const profileState = getProfileState();
|
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();
|
const profileState = getProfileState();
|
||||||
profileState.lastTargetId = resolvedTargetId;
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
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 { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +58,8 @@ export function createProfileTabOps({
|
|||||||
state,
|
state,
|
||||||
getProfileState,
|
getProfileState,
|
||||||
}: TabOpsDeps): ProfileTabOps {
|
}: TabOpsDeps): ProfileTabOps {
|
||||||
|
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||||
|
|
||||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
||||||
if (!profile.cdpIsLoopback) {
|
if (!profile.cdpIsLoopback) {
|
||||||
@@ -82,7 +84,7 @@ export function createProfileTabOps({
|
|||||||
webSocketDebuggerUrl?: string;
|
webSocketDebuggerUrl?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
}>
|
}>
|
||||||
>(appendCdpPath(profile.cdpUrl, "/json/list"));
|
>(appendCdpPath(cdpHttpBase, "/json/list"));
|
||||||
return raw
|
return raw
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
targetId: t.id ?? "",
|
targetId: t.id ?? "",
|
||||||
@@ -115,7 +117,7 @@ export function createProfileTabOps({
|
|||||||
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
|
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
|
||||||
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
|
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
|
||||||
for (const tab of candidates.slice(0, excessCount)) {
|
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
|
// best-effort cleanup only
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -180,7 +182,7 @@ export function createProfileTabOps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encoded = encodeURIComponent(url);
|
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 });
|
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||||
const endpoint = endpointUrl.search
|
const endpoint = endpointUrl.search
|
||||||
? (() => {
|
? (() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user