refactor: harden browser relay CDP flows

This commit is contained in:
Peter Steinberger
2026-03-08 23:45:59 +00:00
parent d47aa6bae8
commit 362248e559
18 changed files with 874 additions and 176 deletions

View File

@@ -46,3 +46,19 @@ export function isRetryableReconnectError(err) {
} }
return true; return true;
} }
export function isMissingTabError(err) {
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
return (
message.includes("no tab with id") ||
message.includes("no tab with given id") ||
message.includes("tab not found")
);
}
export function isLastRemainingTab(allTabs, tabIdToClose) {
if (!Array.isArray(allTabs)) {
return true;
}
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
}

View File

@@ -1,4 +1,10 @@
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' import {
buildRelayWsUrl,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} from './background-utils.js'
const DEFAULT_PORT = 18792 const DEFAULT_PORT = 18792
@@ -41,6 +47,9 @@ const reattachPending = new Set()
let reconnectAttempt = 0 let reconnectAttempt = 0
let reconnectTimer = null let reconnectTimer = null
const TAB_VALIDATION_ATTEMPTS = 2
const TAB_VALIDATION_RETRY_DELAY_MS = 1000
function nowStack() { function nowStack() {
try { try {
return new Error().stack || '' return new Error().stack || ''
@@ -49,6 +58,37 @@ function nowStack() {
} }
} }
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function validateAttachedTab(tabId) {
try {
await chrome.tabs.get(tabId)
} catch {
return false
}
for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) {
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
return true
} catch (err) {
if (isMissingTabError(err)) {
return false
}
if (attempt < TAB_VALIDATION_ATTEMPTS - 1) {
await sleep(TAB_VALIDATION_RETRY_DELAY_MS)
}
}
}
return false
}
async function getRelayPort() { async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort']) const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort const raw = stored.relayPort
@@ -108,15 +148,11 @@ async function rehydrateState() {
tabBySession.set(entry.sessionId, entry.tabId) tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on') setBadge(entry.tabId, 'on')
} }
// Phase 2: validate asynchronously, remove dead tabs. // Retry once so transient busy/navigation states do not permanently drop
// a still-attached tab after a service worker restart.
for (const entry of entries) { for (const entry of entries) {
try { const valid = await validateAttachedTab(entry.tabId)
await chrome.tabs.get(entry.tabId) if (!valid) {
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(entry.tabId) tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId) tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off') setBadge(entry.tabId, 'off')
@@ -259,13 +295,10 @@ async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) { for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
// Verify debugger is still attached. // Retry once here as well; reconnect races can briefly make an otherwise
try { // healthy tab look unavailable.
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { const valid = await validateAttachedTab(tabId)
expression: '1', if (!valid) {
returnByValue: true,
})
} catch {
tabs.delete(tabId) tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId) if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off') setBadge(tabId, 'off')
@@ -672,6 +705,11 @@ async function handleForwardCdpCommand(msg) {
const toClose = target ? getTabByTargetId(target) : tabId const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false } if (!toClose) return { success: false }
try { try {
const allTabs = await chrome.tabs.query({})
if (isLastRemainingTab(allTabs, toClose)) {
console.warn('Refusing to close the last tab: this would kill the browser process')
return { success: false, error: 'Cannot close the last tab' }
}
await chrome.tabs.remove(toClose) await chrome.tabs.remove(toClose)
} catch { } catch {
return { success: false } return { success: false }

View File

@@ -112,16 +112,19 @@ export async function executeSnapshotAction(params: {
}): Promise<AgentToolResult<unknown>> { }): Promise<AgentToolResult<unknown>> {
const { input, baseUrl, profile, proxyRequest } = params; const { input, baseUrl, profile, proxyRequest } = params;
const snapshotDefaults = loadConfig().browser?.snapshotDefaults; const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
const format = const format: "ai" | "aria" | undefined =
input.snapshotFormat === "ai" || input.snapshotFormat === "aria" ? input.snapshotFormat : "ai"; input.snapshotFormat === "ai" || input.snapshotFormat === "aria"
const mode = ? input.snapshotFormat
: undefined;
const mode: "efficient" | undefined =
input.mode === "efficient" input.mode === "efficient"
? "efficient" ? "efficient"
: format === "ai" && snapshotDefaults?.mode === "efficient" : format !== "aria" && snapshotDefaults?.mode === "efficient"
? "efficient" ? "efficient"
: undefined; : undefined;
const labels = typeof input.labels === "boolean" ? input.labels : undefined; const labels = typeof input.labels === "boolean" ? input.labels : undefined;
const refs = input.refs === "aria" || input.refs === "role" ? input.refs : undefined; const refs: "aria" | "role" | undefined =
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
const hasMaxChars = Object.hasOwn(input, "maxChars"); const hasMaxChars = Object.hasOwn(input, "maxChars");
const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined; const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined;
const limit = const limit =
@@ -130,6 +133,12 @@ export async function executeSnapshotAction(params: {
typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0 typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0
? Math.floor(input.maxChars) ? Math.floor(input.maxChars)
: undefined; : undefined;
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
const depth =
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
const resolvedMaxChars = const resolvedMaxChars =
format === "ai" format === "ai"
? hasMaxChars ? hasMaxChars
@@ -137,46 +146,32 @@ export async function executeSnapshotAction(params: {
: mode === "efficient" : mode === "efficient"
? undefined ? undefined
: DEFAULT_AI_SNAPSHOT_MAX_CHARS : DEFAULT_AI_SNAPSHOT_MAX_CHARS
: hasMaxChars
? maxChars
: undefined; : undefined;
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined; const snapshotQuery = {
const compact = typeof input.compact === "boolean" ? input.compact : undefined; ...(format ? { format } : {}),
const depth = targetId,
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined; limit,
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined; ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined; refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
};
const snapshot = proxyRequest const snapshot = proxyRequest
? ((await proxyRequest({ ? ((await proxyRequest({
method: "GET", method: "GET",
path: "/snapshot", path: "/snapshot",
profile, profile,
query: { query: snapshotQuery,
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
},
})) as Awaited<ReturnType<typeof browserSnapshot>>) })) as Awaited<ReturnType<typeof browserSnapshot>>)
: await browserSnapshot(baseUrl, { : await browserSnapshot(baseUrl, {
format, ...snapshotQuery,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
profile, profile,
}); });
if (snapshot.format === "ai") { if (snapshot.format === "ai") {

View File

@@ -127,7 +127,7 @@ function registerBrowserToolAfterEachReset() {
} }
async function runSnapshotToolCall(params: { async function runSnapshotToolCall(params: {
snapshotFormat: "ai" | "aria"; snapshotFormat?: "ai" | "aria";
refs?: "aria" | "dom"; refs?: "aria" | "dom";
maxChars?: number; maxChars?: number;
profile?: string; profile?: string;
@@ -243,6 +243,23 @@ describe("browser tool snapshot maxChars", () => {
); );
}); });
it("lets the server choose snapshot format when the user does not request one", async () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome",
}),
);
const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as
| { format?: string; maxChars?: number }
| undefined;
expect(opts?.format).toBeUndefined();
expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false);
});
it("routes to node proxy when target=node", async () => { it("routes to node proxy when target=node", async () => {
mockSingleBrowserProxyNode(); mockSingleBrowserProxyNode();
const tool = createBrowserTool(); const tool = createBrowserTool();
@@ -250,15 +267,44 @@ describe("browser tool snapshot maxChars", () => {
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke", "node.invoke",
{ timeoutMs: 20000 }, { timeoutMs: 25000 },
expect.objectContaining({ expect.objectContaining({
nodeId: "node-1", nodeId: "node-1",
command: "browser.proxy", command: "browser.proxy",
params: expect.objectContaining({
timeoutMs: 20000,
}),
}), }),
); );
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
}); });
it("gives node.invoke extra slack beyond the default proxy timeout", async () => {
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
ok: true,
payload: {
result: { ok: true, running: true },
},
});
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "dialog",
target: "node",
accept: true,
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 25000 },
expect.objectContaining({
params: expect.objectContaining({
timeoutMs: 20000,
}),
}),
);
});
it("keeps sandbox bridge url when node proxy is available", async () => { it("keeps sandbox bridge url when node proxy is available", async () => {
mockSingleBrowserProxyNode(); mockSingleBrowserProxyNode();
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });

View File

@@ -115,6 +115,7 @@ type BrowserProxyResult = {
}; };
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
type BrowserNodeTarget = { type BrowserNodeTarget = {
nodeId: string; nodeId: string;
@@ -206,10 +207,11 @@ async function callBrowserProxy(params: {
timeoutMs?: number; timeoutMs?: number;
profile?: string; profile?: string;
}): Promise<BrowserProxyResult> { }): Promise<BrowserProxyResult> {
const gatewayTimeoutMs = const proxyTimeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(1, Math.floor(params.timeoutMs)) ? Math.max(1, Math.floor(params.timeoutMs))
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS; : DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS;
const payload = await callGatewayTool<{ payloadJSON?: string; payload?: string }>( const payload = await callGatewayTool<{ payloadJSON?: string; payload?: string }>(
"node.invoke", "node.invoke",
{ timeoutMs: gatewayTimeoutMs }, { timeoutMs: gatewayTimeoutMs },
@@ -221,7 +223,7 @@ async function callBrowserProxy(params: {
path: params.path, path: params.path,
query: params.query, query: params.query,
body: params.body, body: params.body,
timeoutMs: params.timeoutMs, timeoutMs: proxyTimeoutMs,
profile: params.profile, profile: params.profile,
}, },
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),

View File

@@ -4,6 +4,11 @@ import { describe, expect, it } from "vitest";
type BackgroundUtilsModule = { type BackgroundUtilsModule = {
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>; buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>; deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
isLastRemainingTab: (
allTabs: Array<{ id?: number | undefined } | null | undefined>,
tabIdToClose: number,
) => boolean;
isMissingTabError: (err: unknown) => boolean;
isRetryableReconnectError: (err: unknown) => boolean; isRetryableReconnectError: (err: unknown) => boolean;
reconnectDelayMs: ( reconnectDelayMs: (
attempt: number, attempt: number,
@@ -26,8 +31,14 @@ async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
} }
} }
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } = const {
await loadBackgroundUtils(); buildRelayWsUrl,
deriveRelayToken,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} = await loadBackgroundUtils();
describe("chrome extension background utils", () => { describe("chrome extension background utils", () => {
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => { it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
@@ -107,4 +118,16 @@ describe("chrome extension background utils", () => {
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true); expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true); expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
}); });
it("recognizes missing-tab debugger errors", () => {
expect(isMissingTabError(new Error("No tab with given id"))).toBe(true);
expect(isMissingTabError(new Error("tab not found"))).toBe(true);
expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false);
});
it("blocks closing the final remaining tab only", () => {
expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true);
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false);
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false);
});
}); });

