Compare commits
15 Commits
main
...
docs/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55300ea850 | ||
|
|
8e5f702adf | ||
|
|
56d2662f9d | ||
|
|
e2ecd0a321 | ||
|
|
7fce53976e | ||
|
|
1cc021251e | ||
|
|
a8ad7e42af | ||
|
|
42320281c6 | ||
|
|
f60168b735 | ||
|
|
0a5701f468 | ||
|
|
07f65838ed | ||
|
|
4d326271f0 | ||
|
|
eb4ff4464e | ||
|
|
cbcf9d0811 | ||
|
|
83a854bfa0 |
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -196,6 +196,53 @@ Notes:
|
|||||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||||
|
|
||||||
|
## Direct WebSocket CDP providers
|
||||||
|
|
||||||
|
Some hosted browser services expose a **direct WebSocket** endpoint rather than
|
||||||
|
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
|
||||||
|
|
||||||
|
- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to
|
||||||
|
discover the WebSocket debugger URL, then connects.
|
||||||
|
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
|
||||||
|
skipping `/json/version`. Use this for services like
|
||||||
|
[Browserbase](https://www.browserbase.com) or any provider that hands you a
|
||||||
|
WebSocket URL.
|
||||||
|
|
||||||
|
### Browserbase
|
||||||
|
|
||||||
|
[Browserbase](https://www.browserbase.com) is a cloud platform for running
|
||||||
|
headless browsers with built-in CAPTCHA solving, stealth mode, and residential
|
||||||
|
proxies.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
defaultProfile: "browserbase",
|
||||||
|
remoteCdpTimeoutMs: 3000,
|
||||||
|
remoteCdpHandshakeTimeoutMs: 5000,
|
||||||
|
profiles: {
|
||||||
|
browserbase: {
|
||||||
|
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
|
||||||
|
color: "#F97316",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
|
||||||
|
from the [Overview dashboard](https://www.browserbase.com/overview).
|
||||||
|
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
|
||||||
|
- Browserbase auto-creates a browser session on WebSocket connect, so no
|
||||||
|
manual session creation step is needed.
|
||||||
|
- The free tier allows one concurrent session and one browser hour per month.
|
||||||
|
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
|
||||||
|
- See the [Browserbase docs](https://docs.browserbase.com) for full API
|
||||||
|
reference, SDK guides, and integration examples.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Key ideas:
|
Key ideas:
|
||||||
@ -207,7 +254,7 @@ Key ideas:
|
|||||||
|
|
||||||
Remote CDP tips:
|
Remote CDP tips:
|
||||||
|
|
||||||
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible.
|
||||||
- Avoid embedding long-lived tokens directly in config files.
|
- Avoid embedding long-lived tokens directly in config files.
|
||||||
|
|
||||||
## Profiles (multi-browser)
|
## Profiles (multi-browser)
|
||||||
|
|||||||
@ -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")}`);
|
||||||
|
|||||||
@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
|
|||||||
|
|
||||||
export { isLoopbackHost };
|
export { isLoopbackHost };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
|
||||||
|
* Used to distinguish direct-WebSocket CDP endpoints
|
||||||
|
* from HTTP(S) endpoints that require /json/version discovery.
|
||||||
|
*/
|
||||||
|
export function isWebSocketUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CdpResponse = {
|
type CdpResponse = {
|
||||||
id: number;
|
id: number;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
@ -53,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>();
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
import { isWebSocketUrl } from "./cdp.helpers.js";
|
||||||
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||||
|
import { parseHttpUrl } from "./config.js";
|
||||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||||
|
|
||||||
describe("cdp", () => {
|
describe("cdp", () => {
|
||||||
@ -95,6 +97,79 @@ describe("cdp", () => {
|
|||||||
expect(created.targetId).toBe("TARGET_123");
|
expect(created.targetId).toBe("TARGET_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
|
||||||
|
const wsPort = await startWsServerWithMessages((msg, socket) => {
|
||||||
|
if (msg.method !== "Target.createTarget") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: msg.id,
|
||||||
|
result: { targetId: "TARGET_WS_DIRECT" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
try {
|
||||||
|
const created = await createTargetViaCdp({
|
||||||
|
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.targetId).toBe("TARGET_WS_DIRECT");
|
||||||
|
// /json/version should NOT have been called — direct WS skips HTTP discovery
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves query params when connecting via direct WebSocket URL", async () => {
|
||||||
|
let receivedHeaders: Record<string, string> = {};
|
||||||
|
const wsPort = await startWsServer();
|
||||||
|
if (!wsServer) {
|
||||||
|
throw new Error("ws server not initialized");
|
||||||
|
}
|
||||||
|
wsServer.on("headers", (headers, req) => {
|
||||||
|
receivedHeaders = Object.fromEntries(
|
||||||
|
Object.entries(req.headers).map(([k, v]) => [k, String(v)]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wsServer.on("connection", (socket) => {
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string };
|
||||||
|
if (msg.method === "Target.createTarget") {
|
||||||
|
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await createTargetViaCdp({
|
||||||
|
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`,
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(created.targetId).toBe("T_QP");
|
||||||
|
// The WebSocket upgrade request should have been made to the URL with the query param
|
||||||
|
expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enforces SSRF policy for direct WebSocket URLs", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
createTargetViaCdp({
|
||||||
|
cdpUrl: "ws://127.0.0.1:9222",
|
||||||
|
url: "http://127.0.0.1:8080",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||||
|
// SSRF check happens before any connection attempt
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks private navigation targets by default", async () => {
|
it("blocks private navigation targets by default", async () => {
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
try {
|
try {
|
||||||
@ -253,3 +328,58 @@ describe("cdp", () => {
|
|||||||
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
|
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isWebSocketUrl", () => {
|
||||||
|
it("returns true for ws:// URLs", () => {
|
||||||
|
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
|
||||||
|
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for wss:// URLs", () => {
|
||||||
|
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
|
||||||
|
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for http:// and https:// URLs", () => {
|
||||||
|
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
|
||||||
|
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid or non-URL strings", () => {
|
||||||
|
expect(isWebSocketUrl("not-a-url")).toBe(false);
|
||||||
|
expect(isWebSocketUrl("")).toBe(false);
|
||||||
|
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseHttpUrl with WebSocket protocols", () => {
|
||||||
|
it("accepts wss:// URLs and defaults to port 443", () => {
|
||||||
|
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");
|
||||||
|
expect(result.parsed.protocol).toBe("wss:");
|
||||||
|
expect(result.port).toBe(443);
|
||||||
|
expect(result.normalized).toContain("wss://connect.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts ws:// URLs and defaults to port 80", () => {
|
||||||
|
const result = parseHttpUrl("ws://127.0.0.1/devtools", "test");
|
||||||
|
expect(result.parsed.protocol).toBe("ws:");
|
||||||
|
expect(result.port).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit ports in wss:// URLs", () => {
|
||||||
|
const result = parseHttpUrl("wss://connect.example.com:8443/path", "test");
|
||||||
|
expect(result.port).toBe(8443);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts http:// and https:// URLs", () => {
|
||||||
|
const http = parseHttpUrl("http://127.0.0.1:9222", "test");
|
||||||
|
expect(http.port).toBe(9222);
|
||||||
|
const https = parseHttpUrl("https://browserless.example?token=abc", "test");
|
||||||
|
expect(https.port).toBe(443);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported protocols", () => {
|
||||||
|
expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)");
|
||||||
|
expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,8 +1,20 @@
|
|||||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
import {
|
||||||
|
appendCdpPath,
|
||||||
|
fetchJson,
|
||||||
|
isLoopbackHost,
|
||||||
|
isWebSocketUrl,
|
||||||
|
withCdpSocket,
|
||||||
|
} from "./cdp.helpers.js";
|
||||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||||
|
|
||||||
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
|
export {
|
||||||
|
appendCdpPath,
|
||||||
|
fetchJson,
|
||||||
|
fetchOk,
|
||||||
|
getHeadersWithAuth,
|
||||||
|
isWebSocketUrl,
|
||||||
|
} from "./cdp.helpers.js";
|
||||||
|
|
||||||
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||||
const ws = new URL(wsUrl);
|
const ws = new URL(wsUrl);
|
||||||
@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: {
|
|||||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||||
});
|
});
|
||||||
|
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
let wsUrl: string;
|
||||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
if (isWebSocketUrl(opts.cdpUrl)) {
|
||||||
1500,
|
// Direct WebSocket URL — skip /json/version discovery.
|
||||||
);
|
wsUrl = opts.cdpUrl;
|
||||||
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
} else {
|
||||||
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
|
||||||
if (!wsUrl) {
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
appendCdpPath(opts.cdpUrl, "/json/version"),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
|
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||||
|
if (!wsUrl) {
|
||||||
|
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await withCdpSocket(wsUrl, async (send) => {
|
return await withCdpSocket(wsUrl, async (send) => {
|
||||||
|
|||||||
@ -350,6 +350,16 @@ describe("browser chrome helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("probes WebSocket URLs via handshake instead of HTTP", async () => {
|
||||||
|
// For ws:// URLs, isChromeReachable should NOT call fetch at all —
|
||||||
|
// it should attempt a WebSocket handshake instead.
|
||||||
|
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
// No WS server listening → handshake fails → not reachable
|
||||||
|
await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("stopOpenClawChrome no-ops when process is already killed", async () => {
|
it("stopOpenClawChrome no-ops when process is already killed", async () => {
|
||||||
const proc = makeChromeTestProc({ killed: true });
|
const proc = makeChromeTestProc({ killed: true });
|
||||||
await stopChromeWithProc(proc, 10);
|
await stopChromeWithProc(proc, 10);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
CHROME_STOP_TIMEOUT_MS,
|
CHROME_STOP_TIMEOUT_MS,
|
||||||
CHROME_WS_READY_TIMEOUT_MS,
|
CHROME_WS_READY_TIMEOUT_MS,
|
||||||
} from "./cdp-timeouts.js";
|
} from "./cdp-timeouts.js";
|
||||||
import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js";
|
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
|
||||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
type BrowserExecutable,
|
type BrowserExecutable,
|
||||||
@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) {
|
|||||||
return `http://127.0.0.1:${cdpPort}`;
|
return `http://127.0.0.1:${cdpPort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
|
||||||
|
ws.once("open", () => {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
ws.once("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function isChromeReachable(
|
export async function isChromeReachable(
|
||||||
cdpUrl: string,
|
cdpUrl: string,
|
||||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isWebSocketUrl(cdpUrl)) {
|
||||||
|
// Direct WebSocket endpoint — probe via WS handshake.
|
||||||
|
return await canOpenWebSocket(cdpUrl, timeoutMs);
|
||||||
|
}
|
||||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||||
return Boolean(version);
|
return Boolean(version);
|
||||||
}
|
}
|
||||||
@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl(
|
|||||||
cdpUrl: string,
|
cdpUrl: string,
|
||||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
if (isWebSocketUrl(cdpUrl)) {
|
||||||
|
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
|
||||||
|
return cdpUrl;
|
||||||
|
}
|
||||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
if (!wsUrl) {
|
if (!wsUrl) {
|
||||||
|
|||||||
@ -165,8 +165,21 @@ describe("browser config", () => {
|
|||||||
expect(work?.cdpUrl).toBe("https://example.com:18801");
|
expect(work?.cdpUrl).toBe("https://example.com:18801");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves wss:// cdpUrl with query params for the default profile", () => {
|
||||||
|
const resolved = resolveBrowserConfig({
|
||||||
|
cdpUrl: "wss://connect.browserbase.com?apiKey=test-key",
|
||||||
|
});
|
||||||
|
const profile = resolveProfile(resolved, "openclaw");
|
||||||
|
expect(profile?.cdpUrl).toBe("wss://connect.browserbase.com/?apiKey=test-key");
|
||||||
|
expect(profile?.cdpHost).toBe("connect.browserbase.com");
|
||||||
|
expect(profile?.cdpPort).toBe(443);
|
||||||
|
expect(profile?.cdpIsLoopback).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unsupported protocols", () => {
|
it("rejects unsupported protocols", () => {
|
||||||
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
|
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
|
||||||
|
"must be http(s) or ws(s)",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
||||||
|
|||||||
@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
|
|||||||
export function parseHttpUrl(raw: string, label: string) {
|
export function parseHttpUrl(raw: string, label: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
const parsed = new URL(trimmed);
|
const parsed = new URL(trimmed);
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
const allowed = ["http:", "https:", "ws:", "wss:"];
|
||||||
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
|
if (!allowed.includes(parsed.protocol)) {
|
||||||
|
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
||||||
const port =
|
const port =
|
||||||
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
||||||
? Number.parseInt(parsed.port, 10)
|
? Number.parseInt(parsed.port, 10)
|
||||||
: parsed.protocol === "https:"
|
: isSecure
|
||||||
? 443
|
? 443
|
||||||
: 80;
|
: 80;
|
||||||
|
|
||||||
@ -160,12 +162,17 @@ function ensureDefaultProfile(
|
|||||||
defaultColor: string,
|
defaultColor: string,
|
||||||
legacyCdpPort?: number,
|
legacyCdpPort?: number,
|
||||||
derivedDefaultCdpPort?: number,
|
derivedDefaultCdpPort?: number,
|
||||||
|
legacyCdpUrl?: string,
|
||||||
): Record<string, BrowserProfileConfig> {
|
): Record<string, BrowserProfileConfig> {
|
||||||
const result = { ...profiles };
|
const result = { ...profiles };
|
||||||
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
||||||
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
||||||
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
|
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
|
||||||
color: defaultColor,
|
color: defaultColor,
|
||||||
|
// Preserve the full cdpUrl for ws/wss endpoints so resolveProfile()
|
||||||
|
// doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops
|
||||||
|
// the WebSocket protocol and query params like API keys).
|
||||||
|
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@ -258,8 +265,16 @@ export function resolveBrowserConfig(
|
|||||||
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
||||||
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
||||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||||
|
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
|
||||||
|
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
|
||||||
const profiles = ensureDefaultChromeExtensionProfile(
|
const profiles = ensureDefaultChromeExtensionProfile(
|
||||||
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart),
|
ensureDefaultProfile(
|
||||||
|
cfg?.profiles,
|
||||||
|
defaultColor,
|
||||||
|
legacyCdpPort,
|
||||||
|
cdpPortRangeStart,
|
||||||
|
legacyCdpUrl,
|
||||||
|
),
|
||||||
controlPort,
|
controlPort,
|
||||||
);
|
);
|
||||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||||
|
|||||||
@ -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
|
||||||
? (() => {
|
? (() => {
|
||||||
|
|||||||
@ -110,7 +110,7 @@ describe("profile CRUD endpoints", () => {
|
|||||||
const createBadRemote = await realFetch(`${base}/profiles/create`, {
|
const createBadRemote = await realFetch(`${base}/profiles/create`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
|
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
|
||||||
});
|
});
|
||||||
expect(createBadRemote.status).toBe(400);
|
expect(createBadRemote.status).toBe(400);
|
||||||
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user