mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(browser): cancel Chrome MCP requests on crash (#101454)
* fix(browser): cancel Chrome MCP requests on crash * test(browser): cover request budget options
This commit is contained in:
committed by
GitHub
parent
988f30e30e
commit
3901094ace
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -27,10 +28,22 @@ type ToolCall = {
|
||||
};
|
||||
type ToolCallMock = {
|
||||
mock: {
|
||||
calls: Array<[ToolCall]>;
|
||||
calls: Array<[ToolCall, unknown?, { signal?: AbortSignal; timeout?: number }?]>;
|
||||
};
|
||||
};
|
||||
|
||||
function createSdkTimeoutCallTool() {
|
||||
return vi.fn(
|
||||
async (_call: ToolCall, _resultSchema?: unknown, options?: { timeout?: number }) =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new McpError(ErrorCode.RequestTimeout, "Request timed out")),
|
||||
options?.timeout,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type ChromeMcpSessionFactory = Exclude<
|
||||
Parameters<typeof setChromeMcpSessionFactoryForTest>[0],
|
||||
null
|
||||
@@ -1195,20 +1208,29 @@ describe("chrome MCP page parsing", () => {
|
||||
|
||||
it("times out a stuck click and recovers on the next call", async () => {
|
||||
let factoryCalls = 0;
|
||||
let forwardedTimeout: number | undefined;
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
factoryCalls += 1;
|
||||
const session = createFakeSession();
|
||||
const callTool = vi.fn(async ({ name }: ToolCall) => {
|
||||
if (name === "click") {
|
||||
return await new Promise(() => {});
|
||||
}
|
||||
if (name === "list_pages") {
|
||||
return {
|
||||
content: [{ type: "text", text: "## Pages\n1: https://example.com [selected]" }],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected tool ${name}`);
|
||||
});
|
||||
const callTool = vi.fn(
|
||||
async ({ name }: ToolCall, _resultSchema?: unknown, options?: { timeout?: number }) => {
|
||||
if (name === "click") {
|
||||
forwardedTimeout = options?.timeout;
|
||||
return await new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new McpError(ErrorCode.RequestTimeout, "Request timed out")),
|
||||
options?.timeout,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (name === "list_pages") {
|
||||
return {
|
||||
content: [{ type: "text", text: "## Pages\n1: https://example.com [selected]" }],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected tool ${name}`);
|
||||
},
|
||||
);
|
||||
session.client.callTool = callTool as typeof session.client.callTool;
|
||||
return session;
|
||||
};
|
||||
@@ -1223,11 +1245,61 @@ describe("chrome MCP page parsing", () => {
|
||||
}),
|
||||
).rejects.toThrow(/timed out/i);
|
||||
|
||||
expect(forwardedTimeout).toBe(25);
|
||||
const tabs = await listChromeMcpTabs("chrome-live");
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels a stuck evaluate through the SDK signal and reconnects", async () => {
|
||||
let factoryCalls = 0;
|
||||
let forwardedSignal: AbortSignal | undefined;
|
||||
let notifyToolStarted: (() => void) | undefined;
|
||||
const toolStarted = new Promise<void>((resolve) => {
|
||||
notifyToolStarted = resolve;
|
||||
});
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
factoryCalls += 1;
|
||||
const session = createFakeSession();
|
||||
if (factoryCalls === 1) {
|
||||
session.client.callTool = vi.fn(
|
||||
async (_call: ToolCall, _resultSchema?: unknown, options?: { signal?: AbortSignal }) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
const signal = options?.signal;
|
||||
forwardedSignal = signal;
|
||||
notifyToolStarted?.();
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
) as typeof session.client.callTool;
|
||||
}
|
||||
return session;
|
||||
};
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
const ctrl = new AbortController();
|
||||
const evaluatePromise = evaluateChromeMcpScript({
|
||||
profileName: "chrome-live",
|
||||
targetId: "1",
|
||||
fn: "() => window.location.href",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
await toolStarted;
|
||||
expect(forwardedSignal).toBe(ctrl.signal);
|
||||
ctrl.abort(new Error("target browser crashed"));
|
||||
|
||||
await expect(evaluatePromise).rejects.toThrow(/target browser crashed/i);
|
||||
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
|
||||
expect(factoryCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("does not dispatch a click when the signal is already aborted", async () => {
|
||||
const session = createFakeSession();
|
||||
const callTool = vi.fn(async (_call: ToolCall) => {
|
||||
@@ -1403,11 +1475,7 @@ describe("chrome MCP page parsing", () => {
|
||||
factoryCalls += 1;
|
||||
const session = createFakeSession();
|
||||
if (factoryCalls === 1) {
|
||||
// First session: all tool calls hang — simulates a Chrome MCP subprocess that is
|
||||
// completely blocked (e.g., stuck waiting for a slow navigation to complete).
|
||||
session.client.callTool = vi.fn(
|
||||
async () => new Promise<never>(() => {}),
|
||||
) as typeof session.client.callTool;
|
||||
session.client.callTool = createSdkTimeoutCallTool() as typeof session.client.callTool;
|
||||
}
|
||||
return session;
|
||||
};
|
||||
@@ -1437,12 +1505,10 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("forwards an explicit timeoutMs to take_snapshot via the callTool race", async () => {
|
||||
it("forwards an explicit timeoutMs to take_snapshot through the SDK", async () => {
|
||||
vi.useFakeTimers();
|
||||
const session = createFakeSession();
|
||||
session.client.callTool = vi.fn(
|
||||
async () => new Promise<never>(() => {}),
|
||||
) as typeof session.client.callTool;
|
||||
session.client.callTool = createSdkTimeoutCallTool() as typeof session.client.callTool;
|
||||
setChromeMcpSessionFactoryForTest(async () => session);
|
||||
|
||||
const snapshotPromise = takeChromeMcpSnapshot({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setTimeout as sleepTimeout } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
addTimerTimeoutGraceMs,
|
||||
parseStrictPositiveInteger,
|
||||
@@ -54,12 +55,24 @@ type ChromeMcpSession = {
|
||||
ownsProcessTree?: boolean;
|
||||
};
|
||||
|
||||
type ChromeMcpCallOptions = {
|
||||
ephemeral?: boolean;
|
||||
export type ChromeMcpOperationOptions = {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ChromeMcpTargetOperation = ChromeMcpOperationOptions & {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
type ChromeMcpCallOptions = ChromeMcpOperationOptions & {
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
const MCP_REQUEST_TIMEOUT_CODE: number = ErrorCode.RequestTimeout;
|
||||
|
||||
/** Browser profile options used to connect or launch chrome-devtools-mcp. */
|
||||
export type ChromeMcpProfileOptions = {
|
||||
userDataDir?: string;
|
||||
@@ -1231,44 +1244,22 @@ async function callTool(
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const lease = await leaseSession(profileName, normalizedProfileOptions, options);
|
||||
const rawCall = lease.session.client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}) as Promise<ChromeMcpToolResult>;
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortListener: (() => void) | undefined;
|
||||
const racers: Array<Promise<ChromeMcpToolResult> | Promise<never>> = [rawCall];
|
||||
|
||||
if (timeoutMs !== undefined && timeoutMs > 0) {
|
||||
racers.push(
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Chrome MCP "${name}" timed out after ${timeoutMs}ms. Session reset for reconnect.`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<never>((_, reject) => {
|
||||
abortListener = () =>
|
||||
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
// SDK-owned cancellation removes its request correlation entry. An outer race would return
|
||||
// early while leaving the underlying MCP request pending after a target-browser crash.
|
||||
const request = { name, arguments: args };
|
||||
const rawCall = (
|
||||
(timeoutMs !== undefined && timeoutMs > 0) || signal
|
||||
? lease.session.client.callTool(request, undefined, {
|
||||
...(timeoutMs !== undefined && timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
: lease.session.client.callTool(request)
|
||||
) as Promise<ChromeMcpToolResult>;
|
||||
|
||||
let result: ChromeMcpToolResult;
|
||||
try {
|
||||
result = racers.length === 1 ? await rawCall : await Promise.race(racers);
|
||||
result = await rawCall;
|
||||
} catch (err) {
|
||||
void rawCall.catch(() => {});
|
||||
// Transport/connection error, timeout, or abort: tear down session so it reconnects.
|
||||
// Transport-identity check prevents clobbering a replacement session created concurrently.
|
||||
if (!lease.temporary) {
|
||||
@@ -1278,14 +1269,17 @@ async function callTool(
|
||||
await closeChromeMcpSessionHandle(lease.session);
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw toLintErrorObject(signal.reason ?? err, "Non-Error abort reason");
|
||||
}
|
||||
if (timeoutMs && err instanceof McpError && err.code === MCP_REQUEST_TIMEOUT_CODE) {
|
||||
throw new Error(
|
||||
`Chrome MCP "${name}" timed out after ${timeoutMs}ms. Session reset for reconnect.`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (signal && abortListener) {
|
||||
signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
if (lease.temporary) {
|
||||
await closeChromeMcpSessionHandle(lease.session);
|
||||
}
|
||||
@@ -1321,6 +1315,20 @@ async function callTool(
|
||||
throw new Error(`Chrome MCP tool "${name}" failed after reconnect.`);
|
||||
}
|
||||
|
||||
async function callTargetTool(
|
||||
params: ChromeMcpTargetOperation,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ChromeMcpToolResult> {
|
||||
return await callTool(
|
||||
params.profileName,
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
name,
|
||||
args,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
|
||||
const dir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-chrome-mcp-"));
|
||||
const filePath = path.join(dir, randomUUID());
|
||||
@@ -1335,8 +1343,9 @@ async function findPageById(
|
||||
profileName: string,
|
||||
pageId: number,
|
||||
profileOptions?: string | ChromeMcpProfileOptions,
|
||||
options: ChromeMcpOperationOptions = {},
|
||||
): Promise<ChromeMcpStructuredPage> {
|
||||
const pages = await listChromeMcpPages(profileName, profileOptions);
|
||||
const pages = await listChromeMcpPages(profileName, profileOptions, options);
|
||||
const page = pages.find((entry) => entry.id === pageId);
|
||||
if (!page) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
@@ -1441,11 +1450,18 @@ export async function focusChromeMcpTab(
|
||||
profileName: string,
|
||||
targetId: string,
|
||||
profileOptions?: string | ChromeMcpProfileOptions,
|
||||
options: ChromeMcpOperationOptions = {},
|
||||
): Promise<void> {
|
||||
await callTool(profileName, profileOptions, "select_page", {
|
||||
pageId: parsePageId(targetId),
|
||||
bringToFront: true,
|
||||
});
|
||||
await callTool(
|
||||
profileName,
|
||||
profileOptions,
|
||||
"select_page",
|
||||
{
|
||||
pageId: parsePageId(targetId),
|
||||
bringToFront: true,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Close a Chrome MCP page by target id. */
|
||||
@@ -1453,8 +1469,15 @@ export async function closeChromeMcpTab(
|
||||
profileName: string,
|
||||
targetId: string,
|
||||
profileOptions?: string | ChromeMcpProfileOptions,
|
||||
options: ChromeMcpOperationOptions = {},
|
||||
): Promise<void> {
|
||||
await callTool(profileName, profileOptions, "close_page", { pageId: parsePageId(targetId) });
|
||||
await callTool(
|
||||
profileName,
|
||||
profileOptions,
|
||||
"close_page",
|
||||
{ pageId: parsePageId(targetId) },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate a Chrome MCP page and return its resolved URL. */
|
||||
@@ -1465,6 +1488,7 @@ export async function navigateChromeMcpPage(params: {
|
||||
targetId: string;
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ url: string }> {
|
||||
const resolvedTimeoutMs = params.timeoutMs ?? CHROME_MCP_NAVIGATE_TIMEOUT_MS;
|
||||
const callTimeoutMs = resolveChromeMcpNavigateCallTimeoutMs(resolvedTimeoutMs);
|
||||
@@ -1478,12 +1502,13 @@ export async function navigateChromeMcpPage(params: {
|
||||
url: params.url,
|
||||
timeout: resolvedTimeoutMs,
|
||||
},
|
||||
{ timeoutMs: callTimeoutMs },
|
||||
{ timeoutMs: callTimeoutMs, signal: params.signal },
|
||||
);
|
||||
const page = await findPageById(
|
||||
params.profileName,
|
||||
parsePageId(params.targetId),
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
{ timeoutMs: callTimeoutMs, signal: params.signal },
|
||||
);
|
||||
return { url: page.url ?? params.url };
|
||||
}
|
||||
@@ -1494,94 +1519,60 @@ export function resolveChromeMcpNavigateCallTimeoutMs(timeoutMs: number): number
|
||||
}
|
||||
|
||||
/** Take a structured Chrome MCP snapshot for one page. */
|
||||
export async function takeChromeMcpSnapshot(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ChromeMcpSnapshotNode> {
|
||||
const result = await callTool(
|
||||
params.profileName,
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
"take_snapshot",
|
||||
{
|
||||
pageId: parsePageId(params.targetId),
|
||||
},
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
export async function takeChromeMcpSnapshot(
|
||||
params: ChromeMcpTargetOperation,
|
||||
): Promise<ChromeMcpSnapshotNode> {
|
||||
const result = await callTargetTool(params, "take_snapshot", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
});
|
||||
return extractSnapshot(result);
|
||||
}
|
||||
|
||||
/** Take a screenshot via Chrome MCP and return the image bytes. */
|
||||
export async function takeChromeMcpScreenshot(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid?: string;
|
||||
fullPage?: boolean;
|
||||
format?: "png" | "jpeg";
|
||||
timeoutMs?: number;
|
||||
}): Promise<Buffer> {
|
||||
export async function takeChromeMcpScreenshot(
|
||||
params: ChromeMcpTargetOperation & {
|
||||
uid?: string;
|
||||
fullPage?: boolean;
|
||||
format?: "png" | "jpeg";
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
return await withTempFile(async (filePath) => {
|
||||
const format = params.format ?? "png";
|
||||
await callTool(
|
||||
params.profileName,
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
"take_screenshot",
|
||||
{
|
||||
pageId: parsePageId(params.targetId),
|
||||
filePath,
|
||||
format,
|
||||
...(params.uid ? { uid: params.uid } : {}),
|
||||
...(params.fullPage ? { fullPage: true } : {}),
|
||||
},
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
await callTargetTool(params, "take_screenshot", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
filePath,
|
||||
format,
|
||||
...(params.uid ? { uid: params.uid } : {}),
|
||||
...(params.fullPage ? { fullPage: true } : {}),
|
||||
});
|
||||
return await fs.readFile(`${filePath}.${format}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Click a Chrome MCP snapshot element by uid. */
|
||||
export async function clickChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
doubleClick?: boolean;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
await callTool(
|
||||
params.profileName,
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
"click",
|
||||
{
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
...(params.doubleClick ? { dblClick: true } : {}),
|
||||
},
|
||||
{
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
export async function clickChromeMcpElement(
|
||||
params: ChromeMcpTargetOperation & {
|
||||
uid: string;
|
||||
doubleClick?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "click", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
...(params.doubleClick ? { dblClick: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Dispatch mouse events at page coordinates through an in-page script. */
|
||||
export async function clickChromeMcpCoords(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
doubleClick?: boolean;
|
||||
button?: "left" | "right" | "middle";
|
||||
delayMs?: number;
|
||||
}): Promise<void> {
|
||||
export async function clickChromeMcpCoords(
|
||||
params: ChromeMcpTargetOperation & {
|
||||
x: number;
|
||||
y: number;
|
||||
doubleClick?: boolean;
|
||||
button?: "left" | "right" | "middle";
|
||||
delayMs?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const button = params.button ?? "left";
|
||||
const buttonCode = button === "middle" ? 1 : button === "right" ? 2 : 0;
|
||||
const pressedButtons = button === "middle" ? 4 : button === "right" ? 2 : 1;
|
||||
@@ -1590,10 +1581,7 @@ export async function clickChromeMcpCoords(params: {
|
||||
const delayMs = JSON.stringify(resolveNonNegativeIntegerOption(params.delayMs, 0));
|
||||
const doubleClick = params.doubleClick ? "true" : "false";
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
fn: `async () => {
|
||||
const x = ${x};
|
||||
const y = ${y};
|
||||
@@ -1633,15 +1621,10 @@ export async function clickChromeMcpCoords(params: {
|
||||
}
|
||||
|
||||
/** Fill one Chrome MCP element by uid. */
|
||||
export async function fillChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "fill", {
|
||||
export async function fillChromeMcpElement(
|
||||
params: ChromeMcpTargetOperation & { uid: string; value: string },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "fill", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
value: params.value,
|
||||
@@ -1649,43 +1632,32 @@ export async function fillChromeMcpElement(params: {
|
||||
}
|
||||
|
||||
/** Fill multiple Chrome MCP form elements in one tool call. */
|
||||
export async function fillChromeMcpForm(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
elements: Array<{ uid: string; value: string }>;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "fill_form", {
|
||||
export async function fillChromeMcpForm(
|
||||
params: ChromeMcpTargetOperation & {
|
||||
elements: Array<{ uid: string; value: string }>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "fill_form", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
elements: params.elements,
|
||||
});
|
||||
}
|
||||
|
||||
/** Hover a Chrome MCP snapshot element by uid. */
|
||||
export async function hoverChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "hover", {
|
||||
export async function hoverChromeMcpElement(
|
||||
params: ChromeMcpTargetOperation & { uid: string },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "hover", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
});
|
||||
}
|
||||
|
||||
/** Drag between two Chrome MCP snapshot element uids. */
|
||||
export async function dragChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
fromUid: string;
|
||||
toUid: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "drag", {
|
||||
export async function dragChromeMcpElement(
|
||||
params: ChromeMcpTargetOperation & { fromUid: string; toUid: string },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "drag", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
from_uid: params.fromUid,
|
||||
to_uid: params.toUid,
|
||||
@@ -1693,15 +1665,10 @@ export async function dragChromeMcpElement(params: {
|
||||
}
|
||||
|
||||
/** Upload a local file into a Chrome MCP file input by uid. */
|
||||
export async function uploadChromeMcpFile(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
filePath: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "upload_file", {
|
||||
export async function uploadChromeMcpFile(
|
||||
params: ChromeMcpTargetOperation & { uid: string; filePath: string },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "upload_file", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
filePath: params.filePath,
|
||||
@@ -1709,29 +1676,20 @@ export async function uploadChromeMcpFile(params: {
|
||||
}
|
||||
|
||||
/** Press a keyboard key in a Chrome MCP page. */
|
||||
export async function pressChromeMcpKey(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "press_key", {
|
||||
export async function pressChromeMcpKey(
|
||||
params: ChromeMcpTargetOperation & { key: string },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "press_key", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
key: params.key,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resize a Chrome MCP page viewport. */
|
||||
export async function resizeChromeMcpPage(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, chromeMcpProfileOptionsFromParams(params), "resize_page", {
|
||||
export async function resizeChromeMcpPage(
|
||||
params: ChromeMcpTargetOperation & { width: number; height: number },
|
||||
): Promise<void> {
|
||||
await callTargetTool(params, "resize_page", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
@@ -1739,24 +1697,14 @@ export async function resizeChromeMcpPage(params: {
|
||||
}
|
||||
|
||||
/** Evaluate a JavaScript function in a Chrome MCP page. */
|
||||
export async function evaluateChromeMcpScript(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
fn: string;
|
||||
args?: string[];
|
||||
}): Promise<unknown> {
|
||||
const result = await callTool(
|
||||
params.profileName,
|
||||
chromeMcpProfileOptionsFromParams(params),
|
||||
"evaluate_script",
|
||||
{
|
||||
pageId: parsePageId(params.targetId),
|
||||
function: params.fn,
|
||||
...(params.args?.length ? { args: params.args } : {}),
|
||||
},
|
||||
);
|
||||
export async function evaluateChromeMcpScript(
|
||||
params: ChromeMcpTargetOperation & { fn: string; args?: string[] },
|
||||
): Promise<unknown> {
|
||||
const result = await callTargetTool(params, "evaluate_script", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
function: params.fn,
|
||||
...(params.args?.length ? { args: params.args } : {}),
|
||||
});
|
||||
return extractJsonMessage(result);
|
||||
}
|
||||
|
||||
|
||||
+52
-1
@@ -7,9 +7,10 @@ import {
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
|
||||
const chromeMcpMocks = vi.hoisted(() => ({
|
||||
clickChromeMcpCoords: vi.fn(async () => {}),
|
||||
clickChromeMcpElement: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: vi.fn(async () => {}),
|
||||
evaluateChromeMcpScript: vi.fn(async () => "https://example.com"),
|
||||
evaluateChromeMcpScript: vi.fn(async (_params: unknown) => "https://example.com"),
|
||||
fillChromeMcpElement: vi.fn(async () => {}),
|
||||
fillChromeMcpForm: vi.fn(async () => {}),
|
||||
hoverChromeMcpElement: vi.fn(async () => {}),
|
||||
@@ -25,6 +26,7 @@ const navigationGuardMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../chrome-mcp.js", () => ({
|
||||
clickChromeMcpCoords: chromeMcpMocks.clickChromeMcpCoords,
|
||||
clickChromeMcpElement: chromeMcpMocks.clickChromeMcpElement,
|
||||
closeChromeMcpTab: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: chromeMcpMocks.dragChromeMcpElement,
|
||||
@@ -52,6 +54,7 @@ function getActPostHandler(
|
||||
registerBrowserAgentActRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
actionTimeoutMs: 60_000,
|
||||
evaluateEnabled: true,
|
||||
ssrfPolicy: ssrfPolicy ?? undefined,
|
||||
},
|
||||
@@ -147,6 +150,54 @@ describe("existing-session interaction navigation guard", () => {
|
||||
expectNavigationProbeUrls(Array.from({ length: 8 }, () => "https://example.com"));
|
||||
});
|
||||
|
||||
it("threads one request budget through coordinate actions and navigation probes", async () => {
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const ctrl = new AbortController();
|
||||
const pending = handler?.(
|
||||
{
|
||||
params: {},
|
||||
query: {},
|
||||
body: { kind: "clickCoords", x: 20, y: 30 },
|
||||
signal: ctrl.signal,
|
||||
},
|
||||
response.res,
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
|
||||
const expectedOptions = { signal: ctrl.signal, timeoutMs: 60_000 };
|
||||
expect(chromeMcpMocks.clickChromeMcpCoords).toHaveBeenCalledWith(
|
||||
expect.objectContaining(expectedOptions),
|
||||
);
|
||||
for (const [params] of chromeMcpMocks.evaluateChromeMcpScript.mock.calls) {
|
||||
expect(params).toEqual(expect.objectContaining(expectedOptions));
|
||||
}
|
||||
expect(routeState.profileCtx.listTabs).toHaveBeenCalledWith(expectedOptions);
|
||||
});
|
||||
|
||||
it("cancels a pending existing-session wait when its request aborts", async () => {
|
||||
const handler = getActPostHandler(null);
|
||||
const response = createBrowserRouteResponse();
|
||||
const ctrl = new AbortController();
|
||||
const pending = handler?.(
|
||||
{
|
||||
params: {},
|
||||
query: {},
|
||||
body: { kind: "wait", timeMs: 30_000 },
|
||||
signal: ctrl.signal,
|
||||
},
|
||||
response.res,
|
||||
);
|
||||
void pending?.catch(() => {});
|
||||
|
||||
ctrl.abort(new Error("request cancelled after browser crash"));
|
||||
|
||||
await expect(pending).rejects.toThrow(/aborted|cancelled/i);
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the page url after delayed navigation-triggering interactions", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce(42 as never)
|
||||
|
||||
@@ -80,6 +80,8 @@ export function registerBrowserAgentActHookRoutes(
|
||||
targetId: tab.targetId,
|
||||
uid,
|
||||
filePath: resolvedPaths[0] ?? "",
|
||||
timeoutMs: timeoutMs ?? ctx.state().resolved.actionTimeoutMs,
|
||||
signal: req.signal,
|
||||
});
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -157,6 +159,8 @@ export function registerBrowserAgentActHookRoutes(
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
timeoutMs: ctx.state().resolved.actionTimeoutMs,
|
||||
signal: req.signal,
|
||||
// Existing-session Chrome MCP has no dialog hook primitive. Patch
|
||||
// one-shot window dialog functions in-page, then restore them.
|
||||
fn: `() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Dispatches normalized actions to either Playwright-backed OpenClaw browser
|
||||
* control or Chrome MCP existing-session operations with navigation guards.
|
||||
*/
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import {
|
||||
clickChromeMcpElement,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
hoverChromeMcpElement,
|
||||
pressChromeMcpKey,
|
||||
resizeChromeMcpPage,
|
||||
type ChromeMcpOperationOptions,
|
||||
type ChromeMcpProfileOptions,
|
||||
} from "../chrome-mcp.js";
|
||||
import type { BrowserActRequest } from "../client-actions.types.js";
|
||||
@@ -54,17 +55,16 @@ import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;
|
||||
|
||||
async function readExistingSessionLocationHref(params: {
|
||||
type ExistingSessionOperation = ChromeMcpOperationOptions & {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<string> {
|
||||
};
|
||||
|
||||
async function readExistingSessionLocationHref(params: ExistingSessionOperation): Promise<string> {
|
||||
const currentUrl = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
fn: "() => window.location.href",
|
||||
});
|
||||
if (typeof currentUrl !== "string") {
|
||||
@@ -77,15 +77,13 @@ async function readExistingSessionLocationHref(params: {
|
||||
return normalizedUrl;
|
||||
}
|
||||
|
||||
async function assertExistingSessionPostInteractionNavigationAllowed(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
|
||||
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
|
||||
initialTabTargetIds: ReadonlySet<string>;
|
||||
}): Promise<void> {
|
||||
async function assertExistingSessionPostInteractionNavigationAllowed(
|
||||
params: ExistingSessionOperation & {
|
||||
ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
|
||||
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
|
||||
initialTabTargetIds: ReadonlySet<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
|
||||
if (!ssrfPolicyOpts.ssrfPolicy) {
|
||||
return;
|
||||
@@ -110,12 +108,13 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
|
||||
let sawStableAllowedUrl = false;
|
||||
for (const delayMs of EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS) {
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
await sleep(delayMs, undefined, { signal: params.signal });
|
||||
}
|
||||
let currentUrl: string;
|
||||
try {
|
||||
currentUrl = await readExistingSessionLocationHref(params);
|
||||
} catch {
|
||||
params.signal?.throwIfAborted();
|
||||
sawStableAllowedUrl = false;
|
||||
continue;
|
||||
}
|
||||
@@ -144,7 +143,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
|
||||
EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS[
|
||||
EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS.length - 1
|
||||
];
|
||||
await sleep(lastDelay);
|
||||
await sleep(lastDelay, undefined, { signal: params.signal });
|
||||
try {
|
||||
const followUpUrl = await readExistingSessionLocationHref(params);
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
@@ -156,6 +155,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
params.signal?.throwIfAborted();
|
||||
// Probe failed — fall through to throw
|
||||
}
|
||||
}
|
||||
@@ -217,22 +217,19 @@ function buildExistingSessionWaitPredicate(params: {
|
||||
return checks.length === 1 ? checks[0] : checks.map((check) => `(${check})`).join(" && ");
|
||||
}
|
||||
|
||||
async function waitForExistingSessionCondition(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
timeMs?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
selector?: string;
|
||||
url?: string;
|
||||
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||
fn?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
async function waitForExistingSessionCondition(
|
||||
params: ExistingSessionOperation & {
|
||||
timeMs?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
selector?: string;
|
||||
url?: string;
|
||||
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||
fn?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (params.timeMs && params.timeMs > 0) {
|
||||
await sleep(params.timeMs);
|
||||
await sleep(params.timeMs, undefined, { signal: params.signal });
|
||||
}
|
||||
const predicate = buildExistingSessionWaitPredicate(params);
|
||||
if (!predicate && !params.url) {
|
||||
@@ -245,20 +242,14 @@ async function waitForExistingSessionCondition(params: {
|
||||
if (predicate) {
|
||||
ready = Boolean(
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
fn: `async () => ${predicate}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (ready && params.url) {
|
||||
const currentUrl = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
fn: "() => window.location.href",
|
||||
});
|
||||
ready = typeof currentUrl === "string" && matchBrowserUrlPattern(params.url, currentUrl);
|
||||
@@ -266,7 +257,7 @@ async function waitForExistingSessionCondition(params: {
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
await sleep(250, undefined, { signal: params.signal });
|
||||
}
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
@@ -404,6 +395,14 @@ export function registerBrowserAgentActRoutes(
|
||||
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
|
||||
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
|
||||
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const requestedTimeoutMs =
|
||||
"timeoutMs" in action && typeof action.timeoutMs === "number"
|
||||
? action.timeoutMs
|
||||
: undefined;
|
||||
const existingSessionCallOptions: ChromeMcpOperationOptions = {
|
||||
timeoutMs: requestedTimeoutMs ?? ctx.state().resolved.actionTimeoutMs,
|
||||
signal: req.signal,
|
||||
};
|
||||
const hasNavigationResultPolicy = Boolean(
|
||||
withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy,
|
||||
);
|
||||
@@ -417,7 +416,7 @@ export function registerBrowserAgentActRoutes(
|
||||
? await resolveTargetIdAfterNavigate({
|
||||
oldTargetId: tab.targetId,
|
||||
navigatedUrl: tab.url,
|
||||
listTabs: () => profileCtx.listTabs(),
|
||||
listTabs: () => profileCtx.listTabs(existingSessionCallOptions),
|
||||
})
|
||||
: tab.targetId;
|
||||
const url =
|
||||
@@ -428,6 +427,7 @@ export function registerBrowserAgentActRoutes(
|
||||
profileCtx,
|
||||
targetId: responseTargetId,
|
||||
fallbackUrl: tab.url,
|
||||
...(isExistingSession ? existingSessionCallOptions : {}),
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
@@ -449,15 +449,23 @@ export function registerBrowserAgentActRoutes(
|
||||
}
|
||||
const profileName = profileCtx.profile.name;
|
||||
if (isExistingSession) {
|
||||
const initialTabTargetIds = hasNavigationResultPolicy
|
||||
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
|
||||
: new Set<string>();
|
||||
const existingSessionNavigationGuard = {
|
||||
const existingSessionTarget: ExistingSessionOperation = {
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionCallOptions,
|
||||
};
|
||||
const initialTabTargetIds = hasNavigationResultPolicy
|
||||
? new Set(
|
||||
(await profileCtx.listTabs(existingSessionCallOptions)).map(
|
||||
(currentTab) => currentTab.targetId,
|
||||
),
|
||||
)
|
||||
: new Set<string>();
|
||||
const existingSessionNavigationGuard = {
|
||||
...existingSessionTarget,
|
||||
ssrfPolicy,
|
||||
listTabs: () => profileCtx.listTabs(),
|
||||
listTabs: () => profileCtx.listTabs(existingSessionCallOptions),
|
||||
initialTabTargetIds,
|
||||
};
|
||||
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
|
||||
@@ -474,13 +482,9 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
clickChromeMcpElement({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
uid: action.ref!,
|
||||
doubleClick: action.doubleClick ?? false,
|
||||
timeoutMs: action.timeoutMs,
|
||||
signal: req.signal,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
@@ -489,9 +493,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
clickChromeMcpCoords({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
doubleClick: action.doubleClick ?? false,
|
||||
@@ -505,17 +507,13 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: async () => {
|
||||
await fillChromeMcpElement({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
uid: action.ref!,
|
||||
value: action.text,
|
||||
});
|
||||
if (action.submit) {
|
||||
await pressChromeMcpKey({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
key: "Enter",
|
||||
});
|
||||
}
|
||||
@@ -527,9 +525,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
pressChromeMcpKey({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
key: action.key,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
@@ -539,9 +535,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
hoverChromeMcpElement({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
uid: action.ref!,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
@@ -551,9 +545,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
evaluateChromeMcpScript({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
|
||||
args: [action.ref!],
|
||||
}),
|
||||
@@ -564,9 +556,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
dragChromeMcpElement({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
fromUid: action.startRef!,
|
||||
toUid: action.endRef!,
|
||||
}),
|
||||
@@ -577,9 +567,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
fillChromeMcpElement({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
uid: action.ref!,
|
||||
value: action.values[0] ?? "",
|
||||
}),
|
||||
@@ -590,9 +578,7 @@ export function registerBrowserAgentActRoutes(
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
fillChromeMcpForm({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
elements: action.fields.map((field) => ({
|
||||
uid: field.ref,
|
||||
value: String(field.value ?? ""),
|
||||
@@ -603,18 +589,14 @@ export function registerBrowserAgentActRoutes(
|
||||
return await jsonOk();
|
||||
case "resize":
|
||||
await resizeChromeMcpPage({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
width: action.width,
|
||||
height: action.height,
|
||||
});
|
||||
return await jsonOk();
|
||||
case "wait":
|
||||
await waitForExistingSessionCondition({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
timeMs: action.timeMs,
|
||||
text: action.text,
|
||||
textGone: action.textGone,
|
||||
@@ -622,16 +604,13 @@ export function registerBrowserAgentActRoutes(
|
||||
url: action.url,
|
||||
loadState: action.loadState,
|
||||
fn: action.fn,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
return await jsonOk();
|
||||
case "evaluate": {
|
||||
const result = await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
evaluateChromeMcpScript({
|
||||
profileName,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...existingSessionTarget,
|
||||
fn: normalizeBrowserEvaluateFunctionSource(
|
||||
action.fn,
|
||||
action.ref ? { argumentName: "el" } : undefined,
|
||||
@@ -643,7 +622,12 @@ export function registerBrowserAgentActRoutes(
|
||||
return await jsonOk({ result });
|
||||
}
|
||||
case "close":
|
||||
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile);
|
||||
await closeChromeMcpTab(
|
||||
profileName,
|
||||
tab.targetId,
|
||||
profileCtx.profile,
|
||||
existingSessionCallOptions,
|
||||
);
|
||||
return await jsonOk();
|
||||
case "batch":
|
||||
return jsonActError(
|
||||
@@ -787,6 +771,8 @@ export function registerBrowserAgentActRoutes(
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
args: [ref],
|
||||
timeoutMs: ctx.state().resolved.actionTimeoutMs,
|
||||
signal: req.signal,
|
||||
fn: `(el) => {
|
||||
if (!(el instanceof Element)) {
|
||||
return false;
|
||||
|
||||
@@ -163,7 +163,11 @@ describe("existing-session browser routes", () => {
|
||||
it("allows labeled AI snapshots for existing-session profiles", async () => {
|
||||
const handler = getSnapshotGetHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { format: "ai", labels: "1" } }, response.res);
|
||||
const ctrl = new AbortController();
|
||||
await handler?.(
|
||||
{ params: {}, query: { format: "ai", labels: "1" }, signal: ctrl.signal },
|
||||
response.res,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = requireRecord(response.body, "response body");
|
||||
@@ -179,6 +183,16 @@ describe("existing-session browser routes", () => {
|
||||
expect(snapshotParams.profileName).toBe("chrome-live");
|
||||
expectExistingSessionProfile(snapshotParams.profile);
|
||||
expect(snapshotParams.targetId).toBe("7");
|
||||
const renderParams = requireRecord(
|
||||
callArg(chromeMcpMocks.evaluateChromeMcpScript, 0, 0, "label params"),
|
||||
"label params",
|
||||
);
|
||||
const cleanupParams = requireRecord(
|
||||
callArg(chromeMcpMocks.evaluateChromeMcpScript, 1, 0, "label cleanup params"),
|
||||
"label cleanup params",
|
||||
);
|
||||
expect(renderParams.signal).toBe(ctrl.signal);
|
||||
expect(cleanupParams.signal).toBeUndefined();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
|
||||
expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ function routeContextForTab(
|
||||
forProfile: () => profileCtx,
|
||||
state: () => ({
|
||||
resolved: {
|
||||
actionTimeoutMs: 60_000,
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
}),
|
||||
@@ -153,6 +154,8 @@ describe("browser route shared helpers", () => {
|
||||
|
||||
expect(ensureTabAvailable).toHaveBeenCalledWith(undefined, {
|
||||
allowPlaywrightFallback: true,
|
||||
signal: undefined,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -150,6 +150,8 @@ export async function withRouteTabContext<T>(
|
||||
// Agent routes can address local-managed tabs through Playwright when per-tab WS discovery lags.
|
||||
const tab = await profileCtx.ensureTabAvailable(params.targetId, {
|
||||
allowPlaywrightFallback: true,
|
||||
signal: params.req.signal,
|
||||
timeoutMs: params.ctx.state().resolved.actionTimeoutMs,
|
||||
});
|
||||
if (params.enforceCurrentUrlAllowed) {
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
@@ -167,6 +169,8 @@ export async function withRouteTabContext<T>(
|
||||
profileCtx,
|
||||
targetId: tab.targetId,
|
||||
fallbackUrl,
|
||||
signal: params.req.signal,
|
||||
timeoutMs: params.ctx.state().resolved.actionTimeoutMs,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -184,8 +188,16 @@ export async function resolveSafeRouteTabUrl(params: {
|
||||
profileCtx: ProfileContext;
|
||||
targetId: string;
|
||||
fallbackUrl?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}): Promise<string | undefined> {
|
||||
const tabs = await params.profileCtx.listTabs().catch(() => []);
|
||||
let tabs: Array<{ targetId: string; url: string }>;
|
||||
try {
|
||||
tabs = await params.profileCtx.listTabs({ signal: params.signal, timeoutMs: params.timeoutMs });
|
||||
} catch {
|
||||
params.signal?.throwIfAborted();
|
||||
tabs = [];
|
||||
}
|
||||
const candidateUrl =
|
||||
tabs.find((tab) => tab.targetId === params.targetId)?.url ?? params.fallbackUrl;
|
||||
if (!candidateUrl) {
|
||||
|
||||
@@ -101,6 +101,7 @@ function getSnapshotGetHandler() {
|
||||
registerBrowserAgentSnapshotRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
actionTimeoutMs: 60_000,
|
||||
extraArgs: [],
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
},
|
||||
@@ -130,6 +131,8 @@ describe("local-managed browser snapshot routes", () => {
|
||||
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
|
||||
expect(routeState.profileCtx.ensureTabAvailable).toHaveBeenCalledWith(undefined, {
|
||||
allowPlaywrightFallback: false,
|
||||
signal: undefined,
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8080/admin",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
navigateChromeMcpPage,
|
||||
takeChromeMcpScreenshot,
|
||||
takeChromeMcpSnapshot,
|
||||
type ChromeMcpOperationOptions,
|
||||
type ChromeMcpProfileOptions,
|
||||
} from "../chrome-mcp.js";
|
||||
import {
|
||||
@@ -59,17 +60,18 @@ import { asyncBrowserRoute, jsonError, toBoolean, toStringOrEmpty } from "./util
|
||||
|
||||
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
|
||||
|
||||
async function collectChromeMcpSnapshotUrls(params: {
|
||||
type ChromeMcpSnapshotOperation = ChromeMcpOperationOptions & {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<SnapshotUrlEntry[]> {
|
||||
};
|
||||
|
||||
async function collectChromeMcpSnapshotUrls(
|
||||
params: ChromeMcpSnapshotOperation,
|
||||
): Promise<SnapshotUrlEntry[]> {
|
||||
const result = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
fn: `() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
@@ -103,17 +105,11 @@ async function collectChromeMcpSnapshotUrls(params: {
|
||||
: [];
|
||||
}
|
||||
|
||||
async function clearChromeMcpOverlay(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
async function clearChromeMcpOverlay(params: ChromeMcpSnapshotOperation): Promise<void> {
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
// Cleanup must outlive a route abort or injected labels remain in the user's tab.
|
||||
signal: undefined,
|
||||
fn: `() => {
|
||||
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
|
||||
return true;
|
||||
@@ -121,19 +117,14 @@ async function clearChromeMcpOverlay(params: {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renderChromeMcpLabels(params: {
|
||||
profileName: string;
|
||||
profile?: ChromeMcpProfileOptions;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
refs: string[];
|
||||
}): Promise<{ labels: number; skipped: number }> {
|
||||
async function renderChromeMcpLabels(
|
||||
params: ChromeMcpSnapshotOperation & {
|
||||
refs: string[];
|
||||
},
|
||||
): Promise<{ labels: number; skipped: number }> {
|
||||
const refList = JSON.stringify(params.refs);
|
||||
const result = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
profile: params.profile,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
...params,
|
||||
args: params.refs,
|
||||
fn: `(...elements) => {
|
||||
const refs = ${refList};
|
||||
@@ -343,6 +334,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
signal: req.signal,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
|
||||
return res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
@@ -438,6 +430,13 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
enforceCurrentUrlAllowed: true,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
const operation: ChromeMcpSnapshotOperation = {
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
timeoutMs,
|
||||
signal: req.signal,
|
||||
};
|
||||
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
|
||||
if (ssrfPolicyOpts.ssrfPolicy) {
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
@@ -449,26 +448,17 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
|
||||
}
|
||||
if (labels) {
|
||||
const snapshot = await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
const snapshot = await takeChromeMcpSnapshot(operation);
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot({ root: snapshot });
|
||||
const labelResult = await renderChromeMcpLabels({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...operation,
|
||||
refs: Object.keys(built.refs),
|
||||
});
|
||||
try {
|
||||
const buffer = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...operation,
|
||||
fullPage,
|
||||
format: type,
|
||||
timeoutMs,
|
||||
});
|
||||
await saveNormalizedScreenshotResponse({
|
||||
res,
|
||||
@@ -481,22 +471,15 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
labelsSkipped: labelResult.skipped,
|
||||
});
|
||||
} finally {
|
||||
await clearChromeMcpOverlay({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await clearChromeMcpOverlay(operation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const buffer = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...operation,
|
||||
uid: ref,
|
||||
fullPage,
|
||||
format: type,
|
||||
timeoutMs,
|
||||
});
|
||||
await saveNormalizedScreenshotResponse({
|
||||
res,
|
||||
@@ -602,6 +585,8 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined, {
|
||||
allowPlaywrightFallback: hasPlaywright,
|
||||
signal: req.signal,
|
||||
timeoutMs: plan.timeoutMs,
|
||||
});
|
||||
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
|
||||
@@ -628,12 +613,14 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (usesChromeMcp) {
|
||||
const snapshot = await takeChromeMcpSnapshot({
|
||||
const operation: ChromeMcpSnapshotOperation = {
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
timeoutMs: plan.timeoutMs,
|
||||
});
|
||||
signal: req.signal,
|
||||
};
|
||||
const snapshot = await takeChromeMcpSnapshot(operation);
|
||||
if (plan.format === "aria") {
|
||||
return res.json({
|
||||
ok: true,
|
||||
@@ -657,29 +644,20 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
...built,
|
||||
snapshot: appendSnapshotUrls(
|
||||
built.snapshot,
|
||||
await collectChromeMcpSnapshotUrls({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
}),
|
||||
await collectChromeMcpSnapshotUrls(operation),
|
||||
),
|
||||
}
|
||||
: built;
|
||||
if (plan.labels) {
|
||||
const refs = Object.keys(builtWithUrls.refs);
|
||||
const labelResult = await renderChromeMcpLabels({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...operation,
|
||||
refs,
|
||||
});
|
||||
try {
|
||||
const labeled = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
...operation,
|
||||
format: "png",
|
||||
timeoutMs: plan.timeoutMs,
|
||||
});
|
||||
const normalized = await normalizeBrowserScreenshot(labeled, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
@@ -705,11 +683,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
...builtWithUrls,
|
||||
});
|
||||
} finally {
|
||||
await clearChromeMcpOverlay({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await clearChromeMcpOverlay(operation);
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
|
||||
@@ -89,6 +89,30 @@ describe("browser profile tab selection", () => {
|
||||
expect(listTabs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes the request budget to tab discovery", async () => {
|
||||
const ready = tab("READY", "ws://127.0.0.1/devtools/page/READY");
|
||||
const { selection, listTabs } = createSelectionHarness({ snapshots: [[ready]] });
|
||||
const ctrl = new AbortController();
|
||||
|
||||
await expect(
|
||||
selection.ensureTabAvailable(undefined, { signal: ctrl.signal, timeoutMs: 1234 }),
|
||||
).resolves.toEqual(ready);
|
||||
|
||||
expect(listTabs).toHaveBeenCalledWith({ signal: ctrl.signal, timeoutMs: 1234 });
|
||||
});
|
||||
|
||||
it("does not start tab discovery for an aborted request", async () => {
|
||||
const { selection, listTabs } = createSelectionHarness({ snapshots: [[tab("READY")]] });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort(new Error("request cancelled"));
|
||||
|
||||
await expect(selection.ensureTabAvailable(undefined, { signal: ctrl.signal })).rejects.toThrow(
|
||||
/request cancelled/i,
|
||||
);
|
||||
|
||||
expect(listTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a target-id-only opened tab for a Playwright-backed caller", async () => {
|
||||
vi.useFakeTimers();
|
||||
const openedTab = tab("OPENED");
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./server-context.constants.js";
|
||||
import type {
|
||||
BrowserTab,
|
||||
BrowserOperationOptions,
|
||||
EnsureTabAvailableOptions,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
@@ -28,7 +29,7 @@ type SelectionDeps = {
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
getCdpControlPolicy: () => SsrFPolicy | undefined;
|
||||
ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise<void>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
listTabs: (options?: BrowserOperationOptions) => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
};
|
||||
|
||||
@@ -83,7 +84,9 @@ export function createProfileSelectionOps({
|
||||
targetId?: string,
|
||||
options?: EnsureTabAvailableOptions,
|
||||
): Promise<BrowserTab> => {
|
||||
options?.signal?.throwIfAborted();
|
||||
await ensureBrowserAvailable();
|
||||
options?.signal?.throwIfAborted();
|
||||
const profileState = getProfileState();
|
||||
let lastNonEmptyTabs: BrowserTab[] = [];
|
||||
let lastListError: unknown;
|
||||
@@ -92,13 +95,14 @@ export function createProfileSelectionOps({
|
||||
|
||||
const readTabs = async (): Promise<BrowserTab[]> => {
|
||||
try {
|
||||
const tabs = await listTabs();
|
||||
const tabs = await listTabs(options);
|
||||
sawSuccessfulList = true;
|
||||
if (tabs.length > 0) {
|
||||
lastNonEmptyTabs = tabs;
|
||||
}
|
||||
return tabs;
|
||||
} catch (err) {
|
||||
options?.signal?.throwIfAborted();
|
||||
lastListError = err;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
OPEN_TAB_DISCOVERY_WINDOW_MS,
|
||||
} from "./server-context.constants.js";
|
||||
import type {
|
||||
BrowserOperationOptions,
|
||||
BrowserServerState,
|
||||
BrowserTab,
|
||||
ProfileRuntimeState,
|
||||
@@ -45,7 +46,7 @@ type TabOpsDeps = {
|
||||
};
|
||||
|
||||
type ProfileTabOps = {
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
listTabs: (options?: BrowserOperationOptions) => Promise<BrowserTab[]>;
|
||||
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
|
||||
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
|
||||
};
|
||||
@@ -200,10 +201,10 @@ export function createProfileTabOps({
|
||||
};
|
||||
};
|
||||
|
||||
const readTabs = async (): Promise<BrowserTab[]> => {
|
||||
const readTabs = async (options?: BrowserOperationOptions): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const { listChromeMcpTabs } = await getChromeMcpModule();
|
||||
return await listChromeMcpTabs(profile.name, profile);
|
||||
return await listChromeMcpTabs(profile.name, profile, options);
|
||||
}
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
@@ -261,8 +262,8 @@ export function createProfileTabOps({
|
||||
return tabs;
|
||||
};
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
const tabs = await readTabs();
|
||||
const listTabs = async (options?: BrowserOperationOptions): Promise<BrowserTab[]> => {
|
||||
const tabs = await readTabs(options);
|
||||
return assignTabAliases(getProfileState(), tabs);
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,12 @@ export type BrowserServerState = {
|
||||
stopUnhandledRejectionHandler?: () => void;
|
||||
};
|
||||
|
||||
export type EnsureTabAvailableOptions = {
|
||||
export type BrowserOperationOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type EnsureTabAvailableOptions = BrowserOperationOptions & {
|
||||
/** Allow a target-id-only tab when the caller can continue through Playwright. */
|
||||
allowPlaywrightFallback?: boolean;
|
||||
};
|
||||
@@ -63,7 +68,7 @@ type BrowserProfileActions = {
|
||||
timeoutMs?: number,
|
||||
options?: { ephemeral?: boolean; signal?: AbortSignal },
|
||||
) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
listTabs: (options?: BrowserOperationOptions) => Promise<BrowserTab[]>;
|
||||
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
|
||||
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user