View File

@@ -101,6 +101,21 @@ describe("browser client", () => {
expect(parsed.searchParams.get("refs")).toBe("aria"); expect(parsed.searchParams.get("refs")).toBe("aria");
}); });
it("omits format when the caller wants server-side snapshot capability defaults", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
profile: "chrome",
});
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
expect(snapshotCall).toBeTruthy();
const parsed = new URL(snapshotCall as string);
expect(parsed.searchParams.get("format")).toBeNull();
expect(parsed.searchParams.get("profile")).toBe("chrome");
});
it("uses the expected endpoints + methods for common calls", async () => { it("uses the expected endpoints + methods for common calls", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = []; const calls: Array<{ url: string; init?: RequestInit }> = [];

View File

@@ -276,7 +276,7 @@ export async function browserTabAction(
export async function browserSnapshot( export async function browserSnapshot(
baseUrl: string | undefined, baseUrl: string | undefined,
opts: { opts: {
format: "aria" | "ai"; format?: "aria" | "ai";
targetId?: string; targetId?: string;
limit?: number; limit?: number;
maxChars?: number; maxChars?: number;
@@ -292,7 +292,9 @@ export async function browserSnapshot(
}, },
): Promise<SnapshotResult> { ): Promise<SnapshotResult> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (opts.format) {
q.set("format", opts.format); q.set("format", opts.format);
}
if (opts.targetId) { if (opts.targetId) {
q.set("targetId", opts.targetId); q.set("targetId", opts.targetId);
} }

View File

@@ -115,4 +115,67 @@ describe("pw-session getPageForTargetId", () => {
fetchSpy.mockRestore(); fetchSpy.mockRestore();
} }
}); });
it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => {
const pageOn = vi.fn();
const contextOn = vi.fn();
const browserOn = vi.fn();
const browserClose = vi.fn(async () => {});
const newCDPSession = vi.fn(async () => {
throw new Error("Target.attachToBrowserTarget: Not allowed");
});
const context = {
pages: () => [],
on: contextOn,
newCDPSession,
} as unknown as import("playwright-core").BrowserContext;
const pageA = {
on: pageOn,
context: () => context,
url: () => "https://alpha.example",
} as unknown as import("playwright-core").Page;
const pageB = {
on: pageOn,
context: () => context,
url: () => "https://beta.example",
} as unknown as import("playwright-core").Page;
(context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB];
const browser = {
contexts: () => [context],
on: browserOn,
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
const fetchSpy = vi.spyOn(globalThis, "fetch");
fetchSpy
.mockResolvedValueOnce({
ok: true,
json: async () => ({ Browser: "OpenClaw/extension-relay" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
],
} as Response);
try {
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:19993",
targetId: "TARGET_B",
});
expect(resolved).toBe(pageB);
expect(newCDPSession).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
}); });

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const cdpHelperMocks = vi.hoisted(() => ({
fetchJson: vi.fn(),
withCdpSocket: vi.fn(),
}));
const chromeMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"),
}));
vi.mock("./cdp.helpers.js", async () => {
const actual = await vi.importActual<typeof import("./cdp.helpers.js")>("./cdp.helpers.js");
return {
...actual,
fetchJson: cdpHelperMocks.fetchJson,
withCdpSocket: cdpHelperMocks.withCdpSocket,
};
});
vi.mock("./chrome.js", () => chromeMocks);
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
describe("pw-session page-scoped CDP client", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
const send = vi.fn(async () => ({ ok: true }));
cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send));
const newCDPSession = vi.fn();
const page = {
context: () => ({
newCDPSession,
}),
};
await withPageScopedCdpClient({
cdpUrl: "http://127.0.0.1:18792",
page: page as never,
targetId: "tab-1",
fn: async (pageSend) => {
await pageSend("Page.bringToFront", { foo: "bar" });
},
});
expect(send).toHaveBeenCalledWith("Page.bringToFront", {
foo: "bar",
targetId: "tab-1",
});
expect(newCDPSession).not.toHaveBeenCalled();
});
it("falls back to Playwright page sessions for non-relay endpoints", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" });
const sessionSend = vi.fn(async () => ({ ok: true }));
const sessionDetach = vi.fn(async () => {});
const newCDPSession = vi.fn(async () => ({
send: sessionSend,
detach: sessionDetach,
}));
const page = {
context: () => ({
newCDPSession,
}),
};
await withPageScopedCdpClient({
cdpUrl: "http://127.0.0.1:9222",
page: page as never,
targetId: "tab-1",
fn: async (pageSend) => {
await pageSend("Emulation.setLocaleOverride", { locale: "en-US" });
},
});
expect(newCDPSession).toHaveBeenCalledWith(page);
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
expect(sessionDetach).toHaveBeenCalledTimes(1);
expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled();
});
it("caches extension-relay endpoint detection by cdpUrl", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true);
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true);
expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,81 @@
import type { CDPSession, Page } from "playwright-core";
import {
appendCdpPath,
fetchJson,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { getChromeWebSocketUrl } from "./chrome.js";
const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay";
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
const extensionRelayByCdpUrl = new Map<string, boolean>();
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
}
export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise<boolean> {
const normalized = normalizeCdpUrl(cdpUrl);
const cached = extensionRelayByCdpUrl.get(normalized);
if (cached !== undefined) {
return cached;
}
try {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized);
const version = await fetchJson<{ Browser?: string }>(
appendCdpPath(cdpHttpBase, "/json/version"),
2000,
);
const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER;
extensionRelayByCdpUrl.set(normalized, isRelay);
return isRelay;
} catch {
extensionRelayByCdpUrl.set(normalized, false);
return false;
}
}
async function withPlaywrightPageCdpSession<T>(
page: Page,
fn: (session: CDPSession) => Promise<T>,
): Promise<T> {
const session = await page.context().newCDPSession(page);
try {
return await fn(session);
} finally {
await session.detach().catch(() => {});
}
}
export async function withPageScopedCdpClient<T>(opts: {
cdpUrl: string;
page: Page;
targetId?: string;
fn: (send: PageCdpSend) => Promise<T>;
}): Promise<T> {
const targetId = opts.targetId?.trim();
if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) {
const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000);
if (!wsUrl) {
throw new Error("CDP websocket unavailable");
}
return await withCdpSocket(wsUrl, async (send) => {
return await opts.fn((method, params) => send(method, { ...params, targetId }));
});
}
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
return await opts.fn((method, params) =>
(
session.send as unknown as (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>
)(method, params),
);
});
}

