refactor(browser): consolidate lifecycle and request handling (#149820)

* refactor(browser): consolidate lifecycle and request handling

Centralize MCP attachment and retained cleanup, share typed client request projection across local and node routes, and remove duplicated CLI, CDP probe, and screenshot control flow. Preserve cancellation, timeout, fallback, and tab ownership contracts; prevent delayed process discovery from reopening completed cleanup. Related: #149795.

* refactor(browser): keep MCP lifecycle contracts acyclic

Use a leaf owner contract and route admission directly through the profile owner, removing the redundant lease wrapper. Remove the obsolete lint-suppression inventory entry for the retired CLI helper.
This commit is contained in:
Peter Steinberger 2026-09-16 00:52:52 -07:00 • committed by GitHub
parent 6878654864
commit ad1c9345f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2028 additions and 2942 deletions

View file

@ -46,8 +46,8 @@ extensions/browser/src/browser-proxy-envelope.ts 8
extensions/browser/src/browser-proxy-upload.ts 2
extensions/browser/src/browser-tool-binding.ts 3
extensions/browser/src/browser-tool-session-tabs.ts 3
extensions/browser/src/browser-tool.actions.ts 10
extensions/browser/src/browser-tool.snapshot.ts 2
extensions/browser/src/browser-tool.actions.ts 9
extensions/browser/src/browser-tool.snapshot.ts 1
extensions/browser/src/browser-tool.ts 6
extensions/browser/src/browser/bridge-server.ts 2
extensions/browser/src/browser/cdp-page-session.ts 3
@ -56,9 +56,8 @@ extensions/browser/src/browser/cdp-websocket.ts 2
extensions/browser/src/browser/cdp.helpers.ts 4
extensions/browser/src/browser/cdp.ts 13
extensions/browser/src/browser/chrome-mcp-routing.ts 1
extensions/browser/src/browser/chrome-mcp-session.ts 1
extensions/browser/src/browser/chrome-mcp-tabs.ts 1
extensions/browser/src/browser/chrome.diagnostics.ts 2
extensions/browser/src/browser/chrome.diagnostics.ts 1
extensions/browser/src/browser/chrome.executables.ts 1
extensions/browser/src/browser/chrome.profile-decoration.ts 1
extensions/browser/src/browser/chrome.ts 4
@ -90,7 +89,7 @@ extensions/browser/src/browser/routes/agent.act.normalize.ts 1
extensions/browser/src/browser/routes/agent.act.shared.ts 3
extensions/browser/src/browser/routes/agent.act.ts 1
extensions/browser/src/browser/routes/agent.shared.ts 2
extensions/browser/src/browser/routes/agent.snapshot.ts 8
extensions/browser/src/browser/routes/agent.snapshot.ts 6
extensions/browser/src/browser/routes/agent.storage.ts 2
extensions/browser/src/browser/routes/basic.ts 6
extensions/browser/src/browser/routes/permissions.ts 1
@ -108,7 +107,7 @@ extensions/browser/src/browser/test-port.ts 1
extensions/browser/src/browser/unhandled-rejections.ts 2
extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts 1
extensions/browser/src/cli/browser-cli-inspect.ts 2
extensions/browser/src/cli/browser-cli-manage.ts 3
extensions/browser/src/cli/browser-cli-manage.ts 1
extensions/browser/src/cli/browser-cli-shared.ts 1
extensions/browser/src/cli/browser-cli-state.ts 1
extensions/browser/src/gateway/browser-request.ts 4

View file

@ -292,18 +292,6 @@ export function createBootstrapDiagnostic() {
restores.push(stop);
return stop;
},
peer(info: { name: string; version: string } | undefined) {
// Actual cached MCP peer's handshake metadata, not an npx/latest version guess.
const version =
info?.name === "chrome_devtools" && /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(info.version);
append({
phase: "mcp.peer",
known: Boolean(version),
major: version ? Number(version[1]) : 0,
minor: version ? Number(version[2]) : 0,
patch: version ? Number(version[3]) : 0,
});
},
flush() {
if (++flushes > 2) {
return;

View file

@ -50,7 +50,6 @@ describe("bootstrap diagnostic observation", () => {
JSON.stringify({ method: "Runtime.consoleAPICalled", params: { args: ["private-content"] } }),
);
callbacks.onMessage("private-malformed");
diagnostic.peer({ name: "private-peer", version: "private-version" });
diagnostic.flush();
expect(receive.mock.calls[0]).toEqual([command]);
expect(receive).toHaveBeenCalledTimes(2);

View file

@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { chromium, type BrowserContext } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { chromeMcpSessions } from "../src/browser/chrome-mcp-state.js";
import { getChromeMcpPid } from "../src/browser/chrome-mcp-session.js";
import {
chromeProductRoots,
generateChromeExtensionIdForPath,
@ -162,6 +162,7 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extension-e2e-")),
);
const existingSessionProfile = "e2e-existing-session";
cleanups.push(async () => await fs.rm(root, { recursive: true, force: true }));
const homeDir = path.join(root, "home");
const stateDir = path.join(root, "custom-state");
@ -253,7 +254,7 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
cleanups.push(async () => {
const currentRelay = getBrowserControlState()?.extensionRelays?.get("e2e");
const bridge = currentRelay?.ownership === "owned" ? currentRelay.bridge : undefined;
const sessions = [...chromeMcpSessions.values()].slice(0, 8);
const hadMcpSession = getChromeMcpPid(existingSessionProfile) !== null;
try {
await stopBrowserControlService();
} finally {
@ -261,11 +262,8 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
"relay.closed",
Boolean(bridge && !bridge.extensionConnected && bridge.cdpClientCount === 0),
);
for (const session of sessions) {
diagnostic.mark(
"mcp.closed",
session.transport.pid === null && session.processCleanup?.status === "closed",
);
if (hadMcpSession) {
diagnostic.mark("mcp.closed", getChromeMcpPid(existingSessionProfile) === null);
}
}
});
@ -423,7 +421,6 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
if (!browserState || !extensionProfile) {
throw new Error("Browser E2E state did not contain the extension profile");
}
const existingSessionProfile = "e2e-existing-session";
const relayAuthorization = `Basic ${Buffer.from(
`openclaw-internal:${relay.internalToken}`,
).toString("base64")}`;
@ -633,9 +630,6 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
const proofUrl = `http://127.0.0.1:${gatewayPort}/browser-owner-proof`;
diagnostic.arm(selectedTab.targetId, unrelatedTab.targetId);
diagnostic.mark("relay.clients", relay.bridge.cdpClientCount);
for (const session of [...chromeMcpSessions.values()].slice(0, 8)) {
diagnostic.peer(session.client.getServerVersion());
}
const stopPageObservation = diagnostic.watchPage(controlled, proofUrl);
const selectedOwner = relay.bridge.captureOperationTarget(selectedTab.targetId);
const unrelatedOwner = relay.bridge.captureOperationTarget(unrelatedTab.targetId);

View file

@ -39,10 +39,6 @@ import {
type BrowserScreenshotOptions,
} from "./browser-tool.screenshot.js";
import { appendNavigatedPageState, executeSnapshotAction } from "./browser-tool.snapshot.js";
import {
BROWSER_ACTION_TRANSPORT_SLACK_MS,
resolveBrowserNavigationTimeoutMs,
} from "./browser/act-policy.js";
import { parseBrowserNavigationUrl } from "./browser/navigation-guard.js";
function readOptionalTargetAndTimeout(params: Record<string, unknown>) {
@ -99,15 +95,8 @@ export async function executeBrowserTabAction(context: {
sessionTabs.touch(targetId);
context.onTabActivity(targetId);
};
const executeTrackedTabRequest = async (
path: string,
body: Record<string, unknown>,
runLocal: () => Promise<unknown>,
) => {
const result = proxyRequest
? await proxyRequest({ method: "POST", path, profile, body })
: await runLocal();
touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? readStringValue(body.targetId));
const trackedTabResult = (result: unknown, targetId?: string) => {
touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
};
switch (action) {
@ -123,20 +112,12 @@ export async function executeBrowserTabAction(context: {
case "open": {
const targetUrl = readTargetUrlParam(params);
const label = normalizeOptionalString(params.label);
const opened = proxyRequest
? await proxyRequest({
method: "POST",
path: "/tabs/open",
profile,
body: { url: targetUrl, ...(label ? { label } : {}) },
timeoutMs: toolTimeoutMs,
})
: await browserOpenTab(baseUrl, targetUrl, {
profile,
label,
timeoutMs: toolTimeoutMs,
signal,
});
const opened = await browserOpenTab(proxyRequest ?? baseUrl, targetUrl, {
profile,
label,
timeoutMs: toolTimeoutMs,
signal,
});
const closeOpenedTab = async (targetId: string, openedProfile?: string) => {
if (nodeRoute && !proxyRequest?.isHostFallbackActive()) {
await nodeRoute.closeTarget({ targetId, profile: openedProfile });
@ -161,50 +142,23 @@ export async function executeBrowserTabAction(context: {
const targetId = readStringParam(params, "targetId", {
required: true,
});
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/tabs/focus",
profile,
body: { targetId },
timeoutMs: toolTimeoutMs,
})
: await browserFocusTab(baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
signal,
});
touchTab(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
const result = await browserFocusTab(proxyRequest ?? baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
signal,
});
return trackedTabResult(result, targetId);
}
case "close": {
const targetId = readStringParam(params, "targetId");
if (proxyRequest) {
const result = targetId
? await proxyRequest({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
profile,
timeoutMs: toolTimeoutMs,
})
: await proxyRequest({
method: "POST",
path: "/act",
profile,
body: { kind: "close" },
timeoutMs: toolTimeoutMs,
});
sessionTabs.untrack(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
}
const result = targetId
? await browserCloseTab(baseUrl, targetId, {
? await browserCloseTab(proxyRequest ?? baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
signal,
})
: await browserAct(
baseUrl,
proxyRequest ?? baseUrl,
{ kind: "close" },
{
profile,
@ -212,7 +166,7 @@ export async function executeBrowserTabAction(context: {
signal,
},
);
sessionTabs.untrack(readStringValue(result.targetId) ?? targetId);
sessionTabs.untrack(readStringValue(asNullableRecord(result)?.targetId) ?? targetId);
return jsonResult(result);
}
case "snapshot":
@ -238,26 +192,13 @@ export async function executeBrowserTabAction(context: {
case "navigate": {
const targetUrl = readTargetUrlParam(params);
const targetId = readStringParam(params, "targetId");
const timeoutMs = resolveBrowserNavigationTimeoutMs(requestedTimeoutMs);
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/navigate",
profile,
body: {
url: targetUrl,
targetId,
timeoutMs,
},
timeoutMs: timeoutMs + BROWSER_ACTION_TRANSPORT_SLACK_MS,
})
: await browserNavigate(baseUrl, {
url: targetUrl,
targetId,
timeoutMs,
profile,
signal,
});
const result = await browserNavigate(proxyRequest ?? baseUrl, {
url: targetUrl,
targetId,
timeoutMs: requestedTimeoutMs,
profile,
signal,
});
const navigatedTargetId = readStringValue(asNullableRecord(result)?.targetId) ?? targetId;
touchTab(navigatedTargetId);
const formatted = formatBrowserExternalToolResult({
@ -310,15 +251,7 @@ export async function executeBrowserTabAction(context: {
}
case "pdf": {
const targetId = normalizeOptionalString(params.targetId);
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
path: "/pdf",
profile,
body: { targetId },
// SAFETY: The node dispatches the same /pdf route as the typed local client.
})) as Awaited<ReturnType<typeof browserPdfSave>>)
: await browserPdfSave(baseUrl, { targetId, profile, signal });
const result = await browserPdfSave(proxyRequest ?? baseUrl, { targetId, profile, signal });
touchTab(readStringValue(result.targetId) ?? targetId);
return {
content: [{ type: "text" as const, text: `FILE:${result.path}` }],
@ -358,10 +291,9 @@ export async function executeBrowserTabAction(context: {
targetId,
timeoutMs,
};
return await executeTrackedTabRequest(
"/hooks/file-chooser",
request,
async () => await browserArmFileChooser(baseUrl, { ...request, profile, signal }),
return trackedTabResult(
await browserArmFileChooser(proxyRequest ?? baseUrl, { ...request, profile, signal }),
targetId,
);
}
case "dialog": {
@ -370,10 +302,9 @@ export async function executeBrowserTabAction(context: {
const dialogId = readStringValue(params.dialogId);
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
const request = { accept, promptText, dialogId, targetId, timeoutMs };
return await executeTrackedTabRequest(
"/hooks/dialog",
request,
async () => await browserArmDialog(baseUrl, { ...request, profile, signal }),
return trackedTabResult(
await browserArmDialog(proxyRequest ?? baseUrl, { ...request, profile, signal }),
targetId,
);
}
case "act": {

View file

@ -21,7 +21,6 @@ import {
browserTabs,
browserWaitForDownload,
jsonResult,
normalizeBrowserTabsResult,
normalizeOptionalString,
readStringParam,
readStringValue,
@ -33,10 +32,7 @@ import {
wrapBrowserExternalJson,
wrapBrowserExternalText,
} from "./browser-tool.snapshot.js";
import {
EXISTING_SESSION_TIMEOUT_OVERRIDE_KINDS,
resolveBrowserActRequestTimeoutMs,
} from "./browser/act-policy.js";
import { EXISTING_SESSION_TIMEOUT_OVERRIDE_KINDS } from "./browser/act-policy.js";
import type {
BrowserBatchAbort,
BrowserBatchActionResult,
@ -44,12 +40,9 @@ import type {
import {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_DOWNLOAD_TIMEOUT_MS,
} from "./browser/constants.js";
import { formatErrorMessage } from "./infra/errors.js";
const BROWSER_DOWNLOAD_REQUEST_TIMEOUT_SLACK_MS = 5_000;
type BrowserActRequest = Parameters<typeof browserAct>[1];
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
@ -232,11 +225,11 @@ export async function executeTabsAction(params: {
signal?: AbortSignal;
}): Promise<AgentToolResult<unknown>> {
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
const result = proxyRequest
? normalizeBrowserTabsResult(
await proxyRequest({ method: "GET", path: "/tabs", profile, timeoutMs }),
)
: await browserTabs(baseUrl, { profile, timeoutMs, signal: params.signal });
const result = await browserTabs(proxyRequest ?? baseUrl, {
profile,
timeoutMs,
signal: params.signal,
});
const tabs = result.running
? result.tabs.filter(
(tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId,
@ -295,12 +288,11 @@ export async function executeConsoleAction(params: {
level: normalizeOptionalString(input.level),
targetId: normalizeOptionalString(input.targetId),
};
const result = proxyRequest
? ((await proxyRequest({ method: "GET", path: "/console", profile, query })) as {
targetId?: string;
messages?: unknown[];
})
: await browserConsoleMessages(baseUrl, { ...query, profile, signal: params.signal });
const result = await browserConsoleMessages(proxyRequest ?? baseUrl, {
...query,
profile,
signal: params.signal,
});
return formatConsoleToolResult(result);
}
@ -315,21 +307,13 @@ export async function executeRequestsAction(
const limit =
readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." }) ??
50;
const result = proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/requests",
profile,
query: { targetId, filter, clear },
// SAFETY: The proxy dispatches the same /requests route as the typed local client.
})) as Awaited<ReturnType<typeof browserRequests>>)
: await browserRequests(baseUrl, {
targetId,
filter,
clear,
profile,
signal,
});
const result = await browserRequests(proxyRequest ?? baseUrl, {
targetId,
filter,
clear,
profile,
signal,
});
return formatBrowserDebugLogResult("requests", result, result.requests, limit);
}
@ -343,20 +327,12 @@ export async function executeErrorsAction(
const limit =
readPositiveIntegerParam(input, "limit", { message: "limit must be a positive integer." }) ??
50;
const result = proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/errors",
profile,
query: { targetId, clear },
// SAFETY: The proxy dispatches the same /errors route as the typed local client.
})) as Awaited<ReturnType<typeof browserErrors>>)
: await browserErrors(baseUrl, {
targetId,
clear,
profile,
signal,
});
const result = await browserErrors(proxyRequest ?? baseUrl, {
targetId,
clear,
profile,
signal,
});
return formatBrowserDebugLogResult("errors", result, result.errors, limit);
}
@ -373,21 +349,13 @@ export async function executeTextAction(
}) ?? DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
);
const result = proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/text",
profile,
query: { targetId, selector, maxChars },
// SAFETY: The proxy dispatches the same /text route as the typed local client.
})) as Awaited<ReturnType<typeof browserPageText>>)
: await browserPageText(baseUrl, {
targetId,
selector,
maxChars,
profile,
signal,
});
const result = await browserPageText(proxyRequest ?? baseUrl, {
targetId,
selector,
maxChars,
profile,
signal,
});
const wrapped = wrapBrowserExternalText({
value: result.text,
marker: "\n[truncated — retry with a narrower selector]",
@ -435,33 +403,18 @@ export async function executeEmulateAction(
const applied: string[] = [];
for (const { field, setting, key, value } of requested) {
const body = { targetId, [key]: value };
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
path: `/set/${setting}`,
profile,
body,
// SAFETY: All four /set routes return the local client's resolved-tab result.
})) as Awaited<ReturnType<typeof browserEmulateSetting>>)
: await browserEmulateSetting(baseUrl, {
setting,
body,
profile,
signal,
});
const result = await browserEmulateSetting(proxyRequest ?? baseUrl, {
setting,
body,
profile,
signal,
});
targetId = result.targetId ?? targetId;
applied.push(field);
}
return jsonResult({ ok: true, targetId, applied });
}
function resolveDownloadProxyTimeoutMs(timeoutMs: number | undefined): number {
const waitTimeoutMs = timeoutMs ?? DEFAULT_BROWSER_DOWNLOAD_TIMEOUT_MS;
// The node proxy must outlive the browser-server request; callBrowserProxy
// adds a second grace window for the outer Gateway node.invoke call.
return waitTimeoutMs + BROWSER_DOWNLOAD_REQUEST_TIMEOUT_SLACK_MS;
}
/** Execute explicit Browser download operations through the local or node-host path. */
export async function executeDownloadAction(params: {
action: "download" | "waitfordownload";
@ -490,21 +443,14 @@ export async function executeDownloadAction(params: {
kind: "waitfordownload" as const,
body: { path: readStringParam(input, "path"), targetId, timeoutMs },
};
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: download ? "/download" : "/wait/download",
profile,
timeoutMs: resolveDownloadProxyTimeoutMs(timeoutMs),
body: request.body,
})
: request.kind === "download"
? await browserDownload(baseUrl, {
const result =
request.kind === "download"
? await browserDownload(proxyRequest ?? baseUrl, {
...request.body,
profile,
signal: params.signal,
})
: await browserWaitForDownload(baseUrl, {
: await browserWaitForDownload(proxyRequest ?? baseUrl, {
...request.body,
profile,
signal: params.signal,
@ -556,18 +502,10 @@ export async function executeActAction(params: {
});
};
const dispatchAndFinishAct = async (actionRequest: BrowserActRequest) => {
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: actionRequest,
timeoutMs: resolveBrowserActRequestTimeoutMs(actionRequest),
})
: await browserAct(baseUrl, actionRequest, {
profile,
signal: params.signal,
});
const result = await browserAct(proxyRequest ?? baseUrl, actionRequest, {
profile,
signal: params.signal,
});
return await finishActResult(
result,
readStringValue((result as { targetId?: unknown }).targetId) ??
@ -585,11 +523,10 @@ export async function executeActAction(params: {
proxyRoute?.status === "resolved" ? proxyRoute.profile : (profile ?? "default");
if (isChromeStaleTargetError(usesChromeMcp, err)) {
let tabRefreshError: unknown;
const availability = await (
proxyRequest
? proxyRequest({ method: "GET", path: "/tabs", profile }).then(normalizeBrowserTabsResult)
: browserTabs(baseUrl, { profile, signal: params.signal })
).catch((refreshError: unknown): BrowserTabsResult => {
const availability = await browserTabs(proxyRequest ?? baseUrl, {
profile,
signal: params.signal,
}).catch((refreshError: unknown): BrowserTabsResult => {
params.signal?.throwIfAborted();
tabRefreshError = refreshError;
return { running: false, tabs: [] };

View file

@ -87,40 +87,20 @@ export async function executeBrowserLifecycleAction({
signal?: AbortSignal;
}): Promise<AgentToolResult<unknown>> {
const readBrowserStatus = async () =>
proxyRequest
? await proxyRequest({
method: "GET",
path: "/",
profile,
timeoutMs: toolTimeoutMs,
})
: await browserStatus(baseUrl, {
profile,
timeoutMs: toolTimeoutMs,
signal,
});
await browserStatus(proxyRequest ?? baseUrl, {
profile,
timeoutMs: toolTimeoutMs,
signal,
});
switch (action) {
case "doctor":
return jsonResult(
proxyRequest
? await proxyRequest({ method: "GET", path: "/doctor", profile })
: await browserDoctor(baseUrl, { profile, signal }),
);
return jsonResult(await browserDoctor(proxyRequest ?? baseUrl, { profile, signal }));
case "status":
return jsonResult(await readBrowserStatus());
case "start":
case "stop": {
if (proxyRequest) {
await proxyRequest({
method: "POST",
path: `/${action}`,
profile,
timeoutMs: toolTimeoutMs,
});
} else {
const updateBrowser = action === "start" ? browserStart : browserStop;
await updateBrowser(baseUrl, { profile, timeoutMs: toolTimeoutMs, signal });
}
const updateBrowser = action === "start" ? browserStart : browserStop;
await updateBrowser(proxyRequest ?? baseUrl, { profile, timeoutMs: toolTimeoutMs, signal });
return jsonResult(await readBrowserStatus());
}
case "profiles": {

View file

@ -62,7 +62,6 @@ export {
browserDoctor,
browserFocusTab,
browserImportProfile,
normalizeBrowserTabsResult,
browserOpenTab,
browserProfiles,
browserSystemProfiles,

View file

@ -82,20 +82,11 @@ export async function executeScreenshotAction({
labels,
timeoutMs: effectiveTimeoutMs,
} satisfies Parameters<typeof browserScreenshotAction>[1];
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
path: "/screenshot",
profile,
timeoutMs: effectiveTimeoutMs,
body: request,
// SAFETY: The browser proxy preserves the /screenshot response contract used by the local client.
})) as Awaited<ReturnType<typeof browserScreenshotAction>>)
: await browserScreenshotAction(baseUrl, {
...request,
profile,
signal,
});
const result = await browserScreenshotAction(proxyRequest ?? baseUrl, {
...request,
profile,
signal,
});
onTabActivity(readStringValue(result.targetId) ?? targetId);
if (opts?.screenshotResultMode === "path") {
const artifactPath = opts.persistScreenshot

View file

@ -273,19 +273,11 @@ export async function executeSnapshotAction(params: {
};
let refsFallback: "role" | undefined;
const readSnapshot = async (query: typeof snapshotQuery) =>
proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/snapshot",
profile,
query,
timeoutMs: snapshotTimeoutMs,
})) as Awaited<ReturnType<typeof browserSnapshot>>)
: await browserSnapshot(baseUrl, {
...query,
profile,
signal: params.signal,
});
await browserSnapshot(proxyRequest ?? baseUrl, {
...query,
profile,
signal: params.signal,
});
let snapshot: Awaited<ReturnType<typeof browserSnapshot>>;
try {
snapshot = await readSnapshot(snapshotQuery);

View file

@ -253,8 +253,27 @@ vi.mock("./sdk-setup-tools.js", async () => {
vi.mock("./browser-tool.runtime.js", async () => {
const { BrowserToolOutputSchema, createBrowserToolSchema, resolveBrowserToolCapabilities } =
await vi.importActual<typeof import("./browser-tool.schema.js")>("./browser-tool.schema.js");
const { normalizeBrowserTabsResult } =
const actualClient =
await vi.importActual<typeof import("./browser/client.js")>("./browser/client.js");
const actualActions = await vi.importActual<typeof import("./browser/client-actions.js")>(
"./browser/client-actions.js",
);
const actualMethods: Record<string, (...args: never[]) => unknown> = {
...actualClient,
...actualActions,
};
// Node requests exercise the shared client projection before reaching the mocked Gateway.
const routedClients = Object.fromEntries(
Object.entries({ ...browserClientMocks, ...browserActionsMocks }).map(([name, local]) => [
name,
(...args: unknown[]) =>
Reflect.apply(
typeof args[0] === "function" ? actualMethods[name]! : local,
undefined,
args,
),
]),
);
const { wrapExternalContent } = await vi.importActual<typeof import("./sdk-security-runtime.js")>(
"./sdk-security-runtime.js",
);
@ -281,10 +300,8 @@ vi.mock("./browser-tool.runtime.js", async () => {
DEFAULT_UPLOAD_DIR: "/tmp/openclaw-browser-uploads",
BrowserToolOutputSchema,
createBrowserToolSchema,
normalizeBrowserTabsResult,
resolveBrowserToolCapabilities,
...browserActionsMocks,
...browserClientMocks,
...routedClients,
...browserConfigMocks,
...configMocks,
...gatewayMocks,
@ -3155,28 +3172,6 @@ describe("browser tool url alias support", () => {
});
});
it.each([
{ requestedTimeoutMs: 10, expectedTimeoutMs: 1_000 },
{ requestedTimeoutMs: 180_000, expectedTimeoutMs: 120_000 },
{ requestedTimeoutMs: Number.MAX_SAFE_INTEGER, expectedTimeoutMs: 120_000 },
])(
"normalizes host navigation timeout $requestedTimeoutMs before browser dispatch",
async ({ requestedTimeoutMs, expectedTimeoutMs }) => {
await createBrowserTool().execute?.("call-1", {
action: "navigate",
target: "host",
url: "https://example.com/slow",
targetId: "tab-1",
timeoutMs: requestedTimeoutMs,
});
expect(browserActionsMocks.browserNavigate).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ timeoutMs: expectedTimeoutMs }),
);
},
);
it.each([
{ label: "default", requestedTimeoutMs: undefined, expectedTimeoutMs: 20_000 },
{ label: "explicit", requestedTimeoutMs: 45_000, expectedTimeoutMs: 45_000 },

View file

@ -41,6 +41,15 @@ export type CdpSendFn = (
sessionId?: string,
) => Promise<unknown>;
export class CdpSocketError extends Error {
constructor(
readonly kind: "closed" | "timeout" | "protocol",
message: string,
) {
super(message);
}
}
function withDefaultPlaywrightUserAgent(headers: Record<string, string>): Record<string, string> {
if (Object.keys(headers).some((key) => key.trim().toLowerCase() === "user-agent")) {
return headers;
@ -155,7 +164,7 @@ function createPinnedAgentForCdpUrl(
return agent;
}
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
function createCdpSender(ws: WebSocket, opts?: CdpSocketOptions) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
@ -178,7 +187,7 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
reject(new CdpSocketError("closed", "CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
@ -186,7 +195,12 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
// A timed-out command closes the whole socket so pending calls do not
// hang on a connection whose CDP command stream is no longer reliable.
entry.timer = setTimeout(() => {
closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`));
closeWithError(
new CdpSocketError(
"timeout",
`CDP command ${method} timed out after ${commandTimeoutMs}ms`,
),
);
}, commandTimeoutMs);
}
pending.set(id, entry);
@ -206,7 +220,15 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
p.reject(err);
}
pending.clear();
ws.close();
if (
opts?.abortScope === "operation" &&
err instanceof CdpSocketError &&
err.kind === "timeout"
) {
ws.terminate();
} else {
ws.close();
}
};
ws.on("error", (err) => {
@ -231,7 +253,7 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
p.reject(new CdpSocketError("protocol", parsed.error.message));
return;
}
p.resolve(parsed.result);
@ -241,7 +263,7 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
closeWithError(new CdpSocketError("closed", "CDP socket closed"));
});
return { send, closeWithError };
@ -301,6 +323,8 @@ type CdpSocketOptions = {
handshakeMaxRetryDelayMs?: number;
lookup?: CdpSocketLookup;
signal?: AbortSignal;
/** Read-only probes can cancel commands; write callers retain the socket for compensation. */
abortScope?: "operation";
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
@ -370,49 +394,51 @@ export async function withCdpSocket<T>(
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
ws.once("close", () => reject(new CdpSocketError("closed", "CDP socket closed")));
});
// A stalled HTTP upgrade must release its TCP socket on cancellation.
const abortHandshake = () => ws.terminate();
opts?.signal?.addEventListener("abort", abortHandshake, { once: true });
const abortSocket = () => {
closeWithError(toStringifiedError(opts?.signal?.reason));
ws.terminate();
};
opts?.signal?.addEventListener("abort", abortSocket, { once: true });
if (opts?.signal?.aborted) {
abortHandshake();
abortSocket();
}
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
// Cancellation on the final attempt must not become a handshake error.
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
try {
await openPromise;
} catch (err) {
closeWithError(toStringifiedError(err));
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only before commands can have side effects.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
// Cancelled route requests must not keep retrying Chrome handshakes.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
} finally {
opts?.signal?.removeEventListener("abort", abortHandshake);
}
try {
return await fn(send);
if (opts?.abortScope !== "operation") {
opts?.signal?.removeEventListener("abort", abortSocket);
} else {
opts.signal?.throwIfAborted();
}
const result = await fn(send);
if (opts?.abortScope === "operation") {
opts.signal?.throwIfAborted();
}
return result;
} catch (err) {
closeWithError(toStringifiedError(err));
throw err;
} finally {
opts?.signal?.removeEventListener("abort", abortSocket);
ws.close();
}
}

View file

@ -697,6 +697,43 @@ describe("cdp.helpers internal", () => {
expect(rejectedHandshakes).toBe(1);
});
it("keeps an admitted write socket available for compensation after caller abort", async () => {
const server = await startWsServer();
wss = server.wss;
const controller = new AbortController();
const cancellation = new Error("browser request cancelled after target creation");
const commands: string[] = [];
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const message = JSON.parse(rawDataToString(raw)) as { id: number; method: string };
commands.push(message.method);
if (message.method === "Target.createTarget") {
controller.abort(cancellation);
}
socket.send(JSON.stringify({ id: message.id, result: { targetId: "created-target" } }));
});
});
await expect(
withCdpSocket(
server.url,
async (send) => {
const created = (await send("Target.createTarget", { url: "about:blank" })) as {
targetId: string;
};
try {
controller.signal.throwIfAborted();
} catch (error) {
await send("Target.closeTarget", { targetId: created.targetId });
throw error;
}
},
{ signal: controller.signal, commandTimeoutMs: 1000 },
),
).rejects.toBe(cancellation);
expect(commands).toEqual(["Target.createTarget", "Target.closeTarget"]);
});
it("rejects and closes the socket when a CDP command exceeds its timeout", async () => {
const server = await startWsServer();
wss = server.wss;

View file

@ -5,8 +5,11 @@ import { createOpenClawTestState, type OpenClawTestState } from "openclaw/plugin
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NormalizedChromeMcpProfileOptions } from "./chrome-mcp-contracts.js";
import { withChromeMcpLease } from "./chrome-mcp-routing.js";
import { closeChromeMcpSession, resetChromeMcpSessionsForTest } from "./chrome-mcp-session.js";
import { getChromeMcpPid } from "./chrome-mcp-tabs.js";
import {
closeChromeMcpSession,
getChromeMcpPid,
resetChromeMcpSessionsForTest,
} from "./chrome-mcp-session.js";
const { warn } = vi.hoisted(() => ({ warn: vi.fn<(message: string) => void>() }));

View file

@ -10,6 +10,8 @@ import { redactCdpUrl } from "./cdp.helpers.js";
import {
CHROME_MCP_HANDSHAKE_TIMEOUT_MS,
type ChromeMcpSession,
type ChromeMcpSessionFactory,
type ChromeMcpSessionOwner,
type NormalizedChromeMcpProfileOptions,
} from "./chrome-mcp-contracts.js";
import {
@ -18,14 +20,15 @@ import {
redactChromeMcpLocalPathForDiagnostic,
redactChromeMcpProfileLabelForDiagnostic,
} from "./chrome-mcp-diagnostics.js";
import {
closeTrackedChromeMcpSession,
refreshChromeMcpCleanupProcess,
} from "./chrome-mcp-process.js";
import { getChromeMcpSessionFactory } from "./chrome-mcp-state.js";
import { refreshChromeMcpCleanupProcess } from "./chrome-mcp-process.js";
import { BrowserProfileUnavailableError } from "./errors.js";
const log = createSubsystemLogger("browser").child("chrome-mcp");
let sessionFactory: ChromeMcpSessionFactory | null = null;
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
sessionFactory = factory;
}
async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
@ -47,7 +50,7 @@ async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
}
async function createRealSession(
cacheKey: string,
owner: ChromeMcpSessionOwner,
profileName: string,
options: NormalizedChromeMcpProfileOptions,
): Promise<ChromeMcpSession> {
@ -82,7 +85,7 @@ async function createRealSession(
};
// SDK initialization and read-buffer failures can close before connect settles.
// Funnel both SDK entry points through the same owner before it clears the PID.
client.close = transport.close = () => closeTrackedChromeMcpSession(cacheKey, session);
client.close = transport.close = () => owner.close(session);
const ready = (async () => {
try {
await withChromeMcpHandshakeTimeout(
@ -213,19 +216,18 @@ export async function waitForChromeMcpPendingSession(
}
export function createChromeMcpSession(
cacheKey: string,
owner: ChromeMcpSessionOwner,
profileName: string,
options: NormalizedChromeMcpProfileOptions,
signal?: AbortSignal,
): { promise: Promise<ChromeMcpSession>; cleanup: Promise<void> } {
const factory = getChromeMcpSessionFactory();
const created = factory
? factory(profileName, options)
: createRealSession(cacheKey, profileName, options);
const created = sessionFactory
? sessionFactory(profileName, options)
: createRealSession(owner, profileName, options);
let adopted = false;
let closePromise: Promise<void> | undefined;
const closeCreated = async (session: ChromeMcpSession) => {
closePromise ??= closeTrackedChromeMcpSession(cacheKey, session);
closePromise ??= owner.close(session);
await closePromise;
};
const promise = (async () => {

View file

@ -104,10 +104,16 @@ export type ChromeMcpOptionsInput =
| ChromeMcpProfileOptions
| NormalizedChromeMcpProfileOptions;
export type ChromeMcpSessionOwner = {
isCurrent: (session: ChromeMcpSession) => boolean;
close: (session: ChromeMcpSession) => Promise<void>;
};
export type ChromeMcpSessionLease = {
session: ChromeMcpSession;
cacheKey: string;
owner: ChromeMcpSessionOwner;
temporary: boolean;
release: () => Promise<void>;
};
export type ChromeMcpSessionFactory = (
@ -115,26 +121,6 @@ export type ChromeMcpSessionFactory = (
options?: NormalizedChromeMcpProfileOptions,
) => Promise<ChromeMcpSession>;
export type PendingChromeMcpSession = {
cacheKey: string;
id: symbol;
promise: Promise<ChromeMcpSession>;
cleanup: Promise<void>;
abortController: AbortController;
state: {
waiters: number;
settled: boolean;
session?: ChromeMcpSession;
cancelled: boolean;
cleanupSettled: boolean;
};
};
export type PendingChromeMcpSessionLease = {
session: ChromeMcpSession;
release: (closeIfLastWaiter: boolean) => Promise<boolean>;
};
/** One OS snapshot row: ancestry and immutable birth identity from the same read. */
export type ChromeMcpProcessSnapshot = {
pid: number;

View file

@ -115,12 +115,3 @@ export function chromeMcpProfileOptionsFromParams(params: {
}): string | ChromeMcpProfileOptions | undefined {
return params.profile ?? params.userDataDir;
}
export function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean {
try {
const parsed = JSON.parse(cacheKey);
return Array.isArray(parsed) && parsed[0] === profileName;
} catch {
return false;
}
}

View file

@ -1,176 +0,0 @@
import { createChromeMcpSession, waitForChromeMcpPendingSession } from "./chrome-mcp-connect.js";
// Coordinates shared Chrome MCP session creation across concurrent waiters.
import type {
ChromeMcpSession,
NormalizedChromeMcpProfileOptions,
PendingChromeMcpSession,
PendingChromeMcpSessionLease,
} from "./chrome-mcp-contracts.js";
import {
closeTrackedChromeMcpSession,
drainRetainedChromeMcpCleanup,
} from "./chrome-mcp-process.js";
import {
chromeMcpSessions as sessions,
pendingChromeMcpSessions as pendingSessions,
} from "./chrome-mcp-state.js";
export function abortPendingChromeMcpSession(
pending: PendingChromeMcpSession,
reason: unknown = new Error("Chrome MCP session attach no longer has active waiters"),
): void {
pending.state.cancelled = true;
if (!pending.state.settled && !pending.abortController.signal.aborted) {
pending.abortController.abort(reason);
}
}
function forgetCancelledChromeMcpPendingSession(pending: PendingChromeMcpSession): void {
if (pendingSessions.get(pending.cacheKey) === pending) {
pendingSessions.delete(pending.cacheKey);
}
}
export async function drainCancelledChromeMcpPendingSession(
pending: PendingChromeMcpSession,
): Promise<void> {
const cleanupWasSettled = pending.state.cleanupSettled;
try {
await pending.cleanup;
} catch (err) {
// All callers already waiting on the first attempt observe the same failure.
// A later caller retries the retained exact handle before admitting a replacement.
if (!cleanupWasSettled) {
throw err;
}
await drainRetainedChromeMcpCleanup(pending.cacheKey);
}
forgetCancelledChromeMcpPendingSession(pending);
}
export function forgetCachedChromeMcpSessionIfCurrent(
cacheKey: string,
session: ChromeMcpSession,
): boolean {
const current = sessions.get(cacheKey);
if (current?.transport !== session.transport) {
return false;
}
sessions.delete(cacheKey);
return true;
}
export function forgetPendingChromeMcpSessionIfCurrent(
cacheKey: string,
pending: PendingChromeMcpSession,
): boolean {
if (pendingSessions.get(cacheKey) !== pending) {
return false;
}
pendingSessions.delete(cacheKey);
return true;
}
export function createSharedPendingChromeMcpSession(
cacheKey: string,
profileName: string,
options: NormalizedChromeMcpProfileOptions,
): PendingChromeMcpSession {
const id = Symbol(cacheKey);
const abortController = new AbortController();
const state: PendingChromeMcpSession["state"] = {
waiters: 0,
settled: false,
cancelled: false,
cleanupSettled: false,
};
const creation = createChromeMcpSession(cacheKey, profileName, options, abortController.signal);
const promise = (async () => {
try {
const created = await creation.promise;
state.session = created;
if (pendingSessions.get(cacheKey)?.id === id) {
sessions.set(cacheKey, created);
} else {
await closeTrackedChromeMcpSession(cacheKey, created);
}
return created;
} finally {
state.settled = true;
if (!state.cancelled && state.waiters === 0 && pendingSessions.get(cacheKey)?.id === id) {
pendingSessions.delete(cacheKey);
}
}
})();
const cleanup = creation.cleanup.finally(() => {
state.cleanupSettled = true;
});
const pending: PendingChromeMcpSession = {
cacheKey,
id,
promise,
cleanup,
abortController,
state,
};
void promise.catch(() => {});
void cleanup.catch(() => {});
return pending;
}
export async function waitForSharedPendingChromeMcpSession(
pending: PendingChromeMcpSession,
signal?: AbortSignal,
): Promise<PendingChromeMcpSessionLease> {
pending.state.waiters += 1;
let released = false;
let leasedSession: ChromeMcpSession | undefined;
const release = async (closeIfLastWaiter: boolean) => {
if (released) {
return false;
}
released = true;
pending.state.waiters = Math.max(0, pending.state.waiters - 1);
if (pending.state.waiters !== 0) {
return false;
}
if (!pending.state.settled) {
abortPendingChromeMcpSession(pending, signal?.reason);
await drainCancelledChromeMcpPendingSession(pending);
} else if (closeIfLastWaiter) {
const session = leasedSession ?? pending.state.session;
if (session) {
abortPendingChromeMcpSession(pending, signal?.reason);
forgetCachedChromeMcpSessionIfCurrent(pending.cacheKey, session);
await closeTrackedChromeMcpSession(pending.cacheKey, session);
}
forgetCancelledChromeMcpPendingSession(pending);
} else {
forgetPendingChromeMcpSessionIfCurrent(pending.cacheKey, pending);
}
return true;
};
let abortRelease: Promise<boolean> | undefined;
const releaseOnAbort = () => {
// Publish last-waiter cleanup synchronously inside the abort event. A new
// caller must cross that barrier instead of adopting the cancelled attach.
abortRelease ??= release(true);
void abortRelease.catch(() => {});
};
signal?.addEventListener("abort", releaseOnAbort, { once: true });
if (signal?.aborted) {
releaseOnAbort();
}
try {
leasedSession = await waitForChromeMcpPendingSession(pending.promise, signal);
return {
session: leasedSession,
release,
};
} catch (err) {
await (abortRelease ?? release(signal?.aborted === true));
throw err;
} finally {
signal?.removeEventListener("abort", releaseOnAbort);
}
}

View file

@ -15,11 +15,14 @@ import {
type ChromeMcpProcessSnapshot,
type ChromeMcpSession,
} from "./chrome-mcp-contracts.js";
import {
chromeMcpCleanupPromises as cleanupPromises,
getChromeMcpProcessCleanupDeps,
retainedChromeMcpCleanupSessions as retainedCleanupSessions,
} from "./chrome-mcp-state.js";
let processCleanupDeps: ChromeMcpProcessCleanupDeps | null = null;
export function setChromeMcpProcessCleanupDepsForTest(
deps: ChromeMcpProcessCleanupDeps | null,
): void {
processCleanupDeps = deps;
}
function readChromeMcpTransportPid(transport: StdioClientTransport): number | undefined {
const pid = transport.pid;
@ -182,7 +185,11 @@ export async function refreshChromeMcpCleanupProcess(session: ChromeMcpSession):
}
return;
}
const snapshots = await listChromeMcpPlatformProcesses(getChromeMcpProcessCleanupDeps());
const snapshots = await listChromeMcpPlatformProcesses(processCleanupDeps);
// A catalog reply can start a final census while transport cleanup is already waiting for exit.
if (session.processCleanup?.status === "closed") {
return;
}
const currentRoot = snapshots.find((snapshot) => snapshot.pid === rootPid);
if (existing && currentRoot?.identity !== existing.root.identity) {
if (state.status === "uncertain") {
@ -211,8 +218,10 @@ export async function refreshChromeMcpCleanupProcess(session: ChromeMcpSession):
await refresh;
} catch (err) {
// Capture can fail during start, before cleanup runs or the SDK loses its PID.
const target = cleanupTarget(state);
session.processCleanup = { status: "uncertain", ...(target ? { target } : {}) };
if (session.processCleanup?.status !== "closed") {
const target = cleanupTarget(state);
session.processCleanup = { status: "uncertain", ...(target ? { target } : {}) };
}
throw err;
} finally {
if (session.processCleanupRefresh === refresh) {
@ -250,7 +259,7 @@ async function terminateChromeMcpProcessTree(
return;
}
const deps = getChromeMcpProcessCleanupDeps();
const deps = processCleanupDeps;
const targets = [...target.descendants.toReversed(), target.root];
let surviving = await currentChromeMcpProcesses(targets, deps);
// A fresh absence proof ends cleanup; snapshots from before awaited shutdown
@ -317,7 +326,7 @@ async function terminateChromeMcpProcessTree(
);
}
async function closeChromeMcpSessionHandle(session: ChromeMcpSession): Promise<void> {
export async function closeChromeMcpSessionHandle(session: ChromeMcpSession): Promise<void> {
let firstError: Error | undefined;
let cleanupUncertain = session.processCleanup?.status === "uncertain";
const attempt = async (operation: () => Promise<void>) => {
@ -331,7 +340,7 @@ async function closeChromeMcpSessionHandle(session: ChromeMcpSession): Promise<v
await attempt(async () => await refreshChromeMcpCleanupProcess(session));
const target = session.processCleanup ? cleanupTarget(session.processCleanup) : undefined;
const terminateFirst =
Boolean(target) && (getChromeMcpProcessCleanupDeps()?.platform ?? process.platform) === "win32";
Boolean(target) && (processCleanupDeps?.platform ?? process.platform) === "win32";
if (terminateFirst) {
await attempt(async () => await terminateChromeMcpProcessTree(target));
}
@ -349,57 +358,3 @@ async function closeChromeMcpSessionHandle(session: ChromeMcpSession): Promise<v
}
session.processCleanup = { status: "closed" };
}
export function closeTrackedChromeMcpSession(
cacheKey: string,
session: ChromeMcpSession,
): Promise<void> {
if (session.processCleanup?.status === "closed") {
return Promise.resolve();
}
const existing = cleanupPromises.get(session);
if (existing) {
return existing;
}
// Revoke sends before discovery yields: a finishing start must not initialize
// and spawn descendants after the cleanup snapshot has been captured.
session.transport.send = async () => {
throw new Error("Chrome MCP session is closing");
};
// Publish cleanup ownership before awaiting so a replacement session cannot
// overtake the exact process/client handle being closed.
const retained = retainedCleanupSessions.get(cacheKey) ?? new Set<ChromeMcpSession>();
retained.add(session);
retainedCleanupSessions.set(cacheKey, retained);
const cleanup = (async () => {
try {
await closeChromeMcpSessionHandle(session);
retained.delete(session);
if (retained.size === 0) {
retainedCleanupSessions.delete(cacheKey);
}
} finally {
cleanupPromises.delete(session);
}
})();
cleanupPromises.set(session, cleanup);
// Client.connect intentionally does not await close on initialization failure.
// Keep that rejection observed without hiding it from cleanup/admission callers.
void cleanup.catch(() => {});
return cleanup;
}
export async function drainRetainedChromeMcpCleanup(cacheKey: string): Promise<void> {
const results = await Promise.allSettled(
[...(retainedCleanupSessions.get(cacheKey) ?? [])].map(
async (session) => await closeTrackedChromeMcpSession(cacheKey, session),
),
);
const failed = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failed) {
throw failed.reason;
}
}

View file

@ -25,16 +25,13 @@ import {
chromeMcpProfileOptionsFromParams,
normalizeChromeMcpOptions,
} from "./chrome-mcp-options.js";
import { forgetCachedChromeMcpSessionIfCurrent } from "./chrome-mcp-pending.js";
import { closeTrackedChromeMcpSession } from "./chrome-mcp-process.js";
import {
extractStructuredPages,
extractToolErrorMessage,
formatChromeMcpToolErrorMessage,
shouldReconnectForToolError,
} from "./chrome-mcp-result.js";
import { leaseSession } from "./chrome-mcp-session.js";
import { chromeMcpSessions as sessions } from "./chrome-mcp-state.js";
import { getChromeMcpSessionOwner } from "./chrome-mcp-session.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
@ -257,12 +254,8 @@ export async function callTool(
result = await rawCall;
} catch (err) {
// Transport/connection error, timeout, or abort: tear down the cached session.
if (!lease.temporary) {
const current = sessions.get(lease.cacheKey);
if (current?.transport === lease.session.transport) {
sessions.delete(lease.cacheKey);
await closeTrackedChromeMcpSession(lease.cacheKey, lease.session);
}
if (!lease.temporary && lease.owner.isCurrent(lease.session)) {
await lease.owner.close(lease.session);
}
if (signal?.aborted) {
throw toErrorObject(signal.reason ?? err, "Non-Error abort reason");
@ -280,12 +273,8 @@ export async function callTool(
if (result.isError) {
const message = extractToolErrorMessage(result, name);
if (shouldReconnectForToolError(name, message)) {
if (!lease.temporary) {
const current = sessions.get(lease.cacheKey);
if (current?.transport === lease.session.transport) {
sessions.delete(lease.cacheKey);
await closeTrackedChromeMcpSession(lease.cacheKey, lease.session);
}
if (!lease.temporary && lease.owner.isCurrent(lease.session)) {
await lease.owner.close(lease.session);
}
throw new ChromeMcpReconnectRequiredError(message);
}
@ -335,27 +324,24 @@ export async function withChromeMcpLease<T>(
) => Promise<T>,
): Promise<T> {
const normalizedProfileOptions = normalizeChromeMcpOptions(profileOptions);
const lease = await leaseSession(profileName, normalizedProfileOptions, options);
options.signal?.throwIfAborted();
const lease = await getChromeMcpSessionOwner(profileName, normalizedProfileOptions).lease(
options,
);
try {
return await withChromeMcpOperationLock(lease.session, options, async () => {
if (!lease.temporary) {
const current = sessions.get(lease.cacheKey);
if (
current?.transport !== lease.session.transport ||
lease.session.transport.pid === null
) {
forgetCachedChromeMcpSessionIfCurrent(lease.cacheKey, lease.session);
throw new BrowserProfileUnavailableError(
`Chrome MCP session for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}" changed before the operation could start. Run the browser command again to reconnect.`,
);
}
if (
!lease.temporary &&
(!lease.owner.isCurrent(lease.session) || lease.session.transport.pid === null)
) {
throw new BrowserProfileUnavailableError(
`Chrome MCP session for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}" changed before the operation could start. Run the browser command again to reconnect.`,
);
}
return await operation(lease, normalizedProfileOptions);
});
} finally {
if (lease.temporary) {
await closeTrackedChromeMcpSession(lease.cacheKey, lease.session);
}
await lease.release();
}
}

View file

@ -1,358 +1,398 @@
// Owns Chrome MCP session creation, sharing, leasing, and shutdown.
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
// Owns Chrome MCP attachment, admission, and retained cleanup per profile configuration.
import { toErrorObject } from "../infra/errors.js";
import { createChromeMcpSession, waitForChromeMcpReady } from "./chrome-mcp-connect.js";
import {
createChromeMcpSession,
setChromeMcpSessionFactoryForTest,
waitForChromeMcpPendingSession,
waitForChromeMcpReady,
} from "./chrome-mcp-connect.js";
import type {
ChromeMcpCallOptions,
ChromeMcpOptionsInput,
ChromeMcpProcessCleanupDeps,
ChromeMcpSession,
ChromeMcpSessionFactory,
ChromeMcpSessionLease,
PendingChromeMcpSession,
PendingChromeMcpSessionLease,
NormalizedChromeMcpProfileOptions,
} from "./chrome-mcp-contracts.js";
import { redactChromeMcpProfileLabelForDiagnostic } from "./chrome-mcp-diagnostics.js";
import { buildChromeMcpSessionCacheKey } from "./chrome-mcp-options.js";
import {
buildChromeMcpSessionCacheKey,
cacheKeyMatchesProfileName,
normalizeChromeMcpOptions,
} from "./chrome-mcp-options.js";
import {
abortPendingChromeMcpSession,
createSharedPendingChromeMcpSession,
drainCancelledChromeMcpPendingSession,
forgetCachedChromeMcpSessionIfCurrent,
forgetPendingChromeMcpSessionIfCurrent,
waitForSharedPendingChromeMcpSession,
} from "./chrome-mcp-pending.js";
import {
closeTrackedChromeMcpSession,
drainRetainedChromeMcpCleanup,
cleanupTarget,
closeChromeMcpSessionHandle,
setChromeMcpProcessCleanupDepsForTest,
} from "./chrome-mcp-process.js";
import {
pendingChromeMcpSessions as pendingSessions,
retainedChromeMcpCleanupSessions as retainedCleanupSessions,
setChromeMcpProcessCleanupDeps,
setChromeMcpSessionFactory,
chromeMcpSessions as sessions,
} from "./chrome-mcp-state.js";
import { BrowserProfileUnavailableError } from "./errors.js";
async function drainChromeMcpCleanupForKey(cacheKey: string): Promise<void> {
const pending = pendingSessions.get(cacheKey);
if (pending?.state.cancelled) {
await drainCancelledChromeMcpPendingSession(pending);
export { setChromeMcpProcessCleanupDepsForTest, setChromeMcpSessionFactoryForTest };
const owners = new Map<string, ChromeMcpSessionOwner>();
type PendingAttach = ReturnType<typeof createChromeMcpSession> & {
controller: AbortController;
waiters: number;
settled: boolean;
cancelled: boolean;
cleanupSettled: boolean;
session?: ChromeMcpSession;
};
class ChromeMcpSessionOwner {
private session?: ChromeMcpSession;
private pending?: PendingAttach;
private readonly retired = new Map<ChromeMcpSession, Promise<void> | undefined>();
private temporary = 0;
private admissions = 0;
constructor(
readonly profileName: string,
readonly options: NormalizedChromeMcpProfileOptions,
private readonly key: string,
) {}
private forgetIfEmpty(): void {
if (
!this.session &&
!this.pending &&
!this.retired.size &&
!this.temporary &&
!this.admissions &&
owners.get(this.key) === this
) {
owners.delete(this.key);
}
}
await drainRetainedChromeMcpCleanup(cacheKey);
}
function hasChromeMcpCleanupForKey(cacheKey: string): boolean {
return (
pendingSessions.get(cacheKey)?.state.cancelled === true ||
(retainedCleanupSessions.get(cacheKey)?.size ?? 0) > 0
);
}
isCurrent(session: ChromeMcpSession): boolean {
return this.session?.transport === session.transport;
}
async function closeChromeMcpSessionsForProfile(
profileName: string,
keepKey?: string,
): Promise<boolean> {
let closed = false;
let firstError: Error | undefined;
const keys = new Set([
...pendingSessions.keys(),
...sessions.keys(),
...retainedCleanupSessions.keys(),
]);
for (const key of keys) {
if (key === keepKey || !cacheKeyMatchesProfileName(key, profileName)) {
continue;
get pid(): number | null {
if (this.session) {
return this.session.transport.pid ?? null;
}
closed = true;
const pending = pendingSessions.get(key);
if (pending) {
abortPendingChromeMcpSession(pending, new Error("Chrome MCP profile session was replaced"));
try {
await drainCancelledChromeMcpPendingSession(pending);
} catch (err) {
firstError ??= toErrorObject(err, "Chrome MCP pending-session cleanup failed.");
continue;
}
const retained = this.retired.keys().next().value;
const target = retained?.processCleanup && cleanupTarget(retained.processCleanup);
return target?.root.pid ?? retained?.transport.pid ?? null;
}
close(session: ChromeMcpSession): Promise<void> {
if (this.session?.transport === session.transport) {
this.session = undefined;
}
if (session.processCleanup?.status === "closed") {
this.retired.delete(session);
this.forgetIfEmpty();
return Promise.resolve();
}
const existing = this.retired.get(session);
if (existing) {
return existing;
}
// Revoke sends and publish the exact handle before process discovery yields.
session.transport.send = async () => {
throw new Error("Chrome MCP session is closing");
};
owners.set(this.key, this);
const cleanup = closeChromeMcpSessionHandle(session)
.then(() => {
this.retired.delete(session);
})
.finally(() => {
if (this.retired.has(session)) {
this.retired.set(session, undefined);
}
this.forgetIfEmpty();
});
this.retired.set(session, cleanup);
void cleanup.catch(() => {});
return cleanup;
}
private cancel(pending: PendingAttach, reason?: unknown): void {
pending.cancelled = true;
if (!pending.settled) {
pending.controller.abort(
reason ?? new Error("Chrome MCP session attach no longer has active waiters"),
);
}
}
private async drainRetired(): Promise<void> {
const results = await Promise.allSettled(
[...this.retired.keys()].map((session) => this.close(session)),
);
const failed = results.find((result) => result.status === "rejected");
if (failed?.status === "rejected") {
throw failed.reason;
}
}
private async drainPending(pending: PendingAttach): Promise<void> {
const settled = pending.cleanupSettled;
try {
await drainRetainedChromeMcpCleanup(key);
} catch (err) {
firstError ??= toErrorObject(err, "Chrome MCP retained-session cleanup failed.");
continue;
}
const session = sessions.get(key);
if (session) {
sessions.delete(key);
try {
await closeTrackedChromeMcpSession(key, session);
} catch (err) {
firstError ??= toErrorObject(err, "Chrome MCP session cleanup failed.");
await pending.cleanup;
} catch (error) {
// Concurrent waiters observe the original failure; later admission retries its retained handle.
if (!settled) {
throw error;
}
await this.drainRetired();
}
if (this.pending === pending) {
this.pending = undefined;
}
}
if (firstError) {
throw firstError;
}
return closed;
}
async function getSession(
profileName: string,
profileOptions?: ChromeMcpOptionsInput,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<ChromeMcpSession> {
const options = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, options);
signal?.throwIfAborted();
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
if (hasChromeMcpCleanupForKey(cacheKey)) {
await drainChromeMcpCleanupForKey(cacheKey);
}
if (signal?.aborted) {
throw signal.reason ?? new Error("aborted");
async stop(): Promise<boolean> {
const active = Boolean(this.pending || this.session || this.retired.size);
if (this.pending) {
this.cancel(this.pending, new Error("Chrome MCP profile session was replaced"));
await this.drainPending(this.pending);
}
await this.drainRetired();
if (this.session) {
await this.close(this.session);
}
this.forgetIfEmpty();
return active;
}
let staleReadySessionRetries = 0;
for (;;) {
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
await closeTrackedChromeMcpSession(cacheKey, session);
session = undefined;
}
let pendingLease: PendingChromeMcpSessionLease | undefined;
let leasedPending: PendingChromeMcpSession | undefined;
const pending = pendingSessions.get(cacheKey);
if (pending?.state.cancelled) {
await drainCancelledChromeMcpPendingSession(pending);
continue;
}
if (pending) {
leasedPending = pending;
pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal);
session = pendingLease.session;
}
if (!session) {
const createdPending = createSharedPendingChromeMcpSession(cacheKey, profileName, options);
pendingSessions.set(cacheKey, createdPending);
leasedPending = createdPending;
pendingLease = await waitForSharedPendingChromeMcpSession(createdPending, signal);
session = pendingLease.session;
}
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
if (session.transport.pid === null) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
}
staleReadySessionRetries += 1;
if (staleReadySessionRetries > 1) {
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}". ` +
"The Chrome MCP subprocess exited before it became usable.",
);
}
continue;
}
return session;
} catch (err) {
if (signal?.aborted && pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
} else if (pendingLease && leasedPending && leasedPending.state.waiters > 1) {
await pendingLease.release(false);
pendingLease = undefined;
} else {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
private start(): PendingAttach {
const controller = new AbortController();
const creation = createChromeMcpSession(
this,
this.profileName,
this.options,
controller.signal,
);
const pending: PendingAttach = {
...creation,
controller,
waiters: 0,
settled: false,
cancelled: false,
cleanupSettled: false,
};
this.pending = pending;
owners.set(this.key, this);
pending.promise = creation.promise
.then(async (session) => {
pending.session = session;
if (this.pending === pending) {
this.session = session;
} else {
await closeTrackedChromeMcpSession(cacheKey, session);
await this.close(session);
}
return session;
})
.finally(() => {
pending.settled = true;
});
pending.cleanup = creation.cleanup.finally(() => {
pending.cleanupSettled = true;
});
void pending.promise.catch(() => {});
void pending.cleanup.catch(() => {});
return pending;
}
private async join(
pending: PendingAttach,
options: ChromeMcpCallOptions,
): Promise<ChromeMcpSession> {
pending.waiters++;
let released = false;
const release = async (close: boolean) => {
if (released) {
return;
}
released = true;
if (--pending.waiters !== 0) {
return;
}
if (!pending.settled) {
this.cancel(pending, options.signal?.reason);
await this.drainPending(pending);
} else if (close && pending.session) {
this.cancel(pending, options.signal?.reason);
await this.close(pending.session);
}
if (this.pending === pending) {
this.pending = undefined;
}
this.forgetIfEmpty();
};
let abortRelease: Promise<void> | undefined;
const abort = () => {
// Last-waiter cancellation publishes its cleanup barrier during the abort event.
abortRelease ??= release(true);
void abortRelease.catch(() => {});
};
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) {
abort();
}
try {
const session = await waitForChromeMcpPendingSession(pending.promise, options.signal);
await waitForChromeMcpReady(session, this.profileName, options.timeoutMs, options.signal);
return session;
} catch (error) {
await (abortRelease ?? release(options.signal?.aborted === true || pending.waiters <= 1));
throw error;
} finally {
options.signal?.removeEventListener("abort", abort);
await release(false);
}
}
async lease(options: ChromeMcpCallOptions): Promise<ChromeMcpSessionLease> {
this.admissions++;
// A caller arriving on a live session must not reconnect after queued cleanup overtakes it.
const admittedSession =
!this.pending && this.session?.transport.pid !== null ? this.session : undefined;
try {
if (!options.ephemeral) {
await stopOwners(this.profileName, this);
}
options.signal?.throwIfAborted();
return await this.acquire(options, admittedSession);
} finally {
this.admissions--;
this.forgetIfEmpty();
}
}
private async acquire(
options: ChromeMcpCallOptions,
admittedSession?: ChromeMcpSession,
): Promise<ChromeMcpSessionLease> {
for (let retry = 0; ; retry++) {
if (!admittedSession) {
if (this.pending?.cancelled) {
await this.drainPending(this.pending);
}
await this.drainRetired();
options.signal?.throwIfAborted();
if (this.session?.transport.pid === null) {
await this.close(this.session);
}
if (this.pending?.cancelled) {
continue;
}
}
throw err;
} finally {
await pendingLease?.release(false);
const temporary = Boolean(
!admittedSession && options.ephemeral && (this.pending || !this.session),
);
let session = admittedSession ?? (this.pending ? undefined : this.session);
if (temporary) {
this.temporary++;
const creation = createChromeMcpSession(
this,
this.profileName,
this.options,
options.signal,
);
try {
session = await creation.promise;
await waitForChromeMcpReady(session, this.profileName, options.timeoutMs, options.signal);
} catch (error) {
try {
await creation.cleanup;
if (session) {
await this.close(session);
}
} finally {
this.temporary--;
this.forgetIfEmpty();
}
throw error;
}
} else if (session) {
try {
await waitForChromeMcpReady(session, this.profileName, options.timeoutMs, options.signal);
} catch (error) {
if (!options.ephemeral || !options.signal?.aborted) {
await this.close(session);
}
throw error;
}
} else {
session = await this.join(this.pending ?? this.start(), options);
}
if (!admittedSession && !options.ephemeral && session.transport.pid === null) {
if (this.pending?.session === session) {
this.pending = undefined;
}
await this.close(session);
if (retry === 0) {
continue;
}
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${redactChromeMcpProfileLabelForDiagnostic(this.profileName)}". ` +
"The Chrome MCP subprocess exited before it became usable.",
);
}
return {
session,
temporary,
owner: this,
release: async () => {
if (temporary) {
try {
await this.close(session);
} finally {
this.temporary--;
this.forgetIfEmpty();
}
}
},
};
}
}
}
async function getExistingSession(
cacheKey: string,
export function getChromeMcpSessionOwner(
profileName: string,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<ChromeMcpSession | null> {
if (pendingSessions.has(cacheKey)) {
return null;
options: NormalizedChromeMcpProfileOptions,
): ChromeMcpSessionOwner {
const key = buildChromeMcpSessionCacheKey(profileName, options);
let owner = owners.get(key);
if (!owner) {
owner = new ChromeMcpSessionOwner(profileName, options, key);
owners.set(key, owner);
}
return owner;
}
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
await closeTrackedChromeMcpSession(cacheKey, session);
session = undefined;
async function stopOwners(profileName?: string, keep?: ChromeMcpSessionOwner): Promise<boolean> {
const results = await Promise.allSettled(
[...owners.values()]
.filter(
(owner) =>
owner !== keep && (profileName === undefined || owner.profileName === profileName),
)
.map((owner) => owner.stop()),
);
const failed = results.find((result) => result.status === "rejected");
if (failed?.status === "rejected") {
throw toErrorObject(failed.reason, "Chrome MCP session cleanup failed.");
}
return results.some((result) => result.status === "fulfilled" && result.value);
}
if (session) {
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
if (signal?.aborted) {
throw err;
}
if (forgetCachedChromeMcpSessionIfCurrent(cacheKey, session)) {
await closeTrackedChromeMcpSession(cacheKey, session);
}
throw err;
export function getChromeMcpPid(profileName: string): number | null {
for (const owner of owners.values()) {
if (owner.profileName === profileName && owner.pid !== null) {
return owner.pid;
}
}
return null;
}
async function createEphemeralSession(
profileName: string,
profileOptions?: ChromeMcpOptionsInput,
timeoutMs?: number,
signal?: AbortSignal,
): Promise<ChromeMcpSession> {
signal?.throwIfAborted();
const options = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, options);
const creation = createChromeMcpSession(cacheKey, profileName, options, signal);
let session: ChromeMcpSession | undefined;
try {
session = await creation.promise;
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
await creation.cleanup;
if (session) {
await closeTrackedChromeMcpSession(cacheKey, session);
}
throw err;
}
}
export async function leaseSession(
profileName: string,
profileOptions?: ChromeMcpOptionsInput,
options: ChromeMcpCallOptions = {},
): Promise<ChromeMcpSessionLease> {
options.signal?.throwIfAborted();
const normalizedProfileOptions = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, normalizedProfileOptions);
if (!options.ephemeral) {
return {
session: await getSession(
profileName,
normalizedProfileOptions,
options.timeoutMs,
options.signal,
),
cacheKey,
temporary: false,
};
}
if (hasChromeMcpCleanupForKey(cacheKey)) {
await drainChromeMcpCleanupForKey(cacheKey);
}
options.signal?.throwIfAborted();
// Status probes should avoid seeding the shared attach session cache, but they can safely
// reuse a real cached session if one already exists.
const existingSession = await getExistingSession(
cacheKey,
profileName,
options.timeoutMs,
options.signal,
);
if (existingSession) {
return {
session: existingSession,
cacheKey,
temporary: false,
};
}
return {
session: await createEphemeralSession(
profileName,
normalizedProfileOptions,
options.timeoutMs,
options.signal,
),
cacheKey,
temporary: true,
};
}
async function stopAllChromeMcpSessions(): Promise<void> {
const names = uniqueStrings(
[...pendingSessions.keys(), ...sessions.keys(), ...retainedCleanupSessions.keys()].map(
(key) => JSON.parse(key)[0] as string,
),
);
let firstError: Error | undefined;
for (const name of names) {
try {
await closeChromeMcpSession(name);
} catch (err) {
firstError ??= toErrorObject(err, "Chrome MCP shutdown failed.");
}
}
if (firstError) {
throw firstError;
}
}
export async function closeChromeMcpSession(profileName: string): Promise<boolean> {
return await closeChromeMcpSessionsForProfile(profileName);
return await stopOwners(profileName);
}
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
setChromeMcpSessionFactory(factory);
}
/** Replace process cleanup hooks for focused tests. */
export function setChromeMcpProcessCleanupDepsForTest(
deps: ChromeMcpProcessCleanupDeps | null,
): void {
setChromeMcpProcessCleanupDeps(deps);
}
/** Reset cached sessions and test hooks. */
export async function resetChromeMcpSessionsForTest(): Promise<void> {
setChromeMcpSessionFactory(null);
for (const pending of pendingSessions.values()) {
abortPendingChromeMcpSession(pending, new Error("Chrome MCP sessions reset for test"));
}
await Promise.allSettled(
[...pendingSessions.values()].map(drainCancelledChromeMcpPendingSession),
);
await stopAllChromeMcpSessions();
pendingSessions.clear();
setChromeMcpProcessCleanupDeps(null);
setChromeMcpSessionFactoryForTest(null);
await stopOwners();
setChromeMcpProcessCleanupDepsForTest(null);
}

View file

@ -1,31 +0,0 @@
// Process-local Chrome MCP session and cleanup ownership state.
import type {
ChromeMcpProcessCleanupDeps,
ChromeMcpSession,
ChromeMcpSessionFactory,
PendingChromeMcpSession,
} from "./chrome-mcp-contracts.js";
export const chromeMcpSessions = new Map<string, ChromeMcpSession>();
export const pendingChromeMcpSessions = new Map<string, PendingChromeMcpSession>();
export const retainedChromeMcpCleanupSessions = new Map<string, Set<ChromeMcpSession>>();
export const chromeMcpCleanupPromises = new WeakMap<ChromeMcpSession, Promise<void>>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
let processCleanupDeps: ChromeMcpProcessCleanupDeps | null = null;
export function getChromeMcpSessionFactory(): ChromeMcpSessionFactory | null {
return sessionFactory;
}
export function setChromeMcpSessionFactory(factory: ChromeMcpSessionFactory | null): void {
sessionFactory = factory;
}
export function getChromeMcpProcessCleanupDeps(): ChromeMcpProcessCleanupDeps | null {
return processCleanupDeps;
}
export function setChromeMcpProcessCleanupDeps(deps: ChromeMcpProcessCleanupDeps | null): void {
processCleanupDeps = deps;
}

View file

@ -17,8 +17,6 @@ import {
type ChromeMcpOperationOptions,
type ChromeMcpProfileOptions,
} from "./chrome-mcp-contracts.js";
import { cacheKeyMatchesProfileName } from "./chrome-mcp-options.js";
import { cleanupTarget } from "./chrome-mcp-process.js";
import { extractStructuredPages } from "./chrome-mcp-result.js";
import {
callTool,
@ -28,10 +26,6 @@ import {
registerChromeMcpTargets,
withChromeMcpLease,
} from "./chrome-mcp-routing.js";
import {
chromeMcpSessions as sessions,
retainedChromeMcpCleanupSessions as retainedCleanupSessions,
} from "./chrome-mcp-state.js";
import type { BrowserOpenResult, BrowserTab, BrowserTabOwnership } from "./client.types.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
@ -59,24 +53,6 @@ export async function ensureChromeMcpAvailable(
});
}
/** Return the cached Chrome MCP process pid for a profile, when present. */
export function getChromeMcpPid(profileName: string): number | null {
for (const [key, session] of sessions.entries()) {
if (cacheKeyMatchesProfileName(key, profileName)) {
return session.transport.pid ?? null;
}
}
for (const [key, retained] of retainedCleanupSessions) {
if (cacheKeyMatchesProfileName(key, profileName)) {
const session = retained.values().next().value;
const target = session?.processCleanup ? cleanupTarget(session.processCleanup) : undefined;
return target?.root.pid ?? session?.transport.pid ?? null;
}
}
return null;
}
/** Close every cached Chrome MCP session. */
async function readChromeMcpTabs(
profileName: string,
profileOptions?: string | ChromeMcpProfileOptions,

View file

@ -9,26 +9,21 @@ import { promisify } from "node:util";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { createChromeMcpSession } from "./chrome-mcp-connect.js";
import { buildChromeMcpSessionCacheKey } from "./chrome-mcp-options.js";
import type { ChromeMcpSession } from "./chrome-mcp-contracts.js";
import { parseChromeMcpUnixProcessListForTest } from "./chrome-mcp-process.js";
import {
closeTrackedChromeMcpSession,
parseChromeMcpUnixProcessListForTest,
} from "./chrome-mcp-process.js";
import { leaseSession } from "./chrome-mcp-session.js";
import {
chromeMcpCleanupPromises,
retainedChromeMcpCleanupSessions,
setChromeMcpProcessCleanupDeps,
setChromeMcpSessionFactory,
} from "./chrome-mcp-state.js";
getChromeMcpSessionOwner,
setChromeMcpProcessCleanupDepsForTest,
setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp-session.js";
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ child: () => ({ warn: vi.fn() }) }),
}));
afterEach(() => {
setChromeMcpProcessCleanupDeps(null);
setChromeMcpSessionFactory(null);
setChromeMcpProcessCleanupDepsForTest(null);
setChromeMcpSessionFactoryForTest(null);
vi.restoreAllMocks();
});
@ -85,7 +80,7 @@ async function createHeldStdioPeer({
const resources: { creation?: ReturnType<typeof createChromeMcpSession> } = {};
let disposing: Promise<void> | undefined;
const options = { command: process.execPath, args: [script] };
const cacheKey = buildChromeMcpSessionCacheKey("cleanup-fixture", options);
const owner = getChromeMcpSessionOwner("cleanup-fixture", options);
const dispose = () =>
(disposing ??= (async () => {
releaseCapture?.();
@ -94,16 +89,19 @@ async function createHeldStdioPeer({
for (const socket of sockets) {
socket.end("release\n");
}
let session: ChromeMcpSession | undefined;
if (resources.creation) {
const session = await resources.creation.promise;
await closeTrackedChromeMcpSession(cacheKey, session).catch(() => {});
session = await resources.creation.promise;
await owner.close(session).catch(() => {});
await resources.creation.cleanup;
await chromeMcpCleanupPromises.get(session)?.catch(() => {});
}
await childClosed;
await Promise.all(socketClosures);
// Test-only state can be discarded only after the fixture peers have closed.
retainedChromeMcpCleanupSessions.delete(cacheKey);
if (session) {
session.processCleanup = { status: "closed" };
await owner.close(session);
}
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
@ -162,14 +160,14 @@ if (!descendant) {
}
return spawned;
});
resources.creation = createChromeMcpSession(cacheKey, "cleanup-fixture", options);
resources.creation = createChromeMcpSession(owner, "cleanup-fixture", options);
const session = await resources.creation.promise;
if (!child) {
throw new Error("SDK did not spawn the fixture child");
}
return {
session,
cacheKey,
owner,
options,
events: connected,
exactChild: child,
@ -186,7 +184,7 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
"joins failed initialization and fences replacement (ephemeral=%s)",
async (ephemeral) => {
const fixture = await createHeldStdioPeer();
const { session, cacheKey, exactChild } = fixture;
const { session, owner, exactChild } = fixture;
try {
let settled = 0;
const readiness = session.ready.finally(() => {
@ -198,11 +196,7 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
expect(exactChild.exitCode).toBeNull();
expect(session.transport.pid).toBeNull();
const cleanup = Promise.all(
[
closeTrackedChromeMcpSession(cacheKey, session),
session.client.close(),
session.transport.close(),
].map((closing) =>
[owner.close(session), session.client.close(), session.transport.close()].map((closing) =>
closing.finally(() => {
settled += 1;
}),
@ -212,8 +206,10 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
const replacementFactory = vi.fn(async () => {
throw new Error("replacement admitted");
});
setChromeMcpSessionFactory(replacementFactory);
const replacement = leaseSession("cleanup-fixture", fixture.options, { ephemeral });
setChromeMcpSessionFactoryForTest(replacementFactory);
const replacement = getChromeMcpSessionOwner("cleanup-fixture", fixture.options).lease({
ephemeral,
});
const replacementResult = expect(replacement).rejects.toThrow("replacement admitted");
await setImmediate();
expect(
@ -245,7 +241,7 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
it("does not admit initialize after close interrupts the initial process snapshot", async () => {
const scanStarted = createDeferred<void>();
const releaseScan = createDeferred<void>();
setChromeMcpProcessCleanupDeps({
setChromeMcpProcessCleanupDepsForTest({
listProcesses: async () => {
const { stdout } = await promisify(childProcess.execFile)(
"ps",
@ -261,7 +257,7 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
const fixture = await createHeldStdioPeer({ releaseCapture: releaseScan.resolve });
try {
await Promise.race([scanStarted.promise, fixture.session.ready]);
const closing = closeTrackedChromeMcpSession(fixture.cacheKey, fixture.session);
const closing = fixture.owner.close(fixture.session);
void closing.catch(() => {});
releaseScan.resolve();
const rootControl = await fixture.waitFor("stdin-ended");
@ -280,7 +276,7 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
it("retains failed initial capture after the child exits with an untracked descendant", async () => {
const scanStarted = createDeferred<void>();
const releaseScan = createDeferred<void>();
setChromeMcpProcessCleanupDeps({
setChromeMcpProcessCleanupDepsForTest({
listProcesses: async () => {
scanStarted.resolve();
await releaseScan.promise;
@ -307,16 +303,16 @@ describe.skipIf(process.platform === "win32")("Chrome MCP SDK-initiated cleanup"
await expect(fixture.session.ready).rejects.toThrow(
"subprocess tree cleanup could not be verified",
);
await expect(closeTrackedChromeMcpSession(fixture.cacheKey, fixture.session)).rejects.toThrow(
await expect(fixture.owner.close(fixture.session)).rejects.toThrow(
"subprocess tree cleanup could not be verified",
);
const replacementFactory = vi.fn(async () => {
throw new Error("replacement admitted");
});
setChromeMcpSessionFactory(replacementFactory);
await expect(leaseSession("cleanup-fixture", fixture.options)).rejects.toThrow(
"subprocess tree cleanup could not be verified",
);
setChromeMcpSessionFactoryForTest(replacementFactory);
await expect(
getChromeMcpSessionOwner("cleanup-fixture", fixture.options).lease({}),
).rejects.toThrow("subprocess tree cleanup could not be verified");
expect(replacementFactory).not.toHaveBeenCalled();
} finally {
releaseScan.resolve();
@ -333,11 +329,11 @@ it.each([
args: [],
},
])("settles cleanup after $name fails without a child", async (options) => {
const cacheKey = buildChromeMcpSessionCacheKey("failed-spawn", options);
const creation = createChromeMcpSession(cacheKey, "failed-spawn", options);
const owner = getChromeMcpSessionOwner("failed-spawn", options);
const creation = createChromeMcpSession(owner, "failed-spawn", options);
const session = await creation.promise;
await expect(session.ready).rejects.toThrow();
await closeTrackedChromeMcpSession(cacheKey, session);
await owner.close(session);
await creation.cleanup;
expect(session.processCleanup?.status).toBe("closed");
expect(session.transport.pid).toBeNull();

View file

@ -8,6 +8,8 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeChromeMcpOptions } from "./chrome-mcp-options.js";
import { refreshChromeMcpCleanupProcess } from "./chrome-mcp-process.js";
import { getChromeMcpPid, getChromeMcpSessionOwner } from "./chrome-mcp-session.js";
import {
ChromeMcpDocumentUnavailableError,
clickChromeMcpCoords,
@ -971,51 +973,64 @@ describe("chrome MCP page parsing", () => {
expect(factoryCalls).toBe(1);
});
it("cancels queued admission before dispatch on abort or timeout", async () => {
const { promise: firstStarted, resolve: markFirstStarted } = createDeferred<void>();
const { promise: firstGate, resolve: releaseFirst } = createDeferred<void>();
let listCalls = 0;
const session = createPageSession({
pid: 138,
pages: [{ id: 1, url: "https://a.example" }],
onTool: async (call) => {
if (call.name !== "list_pages") {
it.each(["during admission", "while queued"])(
"cancels %s without interrupting shared work",
async (phase) => {
const { promise: firstStarted, resolve: markFirstStarted } = createDeferred<void>();
const { promise: firstGate, resolve: releaseFirst } = createDeferred<void>();
let listCalls = 0;
const session = createPageSession({
pid: 138,
pages: [{ id: 1, url: "https://a.example" }],
onTool: async (call) => {
if (call.name !== "list_pages") {
return undefined;
}
listCalls += 1;
if (listCalls === 1) {
markFirstStarted();
await firstGate;
}
return undefined;
}
listCalls += 1;
if (listCalls === 1) {
markFirstStarted();
await firstGate;
}
return undefined;
},
});
setChromeMcpSessionFactoryForTest(async () => session);
},
});
const factory = vi.fn(async () => session);
setChromeMcpSessionFactoryForTest(factory);
const first = listChromeMcpTabs("chrome-live");
await firstStarted;
const ctrl = new AbortController();
const aborted = listChromeMcpTabs("chrome-live", undefined, { signal: ctrl.signal });
const timedOut = listChromeMcpTabs("chrome-live", undefined, { timeoutMs: 20 });
const abortedExpectation = expect(aborted).rejects.toThrow(/queued caller cancelled/);
const timedOutExpectation = expect(timedOut).rejects.toThrow(
/timed out after 20ms while waiting/,
);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
ctrl.abort(new Error("queued caller cancelled"));
const first = listChromeMcpTabs("chrome-live");
await firstStarted;
const close = vi.spyOn(session.client, "close");
const ctrl = new AbortController();
const aborted = listChromeMcpTabs("chrome-live", undefined, { signal: ctrl.signal });
const timedOut = listChromeMcpTabs("chrome-live", undefined, { timeoutMs: 20 });
const abortedExpectation = expect(aborted).rejects.toThrow(/queued caller cancelled/);
const timedOutExpectation = expect(timedOut).rejects.toThrow(
/timed out after 20ms while waiting/,
);
if (phase === "while queued") {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
ctrl.abort(new Error("queued caller cancelled"));
await abortedExpectation;
await timedOutExpectation;
expect(listCalls).toBe(1);
releaseFirst();
await first;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(listCalls).toBe(1);
});
try {
await abortedExpectation;
await timedOutExpectation;
expect(listCalls).toBe(1);
expect(close).not.toHaveBeenCalled();
} finally {
releaseFirst();
await first;
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(listCalls).toBe(1);
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(1);
expect(factory).toHaveBeenCalledTimes(1);
},
);
it("wraps snapshot refs and rejects stale or cross-target refs before dispatch", async () => {
const clickedUids: unknown[] = [];
@ -1357,6 +1372,73 @@ describe("chrome MCP page parsing", () => {
},
);
it.each(["success", "failure"] as const)(
"keeps a replacement attached after a retired session's late census %s",
async (outcome) => {
const first = createFakeSession();
first.processCleanup = { status: "open" };
const second = createPageSession({
pid: 456,
pages: [{ id: 1, url: "https://example.com" }],
});
const factory = vi.fn().mockResolvedValueOnce(first).mockResolvedValue(second);
setChromeMcpSessionFactoryForTest(factory);
const original = await getChromeMcpSessionOwner(
"chrome-live",
normalizeChromeMcpOptions(),
).lease({});
const census = createDeferred<ReturnType<typeof processSnapshot>[]>();
let scans = 0;
let alive = true;
let lateCleanup: Promise<void> | undefined;
let replacement: typeof original | undefined;
setChromeMcpProcessCleanupDepsForTest({
platform: "linux",
listProcesses: async () => {
if (++scans === 2) {
return await census.promise;
}
return alive ? [processSnapshot(123, 1)] : [];
},
});
first.client.close = vi.fn(async () => {
// Model a tools/list reply arriving while SDK close waits for its child to exit.
lateCleanup ??= refreshChromeMcpCleanupProcess(first)
.catch(() => {})
.then(() => original.owner.close(first));
alive = false;
(first.transport as { pid: number | null }).pid = null;
}) as typeof first.client.close;
try {
await closeChromeMcpSession("chrome-live");
expect(first.processCleanup?.status).toBe("closed");
replacement = await getChromeMcpSessionOwner(
"chrome-live",
normalizeChromeMcpOptions(),
).lease({});
expect(getChromeMcpPid("chrome-live")).toBe(456);
if (outcome === "failure") {
census.reject(new Error("late process census failed"));
} else {
census.resolve([processSnapshot(123, 1)]);
}
await lateCleanup;
expect(first.processCleanup?.status).toBe("closed");
expect(getChromeMcpPid("chrome-live")).toBe(456);
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(1);
expect(factory).toHaveBeenCalledTimes(2);
} finally {
census.resolve([]);
await lateCleanup?.catch(() => {});
first.processCleanup = { status: "closed" };
await original.owner.close(first);
if (replacement) {
await replacement.owner.close(replacement.session);
}
}
},
);
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
const session = createFakeSession();
Object.assign(session, { processCleanup: { status: "open" } });

View file

@ -5,6 +5,7 @@ export { decodeChromeMcpStderrTail } from "./chrome-mcp-diagnostics.js";
export { parseChromeMcpUnixProcessListForTest } from "./chrome-mcp-process.js";
export {
closeChromeMcpSession,
getChromeMcpPid,
resetChromeMcpSessionsForTest,
setChromeMcpProcessCleanupDepsForTest,
setChromeMcpSessionFactoryForTest,
@ -12,7 +13,6 @@ export {
export {
countChromeMcpTabs,
ensureChromeMcpAvailable,
getChromeMcpPid,
listChromeMcpTabs,
openChromeMcpTab,
} from "./chrome-mcp-tabs.js";

View file

@ -118,6 +118,25 @@ afterEach(() => {
});
describe("Chrome CDP diagnostic transport", () => {
it("diagnoses stale command channels with the discovered WebSocket URL", async () => {
const fixture = await startCdpFixture({ hold: "command" });
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const probing = diagnoseChromeCdp(fixture.url, 1_000, 1_000);
await fixture.reached.command.promise;
await vi.advanceTimersByTimeAsync(1_100);
await expect(probing).resolves.toMatchObject({
ok: false,
code: "websocket_health_command_timeout",
wsUrl: `${fixture.url.replace("http:", "ws:")}/devtools/browser/test`,
});
await fixture.disconnected.command.promise;
} finally {
await fixture.close();
}
});
it("gives the health command its full timeout after a delayed handshake", async () => {
const fixture = await startCdpFixture({ hold: "handshake", holdCommand: true });
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });

View file

@ -4,13 +4,12 @@
* Probes /json/version and WebSocket health, redacts sensitive endpoint data,
* and formats status output for browser doctor/status flows.
*/
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { redactSensitiveText } from "../logging/redact.js";
import { CHROME_REACHABILITY_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS } from "./cdp-timeouts.js";
import { CdpSocketError } from "./cdp-websocket.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
@ -18,9 +17,9 @@ import {
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
openCdpWebSocket,
redactCdpUrl,
scopeCdpPolicyToConfiguredEndpoint,
withCdpSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
@ -82,23 +81,6 @@ export function safeChromeCdpErrorMessage(error: unknown): string {
return redactSensitiveText(message || "unknown error");
}
function failureDiagnostic(params: {
cdpUrl: string;
code: ChromeCdpDiagnosticCode;
message: string;
startedAt: number;
wsUrl?: string;
}): ChromeCdpDiagnostic {
return {
ok: false,
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
code: params.code,
message: redactSensitiveText(params.message),
elapsedMs: elapsedSince(params.startedAt),
};
}
/** Read and validate Chrome's /json/version endpoint. */
async function readChromeVersion(
cdpUrl: string,
@ -134,32 +116,26 @@ export async function readChromeVersionWithCredentialFallback(
ssrfPolicy?: SsrFPolicy,
signal?: AbortSignal,
): Promise<ChromeVersion> {
let primaryVersion: ChromeVersion | undefined;
let primaryError: unknown;
try {
const primaryVersion = await readChromeVersion(
cdpUrl,
timeoutMs,
ssrfPolicy,
undefined,
signal,
);
primaryVersion = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, undefined, signal);
signal?.throwIfAborted();
if (normalizeOptionalString(primaryVersion.webSocketDebuggerUrl)) {
return primaryVersion;
}
try {
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/", signal);
} catch {
signal?.throwIfAborted();
} catch (error) {
signal?.throwIfAborted();
primaryError = error;
}
try {
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/", signal);
} catch {
signal?.throwIfAborted();
if (primaryVersion) {
return primaryVersion;
}
} catch (primaryError) {
signal?.throwIfAborted();
try {
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/", signal);
} catch {
signal?.throwIfAborted();
throw primaryError;
}
throw primaryError;
}
}
@ -201,122 +177,61 @@ async function diagnoseCdpHealthCommand(
): Promise<CdpHealthDiagnostic> {
signal?.throwIfAborted();
const timeout = normalizeBrowserTimerDelayMs(timeoutMs);
return await new Promise<CdpHealthDiagnostic>((resolve, reject) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeout,
lookup,
});
let settled = false;
let opened = false;
const onMessage = (raw: Parameters<typeof rawDataToString>[0]) => {
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {
return;
}
if (parsed?.id !== 1) {
return;
}
if (parsed.result && typeof parsed.result === "object") {
finish({ ok: true, version: chromeVersionFromCdpResult(parsed.result) });
return;
}
finish({
ok: false,
code: "websocket_health_command_failed",
message: "Browser.getVersion returned no result object",
});
};
const cleanup = () => {
if (settled) {
return false;
}
settled = true;
clearTimeout(timer);
ws.off("message", onMessage);
ws.off("open", onOpen);
signal?.removeEventListener("abort", onAbort);
return true;
};
const finish = (value: CdpHealthDiagnostic) => {
if (!cleanup()) {
return;
}
ws.close();
resolve(value);
};
const onAbort = () => {
if (!cleanup()) {
return;
}
ws.terminate();
reject(toStringifiedError(signal?.reason));
};
const onTimeout = () => {
ws.terminate();
finish({
ok: false,
code: opened ? "websocket_health_command_timeout" : "websocket_handshake_failed",
message: opened
? `Browser.getVersion did not respond within ${timeout}ms`
: `WebSocket handshake did not complete within ${timeout}ms`,
});
};
const timerDelayMs = normalizeBrowserTimerDelayMs(timeout + Math.min(25, timeout));
let timer = setTimeout(onTimeout, timerDelayMs);
const onOpen = () => {
opened = true;
// The health command gets its own budget after the upgrade completes.
clearTimeout(timer);
timer = setTimeout(onTimeout, timerDelayMs);
try {
ws.send(
JSON.stringify({
id: 1,
method: "Browser.getVersion",
}),
);
} catch (err) {
finish({
const timerDelayMs = normalizeBrowserTimerDelayMs(timeout + Math.min(25, timeout));
const handshake = new AbortController();
const timer = setTimeout(
() => handshake.abort(new Error(`WebSocket handshake did not complete within ${timeout}ms`)),
timerDelayMs,
);
let opened = false;
try {
const result = await withCdpSocket(
wsUrl,
async (send) => {
opened = true;
clearTimeout(timer);
return await send("Browser.getVersion");
},
{
handshakeTimeoutMs: timeout,
commandTimeoutMs: timerDelayMs,
handshakeRetries: 0,
lookup,
signal: signal ? AbortSignal.any([signal, handshake.signal]) : handshake.signal,
abortScope: "operation",
},
);
return result && typeof result === "object"
? { ok: true, version: chromeVersionFromCdpResult(result) }
: {
ok: false,
code: "websocket_health_command_failed",
message: safeChromeCdpErrorMessage(err),
});
}
message: "Browser.getVersion returned no result object",
};
} catch (error) {
signal?.throwIfAborted();
const kind = error instanceof CdpSocketError ? error.kind : undefined;
return {
ok: false,
code: !opened
? "websocket_handshake_failed"
: kind === "timeout"
? "websocket_health_command_timeout"
: "websocket_health_command_failed",
message:
kind === "timeout"
? `Browser.getVersion did not respond within ${timeout}ms`
: kind === "closed"
? opened
? "WebSocket closed before Browser.getVersion completed"
: "WebSocket closed before handshake completed"
: kind === "protocol"
? "Browser.getVersion returned no result object"
: safeChromeCdpErrorMessage(error),
};
ws.once("open", onOpen);
ws.on("message", onMessage);
const onError = (err: Error) => {
finish({
ok: false,
code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed",
message: safeChromeCdpErrorMessage(err),
});
};
ws.once("error", onError);
ws.once("close", () => {
ws.off("error", onError);
finish({
ok: false,
code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed",
message: opened
? "WebSocket closed before Browser.getVersion completed"
: "WebSocket closed before handshake completed",
});
});
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
}
});
} finally {
clearTimeout(timer);
}
}
function classifyChromeVersionError(error: unknown): {
@ -372,44 +287,6 @@ function isLikelyEmptyHttpReply(message: string): boolean {
);
}
async function diagnoseCdpWebSocketEndpoint(params: {
cdpUrl: string;
wsUrl: string;
startedAt: number;
handshakeTimeoutMs: number;
lookup?: ChromeCdpEndpointPin["lookup"];
version?: ChromeVersion;
signal?: AbortSignal;
}): Promise<ChromeCdpDiagnostic> {
const health = await diagnoseCdpHealthCommand(
params.wsUrl,
params.handshakeTimeoutMs,
params.lookup,
params.signal,
).catch((error: unknown) => {
params.signal?.throwIfAborted();
throw error;
});
params.signal?.throwIfAborted();
if (!health.ok) {
return failureDiagnostic({
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
code: health.code,
message: health.message,
startedAt: params.startedAt,
});
}
return {
ok: true,
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
browser: params.version?.Browser ?? health.version?.Browser,
userAgent: params.version?.["User-Agent"] ?? health.version?.["User-Agent"],
elapsedMs: elapsedSince(params.startedAt),
};
}
/** Run HTTP and WebSocket health diagnostics for a Chrome CDP endpoint. */
export async function diagnoseChromeCdp(
cdpUrl: string,
@ -420,35 +297,57 @@ export async function diagnoseChromeCdp(
): Promise<ChromeCdpDiagnostic> {
signal?.throwIfAborted();
const startedAt = Date.now();
const failure = (
code: ChromeCdpDiagnosticCode,
message: string,
wsUrl?: string,
): ChromeCdpDiagnostic => ({
ok: false,
cdpUrl,
wsUrl,
code,
message: redactSensitiveText(message),
elapsedMs: elapsedSince(startedAt),
});
const diagnoseEndpoint = async (
wsUrl: string,
lookup?: ChromeCdpEndpointPin["lookup"],
version?: ChromeVersion,
): Promise<ChromeCdpDiagnostic> => {
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs, lookup, signal).catch(
(error: unknown) => {
signal?.throwIfAborted();
throw error;
},
);
signal?.throwIfAborted();
return health.ok
? {
ok: true,
cdpUrl,
wsUrl,
browser: version?.Browser ?? health.version?.Browser,
userAgent: version?.["User-Agent"] ?? health.version?.["User-Agent"],
elapsedMs: elapsedSince(startedAt),
}
: failure(health.code, health.message, wsUrl);
};
let configuredPin: ChromeCdpEndpointPin | undefined;
try {
configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
} catch (err) {
signal?.throwIfAborted();
return failureDiagnostic({
cdpUrl,
code: "ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
return failure("ssrf_blocked", safeChromeCdpErrorMessage(err));
}
signal?.throwIfAborted();
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy);
const webSocket = isWebSocketUrl(cdpUrl);
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
signal,
});
return await diagnoseEndpoint(cdpUrl, configuredPin?.lookup);
}
const discoveryUrl = isWebSocketUrl(cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)
: cdpUrl;
const discoveryUrl = webSocket ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) : cdpUrl;
let version: ChromeVersion;
try {
version = await readChromeVersionWithCredentialFallback(
@ -459,56 +358,29 @@ export async function diagnoseChromeCdp(
);
} catch (err) {
signal?.throwIfAborted();
if (isWebSocketUrl(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
signal,
});
if (webSocket) {
return await diagnoseEndpoint(cdpUrl, configuredPin?.lookup);
}
const classified = classifyChromeVersionError(err);
return failureDiagnostic({
cdpUrl,
code: classified.code,
message: classified.message,
startedAt,
});
return failure(classified.code, classified.message);
}
signal?.throwIfAborted();
const wsUrlRaw = normalizeOptionalString(version.webSocketDebuggerUrl) ?? "";
if (!wsUrlRaw) {
if (isWebSocketUrl(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
version,
signal,
});
if (webSocket) {
return await diagnoseEndpoint(cdpUrl, configuredPin?.lookup, version);
}
return failureDiagnostic({
cdpUrl,
code: "missing_websocket_debugger_url",
message: "CDP /json/version did not include webSocketDebuggerUrl",
startedAt,
});
return failure(
"missing_websocket_debugger_url",
"CDP /json/version did not include webSocketDebuggerUrl",
);
}
let wsUrl: string;
try {
wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
} catch (err) {
return failureDiagnostic({
cdpUrl,
code: "websocket_handshake_failed",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
return failure("websocket_handshake_failed", safeChromeCdpErrorMessage(err));
}
let discoveredPin: ChromeCdpEndpointPin | undefined;
try {
@ -518,34 +390,12 @@ export async function diagnoseChromeCdp(
});
} catch (err) {
signal?.throwIfAborted();
return failureDiagnostic({
cdpUrl,
wsUrl,
code: "websocket_ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
return failure("websocket_ssrf_blocked", safeChromeCdpErrorMessage(err), wsUrl);
}
const diagnostic = await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl,
startedAt,
handshakeTimeoutMs,
lookup: discoveredPin?.lookup,
version,
signal,
});
if (!diagnostic.ok && isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) {
const directDiagnostic = await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
version,
signal,
});
const diagnostic = await diagnoseEndpoint(wsUrl, discoveredPin?.lookup, version);
if (!diagnostic.ok && webSocket && wsUrl !== cdpUrl) {
const directDiagnostic = await diagnoseEndpoint(cdpUrl, configuredPin?.lookup, version);
if (directDiagnostic.ok) {
return directDiagnostic;
}

View file

@ -8,7 +8,6 @@ import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import { WebSocketServer } from "openclaw/plugin-sdk/websocket-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CHROME_STOP_PROBE_TIMEOUT_MS } from "./cdp-timeouts.js";
import * as cdpHelpers from "./cdp.helpers.js";
import { diagnoseChromeCdp, formatChromeCdpDiagnostic } from "./chrome.diagnostics.js";
import { parseBrowserMajorVersion } from "./chrome.executable-probe.js";
import { resolveGoogleChromeExecutableForPlatform } from "./chrome.executables.js";
@ -388,59 +387,6 @@ describe("browser chrome helpers", () => {
});
});
it("diagnoses stale websocket command channels with the discovered websocket URL", async () => {
// Real discovery and command exchange are covered above. Classify an already-open
// stale channel without letting native handshake scheduling choose another failure.
const baseUrl = "http://cdp-fixture.invalid";
const wsUrl = "ws://cdp-fixture.invalid/devtools/browser/stale-diagnostic";
const socket = Object.assign(new EventEmitter(), {
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
});
let probe: Promise<ChromeCdpDiagnostic> | undefined;
vi.useFakeTimers();
try {
const discovery = vi.spyOn(cdpHelpers, "fetchCdpChecked").mockResolvedValue({
response: jsonResponse({ webSocketDebuggerUrl: wsUrl }),
release: async () => {},
});
const openSocket = vi
.spyOn(cdpHelpers, "openCdpWebSocket")
.mockReturnValue(socket as unknown as ReturnType<typeof cdpHelpers.openCdpWebSocket>);
probe = diagnoseChromeCdp(baseUrl, 300, 50);
let settled = false;
const markSettled = () => {
settled = true;
};
void probe.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(0);
expect(discovery.mock.calls[0]?.[0]).toBe(`${baseUrl}/json/version`);
expect(openSocket).toHaveBeenCalledWith(
wsUrl,
expect.objectContaining({ handshakeTimeoutMs: 50 }),
);
socket.emit("open");
expect(socket.send).toHaveBeenCalledExactlyOnceWith(
JSON.stringify({ id: 1, method: "Browser.getVersion" }),
);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(100);
expect(settled).toBe(true);
const diagnostic = expectFailedChromeCdpDiagnostic(await probe);
expect(diagnostic.code).toBe("websocket_health_command_timeout");
expect(diagnostic.wsUrl).toBe(wsUrl);
expect(socket.terminate).toHaveBeenCalledOnce();
expect(socket.close).toHaveBeenCalledOnce();
expect(socket.listenerCount("message")).toBe(0);
} finally {
socket.emit("close");
await probe?.catch(() => undefined);
vi.useRealTimers();
}
});
it("formats diagnostics with redacted CDP credentials", () => {
const formatted = formatChromeCdpDiagnostic({
ok: false,

View file

@ -10,7 +10,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import {
getFileLockProcessStartTime,
isPidAlive,
@ -46,7 +45,6 @@ import {
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
openCdpWebSocket,
scopeCdpPolicyToConfiguredEndpoint,
withCdpSocket,
} from "./cdp.helpers.js";
@ -828,36 +826,17 @@ async function canOpenWebSocket(
lookup?: ChromeCdpEndpointPin["lookup"],
signal?: AbortSignal,
): Promise<boolean> {
signal?.throwIfAborted();
return new Promise<boolean>((resolve, reject) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs, lookup });
let settled = false;
const finish = (ready: boolean) => {
if (settled) {
return;
}
settled = true;
signal?.removeEventListener("abort", onAbort);
ws.close();
resolve(ready);
};
const onAbort = () => {
if (settled) {
return;
}
settled = true;
signal?.removeEventListener("abort", onAbort);
ws.terminate();
reject(toStringifiedError(signal?.reason));
};
ws.once("open", () => finish(true));
ws.once("error", () => finish(false));
ws.once("close", () => finish(false));
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
}
});
try {
return await withCdpSocket(url, async () => true, {
handshakeTimeoutMs: timeoutMs,
handshakeRetries: 0,
lookup,
signal,
});
} catch {
signal?.throwIfAborted();
return false;
}
}
/** Return true when a Chrome CDP endpoint is reachable over HTTP. */

View file

@ -21,8 +21,12 @@ import type {
BrowserBatchAbort,
BrowserBatchActionResult,
} from "./client-actions-types.js";
import { postBrowserJson } from "./client-actions-url.js";
import type { BrowserActRequest } from "./client-actions.types.js";
import {
browserClientTimeout,
postBrowserJson,
type BrowserClientTarget,
} from "./client-request.js";
import {
DEFAULT_BROWSER_DOWNLOAD_TIMEOUT_MS,
DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS,
@ -55,7 +59,7 @@ function resolveBrowserOperationRequestTimeoutMs(timeoutMs: unknown): number {
/** Navigate a browser tab through the control server. */
export async function browserNavigate(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
url: string;
targetId?: string;
@ -76,7 +80,7 @@ export async function browserNavigate(
/** Arm a one-shot browser dialog handler. */
export async function browserArmDialog(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
accept: boolean;
promptText?: string;
@ -97,14 +101,18 @@ export async function browserArmDialog(
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
},
resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
browserClientTimeout(
baseUrl,
undefined,
resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
),
opts,
);
}
/** Arm or execute a browser file chooser upload. */
export async function browserArmFileChooser(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
paths: string[];
ref?: string;
@ -127,14 +135,18 @@ export async function browserArmFileChooser(
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
},
resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
browserClientTimeout(
baseUrl,
undefined,
resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs),
),
opts,
);
}
/** Wait for the next managed browser download and save it under the guarded download root. */
export async function browserWaitForDownload(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
path?: string;
targetId?: string;
@ -158,7 +170,7 @@ export async function browserWaitForDownload(
/** Click a snapshot ref and save its download under the guarded download root. */
export async function browserDownload(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
ref: string;
path: string;
@ -184,7 +196,7 @@ export async function browserDownload(
/** Execute one normalized browser action request. */
export async function browserAct(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
req: BrowserActRequest,
opts?: { profile?: string; timeoutMs?: number; signal?: AbortSignal },
): Promise<BrowserActResponse> {
@ -199,7 +211,7 @@ export async function browserAct(
/** Capture a screenshot through the browser control server. */
export async function browserScreenshotAction(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
targetId?: string;
fullPage?: boolean;

View file

@ -5,48 +5,52 @@
* output without directly mutating page state.
*/
import type { BrowserActionPathResult } from "./client-actions-types.js";
import { postBrowserJson, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import {
browserClientTimeout,
postBrowserJson,
requestBrowserJson,
type BrowserClientTarget,
} from "./client-request.js";
import type {
BrowserConsoleMessage,
BrowserNetworkRequest,
BrowserPageError,
} from "./pw-session.js";
function buildQuerySuffix(params: Array<[string, string | boolean | undefined]>): string {
const query = new URLSearchParams();
function buildQuery(params: Array<[string, string | boolean | undefined]>) {
const query: Record<string, string | boolean | undefined> = {};
for (const [key, value] of params) {
if (typeof value === "boolean") {
query.set(key, String(value));
query[key] = value;
continue;
}
if (typeof value === "string" && value.length > 0) {
query.set(key, value);
query[key] = value;
}
}
const encoded = query.toString();
return encoded.length > 0 ? `?${encoded}` : "";
return query;
}
/** Read browser console messages for a tab. */
export async function browserConsoleMessages(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: { level?: string; targetId?: string; profile?: string; signal?: AbortSignal } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string; url?: string }> {
const suffix = buildQuerySuffix([
const query = buildQuery([
["level", opts.level],
["targetId", opts.targetId],
["profile", opts.profile],
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/console${suffix}`), {
timeoutMs: 20000,
return await requestBrowserJson(baseUrl, "/console", {
query,
profile: opts.profile,
timeoutMs: browserClientTimeout(baseUrl, undefined, 20000),
signal: opts.signal,
});
}
/** Read the collected network request log for a tab. */
export async function browserRequests(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
filter?: string;
clear?: boolean;
@ -55,21 +59,22 @@ export async function browserRequests(
signal?: AbortSignal;
} = {},
): Promise<{ ok: true; requests: BrowserNetworkRequest[]; targetId: string; url?: string }> {
const suffix = buildQuerySuffix([
const query = buildQuery([
["filter", opts.filter],
["clear", opts.clear],
["targetId", opts.targetId],
["profile", opts.profile],
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/requests${suffix}`), {
timeoutMs: 20000,
return await requestBrowserJson(baseUrl, "/requests", {
query,
profile: opts.profile,
timeoutMs: browserClientTimeout(baseUrl, undefined, 20000),
signal: opts.signal,
});
}
/** Read the collected page error log for a tab. */
export async function browserErrors(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
clear?: boolean;
targetId?: string;
@ -77,20 +82,21 @@ export async function browserErrors(
signal?: AbortSignal;
} = {},
): Promise<{ ok: true; errors: BrowserPageError[]; targetId: string; url?: string }> {
const suffix = buildQuerySuffix([
const query = buildQuery([
["clear", opts.clear],
["targetId", opts.targetId],
["profile", opts.profile],
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/errors${suffix}`), {
timeoutMs: 20000,
return await requestBrowserJson(baseUrl, "/errors", {
query,
profile: opts.profile,
timeoutMs: browserClientTimeout(baseUrl, undefined, 20000),
signal: opts.signal,
});
}
/** Read bounded visible text without executing page-supplied code. */
export async function browserPageText(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
targetId?: string;
selector?: string;
@ -99,21 +105,24 @@ export async function browserPageText(
signal?: AbortSignal;
},
): Promise<{ ok: true; targetId: string; url?: string; text: string; truncated: boolean }> {
const suffix = buildQuerySuffix([
["targetId", opts.targetId],
["selector", opts.selector],
["maxChars", String(opts.maxChars)],
["profile", opts.profile],
]);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/text${suffix}`), {
timeoutMs: 20000,
const query = {
...buildQuery([
["targetId", opts.targetId],
["selector", opts.selector],
]),
maxChars: opts.maxChars,
};
return await requestBrowserJson(baseUrl, "/text", {
query,
profile: opts.profile,
timeoutMs: browserClientTimeout(baseUrl, undefined, 20000),
signal: opts.signal,
});
}
/** Apply one of the browser control service's existing emulation settings. */
export async function browserEmulateSetting(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
setting: "device" | "media" | "timezone" | "locale";
body: Record<string, string | undefined>;
@ -121,13 +130,25 @@ export async function browserEmulateSetting(
signal?: AbortSignal;
},
): Promise<{ ok: true; targetId: string }> {
return await postBrowserJson(baseUrl, `/set/${opts.setting}`, opts.body, 20000, opts);
return await postBrowserJson(
baseUrl,
`/set/${opts.setting}`,
opts.body,
browserClientTimeout(baseUrl, undefined, 20000),
opts,
);
}
/** Save the current page as PDF through browser control. */
export async function browserPdfSave(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: { targetId?: string; profile?: string; signal?: AbortSignal } = {},
): Promise<BrowserActionPathResult> {
return await postBrowserJson(baseUrl, "/pdf", { targetId: opts.targetId }, 20000, opts);
return await postBrowserJson(
baseUrl,
"/pdf",
{ targetId: opts.targetId },
browserClientTimeout(baseUrl, undefined, 20000),
opts,
);
}

View file

@ -1,33 +0,0 @@
/**
* URL and JSON request construction for browser clients.
*/
import { fetchBrowserJson } from "./client-fetch.js";
/** Prefix a browser-control path with an optional base URL and profile query. */
export function withBaseUrl(baseUrl: string | undefined, path: string, profile?: string): string {
const profilePath = profile
? `${path}${path.includes("?") ? "&" : "?"}profile=${encodeURIComponent(profile)}`
: path;
const trimmed = baseUrl?.trim();
if (!trimmed) {
return profilePath;
}
return `${trimmed.replace(/\/$/, "")}${profilePath}`;
}
/** Send an explicit JSON payload without including transport options in the body. */
export async function postBrowserJson<T>(
baseUrl: string | undefined,
path: string,
body: object,
timeoutMs: number,
opts?: { profile?: string; signal?: AbortSignal },
): Promise<T> {
return await fetchBrowserJson<T>(withBaseUrl(baseUrl, path, opts?.profile), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
timeoutMs,
signal: opts?.signal,
});
}

View file

@ -0,0 +1,76 @@
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { fetchBrowserJson } from "./client-fetch.js";
type BrowserClientRequest = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
profile?: string;
timeoutMs?: number;
signal?: AbortSignal;
};
/** An explicit execution route; node routing and fallback stay with its caller. */
export type BrowserClientTarget =
| string
| ((request: BrowserClientRequest) => Promise<unknown>)
| undefined;
export function browserClientTimeout(
target: BrowserClientTarget,
requested: number | undefined,
localDefault: number,
): number | undefined {
// An omitted node timeout also belongs to its safe host-fallback contract.
return typeof target === "function" ? requested : resolveTimerTimeoutMs(requested, localDefault);
}
/** Send the same explicitly projected request through the selected transport. */
export async function requestBrowserJson<T>(
target: BrowserClientTarget,
path: string,
opts: Omit<BrowserClientRequest, "path" | "method"> & {
method?: BrowserClientRequest["method"];
} = {},
): Promise<T> {
if (typeof target === "function") {
// SAFETY: Node and local transports dispatch the same typed browser-control routes.
return (await target({ ...opts, method: opts.method ?? "GET", path })) as T;
}
const query = new URLSearchParams();
for (const [key, value] of Object.entries(opts.query ?? {})) {
if (value !== undefined) {
query.set(key, String(value));
}
}
const suffix = query.size ? `${path.includes("?") ? "&" : "?"}${query}` : "";
const profile = opts.profile
? `${suffix || path.includes("?") ? "&" : "?"}profile=${encodeURIComponent(opts.profile)}`
: "";
const baseUrl = target?.trim().replace(/\/$/, "") ?? "";
return await fetchBrowserJson<T>(`${baseUrl}${path}${suffix}${profile}`, {
method: opts.method,
...(opts.body === undefined
? {}
: { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }),
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
}
export function postBrowserJson<T>(
target: BrowserClientTarget,
path: string,
body: object,
timeoutMs: number | undefined,
opts?: { profile?: string; signal?: AbortSignal },
): Promise<T> {
return requestBrowserJson(target, path, {
method: "POST",
body,
timeoutMs,
profile: opts?.profile,
signal: opts?.signal,
});
}

View file

@ -447,12 +447,18 @@ describe("browser client", () => {
const urls = calls.map((call) => call.url);
expect(urls.some((url) => url.endsWith("/tabs"))).toBe(true);
expect(urls.some((url) => url.endsWith("/doctor"))).toBe(true);
expect(urls.some((url) => url.endsWith("/doctor?profile=openclaw&deep=true"))).toBe(true);
const status = calls.find((c) => c.url.endsWith("/"));
expect(status?.init?.timeoutMs).toBe(7_500);
const doctor = calls.find((c) => c.url.endsWith("/doctor"));
expect(doctor?.init?.timeoutMs).toBe(7_500);
const deepDoctor = calls.find((c) => c.url.endsWith("/doctor?profile=openclaw&deep=true"));
const deepDoctor = calls.find(({ url }) => {
const parsed = new URL(url);
return parsed.pathname === "/doctor" && parsed.searchParams.get("deep") === "true";
});
expect(Object.fromEntries(new URL(deepDoctor!.url).searchParams)).toEqual({
profile: "openclaw",
deep: "true",
});
expect(deepDoctor?.init?.timeoutMs).toBe(10_000);
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
expect(open?.init?.method).toBe("POST");

View file

@ -4,13 +4,14 @@
* Provides typed helpers for status, profile lifecycle, tabs, and snapshots
* over the browser-control transport.
*/
import {
clampPositiveTimerTimeoutMs,
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { clampPositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { postBrowserJson, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import {
browserClientTimeout,
postBrowserJson,
requestBrowserJson,
type BrowserClientTarget,
} from "./client-request.js";
import type {
BrowserOpenResult,
BrowserStatus,
@ -43,34 +44,29 @@ type BrowserClientProfileOptions = BrowserClientTimeoutOptions & {
profile?: string;
};
function resolveBrowserClientTimeoutMs(
opts: BrowserClientTimeoutOptions | undefined,
fallbackMs: number,
): number {
return resolveTimerTimeoutMs(opts?.timeoutMs, fallbackMs);
}
async function sendProfilePost(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
path: string,
opts: BrowserClientProfileOptions | undefined,
fallbackTimeoutMs: number,
): Promise<void> {
await fetchBrowserJson(withBaseUrl(baseUrl, path, opts?.profile), {
await requestBrowserJson(baseUrl, path, {
profile: opts?.profile,
method: "POST",
timeoutMs: resolveBrowserClientTimeoutMs(opts, fallbackTimeoutMs),
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, fallbackTimeoutMs),
signal: opts?.signal,
});
}
async function sendTabCloseRequest(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
path: string,
opts: BrowserClientProfileOptions | undefined,
): Promise<{ ok: true; targetId?: string }> {
return await fetchBrowserJson(withBaseUrl(baseUrl, path, opts?.profile), {
return await requestBrowserJson(baseUrl, path, {
profile: opts?.profile,
method: "DELETE",
timeoutMs: resolveBrowserClientTimeoutMs(opts, 5000),
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, 5000),
signal: opts?.signal,
});
}
@ -158,67 +154,65 @@ export type SnapshotResult =
/** Read browser-control status for the selected profile. */
export async function browserStatus(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: BrowserClientProfileOptions,
): Promise<BrowserStatus> {
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, "/", opts?.profile), {
timeoutMs: resolveBrowserClientTimeoutMs(opts, BROWSER_STATUS_REQUEST_TIMEOUT_MS),
return await requestBrowserJson<BrowserStatus>(baseUrl, "/", {
profile: opts?.profile,
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, BROWSER_STATUS_REQUEST_TIMEOUT_MS),
signal: opts?.signal,
});
}
/** Run browser doctor checks for the selected profile. */
export async function browserDoctor(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: { profile?: string; deep?: boolean; signal?: AbortSignal },
): Promise<BrowserDoctorReport> {
const params = new URLSearchParams();
if (opts?.profile) {
params.set("profile", opts.profile);
}
if (opts?.deep) {
params.set("deep", "true");
}
const q = params.size ? `?${params.toString()}` : "";
return await fetchBrowserJson<BrowserDoctorReport>(withBaseUrl(baseUrl, `/doctor${q}`), {
timeoutMs: opts?.deep
? BROWSER_DEEP_DOCTOR_REQUEST_TIMEOUT_MS
: BROWSER_DOCTOR_REQUEST_TIMEOUT_MS,
return await requestBrowserJson(baseUrl, "/doctor", {
profile: opts?.profile,
query: opts?.deep ? { deep: "true" } : undefined,
timeoutMs: browserClientTimeout(
baseUrl,
undefined,
opts?.deep ? BROWSER_DEEP_DOCTOR_REQUEST_TIMEOUT_MS : BROWSER_DOCTOR_REQUEST_TIMEOUT_MS,
),
signal: opts?.signal,
});
}
/** List configured browser profiles and their current status. */
export async function browserProfiles(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: BrowserClientTimeoutOptions,
): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000),
signal: opts?.signal,
},
);
const res = await requestBrowserJson<{ profiles: ProfileStatus[] }>(baseUrl, "/profiles", {
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, 3000),
signal: opts?.signal,
});
return res.profiles ?? [];
}
/** List Chrome-family profiles available on the local macOS host. */
export async function browserSystemProfiles(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: { browser?: string; timeoutMs?: number; signal?: AbortSignal },
): Promise<SystemProfileInfo[]> {
const query = opts?.browser ? `?browser=${encodeURIComponent(opts.browser)}` : "";
const res = await fetchBrowserJson<{ systemProfiles: SystemProfileInfo[] }>(
withBaseUrl(baseUrl, `/system-profiles${query}`),
{ timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000), signal: opts?.signal },
const res = await requestBrowserJson<{ systemProfiles: SystemProfileInfo[] }>(
baseUrl,
"/system-profiles",
{
query: opts?.browser ? { browser: opts.browser } : undefined,
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, 3000),
signal: opts?.signal,
},
);
return res.systemProfiles ?? [];
}
/** Import system-profile cookies into a managed browser profile. */
export async function browserImportProfile(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
browser?: string;
systemProfile?: string;
@ -243,7 +237,7 @@ export async function browserImportProfile(
/** Start the selected browser profile. */
export async function browserStart(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: BrowserClientProfileOptions,
): Promise<void> {
await sendProfilePost(baseUrl, "/start", opts, 15000);
@ -251,7 +245,7 @@ export async function browserStart(
/** Stop the selected browser profile. */
export async function browserStop(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: BrowserClientProfileOptions,
): Promise<void> {
await sendProfilePost(baseUrl, "/stop", opts, 15000);
@ -259,16 +253,14 @@ export async function browserStop(
/** Reset the selected managed browser profile directory. */
export async function browserResetProfile(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
return await fetchBrowserJson<BrowserResetProfileResult>(
withBaseUrl(baseUrl, "/reset-profile", opts?.profile),
{
method: "POST",
timeoutMs: 20000,
},
);
return await requestBrowserJson<BrowserResetProfileResult>(baseUrl, "/reset-profile", {
profile: opts?.profile,
method: "POST",
timeoutMs: 20000,
});
}
/** Result returned after creating a browser profile. */
@ -285,7 +277,7 @@ export type BrowserCreateProfileResult = {
/** Create and persist a browser profile. */
export async function browserCreateProfile(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
name: string;
color?: string;
@ -317,11 +309,12 @@ export type BrowserDeleteProfileResult = {
/** Delete a configured browser profile. */
export async function browserDeleteProfile(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
return await requestBrowserJson<BrowserDeleteProfileResult>(
baseUrl,
`/profiles/${encodeURIComponent(profile)}`,
{
method: "DELETE",
timeoutMs: 20000,
@ -329,7 +322,7 @@ export async function browserDeleteProfile(
);
}
export function normalizeBrowserTabsResult(value: unknown): BrowserTabsResult {
function normalizeBrowserTabsResult(value: unknown): BrowserTabsResult {
const result = asNullableRecord(value);
if (result?.running === false) {
return { running: false, tabs: [] };
@ -341,22 +334,20 @@ export function normalizeBrowserTabsResult(value: unknown): BrowserTabsResult {
}
export async function browserTabs(
baseUrl?: string,
baseUrl?: BrowserClientTarget,
opts?: BrowserClientProfileOptions,
): Promise<BrowserTabsResult> {
const res = await fetchBrowserJson<BrowserTabsResult>(
withBaseUrl(baseUrl, "/tabs", opts?.profile),
{
timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000),
signal: opts?.signal,
},
);
const res = await requestBrowserJson<BrowserTabsResult>(baseUrl, "/tabs", {
profile: opts?.profile,
timeoutMs: browserClientTimeout(baseUrl, opts?.timeoutMs, 3000),
signal: opts?.signal,
});
return normalizeBrowserTabsResult(res);
}
/** Open a new tab in the selected browser profile. */
export async function browserOpenTab(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
url: string,
opts?: {
profile?: string;
@ -374,14 +365,14 @@ export async function browserOpenTab(
...(opts?.label ? { label: opts.label } : {}),
...(opts?.managedOnly ? { managedOnly: true } : {}),
},
resolveBrowserClientTimeoutMs(opts, 15000),
browserClientTimeout(baseUrl, opts?.timeoutMs, 15000),
opts,
);
}
/** Focus an existing browser tab. */
export async function browserFocusTab(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
targetId: string,
opts?: BrowserClientProfileOptions,
): Promise<{ ok: true; targetId?: string }> {
@ -389,14 +380,14 @@ export async function browserFocusTab(
baseUrl,
"/tabs/focus",
{ targetId },
resolveBrowserClientTimeoutMs(opts, 5000),
browserClientTimeout(baseUrl, opts?.timeoutMs, 5000),
opts,
);
}
/** Close an existing browser tab. */
export async function browserCloseTab(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
targetId: string,
opts?: BrowserClientProfileOptions,
): Promise<{ ok: true; targetId?: string }> {
@ -406,7 +397,7 @@ export async function browserCloseTab(
/** Close a canonical raw target id selected by OpenClaw's internal tab bookkeeping. */
export async function browserCloseTabByRawTargetId(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
targetId: string,
opts?: BrowserClientProfileOptions,
): Promise<void> {
@ -416,7 +407,7 @@ export async function browserCloseTabByRawTargetId(
/** Execute legacy index-based tab actions. */
export async function browserTabAction(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
@ -434,7 +425,7 @@ export async function browserTabAction(
/** Capture an ARIA or AI snapshot for the selected tab. */
export async function browserSnapshot(
baseUrl: string | undefined,
baseUrl: BrowserClientTarget,
opts: {
format?: "aria" | "ai";
targetId?: string;
@ -454,53 +445,52 @@ export async function browserSnapshot(
signal?: AbortSignal;
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
const q: Record<string, string | number | boolean | undefined> = {};
if (opts.format) {
q.set("format", opts.format);
q.format = opts.format;
}
if (opts.targetId) {
q.set("targetId", opts.targetId);
q.targetId = opts.targetId;
}
if (typeof opts.limit === "number") {
q.set("limit", String(opts.limit));
q.limit = opts.limit;
}
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
q.maxChars = opts.maxChars;
}
if (opts.refs === "aria" || opts.refs === "role") {
q.set("refs", opts.refs);
q.refs = opts.refs;
}
if (typeof opts.interactive === "boolean") {
q.set("interactive", String(opts.interactive));
q.interactive = opts.interactive;
}
if (typeof opts.compact === "boolean") {
q.set("compact", String(opts.compact));
q.compact = opts.compact;
}
if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) {
q.set("depth", String(opts.depth));
q.depth = opts.depth;
}
if (opts.selector?.trim()) {
q.set("selector", opts.selector.trim());
q.selector = opts.selector.trim();
}
if (opts.frame?.trim()) {
q.set("frame", opts.frame.trim());
q.frame = opts.frame.trim();
}
if (opts.labels === true) {
q.set("labels", "1");
q.labels = "1";
}
if (opts.urls === true) {
q.set("urls", "1");
q.urls = "1";
}
if (opts.mode) {
q.set("mode", opts.mode);
}
if (opts.profile) {
q.set("profile", opts.profile);
q.mode = opts.mode;
}
const resolvedTimeoutMs =
clampPositiveTimerTimeoutMs(opts.timeoutMs) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS;
q.set("timeoutMs", String(resolvedTimeoutMs));
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
q.timeoutMs = resolvedTimeoutMs;
return await requestBrowserJson<SnapshotResult>(baseUrl, "/snapshot", {
query: q,
profile: opts.profile,
timeoutMs: resolvedTimeoutMs,
signal: opts.signal,
});

View file

@ -190,36 +190,19 @@ async function tryTerminateExecutionViaCdp(opts: {
});
const needsAttach = cdpSocketNeedsAttach(wsUrl);
const runWithTimeout = async <T>(work: Promise<T>, ms: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("CDP command timed out")), ms);
});
try {
return await Promise.race([work, timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
};
await withCdpSocket(
wsUrl,
async (send) => {
let sessionId: string | undefined;
try {
if (needsAttach) {
const attached = (await runWithTimeout(
send("Target.attachToTarget", { targetId: opts.targetId, flatten: true }),
1500,
)) as { sessionId?: unknown };
const attachedSessionId = normalizeOptionalString(attached?.sessionId);
if (attachedSessionId) {
sessionId = attachedSessionId;
}
const attached = (await send("Target.attachToTarget", {
targetId: opts.targetId,
flatten: true,
})) as { sessionId?: unknown };
sessionId = normalizeOptionalString(attached?.sessionId);
}
await runWithTimeout(send("Runtime.terminateExecution", undefined, sessionId), 1500);
await send("Runtime.terminateExecution", undefined, sessionId);
if (sessionId) {
// Best-effort cleanup; not required for termination to take effect.
void send("Target.detachFromTarget", { sessionId }).catch(() => {});
@ -228,30 +211,12 @@ async function tryTerminateExecutionViaCdp(opts: {
// Best-effort; ignore
}
},
{ handshakeTimeoutMs: 2000, ...(wsPin?.lookup ? { lookup: wsPin.lookup } : {}) },
{ handshakeTimeoutMs: 2000, commandTimeoutMs: 1500, lookup: wsPin?.lookup },
).catch(() => {});
}
/**
* Best-effort cancellation for stuck page operations.
*
* Playwright serializes CDP commands per page; a long-running or stuck operation (notably evaluate)
* can block all subsequent commands. We cannot safely "cancel" an individual command, and we do
* not want to close the actual Chromium tab. Instead, we disconnect Playwright's CDP connection
* so in-flight commands fail fast and the next request reconnects transparently.
*
* IMPORTANT: We CANNOT call Connection.close() because Playwright shares a single Connection
* across all objects (BrowserType, Browser, etc.). Closing it corrupts the entire Playwright
* instance, preventing reconnection.
*
* Instead we:
* 1. Retire the scoped cached or in-flight connection so the next call reconnects
* 2. Fire-and-forget browser.close() — it may hang but won't block us
* 3. The next connectBrowser() creates a completely new CDP WebSocket connection
*
* The old browser.close() eventually resolves when the in-browser evaluate timeout fires,
* or the old connection gets GC'd. Either way, it doesn't affect the fresh connection.
*/
// Closing Playwright's shared Connection would prevent later reconnects. Retire
// only this browser adapter, and let the next action establish a fresh CDP socket.
/** Force-disconnect a Playwright connection to unblock a stuck target operation. */
export async function forceDisconnectPlaywrightForTarget(opts: {
cdpUrl: string;

View file

@ -26,7 +26,6 @@ import {
blockedTargetsByCdpUrl,
cachedByCdpUrl,
closeConnectionPromises,
closedConnections,
connectingByCdpUrl,
contextStates,
observedContexts,
@ -200,9 +199,6 @@ function releaseClosingPlaywrightConnection(connection: ConnectedBrowser): void
export async function closeTrackedPlaywrightConnection(
connection: ConnectedBrowser,
): Promise<void> {
if (closedConnections.has(connection)) {
return;
}
const existing = closeConnectionPromises.get(connection);
if (existing) {
return await existing;
@ -211,10 +207,10 @@ export async function closeTrackedPlaywrightConnection(
const closing = (async () => {
try {
await connection.browser.close();
closedConnections.add(connection);
releaseClosingPlaywrightConnection(connection);
} finally {
} catch (error) {
closeConnectionPromises.delete(connection);
throw error;
}
})();
closeConnectionPromises.set(connection, closing);
@ -248,65 +244,44 @@ export function retirePlaywrightBrowserConnectionExact(opts: {
const normalized = normalizeCdpUrl(opts.cdpUrl);
clearBlockedTargetsForCdpUrl(normalized);
clearBlockedPageRefsForCdpUrl(normalized);
const connections = new Set<ConnectedBrowser>();
const closeAttempts = new Map<ConnectedBrowser, Promise<void>>();
const connections = new Map<ConnectedBrowser, Promise<void> | undefined>();
const pendingCollections = new Set<Promise<void>>();
let retired = false;
const startClosing = () => {
for (const connection of connections) {
if (closeAttempts.has(connection)) {
continue;
}
const closing = closeTrackedPlaywrightConnection(connection);
closeAttempts.set(connection, closing);
void closing.catch(() => {});
}
};
const awaitClosing = async () => {
const attempts = [...closeAttempts];
const results = await Promise.allSettled(attempts.map(([, closing]) => closing));
let firstError: Error | undefined;
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
const [connection, closing] = attempts[index] ?? [];
if (connection && closeAttempts.get(connection) === closing) {
closeAttempts.delete(connection);
}
firstError ??= toErrorObject(result.reason, "Playwright adapter disconnect failed.");
}
}
if (firstError) {
throw firstError;
const captureConnection = (connection: ConnectedBrowser) => {
const existing = connections.get(connection);
if (existing) {
return existing;
}
const closing = closeTrackedPlaywrightConnection(connection);
connections.set(connection, closing);
void closing.catch(() => {});
return closing;
};
const capture = () => {
const pending = connectingByCdpUrl.get(normalized);
const cached = takeCachedPlaywrightBrowserConnection(normalized);
for (const connection of retainedClosingByCdpUrl.get(normalized) ?? []) {
connections.add(connection);
void captureConnection(connection);
}
if (cached) {
connections.add(cached);
retainClosingPlaywrightConnection(cached);
void captureConnection(cached);
}
if (pending) {
const collection = pending.promise.then(
(connection) => {
connections.add(connection);
void captureConnection(connection);
},
() => {
if (pending.attempt.retired) {
connections.add(pending.attempt.retired);
void captureConnection(pending.attempt.retired);
}
},
);
pendingCollections.add(collection);
void collection.then(() => {
pendingCollections.delete(collection);
startClosing();
});
}
startClosing();
const captured = Boolean(pending || connections.size > 0);
retired ||= captured;
return captured;
@ -320,10 +295,28 @@ export function retirePlaywrightBrowserConnectionExact(opts: {
close: async () => {
await withPlaywrightCloseTimeout(
(async () => {
startClosing();
for (const connection of connections.keys()) {
void captureConnection(connection);
}
await Promise.all(pendingCollections);
startClosing();
await awaitClosing();
const attempts = [...connections.keys()].map(
(connection) => [connection, captureConnection(connection)] as const,
);
const results = await Promise.allSettled(attempts.map(([, closing]) => closing));
let firstError: Error | undefined;
for (const [index, result] of results.entries()) {
if (result.status !== "rejected") {
continue;
}
const [connection, closing] = attempts[index]!;
if (connections.get(connection) === closing) {
connections.set(connection, undefined);
}
firstError ??= toErrorObject(result.reason, "Playwright adapter disconnect failed.");
}
if (firstError) {
throw firstError;
}
})(),
);
},

View file

@ -186,7 +186,6 @@ export const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
export const connectingByCdpUrl = new Map<string, PendingBrowserConnection>();
export const retainedClosingByCdpUrl = new Map<string, Set<ConnectedBrowser>>();
export const closeConnectionPromises = new WeakMap<ConnectedBrowser, Promise<void>>();
export const closedConnections = new WeakSet<ConnectedBrowser>();
export const PLAYWRIGHT_CONNECTION_CLOSE_TIMEOUT_MS = 2_000;
export const blockedTargetsByCdpUrl = new Set<string>();
export const blockedPageRefsByCdpUrl = new Map<string, WeakSet<Page>>();

View file

@ -5,6 +5,40 @@ import { canonicalizeActTargetIds, normalizeActRequest } from "./agent.act.norma
const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;
it("projects nested actions without leaking caller control fields or dropping false and empty values", () => {
expect(
normalizeActRequest({
kind: "batch",
targetId: 123,
stopOnError: false,
signal: "caller-signal",
actions: [
{
kind: "click",
ref: " e1 ",
doubleClick: false,
delayMs: 0,
resolvedPage: { targetId: "other-page" },
assertCurrent: "caller-authority",
},
{ kind: "type", selector: " input ", text: "", submit: false, slowly: false },
{ kind: "select", ref: "e2", values: ["", " spaced "] },
{ kind: "close", timeoutMs: "ignored-for-close" },
],
}),
).toStrictEqual({
kind: "batch",
targetId: "123",
stopOnError: false,
actions: [
{ kind: "click", ref: "e1", doubleClick: false, delayMs: 0 },
{ kind: "type", selector: "input", text: "", submit: false, slowly: false },
{ kind: "select", ref: "e2", values: ["", " spaced "] },
{ kind: "close" },
],
});
});
describe("canonicalizeActTargetIds", () => {
const canonical = "abcd1234";
const tab = { targetId: canonical, suggestedTargetId: "sg-1", tabId: "tab-7", label: "Inbox" };

View file

@ -117,6 +117,15 @@ function readResizeDimension(body: Record<string, unknown>, key: "width" | "heig
return value;
}
function definedAction<T extends BrowserActRequest>(action: T): T {
for (const key in action) {
if (action[key] === undefined) {
delete action[key];
}
}
return action;
}
/** Normalize one model/client action payload into a BrowserActRequest. */
export function normalizeActRequest(
body: Record<string, unknown>,
@ -128,6 +137,7 @@ export function normalizeActRequest(
if (!isActKind(kind)) {
throw new Error("kind is required");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
switch (kind) {
case "click": {
@ -154,18 +164,17 @@ export function normalizeActRequest(
ACT_MAX_CLICK_DELAY_MS,
);
const timeoutMs = readActionTimeoutMs(body);
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
return definedAction({
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
...(targetId ? { targetId } : {}),
...(doubleClick !== undefined ? { doubleClick } : {}),
...(button ? { button } : {}),
...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}),
...(delayMs !== undefined ? { delayMs } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
ref,
selector,
targetId,
doubleClick,
button,
modifiers: parsedModifiers.modifiers,
delayMs,
timeoutMs,
});
}
case "clickCoords": {
const x = readRouteFiniteNumber(body.x, "x");
@ -186,17 +195,7 @@ export function normalizeActRequest(
ACT_MAX_CLICK_DELAY_MS,
);
const timeoutMs = readActionTimeoutMs(body);
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
x,
y,
...(targetId ? { targetId } : {}),
...(doubleClick !== undefined ? { doubleClick } : {}),
...(button ? { button } : {}),
...(delayMs !== undefined ? { delayMs } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, x, y, targetId, doubleClick, button, delayMs, timeoutMs });
}
case "type": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -208,27 +207,16 @@ export function normalizeActRequest(
if (typeof text !== "string") {
throw new Error("type requires text");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const submit = toBoolean(body.submit);
const slowly = toBoolean(body.slowly);
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
text,
...(targetId ? { targetId } : {}),
...(submit !== undefined ? { submit } : {}),
...(slowly !== undefined ? { slowly } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, ref, selector, text, targetId, submit, slowly, timeoutMs });
}
case "insertText": {
if (typeof body.text !== "string") {
throw new Error("insertText requires text");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
return { kind, text: body.text, ...(targetId ? { targetId } : {}) };
return definedAction({ kind, text: body.text, targetId });
}
case "press": {
// Empty chord segments represent a literal plus key and must survive normalization.
@ -239,14 +227,8 @@ export function normalizeActRequest(
if (!key) {
throw new Error("press requires key");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const delayMs = readActionNonNegativeInteger(body, "delayMs");
return {
kind,
key,
...(targetId ? { targetId } : {}),
...(delayMs !== undefined ? { delayMs } : {}),
};
return definedAction({ kind, key, targetId, delayMs });
}
case "hover":
case "scrollIntoView": {
@ -255,15 +237,8 @@ export function normalizeActRequest(
if (!ref && !selector) {
throw new Error(`${kind} requires ref or selector`);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, ref, selector, targetId, timeoutMs });
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef) || undefined;
@ -276,17 +251,16 @@ export function normalizeActRequest(
if (!endRef && !endSelector) {
throw new Error("drag requires endRef or endSelector");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
return definedAction({
kind,
...(startRef ? { startRef } : {}),
...(startSelector ? { startSelector } : {}),
...(endRef ? { endRef } : {}),
...(endSelector ? { endSelector } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
startRef,
startSelector,
endRef,
endSelector,
targetId,
timeoutMs,
});
}
case "select": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -296,30 +270,16 @@ export function normalizeActRequest(
if ((!ref && !selector) || !values.length) {
throw new Error("select requires ref/selector and values");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
...(ref ? { ref } : {}),
...(selector ? { selector } : {}),
values,
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, ref, selector, values, targetId, timeoutMs });
}
case "fill": {
const fields = normalizeFields(body.fields);
if (!fields.length) {
throw new Error("fill requires fields");
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
fields,
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, fields, targetId, timeoutMs });
}
case "resize": {
const width = readResizeDimension(body, "width");
@ -330,13 +290,7 @@ export function normalizeActRequest(
if (width > ACT_MAX_VIEWPORT_DIMENSION || height > ACT_MAX_VIEWPORT_DIMENSION) {
throw new Error(`resize width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
width,
height,
...(targetId ? { targetId } : {}),
};
return definedAction({ kind, width, height, targetId });
}
case "wait": {
const loadStateRaw = toStringOrEmpty(body.loadState);
@ -362,20 +316,19 @@ export function normalizeActRequest(
"wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn",
);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
return definedAction({
kind,
...(timeMs !== undefined ? { timeMs } : {}),
...(text ? { text } : {}),
...(textGone ? { textGone } : {}),
...(selector ? { selector } : {}),
...(url ? { url } : {}),
...(loadState ? { loadState } : {}),
...(fn ? { fn } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
timeMs,
text,
textGone,
selector,
url,
loadState,
fn,
targetId,
timeoutMs,
});
}
case "evaluate": {
const fn = toStringOrEmpty(body.fn);
@ -383,22 +336,11 @@ export function normalizeActRequest(
throw new Error("evaluate requires fn");
}
const ref = toStringOrEmpty(body.ref) || undefined;
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timeoutMs = readActionTimeoutMs(body);
return {
kind,
fn,
...(ref ? { ref } : {}),
...(targetId ? { targetId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
return definedAction({ kind, fn, ref, targetId, timeoutMs });
}
case "close": {
const targetId = toStringOrEmpty(body.targetId) || undefined;
return {
kind,
...(targetId ? { targetId } : {}),
};
return definedAction({ kind, targetId });
}
case "batch": {
// Bound nesting before recursing: oversized bodies parse fine, but
@ -416,14 +358,8 @@ export function normalizeActRequest(
if (countBatchActions(actions) > ACT_MAX_BATCH_ACTIONS) {
throw new Error(`batch exceeds maximum of ${ACT_MAX_BATCH_ACTIONS} actions`);
}
const targetId = toStringOrEmpty(body.targetId) || undefined;
const stopOnError = toBoolean(body.stopOnError);
return {
kind,
actions,
...(targetId ? { targetId } : {}),
...(stopOnError !== undefined ? { stopOnError } : {}),
};
return definedAction({ kind, actions, targetId, stopOnError });
}
}
throw new Error("Unsupported browser act kind");

View file

@ -1,6 +1,7 @@
// Browser tests cover agent.existing session plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { saveMediaBuffer } from "../../media/store.js";
import type { ChromeMcpSnapshotNode } from "../chrome-mcp.snapshot.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import {
@ -218,6 +219,42 @@ describe("existing-session browser routes", () => {
expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalled();
});
it.each(["snapshot", "screenshot"])(
"clears %s labels after media persistence fails and its caller aborts",
async (operation) => {
const failure = new Error("media persistence failed");
const controller = new AbortController();
vi.mocked(saveMediaBuffer).mockImplementationOnce(async () => {
controller.abort();
throw failure;
});
const response = createBrowserRouteResponse();
const handler = operation === "snapshot" ? getSnapshotGetHandler() : getSnapshotPostHandler();
const request = handler?.(
{
params: {},
query: { format: "ai", labels: "1" },
body: { labels: true },
signal: controller.signal,
},
response.res,
);
if (operation === "snapshot") {
await request;
expect(response.body).toEqual({ error: failure.message });
} else {
await expect(request).rejects.toBe(failure);
expect(response.body).toBeUndefined();
}
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenLastCalledWith(
expect.objectContaining({
signal: undefined,
fn: expect.stringContaining("node.remove()"),
}),
);
},
);
it("omits deltas for existing-session snapshots without stable document identity", async () => {
chromeMcpMocks.takeChromeMcpSnapshot
.mockResolvedValueOnce({

View file

@ -35,6 +35,7 @@ import {
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import { getLoadedPwAiModule } from "../pw-ai-module.js";
import { finalizeRoleSnapshot, type RoleRefMap } from "../pw-role-snapshot.js";
import type { BrowserObservedState } from "../pw-session-contracts.js";
import type { AnnotationItem } from "../screenshot-annotate.js";
import { scaleAnnotations } from "../screenshot-annotate.js";
import {
@ -71,7 +72,7 @@ import {
} from "./agent.snapshot.plan.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { readRoutePositiveInteger, readRouteTimerTimeoutMs } from "./route-numeric.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, runProfileRouteOperation, toBoolean, toStringOrEmpty } from "./utils.js";
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
@ -205,40 +206,43 @@ async function renderChromeMcpLabels(
return { labels, skipped };
}
async function saveNormalizedScreenshotResponse(params: {
res: BrowserResponse;
type ScreenshotCapture = {
buffer: Buffer;
type: "png" | "jpeg";
targetId: string;
url: string;
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
labels?: number;
skipped?: number;
truncated?: boolean;
annotations?: AnnotationItem[];
}) {
const normalized = await normalizeBrowserScreenshot(params.buffer, {
};
async function saveBrowserMedia(buffer: Buffer, contentType: string, maxBytes: number) {
await ensureMediaDir();
const saved = await saveMediaBuffer(buffer, contentType, "browser", maxBytes);
return path.resolve(saved.path);
}
async function saveBrowserScreenshot(capture: ScreenshotCapture, type: "png" | "jpeg") {
const normalized = await normalizeBrowserScreenshot(capture.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
const annotations = await rescaleAnnotationsForNormalization({
annotations: params.annotations,
originalBuffer: params.buffer,
annotations: capture.annotations,
originalBuffer: capture.buffer,
normalized,
});
await saveBrowserMediaResponse({
res: params.res,
buffer: normalized.buffer,
contentType: normalized.contentType ?? `image/${params.type}`,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
targetId: params.targetId,
url: params.url,
labels: params.labels,
labelsCount: params.labelsCount,
labelsSkipped: params.labelsSkipped,
truncated: params.truncated,
annotations,
});
const imagePath = await saveBrowserMedia(
normalized.buffer,
normalized.contentType ?? `image/${type}`,
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
return {
imagePath,
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : type,
...(typeof capture.labels === "number" ? { labels: true, labelsCount: capture.labels } : {}),
...(typeof capture.skipped === "number" ? { labelsSkipped: capture.skipped } : {}),
...(capture.truncated ? { truncated: true } : {}),
...(annotations?.length ? { annotations } : {}),
};
}
/**
@ -271,61 +275,6 @@ async function rescaleAnnotationsForNormalization(params: {
return scaleAnnotations(params.annotations, next.width / orig.width, next.height / orig.height);
}
async function saveBrowserMediaResponse(params: {
res: BrowserResponse;
buffer: Buffer;
contentType: string;
maxBytes: number;
targetId: string;
url: string;
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
truncated?: boolean;
annotations?: AnnotationItem[];
}) {
await ensureMediaDir();
const saved = await saveMediaBuffer(
params.buffer,
params.contentType,
"browser",
params.maxBytes,
);
params.res.json({
ok: true,
path: path.resolve(saved.path),
targetId: params.targetId,
url: params.url,
...(params.labels ? { labels: true } : {}),
...(typeof params.labelsCount === "number" ? { labelsCount: params.labelsCount } : {}),
...(typeof params.labelsSkipped === "number" ? { labelsSkipped: params.labelsSkipped } : {}),
...(params.truncated ? { truncated: true } : {}),
...(params.annotations && params.annotations.length > 0
? { annotations: params.annotations }
: {}),
});
}
function hasObservableBrowserState(state: unknown): boolean {
if (!state || typeof state !== "object") {
return false;
}
const dialogs = (state as { dialogs?: { pending?: unknown[]; recent?: unknown[] } }).dialogs;
return Boolean(dialogs?.pending?.length || dialogs?.recent?.length);
}
function hasPendingDialogs(state: unknown): boolean {
if (!state || typeof state !== "object") {
return false;
}
const dialogs = (state as { dialogs?: { pending?: unknown[] } }).dialogs;
return Boolean(dialogs?.pending?.length);
}
function browserStateResponseFields(state: unknown): { browserState?: unknown } {
return hasObservableBrowserState(state) ? { browserState: state } : {};
}
/** Register snapshot, screenshot, and navigation endpoints. */
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
@ -428,11 +377,14 @@ export function registerBrowserAgentSnapshotRoutes(
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
const pdfPath = await saveBrowserMedia(
pdf.buffer,
"application/pdf",
pdf.buffer.byteLength,
);
res.json({
ok: true,
path: pdfPath,
targetId: tab.targetId,
url: tab.url,
});
@ -470,6 +422,14 @@ export function registerBrowserAgentSnapshotRoutes(
targetId,
enforceCurrentUrlAllowed: true,
run: async ({ profileCtx, tab, cdpUrl, signal }) => {
const jsonScreenshot = async (capture: ScreenshotCapture) => {
const {
imagePath,
imageType: _imageType,
...details
} = await saveBrowserScreenshot(capture, type);
res.json({ ok: true, path: imagePath, targetId: tab.targetId, url: tab.url, ...details });
};
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const operation: ChromeMcpSnapshotOperation = {
profileName: profileCtx.profile.name,
@ -488,57 +448,38 @@ export function registerBrowserAgentSnapshotRoutes(
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
let labelResult: { labels: number; skipped: number } | undefined;
let truncated: boolean | undefined;
if (labels) {
const built = ref
? undefined
: buildChromeMcpRouteSnapshot({
root: await takeChromeMcpSnapshot(operation),
});
const labelResult = await renderChromeMcpLabels({
labelResult = await renderChromeMcpLabels({
...operation,
refs: ref ? [ref] : Object.keys(built?.refs ?? {}),
clipToRef: Boolean(ref),
});
try {
const buffer = await takeChromeMcpScreenshot({
...operation,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
truncated: built?.truncated,
});
} finally {
truncated = built?.truncated;
}
try {
const buffer = await takeChromeMcpScreenshot({
...operation,
uid: ref,
fullPage,
format: type,
});
await jsonScreenshot({ buffer, ...labelResult, truncated });
} finally {
if (labels) {
await clearChromeMcpOverlay(operation);
}
return;
}
const buffer = await takeChromeMcpScreenshot({
...operation,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
return;
}
let buffer: Buffer;
let capture: ScreenshotCapture;
const shouldUsePlaywright =
labels ||
getLoadedPwAiModule()?.hasCachedPlaywrightBrowserConnection(cdpUrl) ||
@ -559,7 +500,7 @@ export function registerBrowserAgentSnapshotRoutes(
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
const labeled = await pw.screenshotWithLabelsViaPlaywright({
capture = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
refs: snap.refs,
@ -570,52 +511,36 @@ export function registerBrowserAgentSnapshotRoutes(
element,
signal,
});
await saveNormalizedScreenshotResponse({
res,
buffer: labeled.buffer,
type,
} else {
capture = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
annotations: labeled.annotations,
ref,
element,
fullPage,
type,
timeoutMs,
signal,
});
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
timeoutMs,
signal,
});
buffer = snap.buffer;
} else {
const profileRuntime = ctx.state().profiles.get(profileCtx.profile.name);
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
timeoutMs,
headless:
profileRuntime?.running?.headless ??
(await profileRuntime?.externalBrowserMode?.headless),
});
capture = {
buffer: await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
timeoutMs,
headless:
profileRuntime?.running?.headless ??
(await profileRuntime?.externalBrowserMode?.headless),
}),
};
}
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
await jsonScreenshot(capture);
},
});
});
@ -660,6 +585,14 @@ export function registerBrowserAgentSnapshotRoutes(
});
}
await req.assertCurrent?.(profileCtx.profile);
const jsonSnapshot = (snapshot: Record<string, unknown>) =>
res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...snapshot,
});
const deltaFamily: SnapshotDeltaFamily | undefined =
plan.format === "ai"
? {
@ -720,16 +653,8 @@ export function registerBrowserAgentSnapshotRoutes(
};
const snapshot = await takeChromeMcpSnapshot(operation);
if (plan.format === "aria") {
const flattened = flattenChromeMcpRouteSnapshot(snapshot, plan.limit);
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
...flattened,
});
return jsonSnapshot(flattenChromeMcpRouteSnapshot(snapshot, plan.limit));
}
const deltaState = createDeltaState();
const built = buildChromeMcpRouteSnapshot({
root: snapshot,
options: {
@ -750,7 +675,6 @@ export function registerBrowserAgentSnapshotRoutes(
const finalizedBase = finalizeRoleSnapshot({
...builtWithUrls,
maxChars: plan.resolvedMaxChars,
delta: deltaState.delta,
});
const finalized =
built.truncated && !finalizedBase.truncated
@ -763,50 +687,21 @@ export function registerBrowserAgentSnapshotRoutes(
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
const buffer = await takeChromeMcpScreenshot({
...operation,
format: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
deltaState.record(finalized.refs);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...finalized,
});
const image = await saveBrowserScreenshot({ buffer, ...labelResult }, "png");
return jsonSnapshot({ ...image, ...finalized });
} finally {
await clearChromeMcpOverlay(operation);
}
}
deltaState.record(finalized.refs);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...finalized,
});
return jsonSnapshot(finalized);
}
const readPlaywrightDocumentIdentity =
pwModule?.getMainFrameDocumentIdentityViaPlaywright;
let observedBrowserState: unknown;
let observedBrowserState: BrowserObservedState | undefined;
if (pwModule) {
observedBrowserState = await pwModule
.getObservedBrowserStateViaPlaywright({
@ -816,14 +711,16 @@ export function registerBrowserAgentSnapshotRoutes(
})
.catch(() => undefined);
}
if (hasPendingDialogs(observedBrowserState)) {
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
const browserStateFields =
observedBrowserState &&
(observedBrowserState.dialogs.pending.length ||
observedBrowserState.dialogs.recent.length)
? { browserState: observedBrowserState }
: {};
if (observedBrowserState?.dialogs.pending.length) {
return jsonSnapshot({
blockedByDialog: true,
...browserStateResponseFields(observedBrowserState),
...browserStateFields,
...(plan.format === "aria" ? { nodes: [] } : { snapshot: "", refs: {} }),
});
}
@ -944,6 +841,7 @@ export function registerBrowserAgentSnapshotRoutes(
: {}),
});
}
let image: Awaited<ReturnType<typeof saveBrowserScreenshot>> | undefined;
if (plan.labels) {
if (!pw) {
return jsonError(res, 501, "Snapshot labels require Playwright.");
@ -956,51 +854,14 @@ export function registerBrowserAgentSnapshotRoutes(
timeoutMs: plan.timeoutMs,
signal,
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
const scaledAnnotations = await rescaleAnnotationsForNormalization({
annotations: labeled.annotations,
originalBuffer: labeled.buffer,
normalized,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
await assertDocumentIdentityUnchanged();
deltaState.record(snap.refs ?? {});
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
...(scaledAnnotations && scaledAnnotations.length > 0
? { annotations: scaledAnnotations }
: {}),
imagePath: path.resolve(saved.path),
imageType,
...snap,
});
image = await saveBrowserScreenshot(labeled, "png");
}
await assertDocumentIdentityUnchanged();
deltaState.record(snap.refs ?? {});
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
return jsonSnapshot({
...browserStateFields,
...image,
...snap,
});
}
@ -1009,47 +870,34 @@ export function registerBrowserAgentSnapshotRoutes(
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
});
const snap = usePlaywrightAriaSnapshot
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
timeoutMs: plan.timeoutMs,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()
: snapshotAria({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: plan.limit,
timeoutMs: plan.timeoutMs,
});
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
if (!usePlaywrightAriaSnapshot) {
let resolved: Awaited<ReturnType<typeof snapshotAria>>;
if (usePlaywrightAriaSnapshot) {
const pw = await requirePwAi(res, "aria snapshot");
if (!pw) {
return;
}
resolved = await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
timeoutMs: plan.timeoutMs,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
} else {
resolved = await snapshotAria({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: plan.limit,
timeoutMs: plan.timeoutMs,
});
await pwModule?.storeSnapshotRefsViaPlaywright?.({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
nodes: resolved.nodes,
});
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
return jsonSnapshot({
...browserStateFields,
...resolved,
});
},

View file

@ -2,29 +2,20 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserPathsModule from "../../browser/paths.js";
import * as browserCliSharedModule from "../browser-cli-shared.js";
import {
createBrowserProgram,
mockBrowserGateway,
getBrowserCliRuntime,
getBrowserCliRuntimeCapture,
} from "../browser-cli.test-support.js";
import * as cliCoreApiModule from "../core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
_opts: unknown,
req: { path?: string },
extra?: { timeoutMs?: number },
) => Promise<Record<string, unknown>>
>(async (_opts: unknown, req: { path?: string }) =>
req.path === "/wait/download" || req.path === "/download"
? { download: { path: "/tmp/openclaw/downloads/file.txt" } }
: { ok: true },
),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
const gatewayMock = mockBrowserGateway();
gatewayMock.mockImplementation(async (_method, _opts, request) =>
request.path === "/wait/download" || request.path === "/download"
? { download: { path: "/tmp/openclaw/downloads/file.txt" } }
: { ok: true },
);
const browserCliRuntime = getBrowserCliRuntime();
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log);
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
@ -46,12 +37,12 @@ function createActionInputProgram(): Command {
}
function getLastRequestOptions(): { timeoutMs?: number } | undefined {
return mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as { timeoutMs?: number } | undefined;
return gatewayMock.mock.calls.at(-1)?.[2];
}
describe("browser action input file/download commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
gatewayMock.mockClear();
vi.mocked(browserPathsModule.resolveExistingUploadPaths).mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
getBrowserCliRuntime().exit.mockImplementation(() => {});
@ -81,9 +72,7 @@ describe("browser action input file/download commands", () => {
expect(browserPathsModule.resolveExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/a.pdf", "media://inbound/b"],
});
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { path?: string; body?: Record<string, unknown> }
| undefined;
const request = gatewayMock.mock.calls.at(-1)?.[2];
expect(request).toMatchObject({
path: "/hooks/file-chooser",
body: {
@ -143,7 +132,7 @@ describe("browser action input file/download commands", () => {
from: "user",
}),
).rejects.toThrow("--timeout-ms must be a positive integer.");
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
});
it("rejects conflicting dialog actions without arming the hook", async () => {
@ -152,7 +141,7 @@ describe("browser action input file/download commands", () => {
await program.parseAsync(["browser", "dialog", "--accept", "--dismiss"], { from: "user" });
const errorCall = getBrowserCliRuntime().error.mock.calls.at(-1);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expect(String(errorCall?.[0])).toContain("Specify only one of --accept or --dismiss");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});

View file

@ -6,8 +6,8 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import { resolveExistingUploadPaths } from "../../browser/paths.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerOption,
runBrowserCliRequest,
withBrowserActionTimeoutSlack,
type BrowserParentOpts,
} from "../browser-cli-shared.js";
@ -24,37 +24,6 @@ async function normalizeUploadPaths(paths: string[]): Promise<string[]> {
return result.paths;
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Browser request result type is shared between request and success formatter.
async function runBrowserPostAction<T>(params: {
parent: BrowserParentOpts;
profile: string | undefined;
path: string;
body: Record<string, unknown>;
timeoutMs: number;
describeSuccess: (result: T) => string;
}): Promise<void> {
try {
const result = await callBrowserRequest<T>(
params.parent,
{
method: "POST",
path: params.path,
query: params.profile ? { profile: params.profile } : undefined,
body: params.body,
},
{ timeoutMs: withBrowserActionTimeoutSlack(params.timeoutMs) },
);
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.describeSuccess(result));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}
/** Registers Browser file chooser, dialog, and download commands. */
export function registerBrowserFilesAndDownloadsCommands(
browser: Command,
@ -73,7 +42,7 @@ export function registerBrowserFilesAndDownloadsCommands(
) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction<{ download: { path: string } }>({
await runBrowserCliRequest<{ download: { path: string } }>({
parent,
profile,
path: request.path,
@ -82,8 +51,9 @@ export function registerBrowserFilesAndDownloadsCommands(
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS,
describeSuccess: (result) => `downloaded: ${shortenHomePath(result.download.path)}`,
timeoutMs: withBrowserActionTimeoutSlack(timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS),
errorPolicy: "inline",
successMessage: (result) => `downloaded: ${shortenHomePath(result.download.path)}`,
});
};
@ -108,7 +78,7 @@ export function registerBrowserFilesAndDownloadsCommands(
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const normalizedPaths = await normalizeUploadPaths(paths);
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction({
await runBrowserCliRequest({
parent,
profile,
path: "/hooks/file-chooser",
@ -120,8 +90,9 @@ export function registerBrowserFilesAndDownloadsCommands(
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS,
describeSuccess: () => `upload armed for ${paths.length} file(s)`,
timeoutMs: withBrowserActionTimeoutSlack(timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS),
errorPolicy: "inline",
successMessage: `upload armed for ${paths.length} file(s)`,
});
} catch (err) {
defaultRuntime.error(danger(String(err)));
@ -202,7 +173,7 @@ export function registerBrowserFilesAndDownloadsCommands(
return;
}
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction({
await runBrowserCliRequest({
parent,
profile,
path: "/hooks/dialog",
@ -213,8 +184,9 @@ export function registerBrowserFilesAndDownloadsCommands(
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS,
describeSuccess: () => "dialog armed",
timeoutMs: withBrowserActionTimeoutSlack(timeoutMs ?? DEFAULT_BROWSER_HOOK_TIMEOUT_MS),
errorPolicy: "inline",
successMessage: "dialog armed",
});
});
}

View file

@ -1,25 +1,16 @@
// Browser tests cover browser cli actions observe plugin behavior.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserCliSharedModule from "./browser-cli-shared.js";
import {
createBrowserProgram,
mockBrowserGateway,
getBrowserCliRuntime,
getBrowserCliRuntimeCapture,
} from "./browser-cli.test-support.js";
import * as cliCoreApiModule from "./core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
opts?: unknown,
req?: unknown,
extra?: { timeoutMs?: number },
) => Promise<Record<string, unknown>>
>(async () => ({ response: { body: "ok" } })),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
const gatewayMock = mockBrowserGateway();
gatewayMock.mockResolvedValue({ response: { body: "ok" } });
const browserCliRuntime = getBrowserCliRuntime();
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log);
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
@ -39,7 +30,7 @@ function createActionObserveProgram(): Command {
describe("browser action observe commands", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
gatewayMock.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
@ -54,9 +45,11 @@ describe("browser action observe commands", () => {
await program.parseAsync(["browser", ...parentArgs, command], { from: "user" });
expect(mocks.callBrowserRequest).toHaveBeenLastCalledWith(
expect(gatewayMock).toHaveBeenLastCalledWith(
"browser.request",
expect.objectContaining({ timeout }),
expect.objectContaining({ path }),
expect.objectContaining({ path, timeoutMs: Number(timeout) }),
expect.objectContaining({ scopes: ["operator.admin"] }),
);
});
@ -73,7 +66,7 @@ describe("browser action observe commands", () => {
from: "user",
}),
).rejects.toThrow("--max-chars must be a positive integer.");
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
});
it("rejects unknown console levels before dispatch", async () => {
@ -82,7 +75,7 @@ describe("browser action observe commands", () => {
await expect(
program.parseAsync(["browser", "console", "--level", "bogus"], { from: "user" }),
).rejects.toThrow(/error.*warn.*info/u);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
});
it.each([
@ -103,7 +96,7 @@ describe("browser action observe commands", () => {
...(testCase.truncated === undefined ? {} : { truncated: testCase.truncated }),
},
};
mocks.callBrowserRequest.mockResolvedValueOnce(result);
gatewayMock.mockResolvedValueOnce(result);
await program.parseAsync(
[
@ -154,15 +147,10 @@ describe("browser action observe commands", () => {
await program.parseAsync(args, { from: "user" });
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
| { body?: { timeoutMs?: number; maxChars?: number } }
| undefined;
const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
| { timeoutMs?: number }
| undefined;
const request = gatewayMock.mock.calls.at(-1)?.[2];
expect(request?.body?.timeoutMs).toBe(operationTimeoutMs);
expect(request?.body?.maxChars).toBe(100);
expect(options?.timeoutMs).toBe(requestTimeoutMs);
expect(request?.timeoutMs).toBe(requestTimeoutMs);
},
);
});

View file

@ -5,10 +5,8 @@ import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
parseBrowserPositiveIntegerOption,
printBrowserJsonResult,
runBrowserCliCommand as runBrowserObserve,
runBrowserCliRequest,
withBrowserActionTimeoutSlack,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
@ -41,22 +39,15 @@ export function registerBrowserActionObserveCommands(
)
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const result = await callBrowserRequest<{ messages: unknown[] }>(parent, {
method: "GET",
path: "/console",
query: {
level: normalizeOptionalString(opts.level),
targetId: normalizeOptionalString(opts.targetId),
profile,
},
});
if (printBrowserJsonResult(parent, result)) {
return;
}
defaultRuntime.writeJson(result.messages);
await runBrowserCliRequest<{ messages: unknown[] }>({
parent: parentOpts(cmd),
method: "GET",
path: "/console",
query: {
level: normalizeOptionalString(opts.level),
targetId: normalizeOptionalString(opts.targetId),
},
print: (result) => defaultRuntime.writeJson(result.messages),
});
});
@ -65,19 +56,11 @@ export function registerBrowserActionObserveCommands(
.description("Save page as PDF")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const result = await callBrowserRequest<{ path: string }>(parent, {
method: "POST",
path: "/pdf",
query: profile ? { profile } : undefined,
body: { targetId: normalizeOptionalString(opts.targetId) },
});
if (printBrowserJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`);
await runBrowserCliRequest<{ path: string }>({
parent: parentOpts(cmd),
path: "/pdf",
body: { targetId: normalizeOptionalString(opts.targetId) },
successMessage: (result) => `PDF: ${shortenHomePath(result.path)}`,
});
});
@ -95,37 +78,28 @@ export function registerBrowserActionObserveCommands(
parseBrowserPositiveIntegerOption(v, "--max-chars"),
)
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
const result = await callBrowserRequest<{
response: { body: string; truncated?: boolean };
}>(
parent,
{
method: "POST",
path: "/response/body",
query: profile ? { profile } : undefined,
body: {
url,
targetId: normalizeOptionalString(opts.targetId),
timeoutMs,
maxChars,
},
},
{ timeoutMs: withBrowserActionTimeoutSlack(timeoutMs) },
);
if (printBrowserJsonResult(parent, result)) {
return;
}
defaultRuntime.log(result.response.body);
if (result.response.truncated === true) {
defaultRuntime.error(
"Warning: response body is a truncated prefix. Use --json to inspect response metadata.",
);
}
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
await runBrowserCliRequest<{
response: { body: string; truncated?: boolean };
}>({
parent: parentOpts(cmd),
path: "/response/body",
body: {
url,
targetId: normalizeOptionalString(opts.targetId),
timeoutMs,
maxChars,
},
timeoutMs: withBrowserActionTimeoutSlack(timeoutMs),
print: (result) => {
defaultRuntime.log(result.response.body);
if (result.response.truncated === true) {
defaultRuntime.error(
"Warning: response body is a truncated prefix. Use --json to inspect response metadata.",
);
}
},
});
});
}

View file

@ -1,18 +1,14 @@
// Browser tests cover browser cli debug plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserCliSharedModule from "./browser-cli-shared.js";
import {
createBrowserProgram,
mockBrowserGateway,
getBrowserCliRuntime,
getBrowserCliRuntimeCapture,
} from "./browser-cli.test-support.js";
import * as cliCoreApiModule from "./core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn(async (..._args: unknown[]) => ({ ok: true })),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
const gatewayMock = mockBrowserGateway();
const browserCliRuntime = getBrowserCliRuntime();
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
browserCliRuntime.writeJson,
@ -24,7 +20,7 @@ const { registerBrowserDebugCommands } = await import("./browser-cli-debug.js");
describe("browser debug command timeouts", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
gatewayMock.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
@ -43,9 +39,11 @@ describe("browser debug command timeouts", () => {
await program.parseAsync(["browser", ...parentArgs, ...args], { from: "user" });
expect(mocks.callBrowserRequest).toHaveBeenLastCalledWith(
expect(gatewayMock).toHaveBeenLastCalledWith(
"browser.request",
expect.objectContaining({ timeout }),
expect.objectContaining({ path }),
expect.objectContaining({ path, timeoutMs: Number(timeout) }),
expect.objectContaining({ scopes: ["operator.admin"] }),
);
}
});

View file

@ -5,44 +5,16 @@ import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
printBrowserJsonResult as printJsonResult,
resolveBrowserProfileQuery as resolveProfileQuery,
runBrowserCliCommand,
runBrowserCliRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { defaultRuntime, shortenHomePath } from "./core-api.js";
type DebugContext = {
parent: BrowserParentOpts;
profile?: string;
};
async function withDebugContext(
cmd: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
action: (context: DebugContext) => Promise<void>,
) {
const parent = parentOpts(cmd);
await runBrowserCliCommand(() =>
action({
parent,
profile: parent.browserProfile,
}),
);
}
function resolveDebugQuery(params: {
targetId?: unknown;
clear?: unknown;
profile?: string;
filter?: unknown;
}) {
function resolveDebugQuery(params: { targetId?: unknown; clear?: unknown; filter?: unknown }) {
return {
targetId: normalizeOptionalString(params.targetId),
filter: normalizeOptionalString(params.filter),
clear: Boolean(params.clear),
profile: params.profile,
};
}
@ -57,20 +29,14 @@ export function registerBrowserDebugCommands(
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (ref: string, opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callBrowserRequest(parent, {
method: "POST",
path: "/highlight",
query: resolveProfileQuery(profile),
body: {
ref: ref.trim(),
targetId: normalizeOptionalString(opts.targetId),
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`highlighted ${ref.trim()}`);
await runBrowserCliRequest({
parent: parentOpts(cmd),
path: "/highlight",
body: {
ref: ref.trim(),
targetId: normalizeOptionalString(opts.targetId),
},
successMessage: `highlighted ${ref.trim()}`,
});
});
@ -80,30 +46,27 @@ export function registerBrowserDebugCommands(
.option("--clear", "Clear stored errors after reading", false)
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callBrowserRequest<{
errors: Array<{ timestamp: string; name?: string; message: string }>;
}>(parent, {
method: "GET",
path: "/errors",
query: resolveDebugQuery({
targetId: opts.targetId,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.errors.length) {
defaultRuntime.log("No page errors.");
return;
}
defaultRuntime.log(
result.errors
.map((e) => `${e.timestamp} ${e.name ? `${e.name}: ` : ""}${e.message}`)
.join("\n"),
);
await runBrowserCliRequest<{
errors: Array<{ timestamp: string; name?: string; message: string }>;
}>({
parent: parentOpts(cmd),
method: "GET",
path: "/errors",
query: resolveDebugQuery({
targetId: opts.targetId,
clear: opts.clear,
}),
print: (result) => {
if (!result.errors.length) {
defaultRuntime.log("No page errors.");
return;
}
defaultRuntime.log(
result.errors
.map((e) => `${e.timestamp} ${e.name ? `${e.name}: ` : ""}${e.message}`)
.join("\n"),
);
},
});
});
@ -114,43 +77,40 @@ export function registerBrowserDebugCommands(
.option("--clear", "Clear stored requests after reading", false)
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callBrowserRequest<{
requests: Array<{
timestamp: string;
method: string;
status?: number;
ok?: boolean;
url: string;
failureText?: string;
}>;
}>(parent, {
method: "GET",
path: "/requests",
query: resolveDebugQuery({
targetId: opts.targetId,
filter: opts.filter,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.requests.length) {
defaultRuntime.log("No requests recorded.");
return;
}
defaultRuntime.log(
result.requests
.map((r) => {
const status = typeof r.status === "number" ? ` ${r.status}` : "";
const ok = r.ok === true ? " ok" : r.ok === false ? " fail" : "";
const fail = r.failureText ? ` (${r.failureText})` : "";
return `${r.timestamp} ${r.method}${status}${ok} ${r.url}${fail}`;
})
.join("\n"),
);
await runBrowserCliRequest<{
requests: Array<{
timestamp: string;
method: string;
status?: number;
ok?: boolean;
url: string;
failureText?: string;
}>;
}>({
parent: parentOpts(cmd),
method: "GET",
path: "/requests",
query: resolveDebugQuery({
targetId: opts.targetId,
filter: opts.filter,
clear: opts.clear,
}),
print: (result) => {
if (!result.requests.length) {
defaultRuntime.log("No requests recorded.");
return;
}
defaultRuntime.log(
result.requests
.map((r) => {
const status = typeof r.status === "number" ? ` ${r.status}` : "";
const ok = r.ok === true ? " ok" : r.ok === false ? " fail" : "";
const fail = r.failureText ? ` (${r.failureText})` : "";
return `${r.timestamp} ${r.method}${status}${ok} ${r.url}${fail}`;
})
.join("\n"),
);
},
});
});
@ -164,22 +124,16 @@ export function registerBrowserDebugCommands(
.option("--no-snapshots", "Disable snapshots")
.option("--sources", "Include sources (bigger traces)", false)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callBrowserRequest(parent, {
method: "POST",
path: "/trace/start",
query: resolveProfileQuery(profile),
body: {
targetId: normalizeOptionalString(opts.targetId),
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("trace started");
await runBrowserCliRequest({
parent: parentOpts(cmd),
path: "/trace/start",
body: {
targetId: normalizeOptionalString(opts.targetId),
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
},
successMessage: "trace started",
});
});
@ -192,20 +146,14 @@ export function registerBrowserDebugCommands(
)
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callBrowserRequest<{ path: string }>(parent, {
method: "POST",
path: "/trace/stop",
query: resolveProfileQuery(profile),
body: {
targetId: normalizeOptionalString(opts.targetId),
path: normalizeOptionalString(opts.out),
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);
await runBrowserCliRequest<{ path: string }>({
parent: parentOpts(cmd),
path: "/trace/stop",
body: {
targetId: normalizeOptionalString(opts.targetId),
path: normalizeOptionalString(opts.out),
},
successMessage: (result) => `TRACE:${shortenHomePath(result.path)}`,
});
});
}

View file

@ -3,57 +3,26 @@
*/
import type { Command } from "commander";
import { vi } from "vitest";
import * as browserCliSharedModule from "./browser-cli-shared.js";
import { mockBrowserGateway } from "./browser-cli.test-support.js";
import * as cliCoreApiModule from "./core-api.js";
type BrowserRequest = {
path?: string;
query?: Record<string, string | number | boolean | undefined>;
};
type BrowserRuntimeOptions = { timeoutMs?: number };
type BrowserManageCall = [
{ timeout?: string } | undefined,
BrowserRequest,
BrowserRuntimeOptions | undefined,
];
const browserManageMocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
opts: unknown,
req: BrowserRequest,
runtimeOpts?: BrowserRuntimeOptions,
) => Promise<Record<string, unknown>>
>(async (_opts: unknown, req: BrowserRequest) =>
req.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
),
}));
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(
browserManageMocks.callBrowserRequest,
);
vi.spyOn(cliCoreApiModule, "runCommandWithRuntime").mockImplementation(
async (_runtime, action, onError) => {
try {
await action();
} catch (err) {
onError?.(err);
}
},
const gatewayMock = mockBrowserGateway();
gatewayMock.mockImplementation(async (_method, _opts, request) =>
request.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
);
const { createBrowserProgram, getBrowserCliRuntime } =
await import("./browser-cli.test-support.js");
const browserCliRuntime = getBrowserCliRuntime();
@ -76,14 +45,12 @@ export function createBrowserManageProgram(params?: { withParentTimeout?: boolea
return program;
}
/** Returns the mocked callBrowserRequest used by manage command tests. */
export function getBrowserManageCallBrowserRequestMock() {
return browserManageMocks.callBrowserRequest;
/** Returns the Gateway mock used by manage command tests. */
export function getBrowserManageGatewayMock() {
return gatewayMock;
}
/** Finds the first mocked Browser manage request for a route path. */
export function findBrowserManageCall(path: string): BrowserManageCall | undefined {
return browserManageMocks.callBrowserRequest.mock.calls.find(
(call) => (call[1] ?? {}).path === path,
) as BrowserManageCall | undefined;
export function findBrowserManageCall(path: string) {
return gatewayMock.mock.calls.find((call) => call[2].path === path);
}

View file

@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createBrowserManageProgram,
getBrowserManageCallBrowserRequestMock,
getBrowserManageGatewayMock,
} from "./browser-cli-manage.test-helpers.js";
import { getBrowserCliRuntime, getBrowserCliRuntimeCapture } from "./browser-cli.test-support.js";
@ -27,7 +27,7 @@ describe("browser manage output", () => {
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = 0;
getBrowserManageCallBrowserRequestMock().mockClear();
getBrowserManageGatewayMock().mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
getBrowserCliRuntime().exit.mockClear();
getBrowserCliRuntime().writeJson.mockClear();
@ -38,7 +38,7 @@ describe("browser manage output", () => {
});
it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/"
? {
enabled: true,
@ -76,7 +76,7 @@ describe("browser manage output", () => {
});
it("shows configured userDataDir for existing-session status", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/"
? {
enabled: true,
@ -112,7 +112,7 @@ describe("browser manage output", () => {
});
it("shows configured cdpUrl for existing-session status", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/"
? {
enabled: true,
@ -152,7 +152,7 @@ describe("browser manage output", () => {
});
it("shows chrome-mcp transport in browser profiles output", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/profiles"
? {
profiles: [
@ -183,7 +183,7 @@ describe("browser manage output", () => {
});
it("redacts remote cdpUrl details in browser profiles output", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/profiles"
? {
profiles: [
@ -216,7 +216,7 @@ describe("browser manage output", () => {
});
it("shows chrome-mcp transport after creating an existing-session profile", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/profiles/create"
? {
ok: true,
@ -244,7 +244,7 @@ describe("browser manage output", () => {
});
it("shows cdpUrl after creating an existing-session endpoint profile", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/profiles/create"
? {
ok: true,
@ -285,7 +285,7 @@ describe("browser manage output", () => {
});
it("redacts remote cdpUrl details after creating a remote profile", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/profiles/create"
? {
ok: true,
@ -322,7 +322,7 @@ describe("browser manage output", () => {
});
it("redacts sensitive remote cdpUrl details in status output", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/"
? {
enabled: true,
@ -360,7 +360,7 @@ describe("browser manage output", () => {
});
it("prints managed graphics facts in status output", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/"
? {
enabled: true,
@ -408,7 +408,7 @@ describe("browser manage output", () => {
});
it("prints suggested tab references while keeping raw target ids visible", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) =>
req.path === "/tabs"
? {
running: true,
@ -453,7 +453,8 @@ describe("browser manage output", () => {
expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain(
"index must be a positive integer",
);
expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalledWith(
expect(getBrowserManageGatewayMock()).not.toHaveBeenCalledWith(
"browser.request",
expect.anything(),
expect.objectContaining({ path: "/tabs/action" }),
expect.anything(),
@ -465,7 +466,8 @@ describe("browser manage output", () => {
await program.parseAsync(["browser", "tab", "select", "+2"], { from: "user" });
expect(getBrowserManageCallBrowserRequestMock()).toHaveBeenCalledWith(
expect(getBrowserManageGatewayMock()).toHaveBeenCalledWith(
"browser.request",
expect.anything(),
expect.objectContaining({
path: "/tabs/action",
@ -487,11 +489,11 @@ describe("browser manage output", () => {
expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain(
"--driver must be openclaw or existing-session",
);
expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalled();
expect(getBrowserManageGatewayMock()).not.toHaveBeenCalled();
});
it("prints authenticated extension drift from the canonical browser doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
ok: true,
@ -532,14 +534,14 @@ describe("browser manage output", () => {
"WARN extension-version: running 2.0.0; bundled 2.2.0 (mismatch); Reload the OpenClaw extension.",
);
expect(process.exitCode).toBe(0);
expect(getBrowserManageCallBrowserRequestMock().mock.calls[0]?.[1]).toMatchObject({
expect(getBrowserManageGatewayMock().mock.calls[0]?.[2]).toMatchObject({
path: "/doctor",
query: { profile: "chrome" },
});
});
it("keeps unavailable extension version evidence informational and nonfatal", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [
@ -574,7 +576,7 @@ describe("browser manage output", () => {
});
it("preserves one nonfatal JSON report for confirmed extension version drift", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [
@ -613,7 +615,7 @@ describe("browser manage output", () => {
});
it("runs exactly one deep snapshot after consuming the canonical doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [],
@ -643,14 +645,14 @@ describe("browser manage output", () => {
});
expect(lastRuntimeLog()).toContain("OK live-snapshot: 1 nodes/lines");
const snapshotCalls = getBrowserManageCallBrowserRequestMock().mock.calls.filter(
([, request]) => request.path === "/snapshot",
const snapshotCalls = getBrowserManageGatewayMock().mock.calls.filter(
(call) => call[2].path === "/snapshot",
);
expect(snapshotCalls).toHaveLength(1);
});
it("prints a readable browser doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [],
@ -723,7 +725,7 @@ describe("browser manage output", () => {
});
it("prints one complete JSON browser doctor failure before setting exit status", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [],
@ -760,7 +762,7 @@ describe("browser manage output", () => {
});
it("prints one JSON browser doctor report and succeeds when every check passes", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
getBrowserManageGatewayMock().mockImplementation(async (_method, _opts, req) => {
if (req.path === "/doctor") {
return {
checks: [],
@ -796,7 +798,7 @@ describe("browser manage output", () => {
code: "GATEWAY_SECRET_REF_UNAVAILABLE",
name: "GatewaySecretRefUnavailableError",
});
getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error);
getBrowserManageGatewayMock().mockRejectedValueOnce(error);
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "doctor"], { from: "user" });
@ -819,7 +821,7 @@ describe("browser manage output", () => {
{ deleted: true, json: true },
])("reports profile deletion with deleted=$deleted and json=$json", async ({ deleted, json }) => {
const result = { ok: true, profile: "proof-retained", deleted };
getBrowserManageCallBrowserRequestMock().mockResolvedValueOnce(result);
getBrowserManageGatewayMock().mockResolvedValueOnce(result);
const program = createBrowserManageProgram();
await program.parseAsync(
@ -827,10 +829,12 @@ describe("browser manage output", () => {
{ from: "user" },
);
expect(getBrowserManageCallBrowserRequestMock()).toHaveBeenCalledWith(expect.anything(), {
method: "DELETE",
path: "/profiles/proof-retained",
});
expect(getBrowserManageGatewayMock()).toHaveBeenCalledWith(
"browser.request",
expect.anything(),
expect.objectContaining({ method: "DELETE", path: "/profiles/proof-retained" }),
expect.objectContaining({ scopes: ["operator.admin"] }),
);
if (json) {
expect(parseSingleRuntimeJson()).toEqual(result);
expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1);

View file

@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
createBrowserManageProgram,
findBrowserManageCall,
getBrowserManageCallBrowserRequestMock,
getBrowserManageGatewayMock,
} from "./browser-cli-manage.test-helpers.js";
import { getBrowserCliRuntimeCapture } from "./browser-cli.test-support.js";
describe("browser manage start timeout option", () => {
beforeEach(() => {
getBrowserManageCallBrowserRequestMock().mockClear();
getBrowserManageGatewayMock().mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
@ -21,8 +21,8 @@ describe("browser manage start timeout option", () => {
if (!startCall) {
throw new Error("expected browser /start call");
}
expect((startCall[0] as { timeout?: string } | undefined)?.timeout).toBe("60000");
expect(startCall[2]).toBeUndefined();
expect(startCall[1].timeout).toBe("60000");
expect(startCall[2].timeoutMs).toBe(60000);
});
it.each([
@ -36,8 +36,8 @@ describe("browser manage start timeout option", () => {
});
const request = findBrowserManageCall(path);
expect(request?.[0]).toEqual(expect.objectContaining({ timeout: "60000" }));
expect(request?.[2]).toBeUndefined();
expect(request?.[1]).toEqual(expect.objectContaining({ timeout: "60000" }));
expect(request?.[2].timeoutMs).toBe(60000);
});
it("passes headless=true for browser start --headless", async () => {
@ -45,7 +45,7 @@ describe("browser manage start timeout option", () => {
await program.parseAsync(["browser", "start", "--headless"], { from: "user" });
const startCall = findBrowserManageCall("/start");
expect(startCall?.[1]?.query).toEqual({ headless: true });
expect(startCall?.[2].query).toEqual({ headless: "true" });
});
it("combines browser profile with browser start --headless", async () => {
@ -55,7 +55,7 @@ describe("browser manage start timeout option", () => {
});
const startCall = findBrowserManageCall("/start");
expect(startCall?.[1]?.query).toEqual({ profile: "work", headless: true });
expect(startCall?.[2].query).toEqual({ profile: "work", headless: "true" });
});
it("uses a longer built-in timeout for browser status", async () => {
@ -63,7 +63,7 @@ describe("browser manage start timeout option", () => {
await program.parseAsync(["browser", "status"], { from: "user" });
const statusCall = findBrowserManageCall("/");
expect(statusCall?.[2]).toEqual({ timeoutMs: 45_000 });
expect(statusCall?.[2].timeoutMs).toBe(45_000);
});
it("uses a longer built-in timeout for browser tabs", async () => {
@ -71,7 +71,7 @@ describe("browser manage start timeout option", () => {
await program.parseAsync(["browser", "tabs"], { from: "user" });
const tabsCall = findBrowserManageCall("/tabs");
expect(tabsCall?.[2]).toEqual({ timeoutMs: 45_000 });
expect(tabsCall?.[2].timeoutMs).toBe(45_000);
});
it("uses a longer built-in timeout for browser profiles", async () => {
@ -79,7 +79,7 @@ describe("browser manage start timeout option", () => {
await program.parseAsync(["browser", "profiles"], { from: "user" });
const profilesCall = findBrowserManageCall("/profiles");
expect(profilesCall?.[2]).toEqual({ timeoutMs: 45_000 });
expect(profilesCall?.[2].timeoutMs).toBe(45_000);
});
it("uses a longer built-in timeout for browser open", async () => {
@ -87,6 +87,6 @@ describe("browser manage start timeout option", () => {
await program.parseAsync(["browser", "open", "https://example.com"], { from: "user" });
const openCall = findBrowserManageCall("/tabs/open");
expect(openCall?.[2]).toEqual({ timeoutMs: 45_000 });
expect(openCall?.[2].timeoutMs).toBe(45_000);
});
});

View file

@ -24,6 +24,7 @@ import {
printBrowserJsonResult as printJsonResult,
resolveBrowserProfileQuery as resolveProfileQuery,
runBrowserCliCommand as runBrowserCommand,
runBrowserCliRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { danger, defaultRuntime, info, shortenHomePath } from "./core-api.js";
@ -44,26 +45,6 @@ function sanitizeTableCell(value: string): string {
return value.replace(/\p{Cc}/gu, " ");
}
async function callTabAction(
parent: BrowserParentOpts,
profile: string | undefined,
body:
| { action: "new"; label?: string }
| { action: "select" | "close"; index?: number }
| { action: "label"; targetId: string; label: string },
) {
return callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: resolveProfileQuery(profile),
body,
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
}
async function fetchBrowserStatus(
parent: BrowserParentOpts,
profile?: string,
@ -107,11 +88,7 @@ function parseTabIndex(value: string): number {
return parseBrowserPositiveIntegerValue(value) ?? Number.NaN;
}
function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
if (json) {
defaultRuntime.writeJson({ tabs });
return;
}
function logBrowserTabs(tabs: BrowserTab[]) {
if (tabs.length === 0) {
defaultRuntime.log("No tabs (browser closed or no targets).");
return;
@ -444,23 +421,17 @@ export function registerBrowserManageCommands(
.command("reset-profile")
.description("Reset browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<BrowserResetProfileResult>(parent, {
method: "POST",
path: "/reset-profile",
query: resolveProfileQuery(profile),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.moved) {
defaultRuntime.log(info(`🦞 browser profile already missing.`));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
await runBrowserCliRequest<BrowserResetProfileResult>({
parent: parentOpts(cmd),
path: "/reset-profile",
print: (result) => {
if (!result.moved) {
defaultRuntime.log(info(`🦞 browser profile already missing.`));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
},
});
});
@ -468,20 +439,13 @@ export function registerBrowserManageCommands(
.command("tabs")
.description("List open tabs")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
parent,
{
method: "GET",
path: "/tabs",
query: resolveProfileQuery(profile),
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const tabs = result.tabs ?? [];
logBrowserTabs(tabs, parent?.json);
await runBrowserCliRequest<{ tabs: BrowserTab[] }>({
parent: parentOpts(cmd),
method: "GET",
path: "/tabs",
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
json: (result) => ({ tabs: result.tabs ?? [] }),
print: (result) => logBrowserTabs(result.tabs ?? []),
});
});
@ -489,23 +453,13 @@ export function registerBrowserManageCommands(
.command("tab")
.description("Tab shortcuts (index-based)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
parent,
{
method: "POST",
path: "/tabs/action",
query: resolveProfileQuery(profile),
body: {
action: "list",
},
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const tabs = result.tabs ?? [];
logBrowserTabs(tabs, parent?.json);
await runBrowserCliRequest<{ tabs: BrowserTab[] }>({
parent: parentOpts(cmd),
path: "/tabs/action",
body: { action: "list" },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
json: (result) => ({ tabs: result.tabs ?? [] }),
print: (result) => logBrowserTabs(result.tabs ?? []),
});
});
@ -514,19 +468,15 @@ export function registerBrowserManageCommands(
.description("Open a new tab (about:blank)")
.option("--label <label>", "Assign a friendly tab label")
.action(async (opts: { label?: string }, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "new", label: opts.label });
if (printJsonResult(parent, result)) {
return;
}
const opened = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(
await runBrowserCliRequest<{ tab?: BrowserTab }>({
parent: parentOpts(cmd),
path: "/tabs/action",
body: { action: "new", label: opts.label },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
successMessage: ({ tab: opened }) =>
opened?.tabId
? `opened new tab ${opened.tabId}${opened.label ? ` (${opened.label})` : ""}`
: "opened new tab",
);
});
});
@ -536,17 +486,13 @@ export function registerBrowserManageCommands(
.argument("<targetId>", BROWSER_TAB_REFERENCE_HELP)
.argument("<label>", "Friendly label")
.action(async (targetId: string, label: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "label", targetId, label });
if (printJsonResult(parent, result)) {
return;
}
const tabValue = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(
await runBrowserCliRequest<{ tab?: BrowserTab }>({
parent: parentOpts(cmd),
path: "/tabs/action",
body: { action: "label", targetId, label },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
successMessage: ({ tab: tabValue }) =>
`labeled tab ${tabValue?.tabId ?? targetId} as ${tabValue?.label ?? label}`,
);
});
});
@ -556,21 +502,17 @@ export function registerBrowserManageCommands(
.argument("<index>", "Tab index (1-based)", parseTabIndex)
.action(async (index: number, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
if (!Number.isSafeInteger(index) || index < 1) {
defaultRuntime.error(danger("index must be a positive integer"));
defaultRuntime.exit(1);
return;
}
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, {
action: "select",
index: index - 1,
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`selected tab ${index}`);
await runBrowserCliRequest({
parent,
path: "/tabs/action",
body: { action: "select", index: index - 1 },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
successMessage: `selected tab ${index}`,
});
});
@ -580,19 +522,18 @@ export function registerBrowserManageCommands(
.argument("[index]", "Tab index (1-based)", parseTabIndex)
.action(async (index: number | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
if (typeof index === "number" && (!Number.isSafeInteger(index) || index < 1)) {
defaultRuntime.error(danger("index must be a positive integer"));
defaultRuntime.exit(1);
return;
}
const idx = typeof index === "number" ? index - 1 : undefined;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "close", index: idx });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("closed tab");
await runBrowserCliRequest({
parent,
path: "/tabs/action",
body: { action: "close", index: idx },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
successMessage: "closed tab",
});
});
@ -602,25 +543,13 @@ export function registerBrowserManageCommands(
.argument("<url>", "URL to open")
.option("--label <label>", "Assign a friendly tab label")
.action(async (url: string, opts: { label?: string }, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const tabLocal = await callBrowserRequest<BrowserTab>(
parent,
{
method: "POST",
path: "/tabs/open",
query: resolveProfileQuery(profile),
body: { url, ...(opts.label ? { label: opts.label } : {}) },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, tabLocal)) {
return;
}
defaultRuntime.log(
await runBrowserCliRequest<BrowserTab>({
parent: parentOpts(cmd),
path: "/tabs/open",
body: { url, ...(opts.label ? { label: opts.label } : {}) },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
successMessage: (tabLocal) =>
`opened: ${tabLocal.url}\n${tabLocal.tabId ? `tab: ${tabLocal.tabId}\n` : ""}${tabLocal.label ? `label: ${tabLocal.label}\n` : ""}id: ${tabLocal.targetId}`,
);
});
});
@ -629,23 +558,13 @@ export function registerBrowserManageCommands(
.description("Focus a tab by tab reference")
.argument("<targetId>", BROWSER_TAB_REFERENCE_HELP)
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/focus",
query: resolveProfileQuery(profile),
body: { targetId },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, { ok: true })) {
return;
}
defaultRuntime.log(`focused tab ${targetId}`);
await runBrowserCliRequest({
parent: parentOpts(cmd),
path: "/tabs/focus",
body: { targetId },
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
json: () => ({ ok: true }),
successMessage: `focused tab ${targetId}`,
});
});
@ -691,37 +610,33 @@ export function registerBrowserManageCommands(
.command("profiles")
.description("List all browser profiles")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
parent,
{
method: "GET",
path: "/profiles",
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const profiles = result.profiles ?? [];
if (printJsonResult(parent, { profiles })) {
return;
}
if (profiles.length === 0) {
defaultRuntime.log("No profiles configured.");
return;
}
defaultRuntime.log(
profiles
.map((p) => {
const status = p.running ? "running" : "stopped";
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
const def = p.isDefault ? " [default]" : "";
const loc = formatBrowserConnectionSummary(p);
const remote = p.isRemote ? " [remote]" : "";
const driver = p.driver !== "openclaw" ? ` [${p.driver}]` : "";
return `${p.name}: ${status}${tabs}${def}${remote}${driver}\n ${loc}, color: ${p.color}`;
})
.join("\n"),
);
await runBrowserCliRequest<{ profiles: ProfileStatus[] }>({
parent: parentOpts(cmd),
profile: null,
method: "GET",
path: "/profiles",
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
json: (result) => ({ profiles: result.profiles ?? [] }),
print: (result) => {
const profiles = result.profiles ?? [];
if (profiles.length === 0) {
defaultRuntime.log("No profiles configured.");
return;
}
defaultRuntime.log(
profiles
.map((p) => {
const status = p.running ? "running" : "stopped";
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
const def = p.isDefault ? " [default]" : "";
const loc = formatBrowserConnectionSummary(p);
const remote = p.isRemote ? " [remote]" : "";
const driver = p.driver !== "openclaw" ? ` [${p.driver}]` : "";
return `${p.name}: ${status}${tabs}${def}${remote}${driver}\n ${loc}, color: ${p.color}`;
})
.join("\n"),
);
},
});
});
@ -730,35 +645,31 @@ export function registerBrowserManageCommands(
.description("List Chrome-family profiles available for cookie import")
.option("--browser <browser>", "System browser (chrome|brave|edge|chromium); omit to list all")
.action(async (opts: { browser?: string }, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ systemProfiles: SystemProfileInfo[] }>(
parent,
{
method: "GET",
path: "/system-profiles",
query: opts.browser ? { browser: opts.browser } : undefined,
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const systemProfiles = result.systemProfiles ?? [];
if (printJsonResult(parent, { systemProfiles })) {
return;
}
if (systemProfiles.length === 0) {
defaultRuntime.log("No system browser profiles found.");
return;
}
defaultRuntime.log("browser\tid\tname\thasCookies");
defaultRuntime.log(
systemProfiles
.map((profile) =>
[profile.browser, profile.id, profile.name, profile.hasCookies ? "yes" : "no"]
.map(sanitizeTableCell)
.join("\t"),
)
.join("\n"),
);
await runBrowserCliRequest<{ systemProfiles: SystemProfileInfo[] }>({
parent: parentOpts(cmd),
profile: null,
method: "GET",
path: "/system-profiles",
query: opts.browser ? { browser: opts.browser } : undefined,
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
json: (result) => ({ systemProfiles: result.systemProfiles ?? [] }),
print: (result) => {
const systemProfiles = result.systemProfiles ?? [];
if (systemProfiles.length === 0) {
defaultRuntime.log("No system browser profiles found.");
return;
}
defaultRuntime.log("browser\tid\tname\thasCookies");
defaultRuntime.log(
systemProfiles
.map((profile) =>
[profile.browser, profile.id, profile.name, profile.hasCookies ? "yes" : "no"]
.map(sanitizeTableCell)
.join("\t"),
)
.join("\n"),
);
},
});
});
@ -771,34 +682,25 @@ export function registerBrowserManageCommands(
.option("--domains <domains>", "Comma-separated domain filter")
.action(
async (opts: { browser: string; system: string; into: string; domains?: string }, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const domains = opts.domains
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean);
const result = await callBrowserRequest<BrowserImportProfileResult>(
parent,
{
method: "POST",
path: "/profiles/import",
body: {
browser: opts.browser,
systemProfile: opts.system,
into: opts.into,
domains,
},
},
{ timeoutMs: 120_000 },
);
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(
const domains = opts.domains
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean);
await runBrowserCliRequest<BrowserImportProfileResult>({
parent: parentOpts(cmd),
profile: null,
path: "/profiles/import",
body: {
browser: opts.browser,
systemProfile: opts.system,
into: opts.into,
domains,
},
timeoutMs: 120_000,
successMessage: (result) =>
info(
`Imported cookies into "${result.into}": ${result.cookies.imported}/${result.cookies.total} imported, ${result.cookies.failed} failed, ${result.cookies.skipped} skipped; ${result.domains.length} domains`,
),
);
});
},
);

View file

@ -50,6 +50,60 @@ export function runBrowserCliCommand(action: () => Promise<void>) {
});
}
/** Execute a scoped request with the command family's existing error and output policy. */
export async function runBrowserCliRequest<T = unknown>(params: {
parent: BrowserParentOpts;
method?: BrowserRequestParams["method"];
path: string;
query?: BrowserRequestParams["query"];
body?: unknown;
/** Global commands pass null instead of applying the selected profile. */
profile?: string | null;
timeoutMs?: number;
errorPolicy?: "runtime" | "inline";
successMessage?: string | ((result: T) => string);
print?: (result: T) => void;
json?: (result: T) => unknown;
}): Promise<void> {
const action = async () => {
const profile =
params.profile === null ? undefined : (params.profile ?? params.parent.browserProfile);
const result = await callBrowserRequest<T>(
params.parent,
{
method: params.method ?? "POST",
path: params.path,
query: resolveBrowserProfileQuery(profile, params.query),
body: params.body,
},
{ timeoutMs: params.timeoutMs },
);
if (params.parent.json) {
defaultRuntime.writeJson(params.json ? params.json(result) : result);
} else if (params.print) {
params.print(result);
} else if (params.successMessage !== undefined) {
defaultRuntime.log(
typeof params.successMessage === "function"
? params.successMessage(result)
: params.successMessage,
);
}
};
if (params.errorPolicy !== "inline") {
await runBrowserCliCommand(action);
return;
}
// These older commands report even expected/JSON-mode errors locally. Keep
// that public CLI behavior distinct from runCommandWithRuntime's rethrow path.
try {
await action();
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}
/** Writes a Browser command result when structured output was requested. */
export function printBrowserJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
if (!parent?.json) {

View file

@ -5,7 +5,7 @@ import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
runBrowserCliRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { danger, defaultRuntime, inheritOptionFromParent } from "./core-api.js";
@ -21,24 +21,6 @@ function resolveTargetId(rawTargetId: unknown, command: Command): string | undef
);
}
async function runMutationRequest(params: {
parent: BrowserParentOpts;
request: Parameters<typeof callBrowserRequest>[1];
successMessage: string;
}) {
try {
const result = await callBrowserRequest(params.parent, params.request);
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.successMessage);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}
/** Registers Browser cookies and storage subcommands. */
export function registerBrowserCookiesAndStorageCommands(
browser: Command,
@ -48,26 +30,15 @@ export function registerBrowserCookiesAndStorageCommands(
cookies.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP).action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest<{ cookies?: unknown[] }>(parent, {
method: "GET",
path: "/cookies",
query: {
targetId,
profile,
},
});
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.cookies ?? []);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
await runBrowserCliRequest<{ cookies?: unknown[] }>({
parent,
method: "GET",
path: "/cookies",
query: { targetId },
errorPolicy: "inline",
print: (result) => defaultRuntime.writeJson(result.cookies ?? []),
});
});
cookies
@ -79,7 +50,6 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (name: string, value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
const url = resolveUrl(opts);
if (!url) {
@ -87,17 +57,14 @@ export function registerBrowserCookiesAndStorageCommands(
defaultRuntime.exit(1);
return;
}
await runMutationRequest({
await runBrowserCliRequest({
parent,
request: {
method: "POST",
path: "/cookies/set",
query: profile ? { profile } : undefined,
body: {
targetId,
cookie: { name, value, url },
},
path: "/cookies/set",
body: {
targetId,
cookie: { name, value, url },
},
errorPolicy: "inline",
successMessage: `cookie set: ${name}`,
});
});
@ -108,18 +75,12 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
await runMutationRequest({
await runBrowserCliRequest({
parent,
request: {
method: "POST",
path: "/cookies/clear",
query: profile ? { profile } : undefined,
body: {
targetId,
},
},
path: "/cookies/clear",
body: { targetId },
errorPolicy: "inline",
successMessage: "cookies cleared",
});
});
@ -136,27 +97,15 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (key: string | undefined, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
try {
const result = await callBrowserRequest<{ values?: Record<string, string> }>(parent, {
method: "GET",
path: `/storage/${kind}`,
query: {
key: normalizeOptionalString(key),
targetId,
profile,
},
});
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.values ?? {});
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
await runBrowserCliRequest<{ values?: Record<string, string> }>({
parent,
method: "GET",
path: `/storage/${kind}`,
query: { key: normalizeOptionalString(key), targetId },
errorPolicy: "inline",
print: (result) => defaultRuntime.writeJson(result.values ?? {}),
});
});
cmd
@ -167,20 +116,12 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (key: string, value: string, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
await runMutationRequest({
await runBrowserCliRequest({
parent,
request: {
method: "POST",
path: `/storage/${kind}/set`,
query: profile ? { profile } : undefined,
body: {
key,
value,
targetId,
},
},
path: `/storage/${kind}/set`,
body: { key, value, targetId },
errorPolicy: "inline",
successMessage: `${kind}Storage set: ${key}`,
});
});
@ -191,18 +132,12 @@ export function registerBrowserCookiesAndStorageCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
await runMutationRequest({
await runBrowserCliRequest({
parent,
request: {
method: "POST",
path: `/storage/${kind}/clear`,
query: profile ? { profile } : undefined,
body: {
targetId,
},
},
path: `/storage/${kind}/clear`,
body: { targetId },
errorPolicy: "inline",
successMessage: `${kind}Storage cleared`,
});
});

View file

@ -1,28 +1,18 @@
// Browser tests cover browser cli state.option collisions plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as browserCliResizeModule from "./browser-cli-resize.js";
import * as browserCliSharedModule from "./browser-cli-shared.js";
import { mockBrowserGateway } from "./browser-cli.test-support.js";
import * as cliCoreApiModule from "./core-api.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn(async (..._args: unknown[]) => ({ ok: true })),
runBrowserResizeWithOutput: vi.fn(async (_params: unknown) => {}),
}));
const runActualBrowserResizeWithOutput = browserCliResizeModule.runBrowserResizeWithOutput;
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
const gatewayMock = mockBrowserGateway();
vi.spyOn(browserCliResizeModule, "runBrowserResizeWithOutput").mockImplementation(
mocks.runBrowserResizeWithOutput,
);
vi.spyOn(cliCoreApiModule, "runCommandWithRuntime").mockImplementation(
async (_runtime, action, onError) => {
try {
await action();
} catch (err) {
onError?.(err);
}
},
);
const {
createBrowserProgram: createBrowserProgramShared,
getBrowserCliRuntime,
@ -50,11 +40,11 @@ describe("browser state option collisions", () => {
};
const getLastRequest = () => {
const call = mocks.callBrowserRequest.mock.calls.at(-1);
const call = gatewayMock.mock.calls.at(-1);
if (!call) {
throw new Error("expected browser request call");
}
return call[1] as { body?: Record<string, unknown> };
return call[2];
};
const runBrowserCommand = async (argv: string[]) => {
@ -75,7 +65,7 @@ describe("browser state option collisions", () => {
};
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
gatewayMock.mockClear();
mocks.runBrowserResizeWithOutput.mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
getBrowserCliRuntime().exit.mockImplementation(() => {});
@ -105,9 +95,11 @@ describe("browser state option collisions", () => {
])("inherits parent timeout for $path", async ({ args, path }) => {
await runBrowserCommand(["--timeout", "60000", "--json", ...args]);
expect(mocks.callBrowserRequest).toHaveBeenLastCalledWith(
expect(gatewayMock).toHaveBeenLastCalledWith(
"browser.request",
expect.objectContaining({ timeout: "60000" }),
expect.objectContaining({ path }),
expect.objectContaining({ path, timeoutMs: 60000 }),
expect.objectContaining({ scopes: ["operator.admin"] }),
);
});
@ -134,13 +126,16 @@ describe("browser state option collisions", () => {
successMessage: "unused",
});
expect(mocks.callBrowserRequest).toHaveBeenLastCalledWith(
expect(gatewayMock).toHaveBeenLastCalledWith(
"browser.request",
expect.objectContaining({ timeout: "60000" }),
expect.objectContaining({
path: "/act",
query: { profile: "work" },
body: { kind: "resize", width: 1024, height: 768, targetId: "tab-1" },
timeoutMs: 60000,
}),
expect.objectContaining({ scopes: ["operator.admin"] }),
);
});
@ -156,7 +151,7 @@ describe("browser state option collisions", () => {
"tab-1",
]);
expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1");
expect(request.body?.targetId).toBe("tab-1");
});
it("does not inherit the parent Gateway URL as the cookie scope", async () => {
@ -166,39 +161,35 @@ describe("browser state option collisions", () => {
{ from: "user" },
);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage("Missing required --url option for cookies set");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});
it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => {
const request = (await runBrowserCommandAndGetRequest([
const request = await runBrowserCommandAndGetRequest([
"set",
"headers",
"--json",
'{"x-auth":"ok"}',
])) as {
body?: { headers?: Record<string, string> };
};
]);
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
});
it("filters non-string header values from JSON payload", async () => {
const request = (await runBrowserCommandAndGetRequest([
const request = await runBrowserCommandAndGetRequest([
"set",
"headers",
"--json",
'{"x-auth":"ok","retry":3,"enabled":true}',
])) as {
body?: { headers?: Record<string, string> };
};
]);
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
});
it("errors when set offline receives an invalid value", async () => {
await runBrowserCommand(["set", "offline", "maybe"]);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage("Expected on|off");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});
@ -222,7 +213,7 @@ describe("browser state option collisions", () => {
it("errors when set media receives an invalid value", async () => {
await runBrowserCommand(["set", "media", "sepia"]);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage("Expected dark|light|no-preference|none");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});
@ -236,7 +227,7 @@ describe("browser state option collisions", () => {
it("rejects invalid geolocation numbers before dispatch", async () => {
await runBrowserCommand(["set", "geo", "48.208", "16.373", "--accuracy", "fast"]);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage("Invalid --accuracy: must be a finite number");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});
@ -261,7 +252,7 @@ describe("browser state option collisions", () => {
it("errors when headers JSON is missing", async () => {
await runBrowserCommand(["set", "headers"]);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage(
"Error: Missing headers JSON (pass --headers-json or positional JSON argument)",
);
@ -271,7 +262,7 @@ describe("browser state option collisions", () => {
it("errors when headers JSON is not an object", async () => {
await runBrowserCommand(["set", "headers", "--json", "[]"]);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
expect(gatewayMock).not.toHaveBeenCalled();
expectErrorMessage("Error: Headers JSON must be a JSON object");
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
});

View file

@ -14,6 +14,7 @@ import {
callBrowserRequest,
printBrowserJsonResult,
runBrowserCliCommand as runBrowserCommand,
runBrowserCliRequest,
type BrowserParentOpts,
} from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
@ -37,27 +38,6 @@ function parseFiniteNumberOption(value: string | undefined, label: string): numb
return parsed;
}
async function runBrowserSetRequest(params: {
parent: BrowserParentOpts;
path: string;
body: Record<string, unknown>;
successMessage: string;
}) {
await runBrowserCommand(async () => {
const profile = params.parent?.browserProfile;
const result = await callBrowserRequest(params.parent, {
method: "POST",
path: params.path,
query: profile ? { profile } : undefined,
body: params.body,
});
if (printBrowserJsonResult(params.parent, result)) {
return;
}
defaultRuntime.log(params.successMessage);
});
}
/** Registers Browser state/configuration commands. */
export function registerBrowserStateCommands(
browser: Command,
@ -106,7 +86,7 @@ export function registerBrowserStateCommands(
defaultRuntime.exit(1);
return;
}
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/offline",
body: {
@ -167,7 +147,7 @@ export function registerBrowserStateCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/credentials",
body: {
@ -202,7 +182,7 @@ export function registerBrowserStateCommands(
) {
return;
}
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/geolocation",
body: {
@ -233,7 +213,7 @@ export function registerBrowserStateCommands(
defaultRuntime.exit(1);
return;
}
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/media",
body: {
@ -251,7 +231,7 @@ export function registerBrowserStateCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (timezoneId: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/timezone",
body: {
@ -269,7 +249,7 @@ export function registerBrowserStateCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (locale: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/locale",
body: {
@ -287,7 +267,7 @@ export function registerBrowserStateCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (name: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
await runBrowserCliRequest({
parent,
path: "/set/device",
body: {

View file

@ -2,9 +2,38 @@
* Test support for Browser CLI command registration and runtime capture.
*/
import { Command } from "commander";
import { expect, vi } from "vitest";
import { createCliRuntimeCapture } from "../../test-support.js";
import type { CliRuntimeCapture } from "../../test-support.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import * as cliCoreApiModule from "./core-api.js";
type BrowserGatewayRequest = {
method: string;
path: string;
query?: Record<string, string>;
body?: Record<string, unknown>;
timeoutMs?: number;
};
/** Intercepts the Gateway boundary while keeping Browser request construction real. */
export function mockBrowserGateway() {
const mock = vi.fn<
(
method: string,
opts: Parameters<typeof cliCoreApiModule.callGatewayFromCli>[1],
request: BrowserGatewayRequest,
extra?: Parameters<typeof cliCoreApiModule.callGatewayFromCli>[3],
) => Promise<Record<string, unknown>>
>(async () => ({ ok: true }));
vi.spyOn(cliCoreApiModule, "callGatewayFromCli").mockImplementation(
(method, opts, request, extra) => {
expect(method).toBe("browser.request");
return mock(method, opts, request as BrowserGatewayRequest, extra);
},
);
return mock;
}
/** Creates a minimal Browser command program for CLI unit tests. */
export function createBrowserProgram(params?: { withGatewayUrl?: boolean }): {

View file

@ -190,7 +190,6 @@ describe("production lint suppressions", () => {
"extensions/browser/src/browser/pw-tools-core.activity.ts|unicorn/prefer-dom-node-text-content|1",
"extensions/browser/src/browser/pw-tools-core.interactions.actions.ts|@typescript-eslint/no-implied-eval|2",
"extensions/browser/src/browser/pw-tools-core.interactions.content.ts|@typescript-eslint/no-implied-eval|1",
"extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.ts|typescript/no-unnecessary-type-parameters|1",
"extensions/browser/src/node-host/invoke-browser.ts|typescript/no-unnecessary-type-parameters|1",
// Keep an explicit removal marker beside the temporary Bun Worker preload workaround.
"extensions/codex/session-history-worker-runtime.ts|no-warning-comments|1",