mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor: harden browser relay CDP flows
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
: undefined;
|
: hasMaxChars
|
||||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
? maxChars
|
||||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
: undefined;
|
||||||
const depth =
|
const snapshotQuery = {
|
||||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
...(format ? { format } : {}),
|
||||||
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
|
targetId,
|
||||||
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
|
limit,
|
||||||
|
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||||
|
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") {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
q.set("format", opts.format);
|
if (opts.format) {
|
||||||
|
q.set("format", opts.format);
|
||||||
|
}
|
||||||
if (opts.targetId) {
|
if (opts.targetId) {
|
||||||
q.set("targetId", opts.targetId);
|
q.set("targetId", opts.targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
94
src/browser/pw-session.page-cdp.test.ts
Normal file
94
src/browser/pw-session.page-cdp.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/browser/pw-session.page-cdp.ts
Normal file
81
src/browser/pw-session.page-cdp.ts
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
nodes?: RawAXNode[];
|
fn: async (send) => {
|
||||||
};
|
await send("Accessibility.enable").catch(() => {});
|
||||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
return (await send("Accessibility.getFullAXTree")) as {
|
||||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
nodes?: RawAXNode[];
|
||||||
} finally {
|
};
|
||||||
await session.detach().catch(() => {});
|
},
|
||||||
}
|
})) as {
|
||||||
|
nodes?: RawAXNode[];
|
||||||
|
};
|
||||||
|
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||||
|
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function snapshotAiViaPlaywright(opts: {
|
export async function snapshotAiViaPlaywright(opts: {
|
||||||
|
|||||||
@@ -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({
|
||||||
try {
|
cdpUrl: opts.cdpUrl,
|
||||||
await session.send("Emulation.setLocaleOverride", { locale });
|
page,
|
||||||
} catch (err) {
|
targetId: opts.targetId,
|
||||||
if (String(err).includes("Another locale override is already in effect")) {
|
fn: async (send) => {
|
||||||
return;
|
try {
|
||||||
|
await send("Emulation.setLocaleOverride", { locale });
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err).includes("Another locale override is already in effect")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
throw err;
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,19 +131,24 @@ 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({
|
||||||
try {
|
cdpUrl: opts.cdpUrl,
|
||||||
await session.send("Emulation.setTimezoneOverride", { timezoneId });
|
page,
|
||||||
} catch (err) {
|
targetId: opts.targetId,
|
||||||
const msg = String(err);
|
fn: async (send) => {
|
||||||
if (msg.includes("Timezone override is already in effect")) {
|
try {
|
||||||
return;
|
await send("Emulation.setTimezoneOverride", { timezoneId });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = String(err);
|
||||||
|
if (msg.includes("Timezone override is already in effect")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.includes("Invalid timezone")) {
|
||||||
|
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (msg.includes("Invalid timezone")) {
|
},
|
||||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,27 +184,32 @@ export async function setDeviceViaPlaywright(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await withCdpSession(page, async (session) => {
|
await withPageScopedCdpClient({
|
||||||
if (descriptor.userAgent || descriptor.locale) {
|
cdpUrl: opts.cdpUrl,
|
||||||
await session.send("Emulation.setUserAgentOverride", {
|
page,
|
||||||
userAgent: descriptor.userAgent ?? "",
|
targetId: opts.targetId,
|
||||||
acceptLanguage: descriptor.locale ?? undefined,
|
fn: async (send) => {
|
||||||
});
|
if (descriptor.userAgent || descriptor.locale) {
|
||||||
}
|
await send("Emulation.setUserAgentOverride", {
|
||||||
if (descriptor.viewport) {
|
userAgent: descriptor.userAgent ?? "",
|
||||||
await session.send("Emulation.setDeviceMetricsOverride", {
|
acceptLanguage: descriptor.locale ?? undefined,
|
||||||
mobile: Boolean(descriptor.isMobile),
|
});
|
||||||
width: descriptor.viewport.width,
|
}
|
||||||
height: descriptor.viewport.height,
|
if (descriptor.viewport) {
|
||||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
await send("Emulation.setDeviceMetricsOverride", {
|
||||||
screenWidth: descriptor.viewport.width,
|
mobile: Boolean(descriptor.isMobile),
|
||||||
screenHeight: descriptor.viewport.height,
|
width: descriptor.viewport.width,
|
||||||
});
|
height: descriptor.viewport.height,
|
||||||
}
|
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||||
if (descriptor.hasTouch) {
|
screenWidth: descriptor.viewport.width,
|
||||||
await session.send("Emulation.setTouchEmulationEnabled", {
|
screenHeight: descriptor.viewport.height,
|
||||||
enabled: true,
|
});
|
||||||
});
|
}
|
||||||
}
|
if (descriptor.hasTouch) {
|
||||||
|
await send("Emulation.setTouchEmulationEnabled", {
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
99
src/node-host/invoke-browser.test.ts
Normal file
99
src/node-host/invoke-browser.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,18 +248,41 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||||
const response = await withTimeout(
|
let response;
|
||||||
(signal) =>
|
try {
|
||||||
dispatcher.dispatch({
|
response = await withTimeout(
|
||||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
(signal) =>
|
||||||
|
dispatcher.dispatch({
|
||||||
|
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
timeoutMs,
|
||||||
|
"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,
|
path,
|
||||||
query,
|
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
|
||||||
body,
|
timeoutMs,
|
||||||
signal,
|
wsBacked: isWsBackedBrowserProxyPath(path),
|
||||||
|
status,
|
||||||
}),
|
}),
|
||||||
params.timeoutMs,
|
{ cause: err },
|
||||||
"browser proxy request",
|
);
|
||||||
);
|
}
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user