View File

@@ -24,6 +24,7 @@ import {
assertBrowserNavigationResultAllowed, assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy, withBrowserNavigationPolicy,
} from "./navigation-guard.js"; } from "./navigation-guard.js";
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export type BrowserConsoleMessage = { export type BrowserConsoleMessage = {
type: string; type: string;
@@ -398,14 +399,70 @@ async function pageTargetId(page: Page): Promise<string | null> {
} }
} }
function matchPageByTargetList(
pages: Page[],
targets: Array<{ id: string; url: string; title?: string }>,
targetId: string,
): Page | null {
const target = targets.find((entry) => entry.id === targetId);
if (!target) {
return null;
}
const urlMatch = pages.filter((page) => page.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0] ?? null;
}
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx] ?? null;
}
}
}
return null;
}
async function findPageByTargetIdViaTargetList(
pages: Page[],
targetId: string,
cdpUrl: string,
): Promise<Page | null> {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
const targets = await fetchJson<
Array<{
id: string;
url: string;
title?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
return matchPageByTargetList(pages, targets, targetId);
}
async function findPageByTargetId( async function findPageByTargetId(
browser: Browser, browser: Browser,
targetId: string, targetId: string,
cdpUrl?: string, cdpUrl?: string,
): Promise<Page | null> { ): Promise<Page | null> {
const pages = await getAllPages(browser); const pages = await getAllPages(browser);
const isExtensionRelay = cdpUrl
? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false)
: false;
if (cdpUrl && isExtensionRelay) {
try {
const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
if (matched) {
return matched;
}
} catch {
// Ignore fetch errors and fall through to best-effort single-page fallback.
}
return pages.length === 1 ? (pages[0] ?? null) : null;
}
let resolvedViaCdp = false; let resolvedViaCdp = false;
// First, try the standard CDP session approach
for (const page of pages) { for (const page of pages) {
let tid: string | null = null; let tid: string | null = null;
try { try {
@@ -418,46 +475,16 @@ async function findPageByTargetId(
return page; return page;
} }
} }
// Extension relays can block CDP attachment APIs entirely. If that happens and
// Playwright only exposes one page, return it as the best available mapping.
if (!resolvedViaCdp && pages.length === 1) {
return pages[0];
}
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
// fall back to URL-based matching using the /json/list endpoint
if (cdpUrl) { if (cdpUrl) {
try { try {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
const targets = await fetchJson<
Array<{
id: string;
url: string;
title?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}
}
}
} catch { } catch {
// Ignore fetch errors and fall through to return null // Ignore fetch errors and fall through to return null.
} }
} }
if (!resolvedViaCdp && pages.length === 1) {
return pages[0] ?? null;
}
return null; return null;
} }
@@ -806,14 +833,18 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
try { try {
await page.bringToFront(); await page.bringToFront();
} catch (err) { } catch (err) {
const session = await page.context().newCDPSession(page);
try { try {
await session.send("Page.bringToFront"); await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
await send("Page.bringToFront");
},
});
return; return;
} catch { } catch {
throw err; throw err;
} finally {
await session.detach().catch(() => {});
} }
} }
} }

View File

@@ -19,6 +19,7 @@ import {
storeRoleRefsForTarget, storeRoleRefsForTarget,
type WithSnapshotForAI, type WithSnapshotForAI,
} from "./pw-session.js"; } from "./pw-session.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export async function snapshotAriaViaPlaywright(opts: { export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string; cdpUrl: string;
@@ -31,17 +32,21 @@ export async function snapshotAriaViaPlaywright(opts: {
targetId: opts.targetId, targetId: opts.targetId,
}); });
ensurePageState(page); ensurePageState(page);
const session = await page.context().newCDPSession(page); const res = (await withPageScopedCdpClient({
try { cdpUrl: opts.cdpUrl,
await session.send("Accessibility.enable").catch(() => {}); page,
const res = (await session.send("Accessibility.getFullAXTree")) as { targetId: opts.targetId,
fn: async (send) => {
await send("Accessibility.enable").catch(() => {});
return (await send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
},
})) as {
nodes?: RawAXNode[]; nodes?: RawAXNode[];
}; };
const nodes = Array.isArray(res?.nodes) ? res.nodes : []; const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) }; return { nodes: formatAriaSnapshot(nodes, limit) };
} finally {
await session.detach().catch(() => {});
}
} }
export async function snapshotAiViaPlaywright(opts: { export async function snapshotAiViaPlaywright(opts: {

View File

@@ -1,15 +1,6 @@
import type { CDPSession, Page } from "playwright-core";
import { devices as playwrightDevices } from "playwright-core"; import { devices as playwrightDevices } from "playwright-core";
import { ensurePageState, getPageForTargetId } from "./pw-session.js"; import { ensurePageState, getPageForTargetId } from "./pw-session.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
async function withCdpSession<T>(page: Page, fn: (session: CDPSession) => Promise<T>): Promise<T> {
const session = await page.context().newCDPSession(page);
try {
return await fn(session);
} finally {
await session.detach().catch(() => {});
}
}
export async function setOfflineViaPlaywright(opts: { export async function setOfflineViaPlaywright(opts: {
cdpUrl: string; cdpUrl: string;
@@ -112,15 +103,20 @@ export async function setLocaleViaPlaywright(opts: {
if (!locale) { if (!locale) {
throw new Error("locale is required"); throw new Error("locale is required");
} }
await withCdpSession(page, async (session) => { await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
try { try {
await session.send("Emulation.setLocaleOverride", { locale }); await send("Emulation.setLocaleOverride", { locale });
} catch (err) { } catch (err) {
if (String(err).includes("Another locale override is already in effect")) { if (String(err).includes("Another locale override is already in effect")) {
return; return;
} }
throw err; throw err;
} }
},
}); });
} }
@@ -135,9 +131,13 @@ export async function setTimezoneViaPlaywright(opts: {
if (!timezoneId) { if (!timezoneId) {
throw new Error("timezoneId is required"); throw new Error("timezoneId is required");
} }
await withCdpSession(page, async (session) => { await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
try { try {
await session.send("Emulation.setTimezoneOverride", { timezoneId }); await send("Emulation.setTimezoneOverride", { timezoneId });
} catch (err) { } catch (err) {
const msg = String(err); const msg = String(err);
if (msg.includes("Timezone override is already in effect")) { if (msg.includes("Timezone override is already in effect")) {
@@ -148,6 +148,7 @@ export async function setTimezoneViaPlaywright(opts: {
} }
throw err; throw err;
} }
},
}); });
} }
@@ -183,15 +184,19 @@ export async function setDeviceViaPlaywright(opts: {
}); });
} }
await withCdpSession(page, async (session) => { await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
if (descriptor.userAgent || descriptor.locale) { if (descriptor.userAgent || descriptor.locale) {
await session.send("Emulation.setUserAgentOverride", { await send("Emulation.setUserAgentOverride", {
userAgent: descriptor.userAgent ?? "", userAgent: descriptor.userAgent ?? "",
acceptLanguage: descriptor.locale ?? undefined, acceptLanguage: descriptor.locale ?? undefined,
}); });
} }
if (descriptor.viewport) { if (descriptor.viewport) {
await session.send("Emulation.setDeviceMetricsOverride", { await send("Emulation.setDeviceMetricsOverride", {
mobile: Boolean(descriptor.isMobile), mobile: Boolean(descriptor.isMobile),
width: descriptor.viewport.width, width: descriptor.viewport.width,
height: descriptor.viewport.height, height: descriptor.viewport.height,
@@ -201,9 +206,10 @@ export async function setDeviceViaPlaywright(opts: {
}); });
} }
if (descriptor.hasTouch) { if (descriptor.hasTouch) {
await session.send("Emulation.setTouchEmulationEnabled", { await send("Emulation.setTouchEmulationEnabled", {
enabled: true, enabled: true,
}); });
} }
},
}); });
} }

View File

@@ -5,17 +5,27 @@ const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(
ensureChromeExtensionRelayServerMock: vi.fn(), ensureChromeExtensionRelayServerMock: vi.fn(),
})); }));
const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({
stopOpenClawChromeMock: vi.fn(async () => {}),
stopChromeExtensionRelayServerMock: vi.fn(async () => true),
}));
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({ const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
createBrowserRouteContextMock: vi.fn(), createBrowserRouteContextMock: vi.fn(),
listKnownProfileNamesMock: vi.fn(), listKnownProfileNamesMock: vi.fn(),
})); }));
vi.mock("./chrome.js", () => ({
stopOpenClawChrome: stopOpenClawChromeMock,
}));
vi.mock("./config.js", () => ({ vi.mock("./config.js", () => ({
resolveProfile: resolveProfileMock, resolveProfile: resolveProfileMock,
})); }));
vi.mock("./extension-relay.js", () => ({ vi.mock("./extension-relay.js", () => ({
ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock, ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock,
stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock,
})); }));
vi.mock("./server-context.js", () => ({ vi.mock("./server-context.js", () => ({
@@ -76,6 +86,8 @@ describe("stopKnownBrowserProfiles", () => {
beforeEach(() => { beforeEach(() => {
createBrowserRouteContextMock.mockClear(); createBrowserRouteContextMock.mockClear();
listKnownProfileNamesMock.mockClear(); listKnownProfileNamesMock.mockClear();
stopOpenClawChromeMock.mockClear();
stopChromeExtensionRelayServerMock.mockClear();
}); });
it("stops all known profiles and ignores per-profile failures", async () => { it("stops all known profiles and ignores per-profile failures", async () => {
@@ -104,6 +116,53 @@ describe("stopKnownBrowserProfiles", () => {
expect(onWarn).not.toHaveBeenCalled(); expect(onWarn).not.toHaveBeenCalled();
}); });
it("stops tracked runtime browsers even when the profile no longer resolves", async () => {
listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]);
createBrowserRouteContextMock.mockReturnValue({
forProfile: vi.fn(() => {
throw new Error("profile not found");
}),
});
const localRuntime = {
profile: {
name: "deleted-local",
driver: "openclaw",
},
running: {
pid: 42,
cdpPort: 18888,
},
};
const launchedBrowser = localRuntime.running;
const extensionRuntime = {
profile: {
name: "deleted-extension",
driver: "extension",
cdpUrl: "http://127.0.0.1:19999",
},
running: null,
};
const profiles = new Map<string, unknown>([
["deleted-local", localRuntime],
["deleted-extension", extensionRuntime],
]);
const state = {
resolved: { profiles: {} },
profiles,
};
await stopKnownBrowserProfiles({
getState: () => state as never,
onWarn: vi.fn(),
});
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
expect(localRuntime.running).toBeNull();
expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:19999",
});
});
it("warns when profile enumeration fails", async () => { it("warns when profile enumeration fails", async () => {
listKnownProfileNamesMock.mockImplementation(() => { listKnownProfileNamesMock.mockImplementation(() => {
throw new Error("oops"); throw new Error("oops");

View File

@@ -1,6 +1,10 @@
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js"; import type { ResolvedBrowserConfig } from "./config.js";
import { resolveProfile } from "./config.js"; import { resolveProfile } from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { import {
type BrowserServerState, type BrowserServerState,
createBrowserRouteContext, createBrowserRouteContext,
@@ -40,6 +44,18 @@ export async function stopKnownBrowserProfiles(params: {
try { try {
for (const name of listKnownProfileNames(current)) { for (const name of listKnownProfileNames(current)) {
try { try {
const runtime = current.profiles.get(name);
if (runtime?.running) {
await stopOpenClawChrome(runtime.running);
runtime.running = null;
continue;
}
if (runtime?.profile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch(
() => false,
);
continue;
}
await ctx.forProfile(name).stopRunningBrowser(); await ctx.forProfile(name).stopRunningBrowser();
} catch { } catch {
// ignore // ignore

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const controlServiceMocks = vi.hoisted(() => ({
createBrowserControlContext: vi.fn(() => ({ control: true })),
startBrowserControlServiceFromConfig: vi.fn(async () => true),
}));
const dispatcherMocks = vi.hoisted(() => ({
dispatch: vi.fn(),
createBrowserRouteDispatcher: vi.fn(() => ({
dispatch: dispatcherMocks.dispatch,
})),
}));
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({
browser: {},
nodeHost: { browserProxy: { enabled: true } },
})),
}));
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
defaultProfile: "chrome",
})),
}));
vi.mock("../browser/control-service.js", () => controlServiceMocks);
vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks);
vi.mock("../config/config.js", () => configMocks);
vi.mock("../browser/config.js", () => browserConfigMocks);
vi.mock("../media/mime.js", () => ({
detectMime: vi.fn(async () => "image/png"),
}));
import { runBrowserProxyCommand } from "./invoke-browser.js";
describe("runBrowserProxyCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true } },
});
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
enabled: true,
defaultProfile: "chrome",
});
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
});
it("adds profile and browser status details on ws-backed timeouts", async () => {
dispatcherMocks.dispatch
.mockImplementationOnce(async () => {
await new Promise(() => {});
})
.mockResolvedValueOnce({
status: 200,
body: {
running: true,
cdpHttp: true,
cdpReady: false,
cdpUrl: "http://127.0.0.1:18792",
},
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "GET",
path: "/snapshot",
profile: "chrome",
timeoutMs: 5,
}),
),
).rejects.toThrow(
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
);
});
it("keeps non-timeout browser errors intact", async () => {
dispatcherMocks.dispatch.mockResolvedValue({
status: 500,
body: { error: "tab not found" },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/act",
profile: "chrome",
timeoutMs: 50,
}),
),
).rejects.toThrow("tab not found");
});
});

View File

@@ -30,6 +30,8 @@ type BrowserProxyResult = {
}; };
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
function normalizeProfileAllowlist(raw?: string[]): string[] { function normalizeProfileAllowlist(raw?: string[]): string[] {
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : []; return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
@@ -119,6 +121,87 @@ function decodeParams<T>(raw?: string | null): T {
return JSON.parse(raw) as T; return JSON.parse(raw) as T;
} }
function resolveBrowserProxyTimeout(timeoutMs?: number): number {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.max(1, Math.floor(timeoutMs))
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
}
function isBrowserProxyTimeoutError(err: unknown): boolean {
return String(err).includes("browser proxy request timed out");
}
function isWsBackedBrowserProxyPath(path: string): boolean {
return (
path === "/act" ||
path === "/navigate" ||
path === "/pdf" ||
path === "/screenshot" ||
path === "/snapshot"
);
}
async function readBrowserProxyStatus(params: {
dispatcher: ReturnType<typeof createBrowserRouteDispatcher>;
profile?: string;
}): Promise<Record<string, unknown> | null> {
const query = params.profile ? { profile: params.profile } : {};
try {
const response = await withTimeout(
(signal) =>
params.dispatcher.dispatch({
method: "GET",
path: "/",
query,
signal,
}),
BROWSER_PROXY_STATUS_TIMEOUT_MS,
"browser proxy status",
);
if (response.status >= 400 || !response.body || typeof response.body !== "object") {
return null;
}
const body = response.body as Record<string, unknown>;
return {
running: body.running,
cdpHttp: body.cdpHttp,
cdpReady: body.cdpReady,
cdpUrl: body.cdpUrl,
};
} catch {
return null;
}
}
function formatBrowserProxyTimeoutMessage(params: {
method: string;
path: string;
profile?: string;
timeoutMs: number;
wsBacked: boolean;
status: Record<string, unknown> | null;
}): string {
const parts = [
`browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`,
params.wsBacked ? "ws-backed browser action" : "browser action",
];
if (params.profile) {
parts.push(`profile=${params.profile}`);
}
if (params.status) {
const statusParts = [
`running=${String(params.status.running)}`,
`cdpHttp=${String(params.status.cdpHttp)}`,
`cdpReady=${String(params.status.cdpReady)}`,
];
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
}
parts.push(`status(${statusParts.join(", ")})`);
}
return parts.join("; ");
}
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> { export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
const params = decodeParams<BrowserProxyParams>(paramsJSON); const params = decodeParams<BrowserProxyParams>(paramsJSON);
const pathValue = typeof params.path === "string" ? params.path.trim() : ""; const pathValue = typeof params.path === "string" ? params.path.trim() : "";
@@ -151,6 +234,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`; const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
const body = params.body; const body = params.body;
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
const query: Record<string, unknown> = {}; const query: Record<string, unknown> = {};
if (requestedProfile) { if (requestedProfile) {
query.profile = requestedProfile; query.profile = requestedProfile;
@@ -164,7 +248,9 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
} }
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
const response = await withTimeout( let response;
try {
response = await withTimeout(
(signal) => (signal) =>
dispatcher.dispatch({ dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET", method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
@@ -173,9 +259,30 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
body, body,
signal, signal,
}), }),
params.timeoutMs, timeoutMs,
"browser proxy request", "browser proxy request",
); );
} catch (err) {
if (!isBrowserProxyTimeoutError(err)) {
throw err;
}
const profileForStatus = requestedProfile || resolved.defaultProfile;
const status = await readBrowserProxyStatus({
dispatcher,
profile: path === "/profiles" ? undefined : profileForStatus,
});
throw new Error(
formatBrowserProxyTimeoutMessage({
method,
path,
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status,
}),
{ cause: err },
);
}
if (response.status >= 400) { if (response.status >= 400) {
const message = const message =
response.body && typeof response.body === "object" && "error" in response.body response.body && typeof response.body === "object" && "error" in response.body