Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
/**
* Browser bridge API barrel. It exposes the host/sandbox bridge server handle
* and lifecycle helpers without importing the full browser plugin entry.
*/
export type { BrowserBridge } from "./src/browser/bridge-server.js";
export { startBrowserBridgeServer, stopBrowserBridgeServer } from "./src/browser/bridge-server.js";

View File

@@ -0,0 +1,5 @@
/**
* Browser CDP helper barrel. It exposes URL parsing/redaction helpers used by
* browser config and diagnostics surfaces.
*/
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/cdp.helpers.js";

View File

@@ -0,0 +1,20 @@
/**
* Browser config API barrel. It re-exports default profile, upload, auth, and
* CDP config helpers for setup/runtime consumers.
*/
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
resolveBrowserConfig,
resolveProfile,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
type ResolvedBrowserTabCleanupConfig,
} from "./browser-profiles.js";
export { resolveBrowserControlAuth, type BrowserControlAuth } from "./browser-control-auth.js";
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/config.js";

View File

@@ -0,0 +1,10 @@
/**
* Browser control-auth API barrel. It exposes auth generation and validation
* helpers for the browser control server.
*/
export type { BrowserControlAuth } from "./src/browser/control-auth.js";
export {
ensureBrowserControlAuth,
resolveBrowserControlAuth,
shouldAutoGenerateBrowserAuth,
} from "./src/browser/control-auth.js";

View File

@@ -0,0 +1,10 @@
/**
* Browser doctor API barrel. It exposes legacy profile cleanup and Chrome MCP
* readiness helpers for OpenClaw doctor.
*/
export {
detectLegacyClawdBrowserProfileResidue,
maybeArchiveLegacyClawdBrowserProfileResidue,
noteChromeMcpBrowserReadiness,
} from "./src/doctor-browser.js";
export type { LegacyClawdBrowserProfileResidue } from "./src/doctor-browser.js";

View File

@@ -0,0 +1,10 @@
/**
* Browser host-inspection API barrel. It exposes Chrome executable discovery
* and version parsing helpers.
*/
export type { BrowserExecutable } from "./src/browser/chrome.executables.js";
export {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./src/browser/chrome.executables.js";

View File

@@ -0,0 +1,6 @@
/**
* Browser maintenance API barrel. It exposes tab cleanup and trash helpers for
* runtime and doctor flows.
*/
export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js";
export { movePathToTrash } from "./src/browser/trash.js";

View File

@@ -0,0 +1,19 @@
/**
* Browser profile API barrel. It exposes browser profile defaults and config
* resolution helpers for setup and runtime paths.
*/
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
resolveBrowserConfig,
resolveProfile,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
type ResolvedBrowserTabCleanupConfig,
} from "./src/browser/config.js";

View File

@@ -0,0 +1,21 @@
/**
* Browser CLI metadata entry. It registers the `openclaw browser` command lazily
* so command discovery does not load the full browser runtime.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
/** Plugin entry that contributes Browser CLI commands. */
export default definePluginEntry({
id: "browser",
name: "Browser",
description: "Default browser tool plugin",
register(api) {
api.registerCli(
async ({ program }) => {
const { registerBrowserCli } = await import("./src/cli/browser-cli.js");
registerBrowserCli(program);
},
{ commands: ["browser"] },
);
},
});

View File

@@ -0,0 +1,336 @@
// Browser tests cover index plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
browserPluginNodeHostCommands,
browserPluginReload,
browserSecurityAuditCollectors,
registerBrowserPlugin,
} from "./plugin-registration.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import setupPlugin from "./setup-api.js";
type BrowserAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
const runtimeApiMocks = vi.hoisted(() => ({
createBrowserPluginService: vi.fn(() => ({ id: "browser-control", start: vi.fn() })),
createBrowserTool: vi.fn(() => ({
name: "browser",
description: "browser",
parameters: { type: "object", properties: {} },
execute: vi.fn(async () => ({ type: "json", value: { ok: true } })),
})),
collectBrowserSecurityAuditFindings: vi.fn(() => []),
handleBrowserGatewayRequest: vi.fn(),
registerBrowserCli: vi.fn(),
runBrowserProxyCommand: vi.fn(async () => "ok"),
stopBrowserControlService: vi.fn(async () => undefined),
}));
vi.mock("./register.runtime.js", async () => {
const actual =
await vi.importActual<typeof import("./register.runtime.js")>("./register.runtime.js");
return {
...actual,
collectBrowserSecurityAuditFindings: runtimeApiMocks.collectBrowserSecurityAuditFindings,
createBrowserPluginService: runtimeApiMocks.createBrowserPluginService,
createBrowserTool: runtimeApiMocks.createBrowserTool,
handleBrowserGatewayRequest: runtimeApiMocks.handleBrowserGatewayRequest,
runBrowserProxyCommand: runtimeApiMocks.runBrowserProxyCommand,
};
});
vi.mock("./src/cli/browser-cli.js", () => ({
registerBrowserCli: runtimeApiMocks.registerBrowserCli,
}));
vi.mock("./src/control-service.js", () => ({
stopBrowserControlService: runtimeApiMocks.stopBrowserControlService,
}));
beforeAll(async () => {
await import("./register.runtime.js");
});
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
function createApi() {
const registerCli = vi.fn();
const registerGatewayMethod = vi.fn();
const registerService = vi.fn();
const registerTool = vi.fn();
const api = createTestPluginApi({
id: "browser",
name: "Browser",
source: "test",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
registerCli,
registerGatewayMethod,
registerService,
registerTool,
});
return { api, registerCli, registerGatewayMethod, registerService, registerTool };
}
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
const call = mock.mock.calls.at(index);
if (!call) {
throw new Error(`expected mock call ${index}`);
}
return call[argIndex];
}
function registerBrowserAutoEnableProbe(): BrowserAutoEnableProbe {
const probes: BrowserAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected browser setup plugin to register an auto-enable probe");
}
return probe;
}
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
expect(browserPluginNodeHostCommands).toHaveLength(1);
expect(browserPluginNodeHostCommands[0]?.command).toBe("browser.proxy");
expect(browserPluginNodeHostCommands[0]?.cap).toBe("browser");
expect(typeof browserPluginNodeHostCommands[0]?.handle).toBe("function");
expect(browserSecurityAuditCollectors).toHaveLength(1);
});
it("bundles the browser automation skill with the plugin", () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "openclaw.plugin.json"), "utf8"),
) as { skills?: string[] };
const skillPath = path.join(__dirname, "skills", "browser-automation", "SKILL.md");
expect(manifest.skills).toEqual(["./skills"]);
expect(fs.readFileSync(skillPath, "utf8")).toContain("name: browser-automation");
});
it("keeps browser tool registration synchronous while loading runtime on execute", async () => {
const { api, registerTool } = createApi();
registerBrowserPlugin(api);
const factory = mockCallArg(registerTool);
if (typeof factory !== "function") {
throw new Error("expected browser plugin to register a tool factory");
}
const tool = factory({
sessionKey: "agent:main:webchat:direct:123",
browser: {
sandboxBridgeUrl: "http://127.0.0.1:9999",
allowHostControl: true,
},
});
if (!tool || Array.isArray(tool)) {
throw new Error("expected browser plugin to return a single tool");
}
expect(tool.name).toBe("browser");
expect(runtimeApiMocks.createBrowserTool).not.toHaveBeenCalled();
await tool.execute("call-1", { action: "status" });
expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({
sandboxBridgeUrl: "http://127.0.0.1:9999",
allowHostControl: true,
agentSessionKey: "agent:main:webchat:direct:123",
mediaScope: {
sessionKey: "agent:main:webchat:direct:123",
chatType: "direct",
},
});
});
it("passes runtime context needed for screenshot image understanding", async () => {
const { api, registerTool } = createApi();
registerBrowserPlugin(api);
const factory = mockCallArg(registerTool);
if (typeof factory !== "function") {
throw new Error("expected browser plugin to register a tool factory");
}
const tool = factory({
sessionKey: "agent:main:webchat:direct:123",
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
activeModel: { provider: "openai", modelId: "gpt-5.5" },
deliveryContext: { channel: "telegram" },
});
if (!tool || Array.isArray(tool)) {
throw new Error("expected browser plugin to return a single tool");
}
await tool.execute("call-1", { action: "status" });
expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({
agentSessionKey: "agent:main:webchat:direct:123",
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
activeModel: { provider: "openai", model: "gpt-5.5" },
mediaScope: {
sessionKey: "agent:main:webchat:direct:123",
channel: "telegram",
chatType: "direct",
},
});
});
it("derives group chat type for browser media scope", async () => {
const { api, registerTool } = createApi();
registerBrowserPlugin(api);
const factory = mockCallArg(registerTool);
if (typeof factory !== "function") {
throw new Error("expected browser plugin to register a tool factory");
}
const tool = factory({
sessionKey: "agent:main:telegram:group:chat-123",
messageChannel: "telegram",
});
if (!tool || Array.isArray(tool)) {
throw new Error("expected browser plugin to return a single tool");
}
await tool.execute("call-1", { action: "status" });
expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({
agentSessionKey: "agent:main:telegram:group:chat-123",
mediaScope: {
sessionKey: "agent:main:telegram:group:chat-123",
channel: "telegram",
chatType: "group",
},
});
});
it("registers CLI descriptors and lazy-loads the lightweight browser CLI", async () => {
const { api, registerCli } = createApi();
registerBrowserPlugin(api);
expect(registerCli).toHaveBeenCalledTimes(1);
const registrar = mockCallArg(registerCli) as (params: { program: never }) => unknown;
expect(typeof registrar).toBe("function");
expect(mockCallArg(registerCli, 0, 1)).toEqual({
commands: ["browser"],
descriptors: [
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
},
],
});
await registrar({ program: {} as never });
expect(runtimeApiMocks.registerBrowserCli).toHaveBeenCalledWith({});
});
it("registers browser.request as an admin gateway method and lazy-loads handler", async () => {
const { api, registerGatewayMethod } = createApi();
registerBrowserPlugin(api);
expect(registerGatewayMethod).toHaveBeenCalledTimes(1);
expect(mockCallArg(registerGatewayMethod)).toBe("browser.request");
const handler = mockCallArg(registerGatewayMethod, 0, 1) as (request: {
method: string;
}) => unknown;
expect(typeof handler).toBe("function");
expect(mockCallArg(registerGatewayMethod, 0, 2)).toEqual({
scope: "operator.admin",
});
await handler({ method: "browser.request" });
expect(runtimeApiMocks.handleBrowserGatewayRequest).toHaveBeenCalledWith({
method: "browser.request",
});
});
it("lazy-loads node host and audit runtime handlers", async () => {
await expect(browserPluginNodeHostCommands[0]?.handle("{}")).resolves.toBe("ok");
expect(runtimeApiMocks.runBrowserProxyCommand).toHaveBeenCalledWith("{}");
await expect(browserSecurityAuditCollectors[0]?.({} as never)).resolves.toStrictEqual([]);
expect(runtimeApiMocks.collectBrowserSecurityAuditFindings).toHaveBeenCalled();
});
it("registers a lazy browser control service", async () => {
const { api, registerService } = createApi();
registerBrowserPlugin(api);
const service = mockCallArg(registerService) as {
id: string;
start: (...args: unknown[]) => unknown;
stop: (...args: unknown[]) => unknown;
};
expect(service?.id).toBe("browser-control");
expect(typeof service?.start).toBe("function");
expect(typeof service?.stop).toBe("function");
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
await service.stop({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
expect(runtimeApiMocks.stopBrowserControlService).toHaveBeenCalledOnce();
});
it("eager-loads the browser control service when explicitly requested", async () => {
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
const { api, registerService } = createApi();
registerBrowserPlugin(api);
const service = mockCallArg(registerService) as {
id: string;
start: (...args: unknown[]) => unknown;
};
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
expect(runtimeApiMocks.createBrowserPluginService).toHaveBeenCalledOnce();
});
for (const value of ["false", "", "disabled"]) {
it(`keeps browser control service env value ${JSON.stringify(value)} lazy`, async () => {
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", value);
const { api, registerService } = createApi();
registerBrowserPlugin(api);
const service = mockCallArg(registerService) as {
id: string;
start: (...args: unknown[]) => unknown;
};
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
});
}
it("declares setup auto-enable reasons for browser config surfaces", () => {
const probe = registerBrowserAutoEnableProbe();
expect(probe({ config: { browser: { defaultProfile: "openclaw" } }, env: {} })).toBe(
"browser configured",
);
expect(probe({ config: { tools: { alsoAllow: ["browser"] } }, env: {} })).toBe(
"browser tool referenced",
);
expect(
probe({ config: { browser: { defaultProfile: "openclaw", enabled: false } }, env: {} }),
).toBeNull();
});
});

View File

@@ -0,0 +1,22 @@
/**
* Browser plugin entry. It wires the browser tool, gateway request handler,
* node-host command, services, reload policy, and security audit collectors.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
browserPluginNodeHostCommands,
browserPluginReload,
browserSecurityAuditCollectors,
registerBrowserPlugin,
} from "./plugin-registration.js";
/** Main Browser plugin entry for runtime registration. */
export default definePluginEntry({
id: "browser",
name: "Browser",
description: "Default browser tool plugin",
reload: browserPluginReload,
nodeHostCommands: browserPluginNodeHostCommands,
securityAuditCollectors: [...browserSecurityAuditCollectors],
register: registerBrowserPlugin,
});

View File

@@ -0,0 +1,18 @@
{
"id": "browser",
"enabledByDefault": true,
"activation": {
"onStartup": true,
"onConfigPaths": ["browser"]
},
"contracts": {
"tools": ["browser"]
},
"commandAliases": [{ "name": "browser" }],
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"commander": "15.0.0",
"express": "5.2.1",
"playwright-core": "1.61.1",
"typebox": "1.3.3",
"ws": "8.21.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"undici": "8.6.0"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,217 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
/**
* Browser plugin registration helpers. This file keeps registration lazy while
* advertising Browser tools, services, node-host commands, and audits.
*/
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginSecurityAuditCollector,
OpenClawPluginService,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE,
} from "./src/browser-gateway-contract.js";
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule(
() => import("./register.runtime.js"),
);
function isTruthyEnvValue(value: string | undefined): boolean {
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
}
function deriveChatTypeFromSessionKey(
sessionKey: string | undefined,
): "direct" | "group" | "channel" | undefined {
const tokens = new Set(sessionKey?.toLowerCase().split(":").filter(Boolean) ?? []);
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
return undefined;
}
const BROWSER_CLI_DESCRIPTOR = {
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
};
function createLazyBrowserTool(opts?: {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
agentSessionKey?: string;
agentDir?: string;
workspaceDir?: string;
activeModel?: {
provider?: string;
model?: string;
};
mediaScope?: {
sessionKey?: string;
channel?: string;
chatType?: string;
};
}): AnyAgentTool {
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint =
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
return {
label: "Browser",
name: "browser",
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
'For the logged-in user browser, use profile="user". A supported Chromium-based browser (v144+) must be running on the selected host or browser node. Use only when existing logins/cookies matter and the user is present.',
'For profile="user" or other existing-session profiles, omit timeoutMs on act:type, evaluate, hover, scrollIntoView, drag, select, and fill; that driver rejects per-call timeout overrides for those actions.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc). For tab operations, targetId also accepts tabId handles (t1) and labels from action=tabs.",
"For multi-step browser work, login checks, stale refs, duplicate tabs, or Google Meet flows, use the bundled browser-automation skill when it is available.",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
hostHint,
].join(" "),
parameters: BrowserToolSchema,
execute: async (toolCallId, args, signal, onUpdate) => {
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
const tool = createBrowserTool(opts);
return await tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
agentSessionKey?: string;
agentDir?: string;
workspaceDir?: string;
activeModel?: {
provider?: string;
model?: string;
};
mediaScope?: {
sessionKey?: string;
channel?: string;
chatType?: string;
};
} {
const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel;
const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey);
return {
...(ctx.browser?.sandboxBridgeUrl ? { sandboxBridgeUrl: ctx.browser.sandboxBridgeUrl } : {}),
...(ctx.browser?.allowHostControl !== undefined
? { allowHostControl: ctx.browser.allowHostControl }
: {}),
...(ctx.sessionKey ? { agentSessionKey: ctx.sessionKey } : {}),
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(ctx.activeModel?.provider || ctx.activeModel?.modelId
? {
activeModel: {
provider: ctx.activeModel.provider,
model: ctx.activeModel.modelId,
},
}
: {}),
...(ctx.sessionKey || mediaChannel
? {
mediaScope: {
...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
...(mediaChannel ? { channel: mediaChannel } : {}),
...(mediaChatType ? { chatType: mediaChatType } : {}),
},
}
: {}),
};
}
/** Browser plugin reload policy. */
export const browserPluginReload = { restartPrefixes: ["browser"] };
/** Node-host command descriptors exposed by the Browser plugin. */
export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "browser.proxy",
cap: "browser",
handle: async (paramsJSON) => {
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
return await runBrowserProxyCommand(paramsJSON);
},
},
];
/** Security audit collectors contributed by the Browser plugin. */
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
async (ctx) => {
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
return collectBrowserSecurityAuditFindings(ctx);
},
];
function createLazyBrowserPluginService(): OpenClawPluginService {
let service: OpenClawPluginService | null = null;
const loadService = async () => {
if (!service) {
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
service = createBrowserPluginService();
}
return service;
};
return {
id: "browser-control",
start: async (ctx) => {
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) {
return;
}
const loaded = await loadService();
await loaded.start(ctx);
},
stop: async (ctx) => {
if (!service) {
const { stopBrowserControlService } = await import("./src/control-service.js");
await stopBrowserControlService().catch(() => {});
return;
}
await service.stop?.(ctx);
},
};
}
/** Register Browser tool factories, CLI, gateway methods, services, and audits. */
export function registerBrowserPlugin(api: OpenClawPluginApi) {
api.registerTool(((ctx: OpenClawPluginToolContext) =>
createLazyBrowserTool(createBrowserToolOptions(ctx))) as OpenClawPluginToolFactory);
api.registerCli(
async ({ program }) => {
const { registerBrowserCli } = await import("./src/cli/browser-cli.js");
registerBrowserCli(program);
},
{ commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] },
);
api.registerGatewayMethod(
BROWSER_REQUEST_GATEWAY_METHOD,
async (opts) => {
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
return await handleBrowserGatewayRequest(opts);
},
{
scope: BROWSER_REQUEST_GATEWAY_SCOPE,
},
);
api.registerService(createLazyBrowserPluginService());
}

View File

@@ -0,0 +1,9 @@
/**
* Browser runtime registration barrel. Node host commands and plugin
* registration lazy-load these exports when browser runtime behavior is needed.
*/
export { createBrowserTool } from "./src/browser-tool.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
export { collectBrowserSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -0,0 +1,95 @@
/**
* Browser runtime API barrel. It exposes the full Browser runtime surface for
* plugin consumers while keeping the entrypoint itself declarative.
*/
export { createBrowserTool } from "./src/browser-tool.js";
export {
applyBrowserProxyPaths,
browserAct,
browserArmDialog,
browserArmFileChooser,
type BrowserBridge,
browserCloseTab,
browserConsoleMessages,
browserCreateProfile,
type BrowserCreateProfileResult,
browserDeleteProfile,
type BrowserDeleteProfileResult,
browserDoctor,
type BrowserDoctorCheck,
type BrowserDoctorReport,
type BrowserExecutable,
browserFocusTab,
type BrowserFormField,
browserNavigate,
browserOpenTab,
browserPdfSave,
browserProfiles,
browserResetProfile,
type BrowserResetProfileResult,
type BrowserRouteRegistrar,
browserScreenshotAction,
type BrowserServerState,
browserSnapshot,
browserStart,
browserStatus,
type BrowserStatus,
browserStop,
type BrowserTab,
browserTabAction,
browserTabs,
type BrowserTransport,
closeTrackedBrowserTabsForSessions,
createBrowserControlContext,
createBrowserRouteContext,
createBrowserRouteDispatcher,
createBrowserRuntimeState,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
ensureBrowserControlAuth,
getBrowserControlState,
getBrowserProfileCapabilities,
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
isPersistentBrowserProfileMutation,
movePathToTrash,
normalizeBrowserFormField,
normalizeBrowserFormFieldValue,
normalizeBrowserRequestPath,
parseBrowserMajorVersion,
persistBrowserProxyFiles,
type ProfileStatus,
readBrowserVersion,
redactCdpUrl,
registerBrowserRoutes,
resolveBrowserConfig,
resolveBrowserControlAuth,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
resolveExistingPathsWithinRoot,
resolveGoogleChromeExecutableForPlatform,
resolveProfile,
resolveRequestedBrowserProfile,
runBrowserProxyCommand,
type SnapshotResult,
startBrowserBridgeServer,
startBrowserControlServiceFromConfig,
stopBrowserBridgeServer,
stopBrowserControlService,
stopBrowserRuntime,
trackSessionBrowserTab,
untrackSessionBrowserTab,
} from "./src/browser-runtime.js";
export { registerBrowserCli } from "./src/cli/browser-cli.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { browserHandlers } from "./src/gateway/browser-request.js";
export {
definePluginEntry,
type OpenClawPluginApi,
type OpenClawPluginToolContext,
type OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";

View File

@@ -0,0 +1,58 @@
/**
* Browser setup entry. It auto-enables the Browser plugin when config or tool
* policies reference browser control.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isRecord } from "./src/record-shared.js";
function listContainsBrowser(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => normalizeOptionalLowercaseString(entry) === "browser")
);
}
function toolPolicyReferencesBrowser(value: unknown): boolean {
return (
isRecord(value) && (listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow))
);
}
function hasBrowserToolReference(config: OpenClawConfig): boolean {
if (toolPolicyReferencesBrowser(config.tools)) {
return true;
}
const agentList = config.agents?.list;
return Array.isArray(agentList)
? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools))
: false;
}
/** Setup entry that detects existing Browser configuration references. */
export default definePluginEntry({
id: "browser",
name: "Browser Setup",
description: "Lightweight Browser setup hooks",
register(api) {
api.registerAutoEnableProbe(({ config }) => {
if (
config.browser?.enabled === false ||
config.plugins?.entries?.browser?.enabled === false
) {
return null;
}
if (Object.hasOwn(config, "browser")) {
return "browser configured";
}
if (config.plugins?.entries && Object.hasOwn(config.plugins.entries, "browser")) {
return "browser plugin configured";
}
if (hasBrowserToolReference(config)) {
return "browser tool referenced";
}
return null;
});
},
});

View File

@@ -0,0 +1,89 @@
---
name: browser-automation
description: Use when controlling web pages with the OpenClaw browser tool, especially multi-step flows, login checks, tab management, or recovery from stale refs/timeouts.
user-invocable: false
---
# Browser Automation
Use this skill when you need the `browser` tool for anything beyond a single page check.
## Operating Loop
1. Check browser state before acting:
- `openclaw browser doctor` or `action="status"` when the browser/plugin setup itself may be broken.
- `action="status"` for availability.
- `action="profiles"` if login state or profile choice matters.
- `action="tabs"` before opening a new tab if retries/timeouts may have left windows behind.
2. Prefer stable tab handles:
- Open important tabs with `label`, for example `label="meet"`.
- After `action="tabs"` or `action="open"`, store `suggestedTargetId` and pass it as `targetId` in later calls.
- `suggestedTargetId` is the label when one exists, otherwise the stable `tabId` handle like `t1`.
- Avoid relying on raw DevTools `targetId` except for immediate diagnostics; it can change under Chromium target replacement.
3. Read before you click:
- Use `action="snapshot"` on the intended `targetId`.
- Use the same `targetId` for follow-up actions so refs stay on the same tab.
- For durable Playwright refs, request `refs="aria"` when supported. If you receive `axN` refs from `snapshotFormat="aria"`, use them only after that same snapshot call; stale or unbound `axN` refs fail fast and need a fresh snapshot.
- Use `urls=true` when link text is ambiguous or a direct navigation target would avoid brittle clicks.
- Use `labels=true` on snapshot or screenshot when visual position matters. On Playwright-backed profiles, the response includes an `annotations` array (`{ref, number, role, name?, box}`) with each ref's bounding box in the captured image's coordinate space, so you can reason about position without re-snapshotting; screenshot labels can also combine with `fullPage=true` (CLI: `--full-page`) to label the whole document, or `ref` / `element` to clip to one element. `profile="user"` and other existing-session (chrome-mcp) profiles render an overlay into page screenshots but do not attach `annotations` or use the Playwright full-page/ref/element projection helper, so read positions from the labeled image itself on those profiles. The raw-CDP fallback (no Playwright) does not support labeled screenshots at all and returns a 501, so only request `labels` when Playwright is available.
4. Act narrowly:
- Prefer `action="act"` with a ref from the latest snapshot.
- After navigation, modal changes, or form submission, snapshot again before the next action.
- Avoid blind waits. Wait for visible UI state when possible.
5. Report real blockers:
- If the page needs login, permission, captcha, 2FA, camera/microphone approval, or another manual step, stop and tell the user exactly what is needed.
- Do not claim the browser is not logged in just because the current page shows a permission or onboarding dialog. Inspect the visible UI first.
## Tab Hygiene
Before creating a tab for a named task, list tabs and reuse an existing matching label or URL when it is still usable.
Example:
```json
{ "action": "tabs" }
```
If no suitable tab exists:
```json
{ "action": "open", "url": "https://example.com", "label": "task" }
```
Then target it by label:
```json
{ "action": "snapshot", "targetId": "task", "refs": "aria" }
```
If a retry creates duplicates, close the extras by `tabId`:
```json
{ "action": "close", "targetId": "t3" }
```
Do not pass bare numbers like `"2"` as `targetId`. Numeric tab positions are only for the CLI `openclaw browser tab select 2` helper; browser tool calls need a `suggestedTargetId`, label, `tabId`, or raw target id.
## Stale Ref Recovery
If an action fails with a missing or stale ref:
1. Snapshot the same `targetId` again.
2. Find the current visible control.
3. Retry once with the new ref.
4. If the UI moved to a blocker state, report the blocker instead of looping.
## Existing User Browser
Use `profile="user"` only when existing cookies/login matter. This attaches to the user's running Chromium-based browser.
For `profile="user"` and other existing-session profiles, omit `timeoutMs` on `act:type`, `evaluate`, `hover`, `scrollIntoView`, `drag`, `select`, and `fill`; that driver rejects per-call timeout overrides for those actions.
## Google Meet Notes
When creating or joining a Meet:
- Treat camera/microphone permission screens as progress, not login failure.
- If asked whether people can hear you, click the microphone option when voice is required.
- If Google asks for sign-in, 2FA, account chooser confirmation, or permission that needs user approval, report the exact manual action.
- Use one labeled tab per meeting flow, for example `label="meet"`, and reuse it during retries.

View File

@@ -0,0 +1,83 @@
/**
* Shared in-process browser control runtime state.
*
* The HTTP server path and background control service both reuse this singleton
* so local tools can attach to the same browser runtime without racing owners.
*/
import type { Server } from "node:http";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
type BrowserControlOwner = "server" | "service";
let state: BrowserServerState | null = null;
let owner: BrowserControlOwner | null = null;
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
/** Create a route context bound to the current shared browser runtime. */
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
/** Start or attach the shared browser runtime for either the server or service owner. */
export async function ensureBrowserControlRuntime(params: {
server?: Server | null;
port: number;
resolved: BrowserServerState["resolved"];
owner: BrowserControlOwner;
onWarn: (message: string) => void;
}): Promise<BrowserServerState> {
if (state) {
if (params.server) {
// A foreground server takes ownership of the already-started service
// runtime so shutdown and port reporting follow the visible server.
state.server = params.server;
state.port = params.port;
state.resolved = { ...params.resolved, controlPort: params.port };
owner = "server";
}
return state;
}
state = await createBrowserRuntimeState({
server: params.server ?? null,
port: params.port,
resolved: params.resolved,
onWarn: params.onWarn,
});
owner = params.owner;
return state;
}
/** Stop the shared browser runtime when the requesting owner is allowed to do so. */
export async function stopBrowserControlRuntime(params: {
requestedBy: BrowserControlOwner;
closeServer?: boolean;
onWarn: (message: string) => void;
}): Promise<void> {
const current = state;
if (!current) {
return;
}
if (params.requestedBy === "service" && current.server && owner === "server") {
// The background service must not close a runtime currently claimed by the
// visible HTTP server; otherwise CLI/browser calls lose their control port.
return;
}
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
owner = null;
},
closeServer: params.closeServer,
onWarn: params.onWarn,
});
}

View File

@@ -0,0 +1,11 @@
/**
* Gateway method and scope constants for browser proxy requests.
*
* Node-hosted browser control uses these values on both sides of the gateway
* contract, so keep them as literal exports instead of duplicated strings.
*/
export const BROWSER_REQUEST_GATEWAY_METHOD = "browser.request" as const;
/** Admin scope required to proxy browser-control requests through Gateway. */
export const BROWSER_REQUEST_GATEWAY_SCOPE = "operator.admin" as const;
/** Scope tuple shape consumed by Gateway tool registration. */
export const BROWSER_REQUEST_GATEWAY_SCOPES = [BROWSER_REQUEST_GATEWAY_SCOPE] as const;

View File

@@ -0,0 +1,100 @@
/**
* Public browser runtime barrel.
*
* Exposes the browser control server, client helpers, config resolution, and
* route/runtime primitives used by the plugin entrypoints and local CLI.
*/
export { startBrowserBridgeServer, stopBrowserBridgeServer } from "./browser/bridge-server.js";
export type { BrowserBridge } from "./browser/bridge-server.js";
export {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "./browser/client-actions.js";
export {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserCreateProfile,
browserDeleteProfile,
browserDoctor,
browserProfiles,
browserResetProfile,
browserSnapshot,
browserStart,
browserStatus,
browserStop,
browserTabAction,
browserTabs,
} from "./browser/client.js";
export { runBrowserProxyCommand } from "./node-host/invoke-browser.js";
export type {
BrowserCreateProfileResult,
BrowserDeleteProfileResult,
BrowserDoctorCheck,
BrowserDoctorReport,
BrowserResetProfileResult,
BrowserStatus,
BrowserTab,
BrowserTransport,
ProfileStatus,
SnapshotResult,
} from "./browser/client.js";
export type { BrowserExecutable } from "./browser/chrome.executables.js";
export type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./browser/config.js";
export { resolveBrowserConfig, resolveProfile } from "./browser/config.js";
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./browser/constants.js";
export {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./browser/chrome.executables.js";
export { redactCdpUrl } from "./browser/cdp.helpers.js";
export {
DEFAULT_UPLOAD_DIR,
resolveExistingPathsWithinRoot,
resolveExistingUploadPaths,
} from "./browser/paths.js";
export { getBrowserProfileCapabilities } from "./browser/profile-capabilities.js";
export { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./browser/proxy-files.js";
export {
isPersistentBrowserProfileMutation,
normalizeBrowserRequestPath,
resolveRequestedBrowserProfile,
} from "./browser/request-policy.js";
export {
closeTrackedBrowserTabsForSessions,
trackSessionBrowserTab,
untrackSessionBrowserTab,
} from "./browser/session-tab-registry.js";
export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./browser/control-auth.js";
export { movePathToTrash } from "./browser/trash.js";
export {
createBrowserControlContext,
getBrowserControlState,
startBrowserControlServiceFromConfig,
stopBrowserControlService,
} from "./control-service.js";
export { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
export { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
export { registerBrowserRoutes } from "./browser/routes/index.js";
export { createBrowserRouteDispatcher } from "./browser/routes/dispatcher.js";
export type { BrowserRouteRegistrar } from "./browser/routes/types.js";
export {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
} from "./browser/server-middleware.js";
export type { BrowserFormField } from "./browser/client-actions-core.js";
export {
normalizeBrowserFormField,
normalizeBrowserFormFieldValue,
} from "./browser/form-fields.js";

View File

@@ -0,0 +1,650 @@
/**
* Browser agent tool action executors.
*
* Converts model-facing parameters into browser control client calls and wraps
* browser-originated text as untrusted content before returning it to agents.
*/
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
readNonNegativeIntegerParam,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/param-readers";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
browserAct,
browserConsoleMessages,
browserSnapshot,
browserTabs,
getBrowserProfileCapabilities,
getRuntimeConfig,
imageResultFromFile,
jsonResult,
normalizeOptionalString,
readStringValue,
resolveBrowserConfig,
resolveProfile,
resolveRuntimeImageSanitization,
wrapExternalContent,
} from "./browser-tool.runtime.js";
import {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS,
} from "./browser/constants.js";
import { neutralizeMediaDirectives } from "./browser/vision.js";
const browserToolActionDeps = {
browserAct,
browserConsoleMessages,
browserSnapshot,
browserTabs,
getRuntimeConfig,
imageResultFromFile,
};
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
type BrowserActRequest = Parameters<typeof browserAct>[1];
type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number };
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
return readPositiveIntegerParam({ value }, "value", {
message: "timeoutMs must be a positive integer.",
});
}
function normalizeNonNegativeDurationMs(value: unknown): number | undefined {
return readNonNegativeIntegerParam({ value }, "value", {
message: "timeMs must be a non-negative integer.",
});
}
function supportsBrowserActTimeout(request: BrowserActRequest): boolean {
switch (request.kind) {
case "click":
case "type":
case "hover":
case "scrollIntoView":
case "drag":
case "select":
case "fill":
case "evaluate":
case "wait":
return true;
default:
return false;
}
}
function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean {
switch (request.kind) {
case "type":
case "hover":
case "scrollIntoView":
case "drag":
case "select":
case "fill":
case "evaluate":
return true;
default:
return false;
}
}
function usesExistingSessionProfile(profileName: string | undefined): boolean {
const cfg = browserToolActionDeps.getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile);
return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false;
}
function withConfiguredActTimeout(
request: BrowserActRequest,
profileName: string | undefined,
): BrowserActRequest {
const typedRequest = request as BrowserActRequestWithTimeout;
if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== undefined) {
return request;
}
if (!supportsBrowserActTimeout(request)) {
return request;
}
if (existingSessionRejectsActTimeout(request) && usesExistingSessionProfile(profileName)) {
// Chrome MCP existing-session actions reject per-call timeouts for these
// operations, so default timeout injection must stay disabled there.
return request;
}
const cfg = browserToolActionDeps.getRuntimeConfig();
const configuredTimeout =
normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS;
return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest;
}
function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined {
const candidateTimeouts: number[] = [];
const explicitTimeout = normalizePositiveTimeoutMs(
(request as BrowserActRequestWithTimeout).timeoutMs,
);
if (explicitTimeout !== undefined) {
candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
if (request.kind === "wait") {
const waitDuration = normalizeNonNegativeDurationMs(request.timeMs);
if (waitDuration !== undefined) {
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
}
return candidateTimeouts.length ? Math.max(...candidateTimeouts) : undefined;
}
export const testing = {
setDepsForTest(
overrides: Partial<{
browserAct: typeof browserAct;
browserConsoleMessages: typeof browserConsoleMessages;
browserSnapshot: typeof browserSnapshot;
browserTabs: typeof browserTabs;
imageResultFromFile: typeof imageResultFromFile;
getRuntimeConfig: typeof getRuntimeConfig;
}> | null,
) {
browserToolActionDeps.browserAct = overrides?.browserAct ?? browserAct;
browserToolActionDeps.browserConsoleMessages =
overrides?.browserConsoleMessages ?? browserConsoleMessages;
browserToolActionDeps.browserSnapshot = overrides?.browserSnapshot ?? browserSnapshot;
browserToolActionDeps.browserTabs = overrides?.browserTabs ?? browserTabs;
browserToolActionDeps.imageResultFromFile =
overrides?.imageResultFromFile ?? imageResultFromFile;
browserToolActionDeps.getRuntimeConfig = overrides?.getRuntimeConfig ?? getRuntimeConfig;
},
};
type BrowserProxyRequest = (opts: {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
profile?: string;
}) => Promise<unknown>;
type BrowserTabLike = {
suggestedTargetId?: unknown;
tabId?: unknown;
label?: unknown;
title?: unknown;
url?: unknown;
type?: unknown;
targetId?: unknown;
wsUrl?: unknown;
};
function formatAgentTab(tab: unknown): Record<string, unknown> {
if (!tab || typeof tab !== "object") {
return { value: tab };
}
const source = tab as BrowserTabLike;
const targetId = readStringValue(source.targetId);
const tabId = readStringValue(source.tabId);
const label = readStringValue(source.label);
const suggestedTargetId = readStringValue(source.suggestedTargetId) ?? label ?? tabId ?? targetId;
return {
...(suggestedTargetId ? { suggestedTargetId } : {}),
...(tabId ? { tabId } : {}),
...(label ? { label } : {}),
title: source.title,
url: source.url,
type: source.type,
...(targetId ? { targetId } : {}),
...(source.wsUrl ? { wsUrl: source.wsUrl } : {}),
};
}
function wrapBrowserExternalJson(params: {
kind: "snapshot" | "console" | "tabs";
payload: unknown;
includeWarning?: boolean;
}): { wrappedText: string; safeDetails: Record<string, unknown> } {
const extractedText = JSON.stringify(
params.payload,
(_key: string, value: unknown) =>
typeof value === "string" ? neutralizeMediaDirectives(value) : value,
2,
);
// Browser tabs, snapshots, and console output are page-controlled data. Keep
// text wrapped even when details carry the structured fields for callers.
const wrappedText = wrapExternalContent(extractedText, {
source: "browser",
includeWarning: params.includeWarning ?? true,
});
return {
wrappedText,
safeDetails: {
ok: true,
externalContent: {
untrusted: true,
source: "browser",
kind: params.kind,
wrapped: true,
},
},
};
}
function formatTabsToolResult(tabs: unknown[]): AgentToolResult<unknown> {
const formattedTabs = tabs.map((tab) => formatAgentTab(tab));
const wrapped = wrapBrowserExternalJson({
kind: "tabs",
payload: { tabs: formattedTabs },
includeWarning: false,
});
const content: AgentToolResult<unknown>["content"] = [
{ type: "text", text: wrapped.wrappedText },
];
return {
content,
details: {
...wrapped.safeDetails,
tabCount: tabs.length,
tabs: formattedTabs,
},
};
}
function formatConsoleToolResult(result: {
targetId?: string;
url?: string;
messages?: unknown[];
}): AgentToolResult<unknown> {
const wrapped = wrapBrowserExternalJson({
kind: "console",
payload: result,
includeWarning: false,
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
targetId: readStringValue(result.targetId),
url: readStringValue(result.url),
messageCount: Array.isArray(result.messages) ? result.messages.length : undefined,
},
};
}
function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean {
if (!profile) {
return false;
}
if (profile === "user") {
const msg = String(err);
return msg.includes("404:") && msg.includes("tab not found");
}
const cfg = browserToolActionDeps.getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const browserProfile = resolveProfile(resolved, profile);
if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) {
return false;
}
const msg = String(err);
return msg.includes("404:") && msg.includes("tab not found");
}
function stripTargetIdFromActRequest(
request: Parameters<typeof browserAct>[1],
): Parameters<typeof browserAct>[1] | null {
const targetId = normalizeOptionalString(request.targetId);
if (!targetId) {
return null;
}
const retryRequest = { ...request };
delete retryRequest.targetId;
return retryRequest as Parameters<typeof browserAct>[1];
}
function canRetryChromeActWithoutTargetId(request: Parameters<typeof browserAct>[1]): boolean {
const typedRequest = request as Partial<Record<"kind" | "action", unknown>>;
const kind =
typeof typedRequest.kind === "string"
? typedRequest.kind
: typeof typedRequest.action === "string"
? typedRequest.action
: "";
return kind === "hover" || kind === "scrollIntoView" || kind === "wait";
}
function isAriaRefsUnsupportedError(err: unknown): boolean {
const msg = String(err).toLowerCase();
return msg.includes("refs=aria") && msg.includes("not support");
}
function withRoleRefsFallback<T extends { refs?: "aria" | "role" }>(
snapshotQuery: T,
): T & { refs: "role" } {
return {
...snapshotQuery,
refs: "role",
};
}
export async function executeTabsAction(params: {
baseUrl?: string;
profile?: string;
timeoutMs?: number;
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
if (proxyRequest) {
const result = await proxyRequest({
method: "GET",
path: "/tabs",
profile,
timeoutMs,
});
const tabs = (result as { tabs?: unknown[] }).tabs ?? [];
return formatTabsToolResult(tabs);
}
const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs });
return formatTabsToolResult(tabs);
}
/** Execute and format browser snapshots for agent consumption. */
export async function executeSnapshotAction(params: {
input: Record<string, unknown>;
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
const { input, baseUrl, profile, proxyRequest } = params;
const snapshotDefaults = browserToolActionDeps.getRuntimeConfig().browser?.snapshotDefaults;
const format: "ai" | "aria" | undefined =
input.snapshotFormat === "ai" ? "ai" : input.snapshotFormat === "aria" ? "aria" : undefined;
const formatExplicit = format !== undefined;
const mode: "efficient" | undefined =
input.mode === "efficient"
? "efficient"
: !formatExplicit && format !== "aria" && snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const labels = typeof input.labels === "boolean" ? input.labels : undefined;
const urls = typeof input.urls === "boolean" ? input.urls : undefined;
const refs: "aria" | "role" | undefined =
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
const hasMaxChars = Object.hasOwn(input, "maxChars");
const targetId = normalizeOptionalString(input.targetId);
const limit = readPositiveIntegerParam(input, "limit", {
message: "limit must be a positive integer.",
});
const maxCharsRaw = readNonNegativeIntegerParam(input, "maxChars", {
message: "maxChars must be a non-negative integer.",
});
const maxChars = maxCharsRaw !== undefined && maxCharsRaw > 0 ? maxCharsRaw : undefined;
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
const depth = readNonNegativeIntegerParam(input, "depth", {
message: "depth must be a non-negative integer.",
});
const selector = normalizeOptionalString(input.selector);
const frame = normalizeOptionalString(input.frame);
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? undefined
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: hasMaxChars
? maxChars
: undefined;
// AI snapshots have a compact default cap; ARIA snapshots keep full structure
// unless maxChars is explicit, because agents often need complete node refs.
const snapshotTimeoutMs =
readPositiveIntegerParam(input, "timeoutMs", {
message: "timeoutMs must be a positive integer.",
}) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS;
const snapshotQuery = {
...(format ? { format } : {}),
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
urls,
mode,
timeoutMs: snapshotTimeoutMs,
};
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 browserToolActionDeps.browserSnapshot(baseUrl, {
...query,
profile,
});
let snapshot: Awaited<ReturnType<typeof browserSnapshot>>;
try {
snapshot = await readSnapshot(snapshotQuery);
} catch (err) {
if (refs !== "aria" || !isAriaRefsUnsupportedError(err)) {
throw err;
}
refsFallback = "role";
snapshot = await readSnapshot(withRoleRefsFallback(snapshotQuery));
}
params.onTabActivity?.(readStringValue(snapshot.targetId) ?? targetId);
if (snapshot.format === "ai") {
const dialogStateFields = {
...(snapshot.blockedByDialog ? { blockedByDialog: true } : {}),
...(snapshot.browserState !== undefined ? { browserState: snapshot.browserState } : {}),
};
if (snapshot.blockedByDialog) {
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: {
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields,
},
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields,
},
};
}
const extractedText = snapshot.snapshot ?? "";
const wrappedSnapshot = wrapExternalContent(neutralizeMediaDirectives(extractedText), {
source: "browser",
includeWarning: true,
});
const safeDetails = {
ok: true,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
truncated: snapshot.truncated,
stats: snapshot.stats,
refs: snapshot.refs ? Object.keys(snapshot.refs).length : undefined,
labels: snapshot.labels,
labelsCount: snapshot.labelsCount,
labelsSkipped: snapshot.labelsSkipped,
annotations: snapshot.annotations,
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback,
...dialogStateFields,
externalContent: {
untrusted: true,
source: "browser",
kind: "snapshot",
format: "ai",
wrapped: true,
},
};
if (labels && snapshot.imagePath) {
return await browserToolActionDeps.imageResultFromFile({
label: "browser:snapshot",
path: snapshot.imagePath,
extraText: wrappedSnapshot,
details: safeDetails,
imageSanitization: resolveRuntimeImageSanitization(),
});
}
return {
content: [{ type: "text" as const, text: wrappedSnapshot }],
details: safeDetails,
};
}
{
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: snapshot,
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
format: "aria",
targetId: snapshot.targetId,
url: snapshot.url,
nodeCount: snapshot.nodes.length,
...(snapshot.blockedByDialog ? { blockedByDialog: true } : {}),
...(snapshot.browserState !== undefined ? { browserState: snapshot.browserState } : {}),
externalContent: {
untrusted: true,
source: "browser",
kind: "snapshot",
format: "aria",
wrapped: true,
},
},
};
}
}
/** Execute browser console retrieval and wrap page-controlled messages. */
export async function executeConsoleAction(params: {
input: Record<string, unknown>;
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
const { input, baseUrl, profile, proxyRequest } = params;
const level = normalizeOptionalString(input.level);
const targetId = normalizeOptionalString(input.targetId);
if (proxyRequest) {
const result = (await proxyRequest({
method: "GET",
path: "/console",
profile,
query: {
level,
targetId,
},
})) as { ok?: boolean; targetId?: string; messages?: unknown[] };
return formatConsoleToolResult(result);
}
const result = await browserToolActionDeps.browserConsoleMessages(baseUrl, {
level,
targetId,
profile,
});
return formatConsoleToolResult(result);
}
/** Execute browser actions with profile-aware timeout defaults and stale-tab recovery. */
export async function executeActAction(params: {
request: BrowserActRequest;
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
const { request, baseUrl, profile, proxyRequest } = params;
const effectiveRequest = withConfiguredActTimeout(request, profile);
try {
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: effectiveRequest,
timeoutMs: resolveActProxyTimeoutMs(effectiveRequest),
})
: await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, {
profile,
});
params.onTabActivity?.(
readStringValue((result as { targetId?: unknown }).targetId) ??
readStringValue(effectiveRequest.targetId),
);
return jsonResult(result);
} catch (err) {
if (isChromeStaleTargetError(profile, err)) {
const retryRequest = stripTargetIdFromActRequest(effectiveRequest);
const tabs = proxyRequest
? ((
(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []);
// Some user-browser targetIds can go stale between snapshots and actions.
// Only retry safe read-only actions, and only when exactly one tab remains attached.
if (retryRequest && canRetryChromeActWithoutTargetId(effectiveRequest) && tabs.length === 1) {
try {
const retryResult = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: retryRequest,
timeoutMs: resolveActProxyTimeoutMs(retryRequest),
})
: await browserToolActionDeps.browserAct(baseUrl, retryRequest, {
profile,
});
params.onTabActivity?.(
readStringValue((retryResult as { targetId?: unknown }).targetId) ??
readStringValue(retryRequest.targetId),
);
return jsonResult(retryResult);
} catch {
// Fall through to explicit stale-target guidance.
}
}
if (!tabs.length) {
throw new Error(
`No browser tabs found for profile="${profile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`,
{ cause: err },
);
}
throw new Error(
`Chrome tab not found (stale targetId?). Run action=tabs profile="${profile}" and use one of the returned targetIds.`,
{ cause: err },
);
}
throw err;
}
}
export { testing as __testing };

View File

@@ -0,0 +1,67 @@
/**
* Runtime dependency barrel for the Browser agent tool.
*
* Kept separate from browser-tool.ts so tests can mock the tool boundary while
* production still imports SDK helpers and browser client actions lazily.
*/
import { getRuntimeConfig } from "./sdk-config.js";
export { getRuntimeConfig };
/** Resolve global image downscaling for screenshots returned to agent tools. */
export function resolveRuntimeImageSanitization(): { maxDimensionPx: number } | undefined {
const configured = getRuntimeConfig().agents?.defaults?.imageMaxDimensionPx;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
return undefined;
}
return { maxDimensionPx: Math.max(1, Math.floor(configured)) };
}
export {
callGatewayTool,
describeImageFile,
imageResultFromFile,
jsonResult,
listNodes,
readPositiveIntegerParam,
readStringParam,
resolveNodeIdFromList,
saveMediaBuffer,
selectDefaultNodeFromList,
} from "./sdk-setup-tools.js";
export type { AnyAgentTool, NodeListNode } from "./sdk-setup-tools.js";
export { wrapExternalContent } from "./sdk-security-runtime.js";
export {
normalizeOptionalString,
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export { BrowserToolSchema } from "./browser-tool.schema.js";
export {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "./browser/client-actions.js";
export {
browserCloseTab,
browserDoctor,
browserFocusTab,
browserOpenTab,
browserProfiles,
browserSnapshot,
browserStart,
browserStatus,
browserStop,
browserTabs,
} from "./browser/client.js";
export { resolveBrowserConfig, resolveProfile } from "./browser/config.js";
export { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./browser/constants.js";
export { resolveExistingUploadPaths } from "./browser/paths.js";
export { getBrowserProfileCapabilities } from "./browser/profile-capabilities.js";
export { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./browser/proxy-files.js";
export {
touchSessionBrowserTab,
trackSessionBrowserTab,
untrackSessionBrowserTab,
} from "./browser/session-tab-registry.js";

View File

@@ -0,0 +1,33 @@
// Browser tests cover browser tool.schema plugin behavior.
import { describe, expect, it } from "vitest";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
type SchemaRecord = Record<string, { maximum?: number; properties?: SchemaRecord }>;
type SchemaProperty = {
description?: string;
maximum?: number;
properties?: SchemaRecord;
};
type BrowserSchemaRecord = Record<string, SchemaProperty>;
describe("browser tool schema", () => {
it("advertises the viewport resize maximum on nested and flattened act params", () => {
const properties = BrowserToolSchema.properties as SchemaRecord;
const requestProperties = properties.request.properties ?? {};
expect(properties.width.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
expect(properties.height.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
expect(requestProperties.width.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
expect(requestProperties.height.maximum).toBe(ACT_MAX_VIEWPORT_DIMENSION);
});
it("describes targetId as a compatible tab reference", () => {
const properties = BrowserToolSchema.properties as BrowserSchemaRecord;
const requestProperties = properties.request.properties as BrowserSchemaRecord;
expect(properties.targetId.description).toContain("Prefer suggestedTargetId");
expect(properties.targetId.description).toContain("raw CDP targetId");
expect(requestProperties.targetId.description).toBe(properties.targetId.description);
});
});

View File

@@ -0,0 +1,164 @@
/**
* JSON schema for the Browser agent tool.
*
* The schema stays intentionally flat because provider function-tool validators
* reject several nested union shapes that TypeBox can otherwise emit.
*/
import {
optionalFiniteNumberSchema,
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
optionalStringEnum,
stringEnum,
} from "openclaw/plugin-sdk/channel-actions";
import { Type } from "typebox";
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
const BROWSER_ACT_KINDS = [
"click",
"clickCoords",
"type",
"press",
"hover",
"drag",
"select",
"fill",
"resize",
"wait",
"evaluate",
"close",
] as const;
const BROWSER_TOOL_ACTIONS = [
"doctor",
"status",
"start",
"stop",
"profiles",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
const BROWSER_SNAPSHOT_REFS = ["role", "aria"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
const TAB_REFERENCE_DESCRIPTION =
"Tab reference. Prefer suggestedTargetId, tabId, or label from tabs output; raw CDP targetId and unique raw prefixes remain supported for compatibility.";
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
// Common fields
targetId: Type.Optional(Type.String({ description: TAB_REFERENCE_DESCRIPTION })),
ref: Type.Optional(Type.String()),
// click
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
x: optionalFiniteNumberSchema(),
y: optionalFiniteNumberSchema(),
// type
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
// press
key: Type.Optional(Type.String()),
delayMs: optionalNonNegativeIntegerSchema(),
// drag
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
// select
values: Type.Optional(Type.Array(Type.String())),
// fill - use permissive array of objects
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
// resize
width: optionalPositiveIntegerSchema({ maximum: ACT_MAX_VIEWPORT_DIMENSION }),
height: optionalPositiveIntegerSchema({ maximum: ACT_MAX_VIEWPORT_DIMENSION }),
// wait
timeMs: optionalNonNegativeIntegerSchema(),
selector: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
loadState: Type.Optional(Type.String()),
textGone: Type.Optional(Type.String()),
timeoutMs: optionalPositiveIntegerSchema(),
// evaluate
fn: Type.Optional(Type.String()),
});
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
/** Provider-compatible Browser tool argument schema. */
export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String({ description: TAB_REFERENCE_DESCRIPTION })),
label: Type.Optional(Type.String()),
limit: optionalPositiveIntegerSchema(),
maxChars: optionalNonNegativeIntegerSchema(),
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: optionalNonNegativeIntegerSchema(),
selector: Type.Optional(Type.String()),
frame: Type.Optional(Type.String()),
labels: Type.Optional(Type.Boolean()),
urls: Type.Optional(Type.Boolean()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: optionalPositiveIntegerSchema(),
dialogId: Type.Optional(Type.String()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
// Legacy flattened act params (preferred: request={...})
kind: Type.Optional(stringEnum(BROWSER_ACT_KINDS)),
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
x: optionalFiniteNumberSchema(),
y: optionalFiniteNumberSchema(),
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
key: Type.Optional(Type.String()),
delayMs: optionalNonNegativeIntegerSchema(),
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
values: Type.Optional(Type.Array(Type.String())),
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
width: optionalPositiveIntegerSchema({ maximum: ACT_MAX_VIEWPORT_DIMENSION }),
height: optionalPositiveIntegerSchema({ maximum: ACT_MAX_VIEWPORT_DIMENSION }),
timeMs: optionalNonNegativeIntegerSchema(),
textGone: Type.Optional(Type.String()),
loadState: Type.Optional(Type.String()),
fn: Type.Optional(Type.String()),
request: Type.Optional(BrowserActSchema),
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
/**
* Browser action limits and timeout normalization.
*
* Shared by the tool schema and runtime action handlers so model-facing limits
* and browser-control enforcement stay aligned.
*/
/** Maximum number of actions accepted in a batched browser action request. */
export const ACT_MAX_BATCH_ACTIONS = 100;
/** Maximum nested action depth accepted by recursive browser actions. */
export const ACT_MAX_BATCH_DEPTH = 5;
/** Maximum click delay accepted from model/tool input. */
export const ACT_MAX_CLICK_DELAY_MS = 5_000;
/** Maximum explicit wait duration accepted from model/tool input. */
export const ACT_MAX_WAIT_TIME_MS = 30_000;
/** Maximum viewport side length accepted by resize actions. */
export const ACT_MAX_VIEWPORT_DIMENSION = 8192;
const ACT_MIN_TIMEOUT_MS = 500;
const ACT_MAX_INTERACTION_TIMEOUT_MS = 60_000;
const ACT_MAX_WAIT_TIMEOUT_MS = 120_000;
const ACT_DEFAULT_INTERACTION_TIMEOUT_MS = 8_000;
const ACT_DEFAULT_WAIT_TIMEOUT_MS = 20_000;
export function normalizeActBoundedNonNegativeMs(
value: number | undefined,
fieldName: string,
maxMs: number,
): number | undefined {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${fieldName} must be >= 0`);
}
const normalized = Math.floor(value);
if (normalized > maxMs) {
throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`);
}
return normalized;
}
/** Clamp interaction actions to the supported browser-control timeout window. */
export function resolveActInteractionTimeoutMs(timeoutMs?: number): number {
const normalized =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.floor(timeoutMs)
: ACT_DEFAULT_INTERACTION_TIMEOUT_MS;
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_INTERACTION_TIMEOUT_MS, normalized));
}
/** Clamp wait actions to their wider supported browser-control timeout window. */
export function resolveActWaitTimeoutMs(timeoutMs?: number): number {
const normalized =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.floor(timeoutMs)
: ACT_DEFAULT_WAIT_TIMEOUT_MS;
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_WAIT_TIMEOUT_MS, normalized));
}

View File

@@ -0,0 +1,43 @@
/**
* Ephemeral auth registry for loopback browser bridge servers.
*
* Dynamic sandbox/host ports need auth lookup without persisting tokens in
* config files, so callers store credentials only for the current process.
*/
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type BridgeAuth = {
token?: string;
password?: string;
};
const authByPort = new Map<number, BridgeAuth>();
/** Store auth material for a loopback bridge port in the current process. */
export function setBridgeAuthForPort(port: number, auth: BridgeAuth): void {
if (!Number.isFinite(port) || port <= 0) {
return;
}
const token = normalizeOptionalString(auth.token) ?? "";
const password = normalizeOptionalString(auth.password) ?? "";
authByPort.set(port, {
token: token || undefined,
password: password || undefined,
});
}
/** Read auth material for a loopback bridge port. */
export function getBridgeAuthForPort(port: number): BridgeAuth | undefined {
if (!Number.isFinite(port) || port <= 0) {
return undefined;
}
return authByPort.get(port);
}
/** Drop auth material when a bridge server closes or changes port. */
export function deleteBridgeAuthForPort(port: number): void {
if (!Number.isFinite(port) || port <= 0) {
return;
}
authByPort.delete(port);
}

View File

@@ -0,0 +1,125 @@
// Browser tests cover bridge server.auth plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
function buildResolvedConfig(): ResolvedBrowserConfig {
return {
enabled: true,
evaluateEnabled: false,
controlPort: 0,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18899,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
extraArgs: [],
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
executablePath: undefined,
headless: true,
noSandbox: false,
attachOnly: true,
defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
profiles: {
[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: {
cdpPort: 1,
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
},
},
} as unknown as ResolvedBrowserConfig;
}
describe("startBrowserBridgeServer auth", () => {
const servers: Array<{ stop: () => Promise<void> }> = [];
async function expectAuthFlow(
authConfig: { authToken?: string; authPassword?: string },
headers: Record<string, string>,
) {
const bridge = await startBrowserBridgeServer({
resolved: buildResolvedConfig(),
...authConfig,
skipRouteRegistrationForTest: true,
});
servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) });
const unauth = await fetch(`${bridge.baseUrl}/`);
expect(unauth.status).toBe(401);
const authed = await fetch(`${bridge.baseUrl}/`, { headers });
expect(authed.status).toBe(200);
}
afterEach(async () => {
while (servers.length) {
const s = servers.pop();
if (s) {
await s.stop();
}
}
});
it("rejects unauthenticated requests when authToken is set", async () => {
await expectAuthFlow({ authToken: "secret-token" }, { Authorization: "Bearer secret-token" });
});
it("accepts x-openclaw-password when authPassword is set", async () => {
await expectAuthFlow(
{ authPassword: "secret-password" },
{ "x-openclaw-password": "secret-password" },
);
});
it("requires auth params", async () => {
await expect(
startBrowserBridgeServer({
resolved: buildResolvedConfig(),
}),
).rejects.toThrow(/requires auth/i);
});
it("serves noVNC bootstrap html without leaking password in Location header", async () => {
let resolveCalls = 0;
const bridge = await startBrowserBridgeServer({
resolved: buildResolvedConfig(),
authToken: "secret-token",
skipRouteRegistrationForTest: true,
resolveSandboxNoVncToken: (token) => {
resolveCalls += 1;
if (token !== "valid-token") {
return null;
}
return { noVncPort: 45678, password: "Abc123xy" }; // pragma: allowlist secret
},
});
servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) });
const unauth = await fetch(`${bridge.baseUrl}/sandbox/novnc?token=valid-token`);
expect(unauth.status).toBe(401);
expect(resolveCalls).toBe(0);
const res = await fetch(`${bridge.baseUrl}/sandbox/novnc?token=valid-token`, {
headers: { Authorization: "Bearer secret-token" },
});
expect(res.status).toBe(200);
expect(resolveCalls).toBe(1);
expect(res.headers.get("location")).toBeNull();
expect(res.headers.get("cache-control")).toContain("no-store");
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
const body = await res.text();
expect(body).toContain("window.location.replace");
expect(body).toContain(
"http://127.0.0.1:45678/vnc.html#autoconnect=1&resize=remote&password=Abc123xy",
);
expect(body).not.toContain("?password=");
});
});

View File

@@ -0,0 +1,168 @@
/**
* Loopback browser bridge server.
*
* Hosts the browser control routes on an authenticated local port for sandbox,
* host, and node browser integrations that need HTTP access to browser control.
*/
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isLoopbackHost } from "../gateway/net.js";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js";
import type { ResolvedBrowserConfig } from "./config.js";
import type { BrowserRouteRegistrar } from "./routes/types.js";
import type { BrowserServerState, ProfileContext } from "./server-context.js";
import {
hasVerifiedBrowserAuth,
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
} from "./server-middleware.js";
/** Running bridge server details returned to callers that manage its lifecycle. */
export type BrowserBridge = {
server: Server;
port: number;
baseUrl: string;
state: BrowserServerState;
};
type ResolvedNoVncObserver = {
noVncPort: number;
password?: string;
};
function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string {
const hash = new URLSearchParams({
autoconnect: "1",
resize: "remote",
});
const password = normalizeOptionalString(params.password);
if (password) {
hash.set("password", password);
}
const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`;
const encodedTarget = JSON.stringify(targetUrl);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>OpenClaw noVNC Observer</title>
</head>
<body>
<p>Opening sandbox observer...</p>
<script>
const target = ${encodedTarget};
window.location.replace(target);
</script>
</body>
</html>`;
}
/** Start an authenticated loopback browser bridge and register browser routes. */
export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
authToken?: string;
authPassword?: string;
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
resolveSandboxNoVncToken?: (token: string) => ResolvedNoVncObserver | null;
skipRouteRegistrationForTest?: boolean;
}): Promise<BrowserBridge> {
const host = params.host ?? "127.0.0.1";
if (!isLoopbackHost(host)) {
throw new Error(`bridge server must bind to loopback host (got ${host})`);
}
const port = params.port ?? 0;
const app = express();
installBrowserCommonMiddleware(app);
const authToken = normalizeOptionalString(params.authToken);
const authPassword = normalizeOptionalString(params.authPassword);
if (!authToken && !authPassword) {
throw new Error("bridge server requires auth (authToken/authPassword missing)");
}
installBrowserAuthMiddleware(app, { token: authToken, password: authPassword });
if (params.resolveSandboxNoVncToken) {
app.get("/sandbox/novnc", (req, res) => {
if (!hasVerifiedBrowserAuth(req)) {
res.status(401).send("Unauthorized");
return;
}
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("Referrer-Policy", "no-referrer");
const rawToken = normalizeOptionalString(req.query?.token);
if (!rawToken) {
res.status(400).send("Missing token");
return;
}
const resolved = params.resolveSandboxNoVncToken?.(rawToken);
if (!resolved) {
res.status(404).send("Invalid or expired token");
return;
}
res.type("html").status(200).send(buildNoVncBootstrapHtml(resolved));
});
}
const state: BrowserServerState = {
server: null as unknown as Server,
port,
resolved: params.resolved,
profiles: new Map(),
};
if (params.skipRouteRegistrationForTest) {
app.get("/", (_req, res) => {
res.status(200).send("OK");
});
} else {
const [{ createBrowserRouteContext }, { registerBrowserRoutes }] = await Promise.all([
import("./server-context.js"),
import("./routes/index.js"),
]);
const ctx = createBrowserRouteContext({
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
}
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
s.once("error", reject);
});
const address = server.address() as AddressInfo | null;
const resolvedPort = address?.port ?? port;
state.server = server;
state.port = resolvedPort;
state.resolved.controlPort = resolvedPort;
setBridgeAuthForPort(resolvedPort, { token: authToken, password: authPassword });
const baseUrl = `http://${host}:${resolvedPort}`;
return { server, port: resolvedPort, baseUrl, state };
}
/** Stop a browser bridge server and clear its ephemeral port auth. */
export async function stopBrowserBridgeServer(server: Server): Promise<void> {
try {
const address = server.address() as AddressInfo | null;
if (address?.port) {
deleteBridgeAuthForPort(address.port);
}
} catch {
// ignore
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}

View File

@@ -0,0 +1,54 @@
// Browser tests cover browser proxy mode plugin behavior.
import { describe, expect, it } from "vitest";
import {
hasChromeProxyControlArg,
hasExplicitChromeProxyRoutingArg,
omitChromeProxyEnv,
resolveBrowserNavigationProxyMode,
} from "./browser-proxy-mode.js";
describe("browser proxy mode", () => {
it("detects Chrome proxy-routing args separately from direct proxy controls", () => {
expect(hasChromeProxyControlArg(["--no-proxy-server"])).toBe(true);
expect(hasExplicitChromeProxyRoutingArg(["--no-proxy-server"])).toBe(false);
expect(hasExplicitChromeProxyRoutingArg(["--proxy-server=http://127.0.0.1:7890"])).toBe(true);
expect(hasExplicitChromeProxyRoutingArg(["--proxy-pac-url", "http://proxy.test/pac"])).toBe(
true,
);
});
it("removes proxy env before launching managed Chrome", () => {
const env = omitChromeProxyEnv({
HTTP_PROXY: "http://proxy.test:8080",
HTTPS_PROXY: "http://proxy.test:8443",
ALL_PROXY: "socks5://proxy.test:1080",
NO_PROXY: "localhost",
PATH: "/usr/bin",
http_proxy: "http://lower.test:8080",
no_proxy: "127.0.0.1",
});
expect(env).toEqual({ PATH: "/usr/bin" });
});
it("marks only managed local Chrome with explicit proxy routing as proxy-routed", () => {
const resolved = { extraArgs: ["--proxy-server=http://127.0.0.1:7890"] };
expect(
resolveBrowserNavigationProxyMode({
resolved,
profile: { driver: "openclaw", cdpIsLoopback: true },
}),
).toBe("explicit-browser-proxy");
expect(
resolveBrowserNavigationProxyMode({
resolved,
profile: { driver: "existing-session", cdpIsLoopback: true },
}),
).toBe("direct");
expect(
resolveBrowserNavigationProxyMode({
resolved,
profile: { driver: "openclaw", cdpIsLoopback: false },
}),
).toBe("direct");
});
});

View File

@@ -0,0 +1,65 @@
/**
* Chrome proxy-mode detection for browser navigation control.
*
* Keeps proxy environment variables and Chrome flags from accidentally changing
* whether OpenClaw-owned browser traffic is direct or explicitly proxied.
*/
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import type { BrowserNavigationProxyMode } from "./navigation-guard.js";
const PROXY_ROUTING_CHROME_ARGS = new Set([
"--proxy-auto-detect",
"--proxy-pac-url",
"--proxy-server",
]);
const PROXY_CONTROL_CHROME_ARGS = new Set(["--no-proxy-server", ...PROXY_ROUTING_CHROME_ARGS]);
const CHROME_PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
] as const;
function chromeArgName(arg: string): string {
return arg.trim().split("=", 1)[0]?.toLowerCase() ?? "";
}
/** Return true when Chrome args contain any proxy control flag. */
export function hasChromeProxyControlArg(args: readonly string[]): boolean {
return args.some((arg) => PROXY_CONTROL_CHROME_ARGS.has(chromeArgName(arg)));
}
/** Return true when Chrome args route traffic through an explicit proxy. */
export function hasExplicitChromeProxyRoutingArg(args: readonly string[]): boolean {
return args.some((arg) => PROXY_ROUTING_CHROME_ARGS.has(chromeArgName(arg)));
}
/** Remove inherited proxy env so launched Chrome follows only configured args. */
export function omitChromeProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = { ...env };
for (const key of CHROME_PROXY_ENV_KEYS) {
delete next[key];
}
return next;
}
/** Resolve the navigation proxy mode used by SSRF/navigation guards. */
export function resolveBrowserNavigationProxyMode(params: {
resolved: Pick<ResolvedBrowserConfig, "extraArgs">;
profile: Pick<ResolvedBrowserProfile, "cdpIsLoopback" | "driver">;
}): BrowserNavigationProxyMode {
if (
params.profile.driver === "openclaw" &&
params.profile.cdpIsLoopback &&
hasExplicitChromeProxyRoutingArg(params.resolved.extraArgs)
) {
return "explicit-browser-proxy";
}
return "direct";
}

View File

@@ -0,0 +1,268 @@
// Browser tests cover browser utils plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
appendCdpPath,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
} from "./cdp.helpers.js";
import { testApi } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
import { toBoolean } from "./routes/utils.js";
import type { BrowserServerState } from "./server-context.js";
import { listKnownProfileNames } from "./server-context.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {
expect(toBoolean("yes")).toBe(true);
expect(toBoolean("1")).toBe(true);
expect(toBoolean("no")).toBe(false);
expect(toBoolean("0")).toBe(false);
});
it("returns undefined for on/off strings", () => {
expect(toBoolean("on")).toBeUndefined();
expect(toBoolean("off")).toBeUndefined();
});
it("passes through boolean values", () => {
expect(toBoolean(true)).toBe(true);
expect(toBoolean(false)).toBe(false);
});
});
describe("browser target id resolution", () => {
it("resolves exact ids", () => {
const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]);
expect(res).toEqual({ ok: true, targetId: "FULL" });
});
it("resolves exact tab ids and labels", () => {
expect(
resolveTargetIdFromTabs("t2", [
{ targetId: "AAA", tabId: "t1" },
{ targetId: "BBB", suggestedTargetId: "docs", tabId: "t2", label: "docs" },
]),
).toEqual({ ok: true, targetId: "BBB" });
expect(
resolveTargetIdFromTabs("docs", [
{ targetId: "AAA", tabId: "t1" },
{ targetId: "BBB", tabId: "t2", label: "docs" },
]),
).toEqual({ ok: true, targetId: "BBB" });
});
it("resolves unique prefixes (case-insensitive)", () => {
const res = resolveTargetIdFromTabs("57a01309", [
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },
]);
expect(res).toEqual({
ok: true,
targetId: "57A01309E14B5DEE0FB41F908515A2FC",
});
});
it("fails on ambiguous prefixes", () => {
const res = resolveTargetIdFromTabs("57A0", [
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },
{ targetId: "57A0BEEF000000000000000000000000" },
]);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.reason).toBe("ambiguous");
expect(res.matches?.length).toBe(2);
}
});
it("fails when no tab matches", () => {
const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]);
expect(res).toEqual({ ok: false, reason: "not_found" });
});
});
describe("browser CSRF loopback mutation guard", () => {
it("rejects mutating methods from non-loopback origin", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "https://evil.example",
}),
).toBe(true);
});
it("allows mutating methods from loopback origin", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "http://127.0.0.1:18789",
}),
).toBe(false);
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "http://localhost:18789",
}),
).toBe(false);
});
it("allows mutating methods without origin/referer (non-browser clients)", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
}),
).toBe(false);
});
it("rejects mutating methods with origin=null", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
origin: "null",
}),
).toBe(true);
});
it("rejects mutating methods from non-loopback referer", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
referer: "https://evil.example/attack",
}),
).toBe(true);
});
it("rejects cross-site mutations via Sec-Fetch-Site when present", () => {
expect(
shouldRejectBrowserMutation({
method: "POST",
secFetchSite: "cross-site",
}),
).toBe(true);
});
it("does not reject non-mutating methods", () => {
expect(
shouldRejectBrowserMutation({
method: "GET",
origin: "https://evil.example",
}),
).toBe(false);
expect(
shouldRejectBrowserMutation({
method: "OPTIONS",
origin: "https://evil.example",
}),
).toBe(false);
});
});
describe("cdp.helpers", () => {
it("preserves query params when appending CDP paths", () => {
const url = appendCdpPath("https://example.com?token=abc", "/json/version");
expect(url).toBe("https://example.com/json/version?token=abc");
});
it("appends paths under a base prefix", () => {
const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list");
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
});
it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints(
"wss://connect.example.com/devtools/browser/ABC?token=abc",
);
expect(url).toBe("https://connect.example.com/?token=abc");
});
it("preserves auth and query params when normalizing secure loopback WebSocket CDP URLs", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints(
"wss://user:pass@127.0.0.1:9222/devtools/browser/ABC?token=abc",
);
expect(url).toBe("https://user:pass@127.0.0.1:9222/?token=abc");
});
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
});
it("preserves base prefixes when stripping a trailing /cdp suffix", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/browser/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/browser?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
});
it("decodes percent-encoded basic auth credentials from URLs", () => {
const headers = getHeadersWithAuth("https://alice:p%40ss%20word@example.com");
expect(headers.Authorization).toBe(
`Basic ${Buffer.from("alice:p@ss word").toString("base64")}`,
);
});
it("keeps preexisting authorization headers", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com", {
Authorization: "Bearer token",
});
expect(headers.Authorization).toBe("Bearer token");
});
it("does not add custom headers when none are required", () => {
expect(getHeadersWithAuth("http://127.0.0.1:19444/json/version")).toStrictEqual({});
});
});
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
it("falls back to per-port bridge auth when config auth is not available", () => {
const port = 18765;
const getBridgeAuthForPort = vi.fn((candidate: number) =>
candidate === port ? { token: "registry-token" } : undefined,
);
const init = testApi.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, {
getRuntimeConfig: () => ({}),
resolveBrowserControlAuth: () => ({}),
getBridgeAuthForPort,
});
const headers = new Headers(init.headers ?? {});
expect(headers.get("authorization")).toBe("Bearer registry-token");
expect(getBridgeAuthForPort).toHaveBeenCalledWith(port);
});
});
describe("browser server-context listKnownProfileNames", () => {
it("includes configured and runtime-only profile names", () => {
const resolved = resolveBrowserConfig({
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
});
const openclaw = resolveProfile(resolved, "openclaw");
if (!openclaw) {
throw new Error("expected openclaw profile");
}
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 18791,
resolved,
profiles: new Map([
[
"stale-removed",
{
profile: { ...openclaw, name: "stale-removed" },
running: null,
},
],
]),
};
expect(listKnownProfileNames(state).toSorted()).toEqual(["openclaw", "stale-removed", "user"]);
});
});

View File

@@ -0,0 +1,524 @@
// Browser tests cover cdp proxy bypass plugin behavior.
import http from "node:http";
import https from "node:https";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
registerManagedProxyBrowserCdpBypassMock: vi.fn<(url: string) => (() => void) | undefined>(
() => undefined,
),
}));
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
import {
assertManagedProxyAllowsCdpUrl,
getDirectAgentForCdp,
hasProxyEnv,
withManagedProxyForCdpUrl,
withNoProxyForCdpUrl,
} from "./cdp-proxy-bypass.js";
const LOOPBACK_CDP_URL = "http://127.0.0.1:9222";
beforeEach(() => {
vi.useRealTimers();
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
});
async function withIsolatedNoProxyEnv(fn: () => Promise<void>) {
const origNoProxy = process.env.NO_PROXY;
const origNoProxyLower = process.env.no_proxy;
const origHttpProxy = process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await fn();
} finally {
if (origHttpProxy !== undefined) {
process.env.HTTP_PROXY = origHttpProxy;
} else {
delete process.env.HTTP_PROXY;
}
if (origNoProxy !== undefined) {
process.env.NO_PROXY = origNoProxy;
} else {
delete process.env.NO_PROXY;
}
if (origNoProxyLower !== undefined) {
process.env.no_proxy = origNoProxyLower;
} else {
delete process.env.no_proxy;
}
}
}
describe("cdp-proxy-bypass", () => {
describe("getDirectAgentForCdp", () => {
it("returns http.Agent for http://localhost URLs", () => {
const agent = getDirectAgentForCdp("http://localhost:9222");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns http.Agent for http://127.0.0.1 URLs", () => {
const agent = getDirectAgentForCdp("http://127.0.0.1:9222/json/version");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns https.Agent for wss://localhost URLs", () => {
const agent = getDirectAgentForCdp("wss://localhost:9222");
expect(agent).toBeInstanceOf(https.Agent);
});
it("returns https.Agent for https://127.0.0.1 URLs", () => {
const agent = getDirectAgentForCdp("https://127.0.0.1:9222/json/version");
expect(agent).toBeInstanceOf(https.Agent);
});
it("returns http.Agent for ws://[::1] URLs", () => {
const agent = getDirectAgentForCdp("ws://[::1]:9222");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns undefined for non-loopback URLs", () => {
expect(getDirectAgentForCdp("http://remote-host:9222")).toBeUndefined();
expect(getDirectAgentForCdp("https://example.com:9222")).toBeUndefined();
});
it("returns undefined for invalid URLs", () => {
expect(getDirectAgentForCdp("not-a-url")).toBeUndefined();
});
});
describe("hasProxyEnv", () => {
const proxyVars = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const v of proxyVars) {
saved[v] = process.env[v];
}
for (const v of proxyVars) {
delete process.env[v];
}
});
afterEach(() => {
for (const v of proxyVars) {
if (saved[v] !== undefined) {
process.env[v] = saved[v];
} else {
delete process.env[v];
}
}
});
it("returns false when no proxy vars set", () => {
expect(hasProxyEnv()).toBe(false);
});
it("returns true when HTTP_PROXY is set", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
expect(hasProxyEnv()).toBe(true);
});
it("returns true when ALL_PROXY is set", () => {
process.env.ALL_PROXY = "socks5://proxy:1080";
expect(hasProxyEnv()).toBe(true);
});
});
describe("withNoProxyForCdpUrl loopback", () => {
const saved: Record<string, string | undefined> = {};
const vars = ["HTTP_PROXY", "NO_PROXY", "no_proxy"];
beforeEach(() => {
for (const v of vars) {
saved[v] = process.env[v];
}
});
afterEach(() => {
for (const v of vars) {
if (saved[v] !== undefined) {
process.env[v] = saved[v];
} else {
delete process.env[v];
}
}
});
it("sets NO_PROXY when proxy is configured", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
delete process.env.no_proxy;
let capturedNoProxy: string | undefined;
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
capturedNoProxy = process.env.NO_PROXY;
});
expect(capturedNoProxy).toContain("localhost");
expect(capturedNoProxy).toContain("127.0.0.1");
expect(capturedNoProxy).toContain("[::1]");
// Restored after
expect(process.env.NO_PROXY).toBeUndefined();
});
it("extends existing NO_PROXY", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
process.env.NO_PROXY = "internal.corp";
let capturedNoProxy: string | undefined;
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
capturedNoProxy = process.env.NO_PROXY;
});
expect(capturedNoProxy).toContain("internal.corp");
expect(capturedNoProxy).toContain("localhost");
// Restored
expect(process.env.NO_PROXY).toBe("internal.corp");
});
it("skips when no proxy env is set", async () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.ALL_PROXY;
delete process.env.NO_PROXY;
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toBeUndefined();
});
});
it("restores env even on error", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
await expect(
withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(process.env.NO_PROXY).toBeUndefined();
});
});
});
describe("withNoProxyForCdpUrl concurrency", () => {
it("does not leak NO_PROXY when called concurrently", async () => {
await withIsolatedNoProxyEnv(async () => {
const releaseA = createDeferred();
const enteredA = createDeferred();
const callA = withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toContain("localhost");
expect(process.env.NO_PROXY).toContain("[::1]");
enteredA.resolve();
await releaseA.promise;
return "a";
});
await enteredA.promise;
const callB = withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
return "b";
});
expect(await callB).toBe("b");
releaseA.resolve();
expect(await callA).toBe("a");
expect(process.env.NO_PROXY).toBeUndefined();
expect(process.env.no_proxy).toBeUndefined();
});
});
});
describe("withNoProxyForCdpUrl reverse exit order", () => {
it("restores NO_PROXY when first caller exits before second", async () => {
await withIsolatedNoProxyEnv(async () => {
const enteredA = createDeferred();
const enteredB = createDeferred();
const releaseA = createDeferred();
const releaseB = createDeferred();
const callA = withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
enteredA.resolve();
await releaseA.promise;
return "a";
});
await enteredA.promise;
const callB = withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
enteredB.resolve();
await releaseB.promise;
return "b";
});
await enteredB.promise;
releaseA.resolve();
expect(await callA).toBe("a");
expect(process.env.NO_PROXY).toContain("localhost");
releaseB.resolve();
expect(await callB).toBe("b");
expect(process.env.NO_PROXY).toBeUndefined();
expect(process.env.no_proxy).toBeUndefined();
});
});
});
describe("withNoProxyForCdpUrl preserves user-configured NO_PROXY", () => {
it("does not delete NO_PROXY when loopback entries already present", async () => {
const userNoProxy = "localhost,127.0.0.1,[::1],myhost.internal";
process.env.NO_PROXY = userNoProxy;
process.env.no_proxy = userNoProxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
// Should not modify since loopback is already covered
expect(process.env.NO_PROXY).toBe(userNoProxy);
return "ok";
});
// After call completes, user's NO_PROXY must still be intact
expect(process.env.NO_PROXY).toBe(userNoProxy);
expect(process.env.no_proxy).toBe(userNoProxy);
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("extends both NO_PROXY casings when only one casing already covers loopback", async () => {
const coveredNoProxy = "localhost,127.0.0.1,[::1],myhost.internal";
const staleLowerNoProxy = "myhost.internal";
process.env.NO_PROXY = coveredNoProxy;
process.env.no_proxy = staleLowerNoProxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toBe(`${coveredNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${staleLowerNoProxy},localhost,127.0.0.1,[::1]`);
});
expect(process.env.NO_PROXY).toBe(coveredNoProxy);
expect(process.env.no_proxy).toBe(staleLowerNoProxy);
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("mirrors lowercase-only bypass entries into uppercase during the lease", async () => {
const lowerNoProxy = "corp.internal";
delete process.env.NO_PROXY;
process.env.no_proxy = lowerNoProxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${lowerNoProxy},localhost,127.0.0.1,[::1]`);
});
expect(process.env.NO_PROXY).toBeUndefined();
expect(process.env.no_proxy).toBe(lowerNoProxy);
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("restores untouched NO_PROXY casing when the lowercase value changes", async () => {
const userNoProxy = "internal.corp";
process.env.NO_PROXY = userNoProxy;
process.env.no_proxy = userNoProxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
delete process.env.no_proxy;
});
expect(process.env.NO_PROXY).toBe(userNoProxy);
expect(process.env.no_proxy).toBeUndefined();
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("does not treat substring matches as complete loopback coverage", async () => {
const userNoProxy = "notlocalhost,127.0.0.10,[::1].example";
process.env.NO_PROXY = userNoProxy;
process.env.no_proxy = userNoProxy;
process.env.HTTP_PROXY = "http://proxy:8080";
try {
await withNoProxyForCdpUrl(LOOPBACK_CDP_URL, async () => {
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
});
expect(process.env.NO_PROXY).toBe(userNoProxy);
expect(process.env.no_proxy).toBe(userNoProxy);
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
});
describe("withNoProxyForCdpUrl", () => {
it("does not mutate NO_PROXY for non-loopback CDP URLs", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
delete process.env.no_proxy;
try {
await withNoProxyForCdpUrl("https://browserless.example/chrome?token=abc", async () => {
expect(process.env.NO_PROXY).toBeUndefined();
expect(process.env.no_proxy).toBeUndefined();
});
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("does not overwrite external NO_PROXY changes made during execution", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
delete process.env.no_proxy;
try {
await withNoProxyForCdpUrl("http://127.0.0.1:9222", async () => {
process.env.NO_PROXY = "externally-set";
process.env.no_proxy = "externally-set";
});
expect(process.env.NO_PROXY).toBe("externally-set");
expect(process.env.no_proxy).toBe("externally-set");
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
it("restores untouched NO_PROXY when no_proxy was deleted during execution", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
process.env.NO_PROXY = "corp.internal";
process.env.no_proxy = "corp.internal";
try {
await withNoProxyForCdpUrl("http://127.0.0.1:9222", async () => {
expect(process.env.NO_PROXY).toBe("corp.internal,localhost,127.0.0.1,[::1]");
expect(process.env.no_proxy).toBe("corp.internal,localhost,127.0.0.1,[::1]");
delete process.env.no_proxy;
});
expect(process.env.NO_PROXY).toBe("corp.internal");
expect(process.env.no_proxy).toBeUndefined();
} finally {
delete process.env.HTTP_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
}
});
});
describe("withManagedProxyForCdpUrl", () => {
it("registers the exact CDP URL and releases after the operation", () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
const result = withManagedProxyForCdpUrl("http://127.0.0.1:9222/json/version", () => "ok");
expect(result).toBe("ok");
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(
"http://127.0.0.1:9222/json/version",
);
expect(release).toHaveBeenCalledOnce();
});
it("releases the exact CDP URL when the operation throws", () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
expect(() =>
withManagedProxyForCdpUrl("ws://127.0.0.1:9222/devtools/browser/abc", () => {
throw new Error("boom");
}),
).toThrow("boom");
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(
"ws://127.0.0.1:9222/devtools/browser/abc",
);
expect(release).toHaveBeenCalledOnce();
});
it("keeps the exact CDP URL registered until an async operation settles", async () => {
const release = vi.fn();
const deferred = createDeferred<string>();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
const result = withManagedProxyForCdpUrl(
"http://127.0.0.1:9222/json/version",
() => deferred.promise,
);
expect(release).not.toHaveBeenCalled();
deferred.resolve("ok");
await expect(result).resolves.toBe("ok");
expect(release).toHaveBeenCalledOnce();
});
it("uses the same scoped registration for launch policy preflight", () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
assertManagedProxyAllowsCdpUrl("http://127.0.0.1:9222");
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith("http://127.0.0.1:9222");
expect(release).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,200 @@
/**
* Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections.
*
* When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
* CDP connections to localhost/127.0.0.1 can be incorrectly routed through
* the proxy, causing browser control to fail.
*
* @see https://github.com/nicepkg/openclaw/issues/31219
*/
import http from "node:http";
import https from "node:https";
import { registerManagedProxyBrowserCdpBypass } from "openclaw/plugin-sdk/ssrf-runtime-internal";
import { isLoopbackHost } from "../gateway/net.js";
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
/** HTTP agent that never uses a proxy — for localhost CDP connections. */
const directHttpAgent = new http.Agent();
const directHttpsAgent = new https.Agent();
/**
* Returns a plain (non-proxy) agent for WebSocket or HTTP connections
* when the target is a loopback address. Returns `undefined` otherwise
* so callers fall through to their default behaviour.
*/
export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined {
try {
const parsed = new URL(url);
if (isLoopbackHost(parsed.hostname)) {
return parsed.protocol === "https:" || parsed.protocol === "wss:"
? directHttpsAgent
: directHttpAgent;
}
} catch {
// not a valid URL — let caller handle it
}
return undefined;
}
/**
* Returns `true` when any proxy-related env var is set that could
* interfere with loopback connections.
*/
export function hasProxyEnv(): boolean {
return hasProxyEnvConfigured();
}
const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]";
function noProxyValueCoversLocalhost(value: string | undefined): boolean {
const entries = new Set(
(value ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
);
return entries.has("localhost") && entries.has("127.0.0.1") && entries.has("[::1]");
}
function noProxyAlreadyCoversLocalhost(): boolean {
return (
noProxyValueCoversLocalhost(process.env.NO_PROXY) &&
noProxyValueCoversLocalhost(process.env.no_proxy)
);
}
function appendLoopbackEntries(value: string | undefined): string {
return value ? `${value},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES;
}
function isLoopbackCdpUrl(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
}
type NoProxySnapshot = {
noProxy: string | undefined;
noProxyLower: string | undefined;
appliedNoProxy: string;
appliedNoProxyLower: string;
};
class NoProxyLeaseManager {
private leaseCount = 0;
private snapshot: NoProxySnapshot | null = null;
acquire(url: string): (() => void) | null {
if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) {
return null;
}
if (this.leaseCount === 0 && !noProxyAlreadyCoversLocalhost()) {
const noProxy = process.env.NO_PROXY;
const noProxyLower = process.env.no_proxy;
const appliedNoProxy = appendLoopbackEntries(noProxy || noProxyLower);
const appliedNoProxyLower = appendLoopbackEntries(noProxyLower || noProxy);
process.env.NO_PROXY = appliedNoProxy;
process.env.no_proxy = appliedNoProxyLower;
this.snapshot = { noProxy, noProxyLower, appliedNoProxy, appliedNoProxyLower };
}
this.leaseCount += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.release();
};
}
private release() {
if (this.leaseCount <= 0) {
return;
}
this.leaseCount -= 1;
if (this.leaseCount > 0 || !this.snapshot) {
return;
}
const { noProxy, noProxyLower, appliedNoProxy, appliedNoProxyLower } = this.snapshot;
const currentNoProxy = process.env.NO_PROXY;
const currentNoProxyLower = process.env.no_proxy;
if (currentNoProxy === appliedNoProxy) {
if (noProxy !== undefined) {
process.env.NO_PROXY = noProxy;
} else {
delete process.env.NO_PROXY;
}
}
if (currentNoProxyLower === appliedNoProxyLower) {
if (noProxyLower !== undefined) {
process.env.no_proxy = noProxyLower;
} else {
delete process.env.no_proxy;
}
}
this.snapshot = null;
}
}
const noProxyLeaseManager = new NoProxyLeaseManager();
/**
* Scoped NO_PROXY bypass for loopback CDP URLs.
*
* This wrapper only mutates env vars for loopback destinations. On restore,
* it avoids clobbering external NO_PROXY changes that happened while calls
* were in-flight.
*/
export async function withNoProxyForCdpUrl<T>(url: string, fn: () => Promise<T>): Promise<T> {
const release = noProxyLeaseManager.acquire(url);
try {
return await fn();
} finally {
release?.();
}
}
/**
* Scoped managed-proxy bypass for the exact CDP URL about to be used.
*
* Proxyline dynamic bypass registrations are exact URL matches, so callers
* must register the concrete `/json/version` or `ws://.../devtools/...` URL
* rather than a CDP base URL.
*/
export function withManagedProxyForCdpUrl<T>(url: string, fn: () => T): T {
const release = registerManagedProxyBrowserCdpBypass(url);
let result: T;
try {
result = fn();
} catch (err) {
release?.();
throw err;
}
const maybeThenable = result as unknown;
if (
typeof maybeThenable === "object" &&
maybeThenable !== null &&
"finally" in maybeThenable &&
typeof maybeThenable.finally === "function"
) {
return maybeThenable.finally(() => release?.()) as T;
}
release?.();
return result;
}
/**
* Validate managed-proxy loopback policy without keeping a long-lived bypass.
* Exact CDP request sites install their own scoped bypasses.
*/
export function assertManagedProxyAllowsCdpUrl(url: string): void {
withManagedProxyForCdpUrl(url, () => undefined);
}

View File

@@ -0,0 +1,40 @@
/**
* SSRF policy adjustments for Chrome DevTools Protocol reachability checks.
*
* CDP control-plane probes may target loopback even when page navigation policy
* is stricter, so this module scopes the exception to browser control only.
*/
import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { withAllowedHostname } from "./ssrf-policy-helpers.js";
function withCdpHostnameAllowed(
profile: ResolvedBrowserProfile,
ssrfPolicy?: SsrFPolicy,
): SsrFPolicy | undefined {
if (!ssrfPolicy || !profile.cdpHost) {
return ssrfPolicy;
}
if (isPrivateNetworkAllowedByPolicy(ssrfPolicy)) {
return ssrfPolicy;
}
return withAllowedHostname(ssrfPolicy, profile.cdpHost);
}
export function resolveCdpReachabilityPolicy(
profile: ResolvedBrowserProfile,
ssrfPolicy?: SsrFPolicy,
): SsrFPolicy | undefined {
const capabilities = getBrowserProfileCapabilities(profile);
// The browser SSRF policy protects page/network navigation, not OpenClaw's
// own local CDP control plane. Explicit local loopback CDP profiles should
// not self-block health/control checks just because they target 127.0.0.1.
if (!capabilities.isRemote && profile.cdpIsLoopback && profile.driver === "openclaw") {
return undefined;
}
return withCdpHostnameAllowed(profile, ssrfPolicy);
}
/** Alias used by callers that treat reachability and control as one CDP policy. */
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;

View File

@@ -0,0 +1,30 @@
/**
* CDP target filtering helpers.
*
* Browser-internal pages cannot be reliably automated as user content, so tab
* selection filters them before exposing targets to browser actions.
*/
const BROWSER_INTERNAL_TARGET_URL_PREFIXES = [
"chrome://",
"chrome-untrusted://",
"devtools://",
"edge://",
"brave://",
"vivaldi://",
"opera://",
];
export type BrowserTargetUrlLike = {
url?: string | null;
};
/** Return true for browser-owned chrome/devtools/internal URLs. */
export function isBrowserInternalTargetUrl(url: string | null | undefined): boolean {
const normalized = url?.trim().toLowerCase() ?? "";
return BROWSER_INTERNAL_TARGET_URL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
/** Return true when a CDP target should be selectable by user-facing actions. */
export function isSelectableCdpBrowserTarget(target: BrowserTargetUrlLike): boolean {
return !isBrowserInternalTargetUrl(target.url);
}

View File

@@ -0,0 +1,100 @@
/**
* CDP and Chrome launch timeout constants.
*
* Centralizes timing so local loopback probes stay fast while remote/browser
* node probes retain enough handshake slack for real networks.
*/
import {
addTimerTimeoutGraceMs,
clampTimerTimeoutMs,
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS } from "./constants.js";
export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;
export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;
export const CDP_JSON_NEW_TIMEOUT_MS = 1500;
export const CHROME_REACHABILITY_TIMEOUT_MS = 500;
export const CHROME_WS_READY_TIMEOUT_MS = 800;
export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000;
export const CHROME_BOOTSTRAP_PREFS_POLL_MS = 100;
export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000;
export const CHROME_BOOTSTRAP_EXIT_POLL_MS = 50;
export const CHROME_LAUNCH_READY_WINDOW_MS = DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS;
export const CHROME_LAUNCH_READY_POLL_MS = 200;
export const CHROME_STOP_TIMEOUT_MS = 2500;
export const CHROME_STOP_PROBE_TIMEOUT_MS = 200;
export const CHROME_STDERR_HINT_MAX_CHARS = 2000;
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
export const PROFILE_ATTACH_RETRY_TIMEOUT_MS = 1200;
export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;
export const CHROME_MCP_ATTACH_READY_POLL_MS = 200;
/** Return true when a profile can use the short loopback CDP probe class. */
export function usesFastLoopbackCdpProbeClass(params: {
profileIsLoopback: boolean;
attachOnly?: boolean;
}): boolean {
return params.profileIsLoopback && params.attachOnly !== true;
}
function normalizeTimeoutMs(value: number | undefined): number | undefined {
return clampTimerTimeoutMs(value);
}
function maxTimerTimeoutMs(...values: number[]): number {
return values.reduce((max, value) => Math.max(max, resolveTimerTimeoutMs(value, 1)), 1);
}
/** Resolve HTTP and WebSocket reachability timeouts for a CDP profile. */
export function resolveCdpReachabilityTimeouts(params: {
profileIsLoopback: boolean;
attachOnly?: boolean;
timeoutMs?: number;
remoteHttpTimeoutMs: number;
remoteHandshakeTimeoutMs: number;
}): { httpTimeoutMs: number; wsTimeoutMs: number } {
const normalized = normalizeTimeoutMs(params.timeoutMs);
const remoteHttpTimeoutMs = resolveTimerTimeoutMs(
params.remoteHttpTimeoutMs,
CDP_HTTP_REQUEST_TIMEOUT_MS,
);
const remoteHandshakeTimeoutMs = resolveTimerTimeoutMs(
params.remoteHandshakeTimeoutMs,
CDP_WS_HANDSHAKE_TIMEOUT_MS,
);
if (
usesFastLoopbackCdpProbeClass({
profileIsLoopback: params.profileIsLoopback,
attachOnly: params.attachOnly,
})
) {
// Local launch probes run frequently during readiness checks; keep them
// short so missing Chrome ports fail quickly without delaying startup.
const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS;
const wsTimeoutMs = Math.max(
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
Math.min(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS, httpTimeoutMs * 2),
);
return { httpTimeoutMs, wsTimeoutMs };
}
if (normalized !== undefined) {
// Remote probes get the caller's timeout plus WebSocket grace, because
// HTTP reachability and WS handshake are separate network operations.
const requestedWsTimeoutMs = addTimerTimeoutGraceMs(normalized, normalized) ?? normalized;
return {
httpTimeoutMs: maxTimerTimeoutMs(normalized, remoteHttpTimeoutMs),
wsTimeoutMs: maxTimerTimeoutMs(requestedWsTimeoutMs, remoteHandshakeTimeoutMs),
};
}
return {
httpTimeoutMs: remoteHttpTimeoutMs,
wsTimeoutMs: remoteHandshakeTimeoutMs,
};
}

View File

@@ -0,0 +1,445 @@
// Browser tests cover cdp.helpers.fuzz plugin behavior.
import { describe, expect, it } from "vitest";
import {
appendCdpPath,
getHeadersWithAuth,
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
parseBrowserHttpUrl,
redactCdpUrl,
} from "./cdp.helpers.js";
/**
* Seeded property-based / fuzz coverage for the URL helpers in cdp.helpers.
*
* The repo intentionally does not pull in `fast-check` (see
* src/gateway/http-common.fuzz.test.ts); this file follows the same
* pattern: a small deterministic PRNG (mulberry32) + hand-rolled
* generators, with every property running N iterations. Failures are
* deterministic because each describe block seeds its own rng.
*
* Focus is on the URL parsing / normalisation primitives that the
* #68027 attachOnly fix depends on: distinguishing direct-WS CDP
* endpoints from bare ws roots, and normalising bare ws URLs to http
* for `/json/version` discovery.
*/
/** Deterministic 32-bit PRNG. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number {
return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive;
}
function pick<T>(rng: () => number, arr: readonly T[]): T {
return arr[randInt(rng, 0, arr.length - 1)];
}
function randHost(rng: () => number): string {
return pick(rng, [
"127.0.0.1",
"localhost",
"[::1]",
"0.0.0.0",
"[::]",
"example.com",
"connect.example.com",
"browserless.example",
"host-1.example.internal",
"user.example.com",
"192.168.1.202",
"10.0.0.5",
]);
}
function randPort(rng: () => number): string {
const kind = randInt(rng, 0, 4);
if (kind === 0) {
return "";
}
if (kind === 1) {
return ":9222";
}
if (kind === 2) {
return `:${randInt(rng, 1, 65535)}`;
}
if (kind === 3) {
return ":3000";
}
return ":443";
}
function randWsScheme(rng: () => number): "ws://" | "wss://" {
return rng() < 0.5 ? "ws://" : "wss://";
}
function randHttpScheme(rng: () => number): "http://" | "https://" {
return rng() < 0.5 ? "http://" : "https://";
}
function randDirectDevtoolsPath(rng: () => number): string {
const kind = pick(rng, ["browser", "page", "worker", "shared_worker", "service_worker"] as const);
const id = `${randInt(rng, 0, 0xffffffff).toString(16)}-${randInt(rng, 0, 9999)}`;
return `/devtools/${kind}/${id}`;
}
function randNonDevtoolsPath(rng: () => number): string {
return pick(rng, [
"",
"/",
"/json/version",
"/devtools",
"/devtools/",
"/devtools/browser/", // trailing slash, no id
"/devtools/unknown/abc",
"/other/path",
"/cdp",
"/json/list",
]);
}
function randQuery(rng: () => number): string {
if (rng() < 0.5) {
return "";
}
return pick(rng, ["?token=abc", "?apiKey=xyz&other=1", "?session=1&token=ws-token", "?t="]);
}
function randUserInfo(rng: () => number): string {
if (rng() < 0.6) {
return "";
}
return pick(rng, ["user:pass@", "u:p@", "alice:s3cr3t@", "only-user@", ":only-pass@"]);
}
const ITERATIONS = 200;
describe("fuzz: isWebSocketUrl", () => {
it("returns true for any syntactically valid ws/wss URL", () => {
const rng = makeRng(0x1001);
for (let i = 0; i < ITERATIONS; i += 1) {
const url = `${randWsScheme(rng)}${randUserInfo(rng)}${randHost(rng)}${randPort(rng)}${
rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng)
}${randQuery(rng)}`;
try {
// Only assert the property when the URL itself parses; assign
// the result to satisfy eslint's no-new rule.
const parsedValue = new URL(url);
void parsedValue;
} catch {
continue;
}
expect(isWebSocketUrl(url)).toBe(true);
}
});
it("returns false for http/https URLs and random non-URL garbage", () => {
const rng = makeRng(0x1002);
for (let i = 0; i < ITERATIONS; i += 1) {
const kind = randInt(rng, 0, 2);
if (kind === 0) {
const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath(
rng,
)}${randQuery(rng)}`;
expect(isWebSocketUrl(url)).toBe(false);
} else if (kind === 1) {
expect(isWebSocketUrl("")).toBe(false);
} else {
// Deliberately malformed: no scheme, or unsupported scheme.
const junk = pick(rng, [
"not-a-url",
"ftp://example.com",
"file:///etc/passwd",
"://foo",
"ws:",
"ws:/",
"ws//",
]);
expect(isWebSocketUrl(junk)).toBe(false);
}
}
});
});
describe("fuzz: isDirectCdpWebSocketEndpoint", () => {
it("returns true iff the URL is ws/wss AND path is /devtools/<kind>/<id>", () => {
const rng = makeRng(0x2001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randWsScheme(rng);
const path = randDirectDevtoolsPath(rng);
const url = `${scheme}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(true);
}
});
it("returns false for bare ws roots and non-devtools ws paths (needs HTTP discovery)", () => {
const rng = makeRng(0x2002);
for (let i = 0; i < ITERATIONS; i += 1) {
const url = `${randWsScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath(
rng,
)}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(false);
}
});
it("returns false for any http/https URL regardless of path", () => {
const rng = makeRng(0x2003);
for (let i = 0; i < ITERATIONS; i += 1) {
const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng);
const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(false);
}
});
it("returns booleans for random input including invalid URLs", () => {
const rng = makeRng(0x2004);
const junkPool = [
"",
" ",
"not-a-url",
"http://",
"ws://",
"ws:///devtools/browser/abc",
"://x",
"\u0000",
"ws://[not-an-ip]/devtools/browser/abc",
];
for (let i = 0; i < ITERATIONS; i += 1) {
const input = rng() < 0.5 ? pick(rng, junkPool) : String.fromCharCode(randInt(rng, 0, 0x7f));
expect(typeof isDirectCdpWebSocketEndpoint(input)).toBe("boolean");
}
});
});
describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => {
it("ws -> http and wss -> https, drops trailing /devtools/browser/... and /cdp", () => {
const rng = makeRng(0x3001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randWsScheme(rng);
const host = randHost(rng);
const port = randPort(rng);
const suffix = pick(rng, [
"",
"/",
"/cdp",
"/devtools/browser/abc",
"/devtools/browser/abc/path-fragment",
]);
const input = `${scheme}${host}${port}${suffix}`;
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
// Scheme mapping
if (scheme === "ws://") {
expect(out.startsWith("http://")).toBe(true);
expect(out.startsWith("ws://")).toBe(false);
} else {
expect(out.startsWith("https://")).toBe(true);
expect(out.startsWith("wss://")).toBe(false);
}
// /devtools/browser/... and /cdp are stripped
expect(out.includes("/devtools/browser/")).toBe(false);
expect(out.endsWith("/cdp")).toBe(false);
// No trailing slash
expect(out.endsWith("/")).toBe(false);
}
});
it("preserves http/https inputs and strips a trailing /cdp when present", () => {
const rng = makeRng(0x3002);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randHttpScheme(rng);
const hasCdp = rng() < 0.5;
const hasTrailingSlash = rng() < 0.3;
// Only exercise the trailing-/cdp branch here (the regex only
// strips /cdp when it's the final path segment, not /cdp/ etc.).
const input = `${scheme}${randHost(rng)}${randPort(rng)}${hasCdp ? "/cdp" : ""}${
hasTrailingSlash && !hasCdp ? "/" : ""
}`;
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
expect(out.startsWith(scheme)).toBe(true);
expect(out.endsWith("/cdp")).toBe(false);
expect(out.endsWith("/")).toBe(false);
}
});
it("returns normalized strings for non-URL-ish inputs", () => {
const rng = makeRng(0x3003);
// These inputs either trigger the catch branch (empty / "garbage" /
// bare "ws://" / "wss://") or are accepted by WHATWG URL as
// special-scheme absolute URLs (e.g. "ws:host/path" becomes
// "ws://host/path"). Both paths must return strings.
const junk = [
"ws:/devtools/browser/abc",
"wss:/devtools/browser/abc",
"ws:no-host/cdp",
"wss:no-host/",
"garbage",
"",
"ws://",
"wss://",
];
for (let i = 0; i < ITERATIONS; i += 1) {
const input = pick(rng, junk);
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
expect(typeof out).toBe("string");
// Scheme swap invariant: whatever branch ran, ws:/wss: never
// appear as a scheme prefix in the normalized output.
expect(out.startsWith("ws:")).toBe(false);
expect(out.startsWith("wss:")).toBe(false);
}
});
it("fallback explicitly handles malformed ws:/wss: scheme-only strings", () => {
// Hand-crafted inputs that parse as URLs via WHATWG but the pattern
// still exercises the scheme swap + suffix strip in both branches.
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host:9222/cdp")).toBe("http://host:9222");
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host:9222/")).toBe("https://host:9222");
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host/devtools/browser/abc")).toBe(
"http://host",
);
// WHATWG URL preserves the root "/" on the path after stripping the
// /devtools/browser/... suffix, so the trailing-slash removal only
// trims the final character of the serialized form (which is "1",
// not "/").
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host/devtools/browser/abc?t=1")).toBe(
"https://host/?t=1",
);
// Fallback branch: inputs `new URL` genuinely rejects. The fallback
// performs a naive scheme swap and suffix strip on the raw string.
expect(normalizeCdpHttpBaseForJsonEndpoints("")).toBe("");
expect(normalizeCdpHttpBaseForJsonEndpoints("garbage")).toBe("garbage");
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://").startsWith("http:")).toBe(true);
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://").startsWith("https:")).toBe(true);
});
});
describe("fuzz: parseBrowserHttpUrl", () => {
it("accepts http/https/ws/wss and assigns sensible default ports", () => {
const rng = makeRng(0x4001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const explicitPort = rng() < 0.5;
const portNum = randInt(rng, 1, 65535);
const url = `${scheme}${randHost(rng)}${explicitPort ? `:${portNum}` : ""}/path`;
const result = parseBrowserHttpUrl(url, "test");
expect(result.parsed.protocol).toBe(scheme.replace("//", ""));
if (explicitPort) {
expect(result.port).toBe(portNum);
} else {
const isSecure = scheme === "https://" || scheme === "wss://";
expect(result.port).toBe(isSecure ? 443 : 80);
}
expect(result.normalized.endsWith("/")).toBe(false);
}
});
it("rejects unsupported protocols", () => {
const rng = makeRng(0x4002);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["ftp://", "file://", "gopher://", "data:"] as const);
const url = scheme === "data:" ? "data:text/plain,hello" : `${scheme}${randHost(rng)}`;
expect(() => parseBrowserHttpUrl(url, "test")).toThrow(/must be http\(s\) or ws\(s\)/);
}
});
it("rejects explicitly configured port zero", () => {
for (const scheme of ["http", "https", "ws", "wss"]) {
expect(() => parseBrowserHttpUrl(`${scheme}://127.0.0.1:0`, "test")).toThrow(/invalid port/);
}
});
});
describe("fuzz: redactCdpUrl", () => {
it("strips username/password from valid URLs and preserves host/path", () => {
const rng = makeRng(0x5001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const host = randHost(rng);
const port = randPort(rng);
const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng);
const url = `${scheme}user:pass@${host}${port}${path}`;
const out = redactCdpUrl(url);
expect(typeof out).toBe("string");
expect(String(out)).not.toContain("user:pass@");
}
});
it("returns non-string inputs unchanged and short-circuits empty/whitespace strings", () => {
expect(redactCdpUrl(undefined)).toBeUndefined();
expect(redactCdpUrl(null)).toBeNull();
// Empty and whitespace-only inputs both short-circuit to the
// trimmed empty string before any URL parsing / redaction.
expect(redactCdpUrl("")).toBe("");
expect(redactCdpUrl(" ")).toBe("");
});
it("falls back to redactSensitiveText for non-URL-ish inputs", () => {
const rng = makeRng(0x5002);
for (let i = 0; i < ITERATIONS; i += 1) {
const junk = pick(rng, ["not-a-url", "http://", "ws://", "::::", "Bearer ey.SECRET.xyz"]);
const out = redactCdpUrl(junk);
expect(typeof out).toBe("string");
}
});
});
describe("fuzz: appendCdpPath", () => {
it("produces a URL that ends with the appended path exactly once", () => {
const rng = makeRng(0x6001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const base = `${scheme}${randHost(rng)}${randPort(rng)}${rng() < 0.5 ? "/" : ""}`;
const path = pick(rng, ["/json/version", "json/version", "/json/close/TARGET_1"]);
const out = appendCdpPath(base, path);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// Path segment should appear in output and not be doubled.
expect(out.endsWith(normalizedPath)).toBe(true);
expect(out.split(normalizedPath).length - 1).toBeGreaterThanOrEqual(1);
}
});
});
describe("fuzz: getHeadersWithAuth", () => {
it("always returns a mergedHeaders object", () => {
const rng = makeRng(0x7001);
for (let i = 0; i < ITERATIONS; i += 1) {
const withAuth = rng() < 0.3;
const url =
rng() < 0.5
? `${randHttpScheme(rng)}${withAuth ? "alice:s3cr3t@" : ""}${randHost(rng)}${randPort(rng)}`
: pick(rng, ["not-a-url", "", "ws://"]);
const headers: Record<string, string> = {};
if (rng() < 0.3) {
headers.Authorization = "Bearer preset";
}
const out = getHeadersWithAuth(url, headers);
expect(typeof out).toBe("object");
// Preset auth header must always be preserved verbatim.
if (headers.Authorization) {
expect(out.Authorization).toBe("Bearer preset");
}
}
});
it("injects Basic auth from URL userinfo when no Authorization header is present", () => {
const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path");
expect(out.Authorization).toBe(`Basic ${Buffer.from("alice:s3cr3t").toString("base64")}`);
});
it("preserves an existing Authorization header (case-insensitive) over URL userinfo", () => {
const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path", {
authorization: "Bearer preset",
});
expect(out.authorization).toBe("Bearer preset");
expect(out.Authorization).toBeUndefined();
});
});

View File

@@ -0,0 +1,590 @@
// Browser tests cover cdp.helpers.internal plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
registerManagedProxyBrowserCdpBypassMock: vi.fn<(url: string) => (() => void) | undefined>(
() => undefined,
),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import {
assertCdpEndpointAllowed,
fetchCdpChecked,
fetchJson,
openCdpWebSocket,
withCdpSocket,
} from "./cdp.helpers.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
/**
* Targets the non-URL-helper code paths in cdp.helpers.ts:
* - assertCdpEndpointAllowed invalid-protocol throw
* - fetchCdpChecked 429 rate-limit + double-release guard
* - createCdpSender message routing (non-number id, unknown id, error body)
* - createCdpSender 'error' event + pending rejection
* - withCdpSocket open-error / fn-throw / close error-close paths
*/
async function startWsServer() {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
}
describe("cdp.helpers internal", () => {
let wss: WebSocketServer | null = null;
afterEach(async () => {
fetchWithSsrFGuardMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
if (wss) {
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
wss = null;
}
});
function requireGuardedFetchRequest() {
const [call] = fetchWithSsrFGuardMock.mock.calls;
if (!call) {
throw new Error("expected guarded CDP fetch call");
}
const [request] = call;
return request;
}
describe("assertCdpEndpointAllowed", () => {
it("throws on non-http/https/ws/wss protocols under any SSRF policy", async () => {
await expect(
assertCdpEndpointAllowed("ftp://example.com/cdp", {
dangerouslyAllowPrivateNetwork: false,
}),
).rejects.toThrow(/Invalid CDP URL protocol: ftp/);
});
it("no-ops when no policy is supplied, regardless of protocol", async () => {
await expect(assertCdpEndpointAllowed("ftp://example.com/cdp")).resolves.toBeUndefined();
});
it("uses the raw ssrfPolicy path for non-loopback hosts", async () => {
// Non-loopback public host: hits the else branch of the loopback
// ternary in assertCdpEndpointAllowed. Using a well-known public IP
// under a permissive policy so the SSRF pin resolves without a DNS
// mock.
await expect(
assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", {
allowPrivateNetwork: true,
}),
).resolves.toBeUndefined();
});
});
describe("fetchCdpChecked", () => {
it("maps HTTP 429 responses into the browser rate-limit error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: false, status: 429 } as unknown as Response,
release: vi.fn(async () => {}),
});
await expect(
fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toThrow(/rate[ -]?limit/i);
});
it("is idempotent when release() is awaited more than once", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
const { release: guardedRelease } = await fetchCdpChecked(
"http://127.0.0.1:9222/json/version",
250,
undefined,
{ dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"] },
);
await guardedRelease();
await guardedRelease();
// The underlying release must be invoked exactly once.
expect(release).toHaveBeenCalledTimes(1);
});
it("registers a managed-proxy bypass for the exact sanitized fetch URL", async () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release: vi.fn(async () => {}),
});
const { release: guardedRelease } = await fetchCdpChecked(
"http://openclaw:secret@127.0.0.1:9222/json/version",
250,
undefined,
{ dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"] },
);
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(
"http://127.0.0.1:9222/json/version",
);
expect(release).toHaveBeenCalledOnce();
await guardedRelease();
});
it("converts SSRF-blocked errors from the underlying fetch into a browser-scoped error", async () => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(new SsrFBlockedError("blocked by policy"));
await expect(
fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("maps non-429 HTTP failures into a generic HTTP error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: false, status: 503 } as unknown as Response,
release: vi.fn(async () => {}),
});
await expect(
fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toThrow(/HTTP 503/);
});
it("uses the caller-supplied policy for non-loopback hosts", async () => {
// Hits the else branch of the isLoopbackHost ternary inside
// withNoProxyForCdpUrl plus the left-hand side of the
// `ssrfPolicy ?? { allowPrivateNetwork: true }` coalescing.
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250, undefined, {
allowPrivateNetwork: true,
});
const request = requireGuardedFetchRequest();
expect(request?.policy?.allowPrivateNetwork).toBe(true);
});
it("falls back to a permissive private-network policy when none is supplied on a non-loopback host", async () => {
// Hits the right-hand side of the `ssrfPolicy ?? { allowPrivateNetwork: true }` default.
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250);
const request = requireGuardedFetchRequest();
expect(request?.policy).toEqual({ allowPrivateNetwork: true });
});
});
describe("createCdpSender (via withCdpSocket)", () => {
it("ignores messages with a non-numeric id", async () => {
const server = await startWsServer();
wss = server.wss;
let received = 0;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
received += 1;
const text = rawDataToString(raw);
const msg = JSON.parse(text) as { id?: number; method?: string };
// First emit a noise message with a non-number id (should be ignored),
// then a garbage-json payload (hits the outer catch), then the real
// response so the caller resolves.
socket.send(JSON.stringify({ id: "oops", method: "unrelated" }));
socket.send("not-json");
socket.send(JSON.stringify({ id: msg.id, result: { echoed: msg.method } }));
});
});
const result = await withCdpSocket<{ echoed: string | undefined }>(
server.url,
async (send) => (await send("Test.ping")) as { echoed: string | undefined },
);
expect(result.echoed).toBe("Test.ping");
expect(received).toBe(1);
});
it("ignores responses whose id does not match any pending call", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string };
// Stranger id with no pending entry — must be silently dropped.
socket.send(JSON.stringify({ id: 99999, result: {} }));
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
const result = await withCdpSocket<{ ok: boolean }>(
server.url,
async (send) => (await send("Test.ping")) as { ok: boolean },
);
expect(result.ok).toBe(true);
});
it("propagates CDP error-body messages as rejections to the caller", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(
JSON.stringify({
id: msg.id,
error: { message: "boom from cdp" },
}),
);
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.failing");
}),
).rejects.toThrow(/boom from cdp/);
});
it("rejects in-flight pending calls when the socket closes mid-call", async () => {
const server = await startWsServer();
wss = server.wss;
let callbackCount = 0;
let connectionCount = 0;
server.wss.on("connection", (socket) => {
connectionCount += 1;
socket.on("message", () => {
// Defer close so the pending entry is definitely registered.
setImmediate(() => socket.close());
});
});
await expect(
withCdpSocket(
server.url,
async (send) => {
callbackCount += 1;
await send("Test.willClose");
},
{ handshakeRetries: 2, handshakeRetryDelayMs: 1, handshakeMaxRetryDelayMs: 1 },
),
).rejects.toThrow(/CDP socket closed/);
expect(callbackCount).toBe(1);
expect(connectionCount).toBe(1);
});
it("retries websocket failures before any CDP command is sent", async () => {
let rejectedHandshakes = 0;
wss = new WebSocketServer({
port: 0,
host: "127.0.0.1",
verifyClient: (_info, cb) => {
if (rejectedHandshakes === 0) {
rejectedHandshakes += 1;
cb(false, 503, "try later");
return;
}
cb(true);
},
});
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
let callbackCount = 0;
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string };
socket.send(JSON.stringify({ id: msg.id, result: { echoed: msg.method } }));
});
});
const result = await withCdpSocket<{ echoed?: string }>(
`ws://127.0.0.1:${port}/devtools/browser/TEST`,
async (send) => {
callbackCount += 1;
return (await send("Test.afterOpen")) as { echoed?: string };
},
{ handshakeRetries: 2, handshakeRetryDelayMs: 1, handshakeMaxRetryDelayMs: 1 },
);
expect(result.echoed).toBe("Test.afterOpen");
expect(rejectedHandshakes).toBe(1);
expect(callbackCount).toBe(1);
});
it("does not retry rate-limited websocket handshakes", async () => {
let rejectedHandshakes = 0;
wss = new WebSocketServer({
port: 0,
host: "127.0.0.1",
verifyClient: (_info, cb) => {
rejectedHandshakes += 1;
cb(false, 429, "too many requests");
},
});
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
await expect(
withCdpSocket(
`ws://127.0.0.1:${port}/devtools/browser/TEST`,
async (send) => {
await send("Test.neverRuns");
},
{ handshakeRetries: 2, handshakeRetryDelayMs: 1, handshakeMaxRetryDelayMs: 1 },
),
).rejects.toThrow(/429/);
expect(rejectedHandshakes).toBe(1);
});
it("rejects and closes the socket when a CDP command exceeds its timeout", async () => {
const server = await startWsServer();
wss = server.wss;
let closed = false;
server.wss.on("connection", (socket) => {
socket.on("message", () => {
// Intentionally leave the command unanswered.
});
socket.on("close", () => {
closed = true;
});
});
await expect(
withCdpSocket(
server.url,
async (send) => {
await send("Page.captureScreenshot");
},
{ commandTimeoutMs: 5 },
),
).rejects.toThrow(/CDP command Page\.captureScreenshot timed out after 5ms/);
await vi.waitFor(() => expect(closed).toBe(true));
});
});
describe("withCdpSocket", () => {
it("rejects and rethrows when the WebSocket fails to open", async () => {
// Port 1 on 127.0.0.1 is reserved and will reliably refuse connections,
// triggering the open-error branch synchronously.
await expect(
withCdpSocket("ws://127.0.0.1:1/devtools/browser/NO", async () => {
return "unreachable";
}),
).rejects.toThrow(/ECONNREFUSED|CDP socket closed/);
});
it("wraps a non-Error callback throw before closing the socket", async () => {
// `fn` is user-supplied and may throw a non-Error. Exercise the
// `err instanceof Error ? err : new Error(String(err))` wrap in the
// fn-throw catch branch.
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
const rejectRawString = () =>
Promise.reject(toLintErrorObject("raw-string-from-callback", "Non-Error rejection"));
return rejectRawString();
}),
).rejects.toThrow(/raw-string-from-callback/);
});
it("rethrows callback errors and still closes the socket cleanly", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
throw new Error("callback boom");
}),
).rejects.toThrow(/callback boom/);
});
it("tolerates a ws.close() that throws in the cleanup finally", async () => {
// Force ws.close() to throw by wrapping withCdpSocket against a live
// server but monkey-patching the ws prototype momentarily. We do this
// via a callback that pre-empts close by calling terminate() first.
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
// The fn throws AFTER sending so both the catch (closeWithError) and
// the finally ws.close() run. ws.close() on an already-closed socket
// is a no-op but exercises the try/catch in the finally.
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
throw new Error("fn post-send boom");
}),
).rejects.toThrow(/fn post-send boom/);
});
});
describe("createCdpSender error/close event forwarding", () => {
beforeEach(() => {
// Ensure a fresh mock registry each scenario.
});
it("rejects pending calls when the ws emits an error event", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", () => {
// Emit a synthetic error event on the server-side socket. The
// client-side ws will see the abrupt close and surface an error.
socket.terminate();
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.boom");
}),
).rejects.toThrow(/CDP socket closed|WebSocket was closed/i);
});
// The non-Error branch of the `err instanceof Error ? ... : new Error(String(err))`
// guard is defensive: node's `ws` library always emits Error instances
// on the 'error' event. Triggering the non-Error branch in a test
// requires synthetically emitting on the client socket, which the
// library then treats as an unhandled error event and hangs the
// suite. The branch is c8-ignored in the source file with an
// accompanying justification.
});
});
describe("openCdpWebSocket option handling", () => {
it("clamps a non-finite handshakeTimeoutMs to the default", () => {
// Exercises the Number.isFinite false side of the handshake-timeout
// ternary in openCdpWebSocket.
const url = "ws://127.0.0.1:1/devtools/browser/X";
const ws = openCdpWebSocket(url, {
handshakeTimeoutMs: Number.NaN,
});
expect(ws.url).toBe(url);
// Ensure we don't leak the socket even though we never await it.
ws.once("error", () => {});
ws.close();
});
it("honours an explicit, finite handshakeTimeoutMs", () => {
// Exercises the truthy side of the handshake-timeout ternary: both
// typeof === "number" AND Number.isFinite must be true.
const url = "ws://127.0.0.1:1/devtools/browser/X";
const ws = openCdpWebSocket(url, {
handshakeTimeoutMs: 500,
});
expect(ws.url).toBe(url);
ws.once("error", () => {});
ws.close();
});
it("registers a managed-proxy bypass for the exact websocket URL during construction", () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
const url = "ws://127.0.0.1:1/devtools/browser/X";
const ws = openCdpWebSocket(url, {
handshakeTimeoutMs: 500,
});
expect(ws.url).toBe(url);
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(url);
expect(release).toHaveBeenCalledOnce();
ws.once("error", () => {});
ws.close();
});
it("registers websocket managed-proxy bypass without URL credentials", () => {
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValueOnce(release);
const ws = openCdpWebSocket("ws://user:secret@127.0.0.1:1/devtools/browser/X", {
handshakeTimeoutMs: 500,
});
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(
"ws://127.0.0.1:1/devtools/browser/X",
);
expect(release).toHaveBeenCalledOnce();
ws.once("error", () => {});
ws.close();
});
it("omits the direct-loopback agent for non-loopback targets", () => {
// Exercises the falsy side of `agent ? { agent } : {}` — the loopback
// agent helper returns undefined for non-loopback hosts.
const url = "ws://93.184.216.34:9222/devtools/browser/X";
const ws = openCdpWebSocket(url);
expect(ws.url).toBe(url);
ws.once("error", () => {});
ws.close();
});
it("injects custom headers when opts.headers is a non-empty object", () => {
// Exercises the truthy side of `Object.keys(headers).length ? ... : {}`.
const url = "ws://127.0.0.1:1/devtools/browser/X";
const ws = openCdpWebSocket(url, {
headers: { "X-Custom": "abc" },
});
expect(ws.url).toBe(url);
ws.once("error", () => {});
ws.close();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,328 @@
// Browser tests cover cdp.helpers plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js";
describe("cdp helpers", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
function requireGuardedFetchRequest() {
const [call] = fetchWithSsrFGuardMock.mock.calls;
if (!call) {
throw new Error("expected guarded CDP fetch call");
}
const [request] = call;
return request;
}
it("releases guarded CDP fetches after the response body is consumed", async () => {
const release = vi.fn(async () => {});
const json = vi.fn(async () => {
expect(release).not.toHaveBeenCalled();
return { ok: true };
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
json,
},
release,
});
await expect(
fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).resolves.toEqual({ ok: true });
expect(json).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
});
it("allows loopback CDP endpoints in strict SSRF mode", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
});
it("still enforces hostname allowlist for loopback CDP endpoints", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).rejects.toThrow("browser endpoint blocked by policy");
});
it("releases guarded CDP fetches for bodyless requests", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/close/TARGET_1", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
});
it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("http://127.0.0.1:9222/json/version");
expect(request?.policy).toEqual({
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
});
expect(release).toHaveBeenCalledTimes(1);
});
it("sends URL credentials as an auth header for guarded CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://openclaw:relay-token@127.0.0.1:9222/json/version", 250),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("http://127.0.0.1:9222/json/version");
expect(request?.init?.headers).toEqual({
Authorization: "Basic b3BlbmNsYXc6cmVsYXktdG9rZW4=",
});
expect(release).toHaveBeenCalledTimes(1);
});
it("decodes URL credentials before sending guarded CDP auth headers", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://alice:p%40ss%20word@127.0.0.1:9222/json/version", 250),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("http://127.0.0.1:9222/json/version");
expect(request?.init?.headers).toEqual({
Authorization: `Basic ${Buffer.from("alice:p@ss word").toString("base64")}`,
});
expect(release).toHaveBeenCalledTimes(1);
});
it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("http://127.0.0.1:9222/json/version");
expect(request?.policy).toEqual({
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
allowedHostnames: ["127.0.0.1"],
});
expect(release).toHaveBeenCalledTimes(1);
});
});
function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrowserProfile {
return {
name: "remote",
cdpPort: 9223,
cdpUrl: "http://172.29.128.1:9223",
cdpHost: "172.29.128.1",
cdpIsLoopback: false,
color: "#123456",
driver: "openclaw",
attachOnly: false,
...overrides,
headless: overrides.headless ?? false,
};
}
describe("resolveCdpReachabilityTimeouts", () => {
it("uses loopback defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
wsTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS * 2,
});
});
it("clamps loopback websocket timeout range", () => {
const low = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 1,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
const high = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 5000,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
expect(low.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS);
expect(high.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS);
});
it("enforces remote minimums even when caller passes lower timeout", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: 200,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: 1500,
wsTimeoutMs: 3000,
});
});
it("uses remote defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1750,
remoteHandshakeTimeoutMs: 3250,
}),
).toEqual({
httpTimeoutMs: 1750,
wsTimeoutMs: 3250,
});
});
it("caps remote reachability timeouts to timer-safe values", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: Number.MAX_SAFE_INTEGER,
remoteHttpTimeoutMs: Number.MAX_SAFE_INTEGER,
remoteHandshakeTimeoutMs: Number.MAX_SAFE_INTEGER,
}),
).toEqual({
httpTimeoutMs: MAX_TIMER_TIMEOUT_MS,
wsTimeoutMs: MAX_TIMER_TIMEOUT_MS,
});
});
});
describe("CDP reachability policy", () => {
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
const browserPolicy = {};
const profile = createProfile({});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
allowedHostnames: ["172.29.128.1"],
});
expect(browserPolicy).toStrictEqual({});
await expect(
assertBrowserNavigationAllowed({
url: "http://172.29.128.1/",
ssrfPolicy: browserPolicy,
}),
).rejects.toThrow(/private\/internal\/special-use ip address/i);
});
it("merges the selected remote profile CDP host with existing CDP policy hostnames", () => {
const profile = createProfile({});
expect(
resolveCdpReachabilityPolicy(profile, {
allowedHostnames: ["metadata.internal"],
}),
).toEqual({
allowedHostnames: ["metadata.internal", "172.29.128.1"],
});
});
it("keeps local managed loopback CDP control outside browser SSRF policy", () => {
const profile = createProfile({
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(resolveCdpReachabilityPolicy(profile, {})).toBeUndefined();
});
});

View File

@@ -0,0 +1,539 @@
/**
* Chrome DevTools Protocol URL, fetch, and socket helpers.
*
* Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential
* redaction/headers, and request/response correlation over WebSocket.
*/
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import {
SsrFBlockedError,
type SsrFPolicy,
resolvePinnedHostnameWithPolicy,
} from "../infra/net/ssrf.js";
import {
getDirectAgentForCdp,
withManagedProxyForCdpUrl,
withNoProxyForCdpUrl,
} from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
import { withAllowedHostname } from "./ssrf-policy-helpers.js";
import { normalizeBrowserTimerDelayMs } from "./timer-delay.js";
export { isLoopbackHost };
export { parseBrowserHttpUrl, redactCdpUrl };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
* Used to distinguish direct-WebSocket CDP endpoints
* from HTTP(S) endpoints that require /json/version discovery.
*/
export function isWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
} catch {
return false;
}
}
/**
* Returns true when `url` is a ws/wss URL with a `/devtools/<kind>/<id>`
* path segment — i.e. a handshake-ready per-browser or per-target CDP
* endpoint that can be opened directly without HTTP discovery.
*
* Bare ws roots (`ws://host:port`, `ws://host:port/`) and any other
* non-`/devtools/...` paths are NOT direct endpoints: Chrome's debug
* port only accepts WebSocket upgrades on the specific path returned
* by `GET /json/version`. Callers with a bare ws root must normalise
* it to http for discovery instead of attempting a root handshake that
* Chrome will reject with HTTP 400.
*/
export function isDirectCdpWebSocketEndpoint(url: string): boolean {
if (!isWebSocketUrl(url)) {
return false;
}
try {
const parsed = new URL(url);
return /\/devtools\/(?:browser|page|worker|shared_worker|service_worker)\/[^/]/i.test(
parsed.pathname,
);
// isWebSocketUrl above already parsed the same URL successfully, so
// new URL(url) cannot throw here. Kept for structural symmetry with
// the other try/catch URL helpers.
/* c8 ignore start */
} catch {
return false;
}
/* c8 ignore stop */
}
export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
): Promise<void> {
if (!ssrfPolicy) {
return;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
try {
const policy = isLoopbackHost(parsed.hostname)
? withAllowedHostname(ssrfPolicy, parsed.hostname)
: ssrfPolicy;
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy,
});
} catch (error) {
throw new BrowserCdpEndpointBlockedError({ cause: error });
}
}
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer?: ReturnType<typeof setTimeout>;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
function decodeUrlUserInfo(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function rawCdpMessageToString(data: WebSocket.RawData): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}
/** Merge URL basic-auth credentials into headers without overriding explicit auth. */
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.trim().toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const username = decodeUrlUserInfo(parsed.username);
const password = decodeUrlUserInfo(parsed.password);
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
function stripUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) {
return url;
}
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
/** Append a JSON endpoint path to a CDP HTTP base URL. */
export function appendCdpPath(cdpUrl: string, path: string): string {
const url = new URL(cdpUrl);
const basePath = url.pathname.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
url.pathname = `${basePath}${suffix}`;
return url.toString();
}
/** Normalize ws/wss and direct devtools URLs back to the HTTP JSON endpoint base. */
export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
try {
const url = new URL(cdpUrl);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
url.pathname = url.pathname.replace(/\/cdp$/, "");
return url.toString().replace(/\/$/, "");
} catch {
// Best-effort fallback for non-URL-ish inputs.
return cdpUrl
.replace(/^ws:/, "http:")
.replace(/^wss:/, "https:")
.replace(/\/devtools\/browser\/.*$/, "")
.replace(/\/cdp$/, "")
.replace(/\/$/, "");
}
}
type CdpFetchResult = {
response: Response;
release: () => Promise<void>;
};
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs)
? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs)
: undefined;
const clearPendingTimer = (p: Pending) => {
if (p.timer !== undefined) {
clearTimeout(p.timer);
}
};
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
if (commandTimeoutMs !== undefined) {
// 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`));
}, commandTimeoutMs);
}
pending.set(id, entry);
try {
ws.send(JSON.stringify(msg));
} catch (err) {
pending.delete(id);
clearPendingTimer(entry);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
clearPendingTimer(p);
p.reject(err);
}
pending.clear();
try {
ws.close();
} catch {
// ignore
}
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(err instanceof Error ? err : new Error(String(err)));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawCdpMessageToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
/** Fetch and parse a CDP JSON endpoint through the configured SSRF guard. */
export async function fetchJson<T>(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
ssrfPolicy?: SsrFPolicy,
): Promise<T> {
const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
try {
return (await response.json()) as T;
} finally {
await release();
}
}
/** Fetch a CDP endpoint and return the response with an idempotent release hook. */
export async function fetchCdpChecked(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
ssrfPolicy?: SsrFPolicy,
): Promise<CdpFetchResult> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), normalizeBrowserTimerDelayMs(timeoutMs));
let guardedRelease: (() => Promise<void>) | undefined;
let released = false;
const release = async () => {
if (released) {
return;
}
released = true;
clearTimeout(t);
await guardedRelease?.();
};
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const fetchUrl = stripUrlCredentials(url);
const res = await withManagedProxyForCdpUrl(fetchUrl, () =>
withNoProxyForCdpUrl(url, async () => {
const parsedUrl = new URL(fetchUrl);
// Loopback CDP is an OpenClaw control plane, not page navigation. Allow
// its exact host while preserving the caller's policy for remote hosts.
const policy = isLoopbackHost(parsedUrl.hostname)
? withAllowedHostname(ssrfPolicy, parsedUrl.hostname)
: (ssrfPolicy ?? { allowPrivateNetwork: true });
const guarded = await fetchWithSsrFGuard({
url: fetchUrl,
init: { ...init, headers },
signal: ctrl.signal,
policy,
auditContext: "browser-cdp",
});
guardedRelease = guarded.release;
return guarded.response;
}),
);
if (!res.ok) {
if (res.status === 429) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`);
}
throw new Error(`HTTP ${res.status}`);
}
return { response: res, release };
} catch (error) {
await release();
if (error instanceof SsrFBlockedError) {
throw new BrowserCdpEndpointBlockedError({ cause: error });
}
throw error;
}
}
/** Probe that a CDP endpoint responds with an OK HTTP status. */
export async function fetchOk(
url: string,
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
init?: RequestInit,
ssrfPolicy?: SsrFPolicy,
): Promise<void> {
const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
await release();
}
/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */
export function openCdpWebSocket(
wsUrl: string,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): WebSocket {
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const agent = getDirectAgentForCdp(wsUrl);
const bypassUrl = stripUrlCredentials(wsUrl);
return withManagedProxyForCdpUrl(
bypassUrl,
() =>
new WebSocket(wsUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
}),
);
}
type CdpSocketOptions = {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
commandTimeoutMs?: number;
handshakeRetries?: number;
handshakeRetryDelayMs?: number;
handshakeMaxRetryDelayMs?: number;
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number {
const baseDelayMs =
typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs)
? Math.max(1, Math.floor(opts.handshakeRetryDelayMs))
: 200;
const maxDelayMs =
typeof opts?.handshakeMaxRetryDelayMs === "number" &&
Number.isFinite(opts.handshakeMaxRetryDelayMs)
? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs))
: 3000;
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
// Jitter keeps several browser sessions from retrying handshakes in lockstep
// after a shared Chrome or network hiccup.
const jitterScale = 0.8 + Math.random() * 0.4;
return Math.max(1, Math.floor(raw * jitterScale));
}
function shouldRetryCdpHandshakeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const msg = err.message.toLowerCase();
if (!msg) {
return false;
}
if (msg.includes("rate limit")) {
return false;
}
const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/);
if (statusMatch?.[1]) {
return Number(statusMatch[1]) >= 500;
}
return (
msg.includes("cdp socket closed") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("econnaborted") ||
msg.includes("ehostunreach") ||
msg.includes("enetunreach") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("websocket error") ||
msg.includes("closed before")
);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: CdpSocketOptions,
): Promise<T> {
const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2);
let lastHandshakeError: unknown;
for (let attempt = 0; attempt <= maxHandshakeRetries; attempt += 1) {
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws, opts);
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")));
});
try {
await openPromise;
} catch (err) {
lastHandshakeError = 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(err instanceof Error ? err : new Error(String(err)));
try {
ws.close();
} catch {
// ignore
}
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
await sleep(computeHandshakeRetryDelayMs(attempt + 1, opts));
continue;
}
try {
return await fn(send);
} catch (err) {
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
try {
ws.close();
} catch {
// ignore
}
}
}
if (lastHandshakeError instanceof Error) {
throw lastHandshakeError;
}
throw new Error("CDP socket failed to open");
}

View File

@@ -0,0 +1,948 @@
// Browser tests cover cdp.internal plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import "../test-support/browser-security.mock.js";
import {
type AriaSnapshotNode,
captureScreenshot,
createTargetViaCdp,
formatAriaSnapshot,
normalizeCdpWsUrl,
type RawAXNode,
snapshotAria,
snapshotRoleViaCdp,
} from "./cdp.js";
/**
* Exercises the CDP session-oriented exports of cdp.ts against a local
* `ws` server. A single `createCdpMockServer` helper echoes replies
* keyed on method, keeping individual tests short.
*/
type CdpReplyHandler = (
msg: { id?: number; method?: string; params?: Record<string, unknown> },
socket: WebSocket,
) => void;
type CdpMockMessage = Parameters<CdpReplyHandler>[0];
function sendCdpResult(socket: WebSocket, id: number | undefined, result: Record<string, unknown>) {
socket.send(JSON.stringify({ id, result }));
}
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
let count = 0;
for (const item of items) {
if (predicate(item)) {
count += 1;
}
}
return count;
}
function replyToPageEnable(msg: CdpMockMessage, socket: WebSocket): boolean {
if (msg.method !== "Page.enable") {
return false;
}
sendCdpResult(socket, msg.id, {});
return true;
}
function replyWithScreenshotData(msg: CdpMockMessage, socket: WebSocket, data: string): boolean {
if (msg.method !== "Page.captureScreenshot") {
return false;
}
sendCdpResult(socket, msg.id, { data: Buffer.from(data).toString("base64") });
return true;
}
function replyToViewportCommandOrScreenshot(
msg: CdpMockMessage,
socket: WebSocket,
data: string,
): boolean {
if (
msg.method === "Emulation.setDeviceMetricsOverride" ||
msg.method === "Emulation.clearDeviceMetricsOverride"
) {
sendCdpResult(socket, msg.id, {});
return true;
}
return replyWithScreenshotData(msg, socket, data);
}
async function startMockWsServer(handle: CdpReplyHandler) {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as {
id?: number;
method?: string;
params?: Record<string, unknown>;
};
handle(msg, socket);
if (
msg.method === "Page.enable" ||
msg.method === "Runtime.enable" ||
msg.method === "Network.enable" ||
msg.method === "DOM.enable" ||
msg.method === "Accessibility.enable" ||
msg.method === "Runtime.runIfWaitingForDebugger"
) {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
});
return {
wss,
port,
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/TEST`,
};
}
describe("cdp internal", () => {
let wss: WebSocketServer | null = null;
afterEach(async () => {
if (wss) {
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
wss = null;
}
});
async function captureScreenshotAndObserveParams(
options: Omit<Parameters<typeof captureScreenshot>[0], "wsUrl">,
) {
const observed: Array<Record<string, unknown>> = [];
const server = await startMockWsServer((msg, socket) => {
if (replyToPageEnable(msg, socket)) {
return;
}
if (msg.method === "Page.captureScreenshot") {
observed.push(msg.params ?? {});
replyWithScreenshotData(msg, socket, "JPG");
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, ...options });
return { buf, observed };
}
describe("captureScreenshot", () => {
it("captures a PNG without fullPage", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
expect(msg.params?.format).toBe("png");
expect(msg.params).not.toHaveProperty("captureBeyondViewport");
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("PNGDATA").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl });
expect(buf.toString("utf8")).toBe("PNGDATA");
});
it("clamps out-of-range JPEG quality values into [0, 100]", async () => {
const { observed } = await captureScreenshotAndObserveParams({
format: "jpeg",
quality: 250,
});
expect(observed[0]?.format).toBe("jpeg");
expect(observed[0]?.quality).toBe(100);
});
it("captures fullPage and restores viewport overrides", async () => {
const events: string[] = [];
const server = await startMockWsServer((msg, socket) => {
events.push(msg.method ?? "");
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 2000, height: 3000 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
// Pre-capture viewport probe + post-capture probe.
const isPre = countMatching(events, (m) => m === "Runtime.evaluate") === 1;
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: {
value: isPre
? { w: 800, h: 600, dpr: 2, sw: 1600, sh: 1200 }
: { w: 2000, h: 3000, dpr: 2 },
},
},
}),
);
return;
}
replyToViewportCommandOrScreenshot(msg, socket, "FULL");
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("FULL");
expect(events).toContain("Emulation.setDeviceMetricsOverride");
expect(events).toContain("Emulation.clearDeviceMetricsOverride");
});
it("restores viewport even when the post-capture probe mismatches", async () => {
// Post probe returns a different dpr than saved → helper reapplies.
const calls: Array<Record<string, unknown>> = [];
let evalCount = 0;
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { contentSize: { width: 1200, height: 800 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
evalCount += 1;
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: {
value:
evalCount === 1
? { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 }
: { w: 9999, h: 9999, dpr: 9 },
},
},
}),
);
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
calls.push(msg.params ?? {});
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("PIC").toString("base64") },
}),
);
}
});
wss = server.wss;
await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
// Two setDeviceMetricsOverride calls: expand then restore.
expect(calls.length).toBeGreaterThanOrEqual(2);
});
it("skips viewport expansion when content size is zero", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 0, height: 0 } },
}),
);
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("Z").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("Z");
});
it("throws when Page.captureScreenshot returns no data", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(captureScreenshot({ wsUrl: server.wsUrl })).rejects.toThrow(
/Screenshot failed: missing data/,
);
});
});
describe("createTargetViaCdp", () => {
it("throws when Target.createTarget returns no targetId", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "" } }));
}
});
wss = server.wss;
await expect(
createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }),
).rejects.toThrow(/Target\.createTarget returned no targetId/);
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
});
it("returns an empty array when no node has an id", () => {
const nodes = [{ role: { value: "Role" }, name: { value: "" } }] as unknown as RawAXNode[];
expect(formatAriaSnapshot(nodes, 100)).toStrictEqual([]);
});
it("skips child references that are absent from the node map", () => {
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Root" },
name: { value: "" },
childIds: ["2", "missing"],
},
{
nodeId: "2",
role: { value: "Leaf" },
name: { value: "ok" },
childIds: [],
},
];
const out: AriaSnapshotNode[] = formatAriaSnapshot(nodes, 100);
// Only the root + the resolvable child — missing is dropped.
expect(out).toHaveLength(2);
expect(out[1]?.name).toBe("ok");
});
it("coerces AX values from strings, numbers, and booleans (with fallback to empty)", () => {
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Root" } as unknown as RawAXNode["role"],
name: { value: 42 } as unknown as RawAXNode["name"],
value: { value: true } as unknown as RawAXNode["value"],
description: { value: {} } as unknown as RawAXNode["description"],
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.role).toBe("Root");
expect(out[0]?.name).toBe("42");
expect(out[0]?.value).toBe("true");
// Unknown/object-shaped AX value → falls back to empty → omitted.
expect(out[0]?.description).toBeUndefined();
});
it("respects the limit argument", () => {
const nodes: RawAXNode[] = Array.from({ length: 10 }, (_, i) => ({
nodeId: String(i + 1),
role: { value: `Role${i + 1}` },
name: { value: "" },
childIds: i === 0 ? ["2", "3", "4", "5", "6", "7", "8", "9", "10"] : [],
}));
const out = formatAriaSnapshot(nodes, 3);
expect(out).toHaveLength(3);
});
it("returns nodes when snapshotAria receives a non-finite limit", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{
nodeId: "1",
role: { value: "RootWebArea" },
name: { value: "Home" },
childIds: [],
},
],
},
}),
);
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl, limit: Number.NaN });
expect(snap.nodes).toHaveLength(1);
expect(snap.nodes[0]?.role).toBe("RootWebArea");
});
});
describe("snapshotAria", () => {
it("forwards the happy-path tree to formatAriaSnapshot", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{ nodeId: "1", role: { value: "Root" }, name: { value: "" }, childIds: [] },
],
},
}),
);
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl, limit: 50 });
expect(snap.nodes[0]?.role).toBe("Root");
});
it("returns an empty list when the server omits nodes", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl });
expect(snap.nodes).toStrictEqual([]);
});
});
describe("snapshotRoleViaCdp", () => {
it("builds role refs, promotes cursor-interactive nodes, and appends link urls", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable" || msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{
nodeId: "1",
role: { value: "RootWebArea" },
name: { value: "" },
childIds: ["2", "3", "4"],
},
{
nodeId: "2",
role: { value: "button" },
name: { value: "Save" },
backendDOMNodeId: 22,
childIds: [],
},
{
nodeId: "3",
role: { value: "link" },
name: { value: "Docs" },
backendDOMNodeId: 33,
childIds: [],
},
{
nodeId: "4",
role: { value: "generic" },
name: { value: "" },
backendDOMNodeId: 44,
childIds: [],
},
],
},
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
const expression =
typeof msg.params?.expression === "string" ? msg.params.expression : "";
if (expression.includes('querySelectorAll("*"')) {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: {
value: [
{
text: "Clickable Card",
tagName: "div",
hasCursorPointer: true,
hasOnClick: true,
},
],
},
},
}),
);
return;
}
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: true } } }));
return;
}
if (msg.method === "DOM.getDocument") {
socket.send(JSON.stringify({ id: msg.id, result: { root: { nodeId: 1 } } }));
return;
}
if (msg.method === "DOM.querySelectorAll") {
socket.send(JSON.stringify({ id: msg.id, result: { nodeIds: [44] } }));
return;
}
if (msg.method === "DOM.describeNode") {
socket.send(
JSON.stringify({
id: msg.id,
result: { node: { backendNodeId: 44, attributes: ["data-openclaw-cdp-ci", "0"] } },
}),
);
return;
}
if (msg.method === "DOM.resolveNode") {
socket.send(JSON.stringify({ id: msg.id, result: { object: { objectId: "link1" } } }));
return;
}
if (msg.method === "Runtime.callFunctionOn") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: "https://docs.openclaw.ai/" } },
}),
);
}
});
wss = server.wss;
const snap = await snapshotRoleViaCdp({
wsUrl: server.wsUrl,
urls: true,
options: { interactive: true },
});
expect(snap.snapshot).toContain('- button "Save" [ref=e1]');
expect(snap.snapshot).toContain('- link "Docs" [ref=e2] [url=https://docs.openclaw.ai/]');
expect(snap.snapshot).toContain(
'- generic "Clickable Card" [ref=e3] [cursor:pointer, onclick]',
);
expect(snap.refs.e3?.backendDOMNodeId).toBe(44);
});
it("expands one level of iframe snapshots with frame metadata", async () => {
const server = await startMockWsServer((msg, socket) => {
if (
msg.method === "Accessibility.enable" ||
msg.method === "Page.enable" ||
msg.method === "Runtime.evaluate"
) {
socket.send(
JSON.stringify({
id: msg.id,
result: msg.method === "Runtime.evaluate" ? { result: { value: [] } } : {},
}),
);
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
const frameId = msg.params?.frameId;
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: frameId
? [
{
nodeId: "c1",
role: { value: "RootWebArea" },
name: { value: "" },
childIds: ["c2"],
},
{
nodeId: "c2",
role: { value: "button" },
name: { value: "Inside" },
backendDOMNodeId: 55,
childIds: [],
},
]
: [
{
nodeId: "1",
role: { value: "RootWebArea" },
name: { value: "" },
childIds: ["2"],
},
{
nodeId: "2",
role: { value: "Iframe" },
name: { value: "Child" },
backendDOMNodeId: 44,
childIds: [],
},
],
},
}),
);
return;
}
if (msg.method === "DOM.describeNode") {
socket.send(
JSON.stringify({
id: msg.id,
result: { node: { contentDocument: { frameId: "FRAME_1" } } },
}),
);
}
});
wss = server.wss;
const snap = await snapshotRoleViaCdp({
wsUrl: server.wsUrl,
options: { interactive: true },
});
expect(snap.snapshot).toContain('- Iframe "Child" [ref=e1]');
expect(snap.snapshot).toContain(' - button "Inside" [ref=e2]');
expect(snap.refs.e1?.frameId).toBe("FRAME_1");
expect(snap.refs.e2?.frameId).toBe("FRAME_1");
});
});
describe("normalizeCdpWsUrl fill-in", () => {
it("respects an already-non-loopback ws hostname (no-rewrite branch)", () => {
// Covers the else side of the loopback/wildcard-guard in normalizeCdpWsUrl.
const out = normalizeCdpWsUrl(
"ws://non-loopback.example:9222/devtools/browser/ABC",
"http://non-loopback.example:9222",
);
expect(out).toContain("non-loopback.example:9222");
});
it("falls back to protocol-default ports when the cdp URL omits a port", () => {
// Covers the right-hand side of `cdp.port || (cdp.protocol === 'https:' ? '443' : '80')`.
// WHATWG URL elides default ports (443 for wss, 80 for ws) in the
// serialized form, so we assert the scheme + host rather than port.
const secure = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"https://example.com/",
);
expect(secure).toBe("wss://example.com/devtools/browser/ABC");
const plain = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"http://example.com/",
);
expect(plain).toBe("ws://example.com/devtools/browser/ABC");
});
});
describe("captureScreenshot branch coverage", () => {
it("uses the default jpeg quality when opts.quality is omitted", async () => {
const { observed } = await captureScreenshotAndObserveParams({ format: "jpeg" });
expect(observed[0]?.quality).toBe(85);
});
it("defaults fullPage content/viewport fields to 0 when the page reports nothing", async () => {
// Covers the right-hand sides of `size?.width ?? 0`, `size?.height ?? 0`,
// `v?.w ?? 0`, `v?.h ?? 0`, `v?.dpr ?? 1`, `v?.sw ?? currentW`, `v?.sh ?? currentH`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
// Both cssContentSize and contentSize absent — forces the
// `?? 0` default on width/height.
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("N").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("N");
});
it("falls back to the non-css contentSize when cssContentSize is absent", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { contentSize: { width: 100, height: 200 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
// viewport probe with a completely empty value to exercise all
// `v?.X ?? default` branches.
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: {} } } }));
return;
}
replyToViewportCommandOrScreenshot(msg, socket, "C");
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("C");
});
});
describe("createTargetViaCdp branch coverage", () => {
it("normalises a bare ws:// CDP URL to http for /json/version discovery", async () => {
// Covers the truthy side of `isWebSocketUrl(opts.cdpUrl) ? normalize... : opts.cdpUrl`
// in createTargetViaCdp — the bare-ws root triggers discovery.
const http = await import("node:http");
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wsServer.once("listening", () => resolve());
});
const wsPort = (wsServer.address() as { port: number }).port;
wsServer.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string };
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_BARE_WS" } }));
return;
}
if (msg.method === "Target.attachToTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S_BARE_WS" } }));
return;
}
if (
msg.method === "Page.enable" ||
msg.method === "Runtime.enable" ||
msg.method === "Network.enable" ||
msg.method === "DOM.enable" ||
msg.method === "Accessibility.enable" ||
msg.method === "Runtime.runIfWaitingForDebugger" ||
msg.method === "Target.detachFromTarget"
) {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
});
const httpServer = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/BARE_WS`,
}),
);
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => {
httpServer.listen(0, "127.0.0.1", () => resolve());
});
const httpPort = (httpServer.address() as { port: number }).port;
try {
const out = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${httpPort}`, // bare ws root → forces discovery
url: "https://example.com",
});
expect(out.targetId).toBe("T_BARE_WS");
} finally {
await new Promise<void>((resolve) => {
wsServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
}
});
it("throws when Target.createTarget returns a missing (undefined) targetId", async () => {
// Covers the right-hand side of `created?.targetId?.trim() ?? ""` (?? "").
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(
createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }),
).rejects.toThrow(/Target\.createTarget returned no targetId/);
});
});
describe("formatAriaSnapshot branch coverage", () => {
it("falls back to 'unknown' role and omits empty value/description", () => {
// role "" triggers `role || "unknown"`; value/description empty
// triggers the falsy side of `value ? { value } : {}`.
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "" },
name: { value: "n" },
value: { value: "" },
description: { value: "" },
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.role).toBe("unknown");
expect(out[0]?.value).toBeUndefined();
expect(out[0]?.description).toBeUndefined();
});
it("includes the description field when the AX node provides a truthy description", () => {
// Covers the truthy side of `description ? { description } : {}`.
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Button" },
name: { value: "n" },
description: { value: "explanatory" },
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.description).toBe("explanatory");
});
it("defaults childIds to an empty array when the AX node omits the field", () => {
// Covers the right-hand side of `(n.childIds ?? [])`.
const nodes: RawAXNode[] = [
{
nodeId: "solo",
role: { value: "Leaf" },
name: { value: "" },
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out).toHaveLength(1);
});
});
describe(".catch(() => {}) swallow arrows", () => {
it("swallows a failing Accessibility.enable in snapshotAria", async () => {
// Exercises the `.catch(() => {})` arrow on `Accessibility.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(JSON.stringify({ id: msg.id, result: { nodes: [] } }));
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl });
expect(snap.nodes).toStrictEqual([]);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 800, height: 600 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 } } },
}),
);
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("S").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("S");
});
});
});

View File

@@ -0,0 +1,226 @@
// Browser tests cover cdp.screenshot params plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withCdpSocket } from "./cdp.helpers.js";
import { captureScreenshot } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { shouldUsePlaywrightForScreenshot } from "./profile-capabilities.js";
const sentMessages = vi.hoisted(() => {
const msgs: Array<{ method: string; params?: Record<string, unknown> }> = [];
return msgs;
});
// Tracks whether emulation has been cleared so post-clear Runtime.evaluate
// can return different values for the "emulated tab" vs "non-emulated tab" tests.
const mockState = vi.hoisted(() => ({
emulationCleared: false,
emulatedTab: true,
viewport: { w: 800, h: 600, dpr: 2, sw: 800, sh: 600 } as Record<string, unknown>,
naturalViewport: { w: 1920, h: 1080, dpr: 1 },
}));
vi.mock("./cdp.helpers.js", () => ({
withCdpSocket: vi.fn(
async (
_wsUrl: string,
fn: (send: unknown) => Promise<unknown>,
_opts?: { commandTimeoutMs?: number },
) => {
const send = (method: string, params?: Record<string, unknown>) => {
sentMessages.push({ method, params });
if (method === "Page.captureScreenshot") {
return Promise.resolve({ data: "AAAA" });
}
if (method === "Page.getLayoutMetrics") {
return Promise.resolve({
cssContentSize: { width: 1200, height: 3000 },
contentSize: { width: 1200, height: 3000 },
});
}
if (method === "Emulation.clearDeviceMetricsOverride") {
mockState.emulationCleared = true;
return Promise.resolve({});
}
if (method === "Emulation.setDeviceMetricsOverride") {
mockState.emulationCleared = false;
return Promise.resolve({});
}
if (method === "Runtime.evaluate") {
if (mockState.emulationCleared && mockState.emulatedTab) {
return Promise.resolve({
result: {
value: mockState.naturalViewport,
},
});
}
return Promise.resolve({
result: {
value: mockState.viewport,
},
});
}
return Promise.resolve({});
};
return fn(send);
},
),
appendCdpPath: vi.fn(),
fetchJson: vi.fn(),
isLoopbackHost: vi.fn(),
isWebSocketUrl: vi.fn(),
}));
vi.mock("./navigation-guard.js", () => ({
assertBrowserNavigationAllowed: vi.fn(),
withBrowserNavigationPolicy: vi.fn(() => ({})),
}));
const localProfile: ResolvedBrowserProfile = {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpPort: 18800,
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
};
beforeEach(() => {
sentMessages.length = 0;
mockState.emulationCleared = false;
mockState.emulatedTab = true;
mockState.viewport = { w: 800, h: 600, dpr: 2, sw: 800, sh: 600 };
mockState.naturalViewport = { w: 1920, h: 1080, dpr: 1 };
});
function requireSentMessage(method: string) {
const message = sentMessages.find((m) => m.method === method);
if (!message) {
throw new Error(`expected ${method} CDP message`);
}
return message;
}
describe("CDP screenshot params", () => {
it("viewport screenshot omits fromSurface and captureBeyondViewport", async () => {
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", format: "png" });
const call = requireSentMessage("Page.captureScreenshot");
expect(call.params?.format).toBe("png");
expect(call.params).not.toHaveProperty("fromSurface");
expect(call.params).not.toHaveProperty("captureBeyondViewport");
expect(call.params).not.toHaveProperty("clip");
const emulationCalls = sentMessages.filter(
(m) => m.method === "Emulation.setDeviceMetricsOverride",
);
expect(emulationCalls).toHaveLength(0);
});
it("uses the requested timeout as the raw CDP command timeout", async () => {
await captureScreenshot({
wsUrl: "ws://localhost:9222/devtools/page/X",
format: "png",
timeoutMs: 12_345,
});
const [wsUrl, sendCallback, options] =
(withCdpSocket as unknown as { mock: { calls: Array<Array<unknown>> } }).mock.calls.at(-1) ??
[];
expect(wsUrl).toBe("ws://localhost:9222/devtools/page/X");
expect(typeof sendCallback).toBe("function");
expect(options).toEqual({ commandTimeoutMs: 12_345 });
});
it("fullPage on emulated tab: clears, detects drift, re-applies saved emulation", async () => {
mockState.emulatedTab = true;
await captureScreenshot({
wsUrl: "ws://localhost:9222/devtools/page/X",
format: "png",
fullPage: true,
});
const setCalls = sentMessages.filter((m) => m.method === "Emulation.setDeviceMetricsOverride");
expect(setCalls.length).toBe(2);
const [firstSetCall, secondSetCall] = setCalls;
if (!firstSetCall || !secondSetCall) {
throw new Error("expected two viewport updates");
}
// Expand: uses saved DPR, mobile defaults to false
expect(firstSetCall.params?.width).toBe(1200);
expect(firstSetCall.params?.height).toBe(3000);
expect(firstSetCall.params?.deviceScaleFactor).toBe(2);
expect(firstSetCall.params?.mobile).toBe(false);
// Clear is called first in the finally block
requireSentMessage("Emulation.clearDeviceMetricsOverride");
const captureCall = requireSentMessage("Page.captureScreenshot");
expect(captureCall.params?.captureBeyondViewport).toBe(true);
// Viewport drifted after clear → re-apply saved dimensions
expect(secondSetCall.params?.width).toBe(800);
expect(secondSetCall.params?.height).toBe(600);
expect(secondSetCall.params?.deviceScaleFactor).toBe(2);
expect(secondSetCall.params?.mobile).toBe(false);
expect(secondSetCall.params?.screenWidth).toBe(800);
expect(secondSetCall.params?.screenHeight).toBe(600);
});
it("fullPage on non-emulated tab: clears and does NOT re-apply emulation", async () => {
mockState.emulatedTab = false;
mockState.viewport = { w: 1920, h: 1080, dpr: 1, sw: 1920, sh: 1080 };
mockState.naturalViewport = { w: 1920, h: 1080, dpr: 1 };
await captureScreenshot({
wsUrl: "ws://localhost:9222/devtools/page/X",
format: "png",
fullPage: true,
});
const setCalls = sentMessages.filter((m) => m.method === "Emulation.setDeviceMetricsOverride");
// Only the expand call — no re-apply after clear
expect(setCalls).toHaveLength(1);
requireSentMessage("Emulation.clearDeviceMetricsOverride");
});
it("fullPage viewport dimensions never shrink below current innerWidth/Height", async () => {
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", fullPage: true });
const expandCall = requireSentMessage("Emulation.setDeviceMetricsOverride");
expect(Number(expandCall.params?.width)).toBeGreaterThanOrEqual(800);
expect(Number(expandCall.params?.height)).toBeGreaterThanOrEqual(600);
});
});
describe("shouldUsePlaywrightForScreenshot routing", () => {
it("returns false for a normal viewport screenshot with wsUrl", () => {
expect(shouldUsePlaywrightForScreenshot({ profile: localProfile, wsUrl: "ws://x" })).toBe(
false,
);
});
it("returns true when wsUrl is missing", () => {
expect(shouldUsePlaywrightForScreenshot({ profile: localProfile })).toBe(true);
});
it("returns true when ref is specified", () => {
expect(
shouldUsePlaywrightForScreenshot({ profile: localProfile, wsUrl: "ws://x", ref: "btn-1" }),
).toBe(true);
});
it("returns true when element is specified", () => {
expect(
shouldUsePlaywrightForScreenshot({
profile: localProfile,
wsUrl: "ws://x",
element: "#submit",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,774 @@
// Browser tests cover cdp plugin behavior.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import "../test-support/browser-security.mock.js";
import {
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
} from "./cdp.helpers.js";
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
BrowserCdpEndpointBlockedError,
BrowserValidationError,
toBrowserErrorResponse,
} from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;
let wsServer: WebSocketServer | null = null;
const startWsServer = async () => {
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
wsServer?.once("listening", resolve);
});
return (wsServer.address() as { port: number }).port;
};
const startWsServerWithMessages = async (
onMessage: (
msg: { id?: number; method?: string; params?: Record<string, unknown> },
socket: WebSocket,
) => void,
) => {
const wsPort = await startWsServer();
if (!wsServer) {
throw new Error("ws server not initialized");
}
wsServer.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as {
id?: number;
method?: string;
params?: Record<string, unknown>;
};
onMessage(msg, socket);
if (msg.method === "Target.attachToTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S1" } }));
} else if (
msg.method === "Target.detachFromTarget" ||
msg.method === "Page.enable" ||
msg.method === "Runtime.enable" ||
msg.method === "Network.enable" ||
msg.method === "DOM.enable" ||
msg.method === "Accessibility.enable" ||
msg.method === "Runtime.runIfWaitingForDebugger"
) {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
});
return wsPort;
};
const startVersionHttpServer = async (versionBody: Record<string, unknown>) => {
httpServer = createServer((req, res) => {
if (req.url === "/json/version") {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(versionBody));
return;
}
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
return (httpServer.address() as { port: number }).port;
};
afterEach(async () => {
vi.unstubAllEnvs();
await new Promise<void>((resolve) => {
if (!httpServer) {
resolve();
return;
}
httpServer.close(() => resolve());
httpServer = null;
});
await new Promise<void>((resolve) => {
if (!wsServer) {
resolve();
return;
}
wsServer.close(() => resolve());
wsServer = null;
});
});
it("creates a target via the browser websocket", async () => {
const methods: string[] = [];
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method) {
methods.push(msg.method);
}
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_123" },
}),
);
});
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
});
const created = await createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
});
expect(created.targetId).toBe("TARGET_123");
expect(methods).toEqual([
"Target.createTarget",
"Target.attachToTarget",
"Page.enable",
"Runtime.enable",
"Network.enable",
"DOM.enable",
"Accessibility.enable",
"Runtime.runIfWaitingForDebugger",
"Target.detachFromTarget",
]);
});
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_WS_DIRECT" },
}),
);
});
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
url: "https://example.com",
});
expect(created.targetId).toBe("TARGET_WS_DIRECT");
// /json/version should NOT have been called — direct WS skips HTTP discovery
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("honors configured HTTP discovery timeouts when creating a target", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "TARGET_SLOW" } }));
});
httpServer = createServer((req, res) => {
if (req.url === "/json/version") {
setTimeout(() => {
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/SLOW`,
}),
);
}, 120);
return;
}
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
const httpPort = (httpServer.address() as AddressInfo).port;
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
timeouts: { httpTimeoutMs: 20 },
}),
).rejects.toThrow(/abort|timeout|timed out/i);
});
it("honors configured WebSocket handshake timeouts when creating a target", async () => {
wsServer = new WebSocketServer({ noServer: true });
httpServer = createServer();
const heldSockets: Duplex[] = [];
httpServer.on("upgrade", (_req, socket) => {
heldSockets.push(socket);
// Hold the TCP connection open without completing the WebSocket handshake.
});
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
const port = (httpServer.address() as AddressInfo).port;
try {
await expect(
createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${port}/devtools/browser/SLOW`,
url: "https://example.com",
timeouts: { handshakeTimeoutMs: 20 },
}),
).rejects.toThrow(/handshake|timeout|timed out/i);
} finally {
for (const socket of heldSockets) {
socket.destroy();
}
}
});
it("preserves query params when connecting via direct WebSocket URL", async () => {
let receivedHeaders: Record<string, string> = {};
const wsPort = await startWsServer();
if (!wsServer) {
throw new Error("ws server not initialized");
}
wsServer.on("headers", (headers, req) => {
receivedHeaders = Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [k, String(v)]),
);
});
wsServer.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string };
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } }));
} else if (msg.method === "Target.attachToTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S1" } }));
} else if (
msg.method === "Target.detachFromTarget" ||
msg.method === "Page.enable" ||
msg.method === "Runtime.enable" ||
msg.method === "Network.enable" ||
msg.method === "DOM.enable" ||
msg.method === "Accessibility.enable" ||
msg.method === "Runtime.runIfWaitingForDebugger"
) {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
});
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`,
url: "https://example.com",
});
expect(created.targetId).toBe("T_QP");
// The WebSocket upgrade request should have been made to the URL with the query param
expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`);
});
it("enforces SSRF policy on the navigation target URL before any CDP connection attempt", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "ws://127.0.0.1:9222",
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
// SSRF check happens before any connection attempt
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks hostname navigation targets when strict SSRF policy is configured", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "https://example.com",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks unsupported non-network navigation URLs", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("allows private navigation targets when explicitly configured", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
expect(msg.params?.url).toBe("http://127.0.0.1:8080");
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_LOCAL" },
}),
);
});
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
});
const created = await createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
});
expect(created.targetId).toBe("TARGET_LOCAL");
});
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",
});
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://93.184.216.34",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("blocks the initial /json/version fetch when the cdpUrl host is outside strict SSRF policy", async () => {
await expect(
createTargetViaCdp({
cdpUrl: "http://169.254.169.254:9222",
url: "https://93.184.216.34",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("blocks direct websocket cdp urls outside strict SSRF policy", async () => {
await expect(
createTargetViaCdp({
cdpUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",
url: "https://93.184.216.34",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
}),
).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl");
});
it("falls back to direct WS connection when /json/version is unavailable for a bare ws:// cdpUrl", async () => {
// Simulates a Browserless/Browserbase-style provider: the cdpUrl IS a
// WebSocket root (no /devtools/ path) but there is no HTTP /json/version
// endpoint. The WS server accepts Target.createTarget directly.
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "WS_FALLBACK" } }));
}
});
// No HTTP server on this port — discovery will fail, triggering the fallback.
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}`,
url: "https://example.com",
});
expect(created.targetId).toBe("WS_FALLBACK");
});
it("falls back to direct WS connection when discovered Browserless endpoint rejects commands", async () => {
const server = createServer((req, res) => {
if (req.url?.startsWith("/json/version")) {
const addr = server.address() as AddressInfo;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/e/bad`,
}),
);
return;
}
res.statusCode = 404;
res.end("not found");
});
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", (socket, req) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as {
id?: number;
method?: string;
};
if (req.url?.startsWith("/e/bad")) {
socket.send(
JSON.stringify({
id: msg.id,
error: { message: "Browserless endpoint rejected command" },
}),
);
return;
}
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "ROOT_FALLBACK" } }));
} else if (msg.method === "Target.attachToTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { sessionId: "S1" } }));
} else if (
msg.method === "Target.detachFromTarget" ||
msg.method === "Page.enable" ||
msg.method === "Runtime.enable" ||
msg.method === "Network.enable" ||
msg.method === "DOM.enable" ||
msg.method === "Accessibility.enable" ||
msg.method === "Runtime.runIfWaitingForDebugger"
) {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const addr = server.address() as AddressInfo;
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${addr.port}?token=abc`,
url: "https://example.com",
});
expect(created.targetId).toBe("ROOT_FALLBACK");
} finally {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("captures an aria snapshot via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{
nodeId: "1",
role: { value: "RootWebArea" },
name: { value: "" },
childIds: ["2"],
},
{
nodeId: "2",
role: { value: "button" },
name: { value: "OK" },
backendDOMNodeId: 42,
childIds: [],
},
],
},
}),
);
}
});
const snap = await snapshotAria({ wsUrl: `ws://127.0.0.1:${wsPort}` });
expect(snap.nodes.length).toBe(2);
expect(snap.nodes[0]?.role).toBe("RootWebArea");
expect(snap.nodes[1]?.role).toBe("button");
expect(snap.nodes[1]?.name).toBe("OK");
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
expect(snap.nodes[1]?.depth).toBe(1);
});
it("normalizes loopback websocket URLs for remote CDP hosts", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"http://example.com:9222",
);
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
});
it("propagates auth and query params onto normalized websocket URLs", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"https://user:pass@example.com?token=abc",
);
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("rewrites localhost absolute-form websocket URLs for remote CDP hosts", () => {
const normalized = normalizeCdpWsUrl(
"ws://localhost.:9222/devtools/browser/ABC",
"https://user:pass@example.com?token=abc",
);
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("normalizes loopback websocket aliases to the configured CDP loopback host", () => {
const normalized = normalizeCdpWsUrl(
"ws://localhost.:18800/devtools/browser/ABC",
"http://127.0.0.1:18800",
);
expect(normalized).toBe("ws://127.0.0.1:18800/devtools/browser/ABC");
});
it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC",
"http://192.168.1.202:18850?token=secret",
);
expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC?token=secret");
});
it("rewrites :: wildcard bind address to remote CDP host", () => {
const normalized = normalizeCdpWsUrl(
"ws://[::]:3000/devtools/browser/ABC",
"http://192.168.1.202:18850",
);
expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC");
});
it("keeps existing websocket query params when appending remote CDP query params", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token",
"http://127.0.0.1:9222?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("rewrites wildcard bind addresses to secure remote CDP hosts without clobbering websocket params", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC?session=1&token=ws-token",
"https://user:pass@example.com:9443?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"wss://user:pass@example.com:9443/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("upgrades ws to wss when CDP uses https", () => {
const normalized = normalizeCdpWsUrl(
"ws://production-sfo.browserless.io",
"https://production-sfo.browserless.io?token=abc",
);
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
});
});
describe("browser error mapping", () => {
it("maps blocked browser targets to conflict responses", () => {
const err = new Error(
"Browser target is unavailable after SSRF policy blocked its navigation.",
);
err.name = "BlockedBrowserTargetError";
expect(toBrowserErrorResponse(err)).toEqual({
status: 409,
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
});
});
it("preserves BrowserError mappings", () => {
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
status: 400,
message: "bad input",
});
});
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
expect(
toBrowserErrorResponse(
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
),
).toEqual({
status: 400,
message: BROWSER_NAVIGATION_BLOCKED_MESSAGE,
});
});
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
status: 400,
message: BROWSER_ENDPOINT_BLOCKED_MESSAGE,
});
});
});
describe("isWebSocketUrl", () => {
it("returns true for ws:// URLs", () => {
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
});
it("returns true for wss:// URLs", () => {
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
});
it("returns false for http:// and https:// URLs", () => {
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
});
it("returns false for invalid or non-URL strings", () => {
expect(isWebSocketUrl("not-a-url")).toBe(false);
expect(isWebSocketUrl("")).toBe(false);
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
});
});
describe("isDirectCdpWebSocketEndpoint", () => {
it("returns true for ws/wss URLs with a /devtools/<kind>/<id> path", () => {
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/browser/ABC")).toBe(true);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/page/42")).toBe(true);
expect(isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz")).toBe(
true,
);
expect(
isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz?token=secret"),
).toBe(true);
});
it("returns false for bare ws/wss URLs without a /devtools/ path (needs discovery)", () => {
// Reproduces the configuration shape reported in #68027.
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example/?token=abc")).toBe(false);
});
it("returns false for ws URLs whose path is not /devtools/*", () => {
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/json/version")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/other/path")).toBe(false);
});
it("returns false for http/https URLs, invalid URLs, and empty strings", () => {
expect(isDirectCdpWebSocketEndpoint("http://127.0.0.1:9222/devtools/browser/ABC")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("https://host/devtools/browser/ABC")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("not-a-url")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("")).toBe(false);
});
});
describe("parseHttpUrl with WebSocket protocols", () => {
it("accepts wss:// URLs and defaults to port 443", () => {
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");
expect(result.parsed.protocol).toBe("wss:");
expect(result.port).toBe(443);
expect(result.normalized).toContain("wss://connect.example.com");
});
it("accepts ws:// URLs and defaults to port 80", () => {
const result = parseHttpUrl("ws://127.0.0.1/devtools", "test");
expect(result.parsed.protocol).toBe("ws:");
expect(result.port).toBe(80);
});
it("preserves explicit ports in wss:// URLs", () => {
const result = parseHttpUrl("wss://connect.example.com:8443/path", "test");
expect(result.port).toBe(8443);
});
it("still accepts http:// and https:// URLs", () => {
const http = parseHttpUrl("http://127.0.0.1:9222", "test");
expect(http.port).toBe(9222);
const https = parseHttpUrl("https://browserless.example?token=abc", "test");
expect(https.port).toBe(443);
});
it("rejects unsupported protocols", () => {
expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)");
expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)");
});
});
const proxyEnvKeys = [
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
] as const;
beforeEach(() => {
for (const key of proxyEnvKeys) {
vi.stubEnv(key, "");
}
});

View File

@@ -0,0 +1,928 @@
/**
* Chrome DevTools Protocol browser operations.
*
* Provides screenshots, target creation, JavaScript evaluation, ARIA/role
* snapshots, DOM text, and selector lookup on top of the CDP socket helpers.
*/
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
type CdpSendFn,
fetchJson,
isDirectCdpWebSocketEndpoint,
isLoopbackHost,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export {
appendCdpPath,
fetchJson,
fetchOk,
getHeadersWithAuth,
isWebSocketUrl,
} from "./cdp.helpers.js";
/** Normalize a reported CDP WebSocket URL against the configured CDP base URL. */
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
const cdp = new URL(cdpUrl);
// Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting.
// Containerized browsers (e.g. browserless) report ws://0.0.0.0:<internal-port>
// in /json/version — these must be rewritten to the external cdpUrl host:port.
const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]";
if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
// `cdpPort` is always truthy: either the explicit cdp.port (truthy
// string), or the "443"/"80" default from the ternary. The guard is
// defensive against future parser edge cases.
/* c8 ignore next 3 */
if (cdpPort) {
ws.port = cdpPort;
}
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
} else if (isLoopbackHost(ws.hostname) && isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
}
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
ws.protocol = "wss:";
}
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
ws.username = cdp.username;
ws.password = cdp.password;
}
for (const [key, value] of cdp.searchParams.entries()) {
if (!ws.searchParams.has(key)) {
ws.searchParams.append(key, value);
}
}
return ws.toString();
}
/** Capture a PNG or JPEG screenshot through CDP, optionally full-page. */
export async function captureScreenshot(opts: {
wsUrl: string;
fullPage?: boolean;
format?: "png" | "jpeg";
quality?: number; // jpeg only (0..100)
timeoutMs?: number;
}): Promise<Buffer> {
return await withCdpSocket(
opts.wsUrl,
async (send) => {
await send("Page.enable");
// For full-page captures, temporarily expand the viewport to the content
// size so the entire page is within the viewport bounds. We save the
// current viewport state and restore it after capture so pre-existing
// device emulation (mobile width, DPR, touch) is not lost.
let savedVp: { w: number; h: number; dpr: number; sw: number; sh: number } | undefined;
if (opts.fullPage) {
const metrics = (await send("Page.getLayoutMetrics")) as {
cssContentSize?: { width?: number; height?: number };
contentSize?: { width?: number; height?: number };
};
const size = metrics?.cssContentSize ?? metrics?.contentSize;
const contentWidth = size?.width ?? 0;
const contentHeight = size?.height ?? 0;
if (contentWidth > 0 && contentHeight > 0) {
const vpResult = (await send("Runtime.evaluate", {
expression:
"({ w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio, sw: screen.width, sh: screen.height })",
returnByValue: true,
})) as {
result?: {
value?: { w?: number; h?: number; dpr?: number; sw?: number; sh?: number };
};
};
const v = vpResult?.result?.value;
const currentW = v?.w ?? 0;
const currentH = v?.h ?? 0;
savedVp = {
w: currentW,
h: currentH,
dpr: v?.dpr ?? 1,
sw: v?.sw ?? currentW,
sh: v?.sh ?? currentH,
};
// mobile: false is the safe default — CDP provides no way to query
// the active mobile flag, and inferring from navigator.maxTouchPoints
// would false-positive on touch-enabled desktops.
await send("Emulation.setDeviceMetricsOverride", {
width: Math.ceil(Math.max(currentW, contentWidth)),
height: Math.ceil(Math.max(currentH, contentHeight)),
deviceScaleFactor: savedVp.dpr,
mobile: false,
screenWidth: savedVp.sw,
screenHeight: savedVp.sh,
});
}
}
const format = opts.format ?? "png";
const quality =
format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined;
try {
// Chrome 146+ managed/headful browsers reject fromSurface: false.
// For ordinary viewport captures, keep CDP's captureBeyondViewport
// default (false), matching Playwright's Chromium path.
const result = (await send("Page.captureScreenshot", {
format,
...(quality !== undefined ? { quality } : {}),
...(opts.fullPage ? { captureBeyondViewport: true } : {}),
})) as { data?: string };
const base64 = result?.data;
if (!base64) {
throw new Error("Screenshot failed: missing data");
}
return Buffer.from(base64, "base64");
} finally {
if (savedVp) {
// Clear the temporary viewport expansion first. If the tab had
// prior device emulation the clear will change the viewport back to
// the browser's natural dimensions — detect that and re-apply the
// saved emulation so the tab's original state is preserved.
await send("Emulation.clearDeviceMetricsOverride").catch(() => {});
try {
const postResult = (await send("Runtime.evaluate", {
expression:
"({ w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio })",
returnByValue: true,
})) as { result?: { value?: { w?: number; h?: number; dpr?: number } } };
const p = postResult?.result?.value;
if (p?.w !== savedVp.w || p?.h !== savedVp.h || p?.dpr !== savedVp.dpr) {
await send("Emulation.setDeviceMetricsOverride", {
width: savedVp.w,
height: savedVp.h,
deviceScaleFactor: savedVp.dpr,
mobile: false,
screenWidth: savedVp.sw,
screenHeight: savedVp.sh,
});
}
} catch {
// Best-effort restoration; ignore failures in the cleanup path.
}
}
}
},
{ commandTimeoutMs: opts.timeoutMs },
);
}
/** HTTP and WebSocket timeout options for CDP actions that need discovery. */
export type CdpActionTimeouts = {
httpTimeoutMs?: number;
handshakeTimeoutMs?: number;
};
/** Create a new browser target after applying navigation and CDP SSRF policy. */
export async function createTargetViaCdp(opts: {
cdpUrl: string;
url: string;
ssrfPolicy?: SsrFPolicy;
timeouts?: CdpActionTimeouts;
}): Promise<{ targetId: string }> {
await assertBrowserNavigationAllowed({
url: opts.url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
let wsUrl: string;
if (isDirectCdpWebSocketEndpoint(opts.cdpUrl)) {
// Handshake-ready direct WebSocket URL — skip /json/version discovery.
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
wsUrl = opts.cdpUrl;
} else {
// Either an HTTP(S) CDP endpoint or a bare ws/wss root. Try
// /json/version discovery first. For bare ws/wss URLs, fall back to
// using the URL itself as a direct WS endpoint when discovery is
// unavailable — some providers (e.g. Browserless/Browserbase) expose
// a direct WebSocket root without a /json/version route.
const discoveryUrl = isWebSocketUrl(opts.cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl)
: opts.cdpUrl;
let version: { webSocketDebuggerUrl?: string } | null = null;
try {
version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(discoveryUrl, "/json/version"),
opts.timeouts?.httpTimeoutMs,
undefined,
opts.ssrfPolicy,
);
} catch (err) {
// Discovery failed for an HTTP/HTTPS URL — propagate immediately.
if (!isWebSocketUrl(opts.cdpUrl)) {
throw err;
}
// For bare ws/wss URLs, fall through: /json/version is unavailable
// so we attempt to use opts.cdpUrl as a direct WS endpoint below.
}
const wsUrlRaw = version?.webSocketDebuggerUrl?.trim() ?? "";
if (wsUrlRaw) {
wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
} else if (isWebSocketUrl(opts.cdpUrl)) {
// /json/version unavailable or returned no WebSocket URL. Treat the
// original URL as a direct WebSocket endpoint.
wsUrl = opts.cdpUrl;
} else {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
}
const candidateWsUrls =
isWebSocketUrl(opts.cdpUrl) && wsUrl !== opts.cdpUrl ? [wsUrl, opts.cdpUrl] : [wsUrl];
let lastError: unknown;
for (const candidateWsUrl of candidateWsUrls) {
try {
await assertCdpEndpointAllowed(candidateWsUrl, opts.ssrfPolicy);
return await withCdpSocket(
candidateWsUrl,
async (send) => {
const created = (await send("Target.createTarget", { url: opts.url })) as {
targetId?: string;
};
const targetId = created?.targetId?.trim() ?? "";
if (!targetId) {
throw new Error("CDP Target.createTarget returned no targetId");
}
await prepareCdpTargetSession(send, targetId);
return { targetId };
},
{
commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? 5000,
handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs,
},
);
} catch (err) {
lastError = err;
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("CDP Target.createTarget failed");
}
async function prepareCdpTargetSession(send: CdpSendFn, targetId: string): Promise<void> {
const attached = (await send("Target.attachToTarget", {
targetId,
flatten: true,
}).catch(() => null)) as { sessionId?: unknown } | null;
const sessionId = typeof attached?.sessionId === "string" ? attached.sessionId : undefined;
if (!sessionId) {
return;
}
try {
await prepareCdpPageSession(send, sessionId);
} finally {
await send("Target.detachFromTarget", { sessionId }).catch(() => {});
}
}
async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promise<void> {
await Promise.all([
send("Page.enable", undefined, sessionId).catch(() => {}),
send("Runtime.enable", undefined, sessionId).catch(() => {}),
send("Network.enable", undefined, sessionId).catch(() => {}),
send("DOM.enable", undefined, sessionId).catch(() => {}),
send("Accessibility.enable", undefined, sessionId).catch(() => {}),
]);
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;
role: string;
name: string;
value?: string;
description?: string;
backendDOMNodeId?: number;
depth: number;
};
/** Prefix assigned to generated accessibility-node refs. */
export const AX_REF_PREFIX = "ax";
export const AX_REF_PATTERN = new RegExp(`^${AX_REF_PREFIX}\\d+$`);
/** Raw accessibility node subset read from CDP Accessibility.getFullAXTree. */
export type RawAXNode = {
nodeId?: string;
role?: { value?: string };
name?: { value?: string };
value?: { value?: string };
description?: { value?: string };
childIds?: string[];
backendDOMNodeId?: number;
};
function axValue(v: unknown): string {
if (!v || typeof v !== "object") {
return "";
}
const value = (v as { value?: unknown }).value;
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
/** Format raw AX nodes into bounded ARIA snapshot nodes. */
export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] {
const byId = new Map<string, RawAXNode>();
for (const n of nodes) {
if (n.nodeId) {
byId.set(n.nodeId, n);
}
}
// Heuristic: pick a root-ish node (one that is not referenced as a child), else first.
const referenced = new Set<string>();
for (const n of nodes) {
for (const c of n.childIds ?? []) {
referenced.add(c);
}
}
const root = nodes.find((n) => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
if (!root?.nodeId) {
return [];
}
const out: AriaSnapshotNode[] = [];
const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }];
while (stack.length && out.length < limit) {
const popped = stack.pop();
// `stack.pop()` only returns undefined on an empty stack, but the
// while guard already asserts `stack.length > 0`. Dead defensive guard.
/* c8 ignore next 3 */
if (!popped) {
break;
}
const { id, depth } = popped;
const n = byId.get(id);
// Every id pushed onto the stack came from `children.filter(c => byId.has(c))`,
// so byId.get(id) is always defined here. Dead defensive guard.
/* c8 ignore next 3 */
if (!n) {
continue;
}
const role = axValue(n.role);
const name = axValue(n.name);
const value = axValue(n.value);
const description = axValue(n.description);
const ref = `${AX_REF_PREFIX}${out.length + 1}`;
out.push({
ref,
role: role || "unknown",
name: name || "",
...(value ? { value } : {}),
...(description ? { description } : {}),
...(typeof n.backendDOMNodeId === "number" ? { backendDOMNodeId: n.backendDOMNodeId } : {}),
depth,
});
const children = (n.childIds ?? []).filter((c) => byId.has(c));
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
// `children` is a string[] from an array filter over RawAXNode.childIds,
// so `child` is always a defined string here. Dead defensive guard.
/* c8 ignore next 3 */
if (child) {
stack.push({ id: child, depth: depth + 1 });
}
}
}
return out;
}
/** Capture an accessibility-tree snapshot through CDP. */
export async function snapshotAria(opts: {
wsUrl: string;
limit?: number;
timeoutMs?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = resolveIntegerOption(opts.limit, 500, { min: 1, max: 2000 });
return await withCdpSocket(
opts.wsUrl,
async (send) => {
await prepareCdpPageSession(send);
const res = (await send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
);
}
/** Role snapshot ref metadata used by agent-facing snapshots. */
export type CdpRoleRef = {
role: string;
name?: string;
nth?: number;
backendDOMNodeId?: number;
frameId?: string;
};
/** Options for CDP role snapshot extraction and compaction. */
export type CdpRoleSnapshotOptions = {
interactive?: boolean;
compact?: boolean;
maxDepth?: number;
};
type CursorInteractiveInfo = {
text: string;
tagName: string;
hasOnClick?: boolean;
hasCursorPointer?: boolean;
hasTabIndex?: boolean;
isEditable?: boolean;
hiddenInputType?: string;
};
type RoleTreeNode = {
raw: RawAXNode;
role: string;
name: string;
value: string;
backendDOMNodeId?: number;
children: number[];
parent?: number;
depth: number;
ref?: string;
nth?: number;
url?: string;
cursorInfo?: CursorInteractiveInfo;
frameId?: string;
};
function buildRoleTree(nodes: RawAXNode[]): { tree: RoleTreeNode[]; roots: number[] } {
const byId = new Map<string, number>();
const tree: RoleTreeNode[] = [];
for (const raw of nodes) {
const nodeId = raw.nodeId ?? "";
if (!nodeId) {
continue;
}
byId.set(nodeId, tree.length);
tree.push({
raw,
role: axValue(raw.role) || "unknown",
name: axValue(raw.name),
value: axValue(raw.value),
backendDOMNodeId:
typeof raw.backendDOMNodeId === "number" && raw.backendDOMNodeId > 0
? Math.floor(raw.backendDOMNodeId)
: undefined,
children: [],
depth: 0,
});
}
const childIndexes = new Set<number>();
for (let index = 0; index < tree.length; index += 1) {
for (const childId of tree[index]?.raw.childIds ?? []) {
const childIndex = byId.get(childId);
if (childIndex === undefined) {
continue;
}
tree[index]?.children.push(childIndex);
tree[childIndex].parent = index;
childIndexes.add(childIndex);
}
}
const roots = tree.map((_node, index) => index).filter((index) => !childIndexes.has(index));
const stack = roots.map((index) => ({ index, depth: 0 }));
while (stack.length) {
const current = stack.pop();
if (!current) {
break;
}
tree[current.index].depth = current.depth;
for (const child of (tree[current.index]?.children ?? []).toReversed()) {
stack.push({ index: child, depth: current.depth + 1 });
}
}
return { tree, roots: roots.length ? roots : tree.length ? [0] : [] };
}
function shouldIncludeRoleNode(node: RoleTreeNode, options: CdpRoleSnapshotOptions): boolean {
const role = node.role.toLowerCase();
if (options.maxDepth !== undefined && node.depth > options.maxDepth) {
return false;
}
if (options.interactive) {
return INTERACTIVE_ROLES.has(role) || role === "iframe" || Boolean(node.cursorInfo);
}
if (options.compact && STRUCTURAL_ROLES.has(role) && !node.name && !node.ref) {
return false;
}
return true;
}
function cursorSuffix(info?: CursorInteractiveInfo): string {
if (!info) {
return "";
}
const parts = [
info.hasCursorPointer ? "cursor:pointer" : undefined,
info.hasOnClick ? "onclick" : undefined,
info.hasTabIndex ? "tabindex" : undefined,
info.isEditable ? "contenteditable" : undefined,
info.hiddenInputType ? `hidden-${info.hiddenInputType}` : undefined,
].filter(Boolean);
return parts.length ? ` [${parts.join(", ")}]` : "";
}
function renderRoleTree(
tree: RoleTreeNode[],
index: number,
output: string[],
options: CdpRoleSnapshotOptions,
indentOffset = 0,
): void {
const node = tree[index];
if (!node) {
return;
}
if (shouldIncludeRoleNode(node, options)) {
const indent = " ".repeat(Math.max(0, node.depth + indentOffset));
const name = node.name ? ` "${node.name.replaceAll('"', '\\"')}"` : "";
const ref = node.ref ? ` [ref=${node.ref}]` : "";
const nth = node.nth !== undefined && node.nth > 0 ? ` [nth=${node.nth}]` : "";
const value = node.value ? ` value="${node.value.replaceAll('"', '\\"')}"` : "";
const url = node.url ? ` [url=${node.url}]` : "";
output.push(
`${indent}- ${node.role}${name}${ref}${nth}${value}${url}${cursorSuffix(node.cursorInfo)}`,
);
}
for (const child of node.children) {
renderRoleTree(tree, child, output, options, indentOffset);
}
}
async function findCursorInteractiveElements(
send: CdpSendFn,
sessionId?: string,
): Promise<Map<number, CursorInteractiveInfo>> {
const attr = "data-openclaw-cdp-ci";
const evaluated = (await send(
"Runtime.evaluate",
{
expression: `(() => {
const out = [];
const roles = new Set(["button","link","textbox","checkbox","radio","combobox","listbox","menuitem","menuitemcheckbox","menuitemradio","option","searchbox","slider","spinbutton","switch","tab","treeitem"]);
const tags = new Set(["a","button","input","select","textarea","details","summary"]);
document.querySelectorAll("[${attr}]").forEach((el) => el.removeAttribute("${attr}"));
for (const el of Array.from(document.body ? document.body.querySelectorAll("*") : [])) {
if (!(el instanceof HTMLElement) || el.closest("[hidden],[aria-hidden='true']")) continue;
const tagName = el.tagName.toLowerCase();
if (tags.has(tagName)) continue;
const role = String(el.getAttribute("role") || "").toLowerCase();
if (roles.has(role)) continue;
const style = getComputedStyle(el);
const hasCursorPointer = style.cursor === "pointer";
const hasOnClick = el.hasAttribute("onclick") || el.onclick !== null;
const tabIndex = el.getAttribute("tabindex");
const hasTabIndex = tabIndex !== null && tabIndex !== "-1";
const ce = el.getAttribute("contenteditable");
const isEditable = ce === "" || ce === "true";
if (!hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) continue;
if (hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) {
const parent = el.parentElement;
if (parent && getComputedStyle(parent).cursor === "pointer") continue;
}
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
let hiddenInputType = "";
const hiddenInput = el.querySelector("input[type='radio'],input[type='checkbox']");
if (hiddenInput instanceof HTMLInputElement) {
const hiddenStyle = getComputedStyle(hiddenInput);
if (hiddenInput.hidden || hiddenStyle.display === "none" || hiddenStyle.visibility === "hidden") {
hiddenInputType = hiddenInput.type;
}
}
el.setAttribute("${attr}", String(out.length));
out.push({
text: String(el.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 101),
tagName,
hasCursorPointer,
hasOnClick,
hasTabIndex,
isEditable,
hiddenInputType,
});
}
return out;
})()`,
returnByValue: true,
awaitPromise: false,
},
sessionId,
).catch(() => null)) as { result?: { value?: unknown } } | null;
const entries = Array.isArray(evaluated?.result?.value)
? (evaluated.result.value as CursorInteractiveInfo[]).map((entry) => {
entry.text = truncateUtf16Safe(entry.text, 100);
return entry;
})
: [];
if (!entries.length) {
return new Map();
}
const doc = (await send("DOM.getDocument", { depth: 0 }, sessionId).catch(() => null)) as {
root?: { nodeId?: number };
} | null;
const rootNodeId = doc?.root?.nodeId;
if (typeof rootNodeId !== "number") {
return new Map();
}
const queried = (await send(
"DOM.querySelectorAll",
{ nodeId: rootNodeId, selector: `[${attr}]` },
sessionId,
).catch(() => null)) as { nodeIds?: number[] } | null;
const out = new Map<number, CursorInteractiveInfo>();
await Promise.all(
(queried?.nodeIds ?? []).map(async (nodeId) => {
const described = (await send("DOM.describeNode", { nodeId }, sessionId).catch(
() => null,
)) as { node?: { backendNodeId?: number; attributes?: string[] } } | null;
const attrs = described?.node?.attributes ?? [];
const attrIndex = attrs.indexOf(attr);
const rawIndex = attrIndex >= 0 ? attrs[attrIndex + 1] : undefined;
const index = typeof rawIndex === "string" ? Number(rawIndex) : Number.NaN;
const backendNodeId = described?.node?.backendNodeId;
if (typeof backendNodeId === "number" && Number.isInteger(index) && entries[index]) {
out.set(backendNodeId, entries[index]);
}
}),
);
await send(
"Runtime.evaluate",
{
expression: `document.querySelectorAll("[${attr}]").forEach((el) => el.removeAttribute("${attr}"))`,
returnByValue: true,
},
sessionId,
).catch(() => {});
return out;
}
async function resolveLinkUrls(
send: CdpSendFn,
refs: Record<string, CdpRoleRef>,
sessionId?: string,
): Promise<Map<number, string>> {
const out = new Map<number, string>();
await Promise.all(
Object.values(refs).map(async (ref) => {
if (ref.role !== "link" || !ref.backendDOMNodeId) {
return;
}
const resolved = (await send(
"DOM.resolveNode",
{ backendNodeId: ref.backendDOMNodeId },
sessionId,
).catch(() => null)) as { object?: { objectId?: string } } | null;
const objectId = resolved?.object?.objectId;
if (!objectId) {
return;
}
const hrefResult = (await send(
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: "function() { return this.href || ''; }",
returnByValue: true,
},
sessionId,
).catch(() => null)) as { result?: { value?: unknown } } | null;
const href = typeof hrefResult?.result?.value === "string" ? hrefResult.result.value : "";
if (href) {
out.set(ref.backendDOMNodeId, href);
}
}),
);
return out;
}
async function resolveIframeFrameIds(
send: CdpSendFn,
tree: RoleTreeNode[],
sessionId?: string,
): Promise<Map<number, string>> {
const out = new Map<number, string>();
await Promise.all(
tree.map(async (node) => {
if (node.role.toLowerCase() !== "iframe" || !node.backendDOMNodeId) {
return;
}
const described = (await send(
"DOM.describeNode",
{ backendNodeId: node.backendDOMNodeId, depth: 1 },
sessionId,
).catch(() => null)) as {
node?: { frameId?: string; contentDocument?: { frameId?: string } };
} | null;
const frameId = described?.node?.contentDocument?.frameId ?? described?.node?.frameId ?? "";
if (frameId) {
out.set(node.backendDOMNodeId, frameId);
}
}),
);
return out;
}
async function buildCdpRoleSnapshot(params: {
send: CdpSendFn;
sessionId?: string;
frameId?: string;
options: CdpRoleSnapshotOptions;
urls?: boolean;
recurseIframes?: boolean;
nextRef: { value: number };
}): Promise<{
lines: string[];
refs: Record<string, CdpRoleRef>;
stats: { refs: number; interactive: number };
}> {
const res = (await params.send(
"Accessibility.getFullAXTree",
params.frameId ? { frameId: params.frameId } : undefined,
params.sessionId,
)) as { nodes?: RawAXNode[] };
const { tree, roots } = buildRoleTree(Array.isArray(res.nodes) ? res.nodes : []);
const cursorElements = await findCursorInteractiveElements(params.send, params.sessionId);
for (const node of tree) {
if (node.backendDOMNodeId && cursorElements.has(node.backendDOMNodeId)) {
const cursorInfo = cursorElements.get(node.backendDOMNodeId);
node.cursorInfo = cursorInfo;
if (!node.name && cursorInfo?.text) {
node.name = cursorInfo.text;
}
}
}
const counts = new Map<string, number>();
const refsByKey = new Map<string, string[]>();
const nodesByRef = new Map<string, RoleTreeNode>();
const refs: Record<string, CdpRoleRef> = {};
for (const node of tree) {
const role = node.role.toLowerCase();
const shouldRef =
INTERACTIVE_ROLES.has(role) ||
(CONTENT_ROLES.has(role) && Boolean(node.name)) ||
role === "iframe" ||
Boolean(node.cursorInfo);
if (!shouldRef) {
continue;
}
const key = `${role}:${node.name}`;
const nth = counts.get(key) ?? 0;
counts.set(key, nth + 1);
const ref = `e${params.nextRef.value}`;
params.nextRef.value += 1;
node.ref = ref;
node.nth = nth;
const refsForKey = refsByKey.get(key);
if (refsForKey) {
refsForKey.push(ref);
} else {
refsByKey.set(key, [ref]);
}
nodesByRef.set(ref, node);
refs[ref] = {
role,
...(node.name ? { name: node.name } : {}),
...(nth > 0 ? { nth } : {}),
...(node.backendDOMNodeId ? { backendDOMNodeId: node.backendDOMNodeId } : {}),
...(params.frameId ? { frameId: params.frameId } : {}),
};
}
for (const refList of refsByKey.values()) {
if (refList.length > 1) {
continue;
}
const ref = refList[0];
if (ref) {
delete refs[ref]?.nth;
const node = nodesByRef.get(ref);
if (node) {
delete node.nth;
}
}
}
const iframeFrameIds = await resolveIframeFrameIds(params.send, tree, params.sessionId);
for (const node of tree) {
if (node.backendDOMNodeId && iframeFrameIds.has(node.backendDOMNodeId)) {
node.frameId = iframeFrameIds.get(node.backendDOMNodeId);
if (node.ref && refs[node.ref]) {
refs[node.ref].frameId = node.frameId;
}
}
}
if (params.urls) {
const urls = await resolveLinkUrls(params.send, refs, params.sessionId);
for (const node of tree) {
if (node.backendDOMNodeId && urls.has(node.backendDOMNodeId)) {
node.url = urls.get(node.backendDOMNodeId);
}
}
}
const lines: string[] = [];
for (const root of roots) {
renderRoleTree(tree, root, lines, params.options);
}
if (params.recurseIframes) {
const iframeNodes = tree.filter((node) => node.ref && node.frameId);
for (const iframe of iframeNodes) {
const marker = `[ref=${iframe.ref}]`;
const lineIndex = lines.findIndex((line) => line.includes(marker));
if (lineIndex < 0 || !iframe.frameId) {
continue;
}
const child = await buildCdpRoleSnapshot({
...params,
frameId: iframe.frameId,
recurseIframes: false,
}).catch(() => null);
if (!child?.lines.length) {
continue;
}
Object.assign(refs, child.refs);
lines.splice(lineIndex + 1, 0, ...child.lines.map((line) => ` ${line}`));
}
}
const refValues = Object.values(refs);
return {
lines,
refs,
stats: {
refs: refValues.length,
interactive: refValues.filter((ref) => INTERACTIVE_ROLES.has(ref.role)).length,
},
};
}
/** Build a role/name text snapshot with stable refs from CDP DOM and AX data. */
export async function snapshotRoleViaCdp(opts: {
wsUrl: string;
options?: CdpRoleSnapshotOptions;
urls?: boolean;
timeoutMs?: number;
}): Promise<{
snapshot: string;
refs: Record<string, CdpRoleRef>;
stats: { lines: number; chars: number; refs: number; interactive: number };
}> {
return await withCdpSocket(
opts.wsUrl,
async (send) => {
await prepareCdpPageSession(send);
const built = await buildCdpRoleSnapshot({
send,
options: opts.options ?? {},
urls: opts.urls,
recurseIframes: true,
nextRef: { value: 1 },
});
const snapshot =
built.lines.join("\n").trim() ||
(opts.options?.interactive ? "(no interactive elements)" : "(empty page)");
return {
snapshot,
refs: built.refs,
stats: {
lines: snapshot.split("\n").length,
chars: snapshot.length,
refs: built.stats.refs,
interactive: built.stats.interactive,
},
};
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
);
}

View File

@@ -0,0 +1,12 @@
/**
* Lazy Chrome MCP module loader.
*
* Keeps the heavy chrome-devtools-mcp adapter behind a runtime import boundary
* for routes that only need it when existing-session profiles are selected.
*/
type ChromeMcpModule = typeof import("./chrome-mcp.js");
/** Import the Chrome MCP adapter module on demand. */
export async function getChromeMcpModule(): Promise<ChromeMcpModule> {
return await import("./chrome-mcp.js");
}

View File

@@ -0,0 +1,69 @@
// Browser tests cover chrome mcp.snapshot plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildAiSnapshotFromChromeMcpSnapshot,
flattenChromeMcpSnapshotToAriaNodes,
} from "./chrome-mcp.snapshot.js";
const snapshot = {
id: "root",
role: "document",
name: "Example",
children: [
{
id: "btn-1",
role: "button",
name: "Continue",
},
{
id: "txt-1",
role: "textbox",
name: "Email",
value: "peter@example.com",
},
],
};
describe("chrome MCP snapshot conversion", () => {
it("flattens structured snapshots into aria-style nodes", () => {
const nodes = flattenChromeMcpSnapshotToAriaNodes(snapshot, 10);
expect(nodes).toEqual([
{
ref: "root",
role: "document",
name: "Example",
value: undefined,
description: undefined,
depth: 0,
},
{
ref: "btn-1",
role: "button",
name: "Continue",
value: undefined,
description: undefined,
depth: 1,
},
{
ref: "txt-1",
role: "textbox",
name: "Email",
value: "peter@example.com",
description: undefined,
depth: 1,
},
]);
});
it("builds AI snapshots that preserve Chrome MCP uids as refs", () => {
const result = buildAiSnapshotFromChromeMcpSnapshot({ root: snapshot });
expect(result.snapshot).toContain('- button "Continue" [ref=btn-1]');
expect(result.snapshot).toContain('- textbox "Email" [ref=txt-1] value="peter@example.com"');
expect(result.refs).toEqual({
"btn-1": { role: "button", name: "Continue" },
"txt-1": { role: "textbox", name: "Email" },
});
expect(result.stats.refs).toBe(2);
});
});

View File

@@ -0,0 +1,193 @@
/**
* Chrome MCP snapshot conversion helpers.
*
* Converts chrome-devtools-mcp structured snapshots into OpenClaw ARIA nodes
* and compact AI snapshots with stable refs and duplicate tracking.
*/
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeString } from "../record-shared.js";
import type { SnapshotAriaNode } from "./client.types.js";
import {
getRoleSnapshotStats,
type RoleRefMap,
type RoleSnapshotOptions,
} from "./pw-role-snapshot.js";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
/** Structured snapshot node shape returned by chrome-devtools-mcp. */
export type ChromeMcpSnapshotNode = {
id?: string;
role?: string;
name?: string;
value?: string | number | boolean;
description?: string;
children?: ChromeMcpSnapshotNode[];
};
function normalizeRole(node: ChromeMcpSnapshotNode): string {
const role = normalizeLowercaseStringOrEmpty(node.role);
return role || "generic";
}
function escapeQuoted(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function shouldIncludeNode(params: {
role: string;
name?: string;
options?: RoleSnapshotOptions;
}): boolean {
if (params.options?.interactive && !INTERACTIVE_ROLES.has(params.role)) {
return false;
}
if (params.options?.compact && STRUCTURAL_ROLES.has(params.role) && !params.name) {
return false;
}
return true;
}
function shouldCreateRef(role: string, name?: string): boolean {
return INTERACTIVE_ROLES.has(role) || (CONTENT_ROLES.has(role) && Boolean(name));
}
type DuplicateTracker = {
counts: Map<string, number>;
keysByRef: Map<string, string>;
duplicates: Set<string>;
};
function createDuplicateTracker(): DuplicateTracker {
return {
counts: new Map(),
keysByRef: new Map(),
duplicates: new Set(),
};
}
function registerRef(
tracker: DuplicateTracker,
ref: string,
role: string,
name?: string,
): number | undefined {
const key = `${role}:${name ?? ""}`;
const count = tracker.counts.get(key) ?? 0;
tracker.counts.set(key, count + 1);
tracker.keysByRef.set(ref, key);
if (count > 0) {
tracker.duplicates.add(key);
return count;
}
return undefined;
}
/** Flatten a Chrome MCP snapshot tree into OpenClaw ARIA-style nodes. */
export function flattenChromeMcpSnapshotToAriaNodes(
root: ChromeMcpSnapshotNode,
limit = 500,
): SnapshotAriaNode[] {
const boundedLimit = Math.max(1, Math.min(2000, Math.floor(limit)));
const out: SnapshotAriaNode[] = [];
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
if (out.length >= boundedLimit) {
return;
}
const ref = normalizeString(node.id);
if (ref) {
out.push({
ref,
role: normalizeRole(node),
name: normalizeString(node.name) ?? "",
value: normalizeString(node.value),
description: normalizeString(node.description),
depth,
});
}
for (const child of node.children ?? []) {
visit(child, depth + 1);
if (out.length >= boundedLimit) {
return;
}
}
};
visit(root, 0);
return out;
}
/** Build a compact text snapshot and ref map from a Chrome MCP snapshot tree. */
export function buildAiSnapshotFromChromeMcpSnapshot(params: {
root: ChromeMcpSnapshotNode;
options?: RoleSnapshotOptions;
maxChars?: number;
}): {
snapshot: string;
truncated?: boolean;
refs: RoleRefMap;
stats: { lines: number; chars: number; refs: number; interactive: number };
} {
const refs: RoleRefMap = {};
const tracker = createDuplicateTracker();
const lines: string[] = [];
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
const role = normalizeRole(node);
const name = normalizeString(node.name);
const value = normalizeString(node.value);
const description = normalizeString(node.description);
const maxDepth = params.options?.maxDepth;
if (maxDepth !== undefined && depth > maxDepth) {
return;
}
const includeNode = shouldIncludeNode({ role, name, options: params.options });
if (includeNode) {
let line = `${" ".repeat(depth)}- ${role}`;
if (name) {
line += ` "${escapeQuoted(name)}"`;
}
const ref = normalizeString(node.id);
if (ref && shouldCreateRef(role, name)) {
const nth = registerRef(tracker, ref, role, name);
refs[ref] = nth === undefined ? { role, name } : { role, name, nth };
line += ` [ref=${ref}]`;
}
if (value) {
line += ` value="${escapeQuoted(value)}"`;
}
if (description) {
line += ` description="${escapeQuoted(description)}"`;
}
lines.push(line);
}
for (const child of node.children ?? []) {
visit(child, depth + 1);
}
};
visit(params.root, 0);
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.keysByRef.get(ref);
if (key && !tracker.duplicates.has(key)) {
delete data.nth;
}
}
let snapshot = lines.join("\n");
let truncated = false;
const maxChars =
typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0
? Math.floor(params.maxChars)
: undefined;
if (maxChars && snapshot.length > maxChars) {
snapshot = `${snapshot.slice(0, maxChars)}\n\n[...TRUNCATED - page too large]`;
truncated = true;
}
const stats = getRoleSnapshotStats(snapshot, refs);
return truncated ? { snapshot, truncated, refs, stats } : { snapshot, refs, stats };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
/**
* Chrome user-data-dir Vitest harness.
*
* Creates and removes an isolated Chrome profile directory for browser tests
* that need filesystem-backed profile state.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll } from "vitest";
type ChromeUserDataDirRef = {
dir: string;
};
/** Install beforeAll/afterAll hooks for a temporary Chrome user-data-dir. */
export function installChromeUserDataDirHooks(chromeUserDataDir: ChromeUserDataDirRef): void {
beforeAll(async () => {
chromeUserDataDir.dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-user-data-"));
});
afterAll(async () => {
await fs.rm(chromeUserDataDir.dir, { recursive: true, force: true });
});
}

View File

@@ -0,0 +1,165 @@
// Browser tests cover chromeefault browser plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
execFileSync: vi.fn(),
},
);
});
vi.mock("node:fs", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
const existsSync = vi.fn();
const readFileSync = vi.fn();
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs")>("node:fs"),
{ existsSync, readFileSync },
{ mirrorToDefault: true },
);
});
vi.mock("node:os", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
const homedir = vi.fn();
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:os")>("node:os"),
{ homedir },
{ mirrorToDefault: true },
);
});
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import os from "node:os";
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
describe("browser default executable detection", () => {
const launchServicesPlist = "com.apple.launchservices.secure.plist";
const chromeExecutablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
function mockMacDefaultBrowser(bundleId: string, appPath = ""): void {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([{ LSHandlerURLScheme: "http", LSHandlerRoleAll: bundleId }]);
}
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
return appPath;
}
if (cmd === "/usr/bin/defaults") {
return "Google Chrome";
}
return "";
});
}
function mockChromeExecutableExists(): void {
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes(launchServicesPlist)) {
return true;
}
return value.includes(chromeExecutablePath);
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(os.homedir).mockReturnValue("/Users/test");
});
it("prefers default Chromium browser on macOS", () => {
mockMacDefaultBrowser("com.google.Chrome", "/Applications/Google Chrome.app");
mockChromeExecutableExists();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
expect(exe?.kind).toBe("chrome");
});
it("detects Edge via LaunchServices bundle ID (com.microsoft.edgemac)", () => {
const edgeExecutablePath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge";
// macOS LaunchServices registers Edge as "com.microsoft.edgemac", which
// differs from the CFBundleIdentifier "com.microsoft.Edge" in the app's
// own Info.plist. Both must be recognised.
//
// The existsSync mock deliberately only returns true for the Edge path
// when checked via the resolved osascript/defaults path — Chrome's
// fallback candidate path is the only other "existing" binary. This
// ensures the test fails if the default-browser detection branch is
// broken, because the fallback candidate list would return Chrome, not
// Edge.
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.microsoft.edgemac" },
]);
}
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
return "/Applications/Microsoft Edge.app/";
}
if (cmd === "/usr/bin/defaults") {
return "Microsoft Edge";
}
return "";
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes(launchServicesPlist)) {
return true;
}
// Only Edge (via osascript resolution) and Chrome (fallback candidate)
// "exist". If default-browser detection breaks, the resolver would
// return Chrome from the fallback list — not Edge — failing the assert.
return value === edgeExecutablePath || value.includes(chromeExecutablePath);
});
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toBe(edgeExecutablePath);
expect(exe?.kind).toBe("edge");
});
it("falls back to Chrome when Edge LaunchServices lookup has no app path", () => {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.microsoft.edgemac" },
]);
}
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
return "";
}
return "";
});
mockChromeExecutableExists();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
expect(exe?.kind).toBe("chrome");
});
it("falls back when default browser is non-Chromium on macOS", () => {
mockMacDefaultBrowser("com.apple.Safari");
mockChromeExecutableExists();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"darwin",
);
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
});
});

View File

@@ -0,0 +1,429 @@
/**
* Chrome CDP diagnostics.
*
* Probes /json/version and WebSocket health, redacts sensitive endpoint data,
* and formats status output for browser doctor/status flows.
*/
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { redactSensitiveText } from "../logging/redact.js";
import { CHROME_REACHABILITY_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchCdpChecked,
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
openCdpWebSocket,
redactCdpUrl,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
/** Machine-readable failure codes for Chrome CDP diagnostics. */
export type ChromeCdpDiagnosticCode =
| "ssrf_blocked"
| "http_unreachable"
| "http_status_failed"
| "invalid_json"
| "missing_websocket_debugger_url"
| "websocket_ssrf_blocked"
| "websocket_handshake_failed"
| "websocket_health_command_failed"
| "websocket_health_command_timeout";
/** Result of a Chrome CDP reachability and WebSocket health probe. */
export type ChromeCdpDiagnostic =
| {
ok: true;
cdpUrl: string;
wsUrl: string;
browser?: string;
userAgent?: string;
elapsedMs: number;
}
| {
ok: false;
code: ChromeCdpDiagnosticCode;
cdpUrl: string;
wsUrl?: string;
message: string;
elapsedMs: number;
};
/** Subset of Chrome /json/version used by browser diagnostics. */
export type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
function elapsedSince(startedAt: number): number {
return Math.max(0, Date.now() - startedAt);
}
/** Convert an error and optional cause to redacted diagnostic text. */
export function safeChromeCdpErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const cause = error instanceof Error ? error.cause : undefined;
const causeMessage =
cause instanceof Error ? cause.message : typeof cause === "string" ? cause : undefined;
if (message && causeMessage && !message.includes(causeMessage)) {
return redactSensitiveText(`${message}: ${causeMessage}`);
}
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. */
export async function readChromeVersion(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeVersion> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const { response, release } = await fetchCdpChecked(
versionUrl,
timeoutMs,
{ signal: ctrl.signal },
ssrfPolicy,
);
try {
const data = (await response.json()) as ChromeVersion;
if (!data || typeof data !== "object") {
throw new Error("CDP /json/version returned non-object JSON");
}
return data;
} finally {
await release();
}
} finally {
clearTimeout(t);
}
}
type CdpHealthDiagnostic =
| { ok: true }
| {
ok: false;
code:
| "websocket_handshake_failed"
| "websocket_health_command_failed"
| "websocket_health_command_timeout";
message: string;
};
async function diagnoseCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
): Promise<CdpHealthDiagnostic> {
return await new Promise<CdpHealthDiagnostic>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
});
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 });
return;
}
finish({
ok: false,
code: "websocket_health_command_failed",
message: "Browser.getVersion returned no result object",
});
};
const finish = (value: CdpHealthDiagnostic) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
ws.off("message", onMessage);
try {
ws.close();
} catch {
// ignore
}
resolve(value);
};
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
finish({
ok: false,
code: opened ? "websocket_health_command_timeout" : "websocket_handshake_failed",
message: opened
? `Browser.getVersion did not respond within ${timeoutMs}ms`
: `WebSocket handshake did not complete within ${timeoutMs}ms`,
});
},
Math.max(1, timeoutMs + Math.min(25, timeoutMs)),
);
ws.once("open", () => {
opened = true;
try {
ws.send(
JSON.stringify({
id: 1,
method: "Browser.getVersion",
}),
);
} catch (err) {
finish({
ok: false,
code: "websocket_health_command_failed",
message: safeChromeCdpErrorMessage(err),
});
}
});
ws.on("message", onMessage);
ws.once("error", (err) => {
finish({
ok: false,
code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed",
message: safeChromeCdpErrorMessage(err),
});
});
ws.once("close", () => {
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",
});
});
});
}
function classifyChromeVersionError(error: unknown): {
code: ChromeCdpDiagnosticCode;
message: string;
} {
const message = safeChromeCdpErrorMessage(error);
if (error instanceof BrowserCdpEndpointBlockedError) {
return { code: "ssrf_blocked", message };
}
if (/^HTTP \d+/.test(message)) {
return { code: "http_status_failed", message };
}
if (error instanceof SyntaxError || message.includes("non-object JSON")) {
return { code: "invalid_json", message };
}
return { code: "http_unreachable", message };
}
/** Format a Chrome CDP diagnostic result for status and doctor output. */
export function formatChromeCdpDiagnostic(diagnostic: ChromeCdpDiagnostic): string {
const redactedCdpUrl = redactCdpUrl(diagnostic.cdpUrl) ?? diagnostic.cdpUrl;
const redactedWsUrl = redactCdpUrl(diagnostic.wsUrl) ?? diagnostic.wsUrl;
if (diagnostic.ok) {
const browser = diagnostic.browser ? ` browser=${diagnostic.browser}` : "";
return `CDP diagnostic: ready after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}; websocket=${redactedWsUrl}.${browser}`;
}
const websocket = redactedWsUrl ? `; websocket=${redactedWsUrl}` : "";
const wslPortproxyHint =
diagnostic.code === "http_unreachable" && isLikelyEmptyHttpReply(diagnostic.message)
? " In WSL2-to-Windows Chrome setups, this can be a stale netsh portproxy self-loop where svchost/iphlpsvc owns the CDP port instead of chrome.exe; verify with tasklist /svc and curl /json/version, then remove any 127.0.0.1:9222 -> 127.0.0.1:9222 portproxy rule."
: "";
return `CDP diagnostic: ${diagnostic.code} after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}${websocket}; ${diagnostic.message}.${wslPortproxyHint}`;
}
function isLikelyEmptyHttpReply(message: string): boolean {
return /empty reply|other side closed|socket closed|terminated before response/i.test(message);
}
async function diagnoseCdpWebSocketEndpoint(params: {
cdpUrl: string;
wsUrl: string;
startedAt: number;
handshakeTimeoutMs: number;
version?: ChromeVersion;
}): Promise<ChromeCdpDiagnostic> {
const health = await diagnoseCdpHealthCommand(params.wsUrl, params.handshakeTimeoutMs);
if (!health.ok) {
return failureDiagnostic({
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
code: health.code,
message: health.message,
startedAt: params.startedAt,
});
}
if (params.version) {
return {
ok: true,
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
browser: params.version.Browser,
userAgent: params.version["User-Agent"],
elapsedMs: elapsedSince(params.startedAt),
};
}
return {
ok: true,
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
elapsedMs: elapsedSince(params.startedAt),
};
}
/** Run HTTP and WebSocket health diagnostics for a Chrome CDP endpoint. */
export async function diagnoseChromeCdp(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeCdpDiagnostic> {
const startedAt = Date.now();
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
code: "ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
}
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
});
}
const discoveryUrl = isWebSocketUrl(cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)
: cdpUrl;
let version: ChromeVersion;
try {
version = await readChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy);
} catch (err) {
if (isWebSocketUrl(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
});
}
const classified = classifyChromeVersionError(err);
return failureDiagnostic({
cdpUrl,
code: classified.code,
message: classified.message,
startedAt,
});
}
const wsUrlRaw = normalizeOptionalString(version.webSocketDebuggerUrl) ?? "";
if (!wsUrlRaw) {
if (isWebSocketUrl(cdpUrl)) {
return await diagnoseCdpWebSocketEndpoint({
cdpUrl,
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
version,
});
}
return failureDiagnostic({
cdpUrl,
code: "missing_websocket_debugger_url",
message: "CDP /json/version did not include webSocketDebuggerUrl",
startedAt,
});
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
try {
await assertCdpEndpointAllowed(wsUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
wsUrl,
code: "websocket_ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
}
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs);
if (!health.ok) {
if (isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) {
const directHealth = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs);
if (directHealth.ok) {
return {
ok: true,
cdpUrl,
wsUrl: cdpUrl,
browser: version.Browser,
userAgent: version["User-Agent"],
elapsedMs: elapsedSince(startedAt),
};
}
}
return failureDiagnostic({
cdpUrl,
wsUrl,
code: health.code,
message: health.message,
startedAt,
});
}
return {
ok: true,
cdpUrl,
wsUrl,
browser: version.Browser,
userAgent: version["User-Agent"],
elapsedMs: elapsedSince(startedAt),
};
}

View File

@@ -0,0 +1,822 @@
/**
* Chrome executable discovery and version parsing.
*
* Locates supported Chromium-family executables across platforms and reads
* their version strings for capability checks.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedBrowserConfig } from "./config.js";
/** Browser executable candidate with product metadata and filesystem path. */
export type BrowserExecutable = {
kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge";
path: string;
};
const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g;
const PLAYWRIGHT_BROWSERS_PATH_ENV = "PLAYWRIGHT_BROWSERS_PATH";
const BROWSER_VERSION_TIMEOUT_MS = 6000;
const MAC_PLISTBUDDY_TIMEOUT_MS = 800;
const CHROMIUM_BUNDLE_IDS = new Set([
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.canary",
"com.google.Chrome.dev",
"com.brave.Browser",
"com.brave.Browser.beta",
"com.brave.Browser.nightly",
"com.microsoft.Edge",
"com.microsoft.EdgeBeta",
"com.microsoft.EdgeDev",
"com.microsoft.EdgeCanary",
// Edge LaunchServices IDs (used in macOS default browser registration —
// these differ from CFBundleIdentifier and are what plutil returns)
"com.microsoft.edgemac",
"com.microsoft.edgemac.beta",
"com.microsoft.edgemac.dev",
"com.microsoft.edgemac.canary",
"org.chromium.Chromium",
"com.vivaldi.Vivaldi",
"com.operasoftware.Opera",
"com.operasoftware.OperaGX",
"com.yandex.desktop.yandex-browser",
"company.thebrowser.Browser", // Arc
]);
const CHROMIUM_DESKTOP_IDS = new Set([
"google-chrome.desktop",
"google-chrome-beta.desktop",
"google-chrome-unstable.desktop",
"brave-browser.desktop",
"microsoft-edge.desktop",
"microsoft-edge-beta.desktop",
"microsoft-edge-dev.desktop",
"microsoft-edge-canary.desktop",
"chromium.desktop",
"chromium-browser.desktop",
"vivaldi.desktop",
"vivaldi-stable.desktop",
"opera.desktop",
"opera-gx.desktop",
"yandex-browser.desktop",
"org.chromium.Chromium.desktop",
]);
const CHROMIUM_EXE_NAMES = new Set([
"chrome.exe",
"msedge.exe",
"brave.exe",
"brave-browser.exe",
"chromium.exe",
"vivaldi.exe",
"opera.exe",
"launcher.exe",
"yandex.exe",
"yandexbrowser.exe",
// mac/linux names
"google chrome",
"google chrome canary",
"brave browser",
"microsoft edge",
"chromium",
"chrome",
"brave",
"msedge",
"brave-browser",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"microsoft-edge",
"microsoft-edge-beta",
"microsoft-edge-dev",
"microsoft-edge-canary",
"chromium-browser",
"vivaldi",
"vivaldi-stable",
"opera",
"opera-stable",
"opera-gx",
"yandex-browser",
]);
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function execText(
command: string,
args: string[],
timeoutMs = 1200,
maxBuffer = 1024 * 1024,
): string | null {
try {
const output = execFileSync(command, args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer,
});
return normalizeOptionalString(output) ?? null;
} catch {
return null;
}
}
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
const id = normalizeLowercaseStringOrEmpty(identifier);
if (id.includes("brave")) {
return "brave";
}
if (id.includes("edge")) {
return "edge";
}
if (id.includes("chromium")) {
return "chromium";
}
if (id.includes("canary")) {
return "canary";
}
if (
id.includes("opera") ||
id.includes("vivaldi") ||
id.includes("yandex") ||
id.includes("thebrowser")
) {
return "chromium";
}
return "chrome";
}
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
const lower = normalizeLowercaseStringOrEmpty(name);
if (lower.includes("brave")) {
return "brave";
}
if (lower.includes("edge") || lower.includes("msedge")) {
return "edge";
}
if (lower.includes("chromium")) {
return "chromium";
}
if (lower.includes("canary") || lower.includes("sxs")) {
return "canary";
}
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) {
return "chromium";
}
return "chrome";
}
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
if (platform === "darwin") {
return detectDefaultChromiumExecutableMac();
}
if (platform === "linux") {
return detectDefaultChromiumExecutableLinux();
}
if (platform === "win32") {
return detectDefaultChromiumExecutableWindows();
}
return null;
}
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
const bundleId = detectDefaultBrowserBundleIdMac();
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) {
return null;
}
const appPathRaw = execText("/usr/bin/osascript", [
"-e",
`POSIX path of (path to application id "${bundleId}")`,
]);
if (!appPathRaw) {
return null;
}
const appPath = appPathRaw.replace(/\/$/, "");
const exeName = execText("/usr/bin/defaults", [
"read",
path.join(appPath, "Contents", "Info"),
"CFBundleExecutable",
]);
if (!exeName) {
return null;
}
const exePath = path.join(appPath, "Contents", "MacOS", exeName);
if (!exists(exePath)) {
return null;
}
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
}
function detectDefaultBrowserBundleIdMac(): string | null {
const plistPath = path.join(
os.homedir(),
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
);
if (!exists(plistPath)) {
return null;
}
const handlersRaw = execText(
"/usr/bin/plutil",
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
2000,
5 * 1024 * 1024,
);
if (!handlersRaw) {
return null;
}
let handlers: unknown;
try {
handlers = JSON.parse(handlersRaw);
} catch {
return null;
}
if (!Array.isArray(handlers)) {
return null;
}
const resolveScheme = (scheme: string) => {
let candidate: string | null = null;
for (const entry of handlers) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
if (record.LSHandlerURLScheme !== scheme) {
continue;
}
const role =
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
null;
if (role) {
candidate = role;
}
}
return candidate;
};
return resolveScheme("http") ?? resolveScheme("https");
}
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
const desktopId =
execText("xdg-settings", ["get", "default-web-browser"]) ||
execText("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
if (!desktopId) {
return null;
}
const trimmed = desktopId.trim();
if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) {
return null;
}
const desktopPath = findDesktopFilePath(trimmed);
if (!desktopPath) {
return null;
}
const execLine = readDesktopExecLine(desktopPath);
if (!execLine) {
return null;
}
const command = extractExecutableFromExecLine(execLine);
if (!command) {
return null;
}
const resolved = resolveLinuxExecutablePath(command);
if (!resolved) {
return null;
}
const exeName = normalizeLowercaseStringOrEmpty(path.posix.basename(resolved));
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: resolved };
}
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
const progId = readWindowsProgId();
const command =
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
if (!command) {
return null;
}
const expanded = expandWindowsEnvVars(command);
const exePath = extractWindowsExecutablePath(expanded);
if (!exePath) {
return null;
}
if (!exists(exePath)) {
return null;
}
const exeName = normalizeLowercaseStringOrEmpty(path.win32.basename(exePath));
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: exePath };
}
function findDesktopFilePath(desktopId: string): string | null {
const candidates = [
path.join(os.homedir(), ".local", "share", "applications", desktopId),
path.join("/usr/local/share/applications", desktopId),
path.join("/usr/share/applications", desktopId),
path.join("/var/lib/snapd/desktop/applications", desktopId),
];
for (const candidate of candidates) {
if (exists(candidate)) {
return candidate;
}
}
return null;
}
function readDesktopExecLine(desktopPath: string): string | null {
try {
const raw = fs.readFileSync(desktopPath, "utf8");
const lines = raw.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith("Exec=")) {
return line.slice("Exec=".length).trim();
}
}
} catch {
// ignore
}
return null;
}
function extractExecutableFromExecLine(execLine: string): string | null {
const tokens = splitExecLine(execLine);
for (const token of tokens) {
if (!token) {
continue;
}
if (token === "env") {
continue;
}
if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) {
continue;
}
return token.replace(/^["']|["']$/g, "");
}
return null;
}
function splitExecLine(line: string): string[] {
const tokens: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
for (const ch of line) {
if ((ch === '"' || ch === "'") && (!inQuotes || ch === quoteChar)) {
if (inQuotes) {
inQuotes = false;
quoteChar = "";
} else {
inQuotes = true;
quoteChar = ch;
}
continue;
}
if (!inQuotes && /\s/.test(ch)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function resolveLinuxExecutablePath(command: string): string | null {
const cleaned = command.trim().replace(/%[a-zA-Z]/g, "");
if (!cleaned) {
return null;
}
if (cleaned.startsWith("/")) {
return cleaned;
}
const resolved = execText("which", [cleaned], 800);
return resolved ? resolved.trim() : null;
}
function readWindowsProgId(): string | null {
const output = execText("reg", [
"query",
"HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
"/v",
"ProgId",
]);
if (!output) {
return null;
}
const match = output.match(/ProgId\s+REG_\w+\s+(.+)$/im);
return match?.[1]?.trim() || null;
}
function readWindowsCommandForProgId(progId: string): string | null {
const key =
progId === "http"
? "HKCR\\http\\shell\\open\\command"
: `HKCR\\${progId}\\shell\\open\\command`;
const output = execText("reg", ["query", key, "/ve"]);
if (!output) {
return null;
}
const match = output.match(/REG_\w+\s+(.+)$/im);
return normalizeOptionalString(match?.[1]) ?? null;
}
function expandWindowsEnvVars(value: string): string {
return value.replace(/%([^%]+)%/g, (_match, name) => {
const key = normalizeOptionalString(name) ?? "";
return key ? (process.env[key] ?? `%${key}%`) : _match;
});
}
function extractWindowsExecutablePath(command: string): string | null {
const quoted = command.match(/"([^"]+\\.exe)"/i);
if (quoted?.[1]) {
return quoted[1];
}
const unquoted = command.match(/([^\\s]+\\.exe)/i);
if (unquoted?.[1]) {
return unquoted[1];
}
return null;
}
function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) {
return candidate;
}
}
return null;
}
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
const normalizedPath = normalizeLowercaseStringOrEmpty(candidate);
return {
kind:
normalizedPath.includes("beta") ||
normalizedPath.includes("canary") ||
normalizedPath.includes("sxs") ||
normalizedPath.includes("unstable")
? "canary"
: "chrome",
path: candidate,
};
}
}
return null;
}
function findPlaywrightChromiumExecutableCandidatesLinux(): Array<BrowserExecutable> {
const candidates: Array<BrowserExecutable> = [];
for (const browserPath of getPlaywrightBrowserCachePaths()) {
for (const entry of readSortedDirNames(browserPath)) {
if (!entry.startsWith("chromium-")) {
continue;
}
for (const linuxDir of ["chrome-linux64", "chrome-linux"]) {
candidates.push({
kind: "chromium",
path: path.join(browserPath, entry, linuxDir, "chrome"),
});
}
}
}
return candidates;
}
function getPlaywrightBrowserCachePaths(): string[] {
const configured = normalizeOptionalString(process.env[PLAYWRIGHT_BROWSERS_PATH_ENV]);
const candidates = [
configured && configured !== "0" ? configured : null,
path.join(os.homedir(), ".cache", "ms-playwright"),
];
const seen = new Set<string>();
return candidates.filter((candidate): candidate is string => {
if (!candidate || seen.has(candidate)) {
return false;
}
seen.add(candidate);
return true;
});
}
function readSortedDirNames(dir: string): string[] {
try {
return fs.readdirSync(dir).toSorted();
} catch {
return [];
}
}
/** Find the best Chromium-family executable on macOS. */
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "chrome",
path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
},
{
kind: "brave",
path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
},
{
kind: "brave",
path: path.join(os.homedir(), "Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
},
{
kind: "edge",
path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
},
{
kind: "edge",
path: path.join(
os.homedir(),
"Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
),
},
{
kind: "chromium",
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
},
{
kind: "chromium",
path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"),
},
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
];
return findFirstExecutable(candidates);
}
function findGoogleChromeExecutableMac(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
]);
}
/** Find the best Chromium-family executable on Linux. */
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chrome", path: "/usr/bin/chrome" },
{ kind: "chrome", path: "/opt/google/chrome/chrome" },
{ kind: "brave", path: "/usr/bin/brave-browser" },
{ kind: "brave", path: "/usr/bin/brave-browser-stable" },
{ kind: "brave", path: "/usr/bin/brave" },
{ kind: "brave", path: "/snap/bin/brave" },
{ kind: "brave", path: "/opt/brave.com/brave/brave-browser" },
{ kind: "edge", path: "/usr/bin/microsoft-edge" },
{ kind: "edge", path: "/usr/bin/microsoft-edge-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/usr/lib/chromium/chromium" },
{ kind: "chromium", path: "/usr/lib/chromium-browser/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
...findPlaywrightChromiumExecutableCandidatesLinux(),
];
return findFirstExecutable(candidates);
}
function findGoogleChromeExecutableLinux(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
"/opt/google/chrome/chrome",
"/snap/bin/google-chrome",
]);
}
/** Find the best Chromium-family executable on Windows. */
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
// Must use bracket notation: variable name contains parentheses.
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];
if (localAppData) {
// Chrome (user install)
candidates.push({
kind: "chrome",
path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (user install)
candidates.push({
kind: "brave",
path: joinWin(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (user install)
candidates.push({
kind: "edge",
path: joinWin(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Chromium (user install)
candidates.push({
kind: "chromium",
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
});
// Chrome Canary (user install)
candidates.push({
kind: "canary",
path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
});
}
// Chrome (system install, 64-bit)
candidates.push({
kind: "chrome",
path: joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
});
// Chrome (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "chrome",
path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (system install, 64-bit)
candidates.push({
kind: "brave",
path: joinWin(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Brave (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "brave",
path: joinWin(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (system install, 64-bit)
candidates.push({
kind: "edge",
path: joinWin(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Edge (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "edge",
path: joinWin(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
});
return findFirstExecutable(candidates);
}
function findGoogleChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: string[] = [];
if (localAppData) {
candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"));
}
candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"));
return findFirstChromeExecutable(candidates);
}
/** Resolve the Google Chrome executable for a named platform when available. */
export function resolveGoogleChromeExecutableForPlatform(
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (platform === "darwin") {
return findGoogleChromeExecutableMac();
}
if (platform === "linux") {
return findGoogleChromeExecutableLinux();
}
if (platform === "win32") {
return findGoogleChromeExecutableWindows();
}
return null;
}
/** Read a browser executable version string using its command-line flag. */
export function readBrowserVersion(executablePath: string): string | null {
if (process.platform === "darwin") {
const bundleVersion = readMacBundleBrowserVersion(executablePath);
if (bundleVersion) {
return bundleVersion;
}
}
const output = execText(executablePath, ["--version"], BROWSER_VERSION_TIMEOUT_MS);
if (!output) {
return null;
}
return output.replace(/\s+/g, " ").trim();
}
function readMacBundleBrowserVersion(executablePath: string): string | null {
const appBundlePath = resolveMacAppBundlePath(executablePath);
if (!appBundlePath) {
return null;
}
const plistPath = path.join(appBundlePath, "Contents", "Info.plist");
return execText(
"/usr/libexec/PlistBuddy",
["-c", "Print :CFBundleShortVersionString", plistPath],
MAC_PLISTBUDDY_TIMEOUT_MS,
);
}
function resolveMacAppBundlePath(executablePath: string): string | null {
const parts = path.normalize(executablePath).split(path.sep);
const appIndex = parts.findIndex((part) => part.endsWith(".app"));
if (appIndex < 0) {
return null;
}
return parts.slice(0, appIndex + 1).join(path.sep) || path.sep;
}
/** Parse a major browser version from a raw version string. */
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
const matches = [...(rawVersion ?? "").matchAll(CHROME_VERSION_RE)];
const match = matches.at(-1);
if (!match?.[1]) {
return null;
}
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
}
/** Resolve the preferred Chromium-family executable for a platform. */
export function resolveBrowserExecutableForPlatform(
resolved: ResolvedBrowserConfig,
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(`browser.executablePath not found: ${resolved.executablePath}`);
}
return { kind: "custom", path: resolved.executablePath };
}
const detected = detectDefaultChromiumExecutable(platform);
if (detected) {
return detected;
}
if (platform === "darwin") {
return findChromeExecutableMac();
}
if (platform === "linux") {
return findChromeExecutableLinux();
}
if (platform === "win32") {
return findChromeExecutableWindows();
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
// Browser tests cover chrome.loopback ssrf.integration plugin behavior.
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
type RunningServer = {
server: Server;
baseUrl: string;
};
const runningServers: Server[] = [];
async function startLoopbackCdpServer(): Promise<RunningServer> {
const server = createServer((req, res) => {
if (req.url !== "/json/version") {
res.statusCode = 404;
res.end("not found");
return;
}
const address = server.address() as AddressInfo;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
Browser: "Chrome/999.0.0.0",
webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`,
}),
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
runningServers.push(server);
const address = server.address() as AddressInfo;
return {
server,
baseUrl: `http://127.0.0.1:${address.port}`,
};
}
afterEach(async () => {
await Promise.all(
runningServers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
),
);
});
describe("chrome loopback SSRF integration", () => {
it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true);
});
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
/\/devtools\/browser\/TEST$/,
);
});
});

View File

@@ -0,0 +1,194 @@
/**
* OpenClaw-managed Chrome profile decoration.
*
* Applies a stable profile name, color, download directory, and clean-exit
* markers to the managed Chrome profile's Local State and Preferences files.
*/
import fs from "node:fs";
import path from "node:path";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".openclaw-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
const parsed = loadJsonFile(filePath);
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
saveJsonFile(filePath, data);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readNestedRecord(root: unknown, key: string): Record<string, unknown> | null {
return asRecord(asRecord(root)?.[key]);
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
if (keys.length === 0) {
return;
}
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
const next = node[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
node[key] = {};
}
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1]] = value;
}
function parseHexRgbToSignedArgbInt(hex: string): number | null {
const cleaned = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {
return null;
}
const rgb = Number.parseInt(cleaned, 16);
const argbUnsigned = (0xff << 24) | rgb;
// Chrome stores colors as signed 32-bit ints (SkColor).
return argbUnsigned > 0x7fffffff ? argbUnsigned - 0x1_0000_0000 : argbUnsigned;
}
/** Return true when a managed Chrome profile already has desired decoration. */
export function isProfileDecorated(
userDataDir: string,
desiredName: string,
desiredColorHex: string,
desiredDownloadDir?: string,
): boolean {
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath);
const profile = localState?.profile;
const info = readNestedRecord(readNestedRecord(profile, "info_cache"), "Default");
const prefs = safeReadJson(preferencesPath);
const browserTheme = readNestedRecord(prefs?.browser, "theme");
const autogeneratedTheme = readNestedRecord(prefs?.autogenerated, "theme");
const download = readNestedRecord(prefs, "download");
const savefile = readNestedRecord(prefs, "savefile");
const nameOk = typeof info?.name === "string" ? info.name === desiredName : true;
const downloadOk = desiredDownloadDir
? download?.default_directory === desiredDownloadDir &&
download.prompt_for_download === false &&
download.directory_upgrade === true &&
savefile?.default_directory === desiredDownloadDir
: true;
if (desiredColorInt == null) {
// If the user provided a non-#RRGGBB value, we can only do best-effort.
return nameOk && downloadOk;
}
const localSeedOk =
typeof info?.profile_color_seed === "number"
? info.profile_color_seed === desiredColorInt
: false;
const prefOk =
(typeof browserTheme?.user_color2 === "number" &&
browserTheme.user_color2 === desiredColorInt) ||
(typeof autogeneratedTheme?.color === "number" && autogeneratedTheme.color === desiredColorInt);
return nameOk && localSeedOk && prefOk && downloadOk;
}
/**
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
* vary by version; we keep this conservative and idempotent.
*/
export function decorateOpenClawProfile(
userDataDir: string,
opts?: { name?: string; color?: string; downloadDir?: string },
) {
const desiredName = opts?.name ?? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME;
const desiredColor = (opts?.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase();
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath) ?? {};
// Common-ish shape: profile.info_cache.Default
setDeep(localState, ["profile", "info_cache", "Default", "name"], desiredName);
setDeep(localState, ["profile", "info_cache", "Default", "shortcut_name"], desiredName);
setDeep(localState, ["profile", "info_cache", "Default", "user_name"], desiredName);
// Color keys are best-effort (Chrome changes these frequently).
setDeep(localState, ["profile", "info_cache", "Default", "profile_color"], desiredColor);
setDeep(localState, ["profile", "info_cache", "Default", "user_color"], desiredColor);
if (desiredColorInt != null) {
// These are the fields Chrome actually uses for profile/avatar tinting.
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color_seed"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "profile_highlight_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_fill_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
desiredColorInt,
);
}
safeWriteJson(localStatePath, localState);
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["profile", "name"], desiredName);
setDeep(prefs, ["profile", "profile_color"], desiredColor);
setDeep(prefs, ["profile", "user_color"], desiredColor);
if (desiredColorInt != null) {
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
// User-selected browser theme color (pref name: browser.theme.user_color2).
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
}
if (opts?.downloadDir) {
setDeep(prefs, ["download", "default_directory"], opts.downloadDir);
setDeep(prefs, ["download", "prompt_for_download"], false);
setDeep(prefs, ["download", "directory_upgrade"], true);
setDeep(prefs, ["savefile", "default_directory"], opts.downloadDir);
}
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
} catch {
// ignore
}
}
/** Mark the managed Chrome profile as cleanly exited. */
export function ensureProfileCleanExit(userDataDir: string) {
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["exit_type"], "Normal");
setDeep(prefs, ["exited_cleanly"], true);
safeWriteJson(preferencesPath, prefs);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
// Browser tests cover chrome.version plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
const execFileSyncMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFileSync: (...args: unknown[]) => execFileSyncMock(...args),
};
});
import { readBrowserVersion } from "./chrome.executables.js";
function stubPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
configurable: true,
value: platform,
});
}
describe("readBrowserVersion", () => {
const originalPlatform = process.platform;
afterEach(() => {
stubPlatform(originalPlatform);
execFileSyncMock.mockReset();
vi.restoreAllMocks();
});
it("reads macOS app bundle versions from Info.plist before spawning Chrome", () => {
stubPlatform("darwin");
execFileSyncMock.mockReturnValue("148.0.7778.179\n");
const version = readBrowserVersion(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
);
expect(version).toBe("148.0.7778.179");
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
expect(execFileSyncMock).toHaveBeenCalledWith(
"/usr/libexec/PlistBuddy",
[
"-c",
"Print :CFBundleShortVersionString",
"/Applications/Google Chrome.app/Contents/Info.plist",
],
expect.objectContaining({ timeout: 800 }),
);
});
it("falls back to a slower --version probe when macOS bundle metadata is unavailable", () => {
stubPlatform("darwin");
execFileSyncMock
.mockImplementationOnce(() => {
throw new Error("plist unavailable");
})
.mockReturnValueOnce("Google Chrome 148.0.7778.179\n");
const version = readBrowserVersion(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
);
expect(version).toBe("Google Chrome 148.0.7778.179");
expect(execFileSyncMock).toHaveBeenNthCalledWith(
2,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
["--version"],
expect.objectContaining({ timeout: 6000 }),
);
});
it("uses the slower --version probe for non-bundle paths", () => {
stubPlatform("darwin");
execFileSyncMock.mockReturnValue("Chromium 148.0.7778.179\n");
const version = readBrowserVersion("/opt/chromium/chrome");
expect(version).toBe("Chromium 148.0.7778.179");
expect(execFileSyncMock).toHaveBeenCalledWith(
"/opt/chromium/chrome",
["--version"],
expect.objectContaining({ timeout: 6000 }),
);
});
});

View File

@@ -0,0 +1,180 @@
/**
* Browser client action helpers.
*
* Wraps browser-control action endpoints for navigation, dialog/file hooks,
* screenshots, and element actions used by the Browser agent tool.
*/
import {
addTimerTimeoutGraceMs,
clampPositiveTimerTimeoutMs,
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import type {
BrowserActionOk,
BrowserActionPathResult,
BrowserActionTabResult,
} from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import type { BrowserActRequest } from "./client-actions.types.js";
import { fetchBrowserJson } from "./client-fetch.js";
import {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS,
} from "./constants.js";
export type { BrowserFormField } from "./client-actions.types.js";
type BrowserActResponse = {
ok: true;
targetId: string;
url?: string;
result?: unknown;
results?: Array<{ ok: boolean; error?: string }>;
blockedByDialog?: boolean;
browserState?: unknown;
};
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
return clampPositiveTimerTimeoutMs(value);
}
function resolveBrowserActRequestTimeoutMs(req: BrowserActRequest): number {
const explicitTimeout = normalizePositiveTimeoutMs((req as { timeoutMs?: unknown }).timeoutMs);
const candidateTimeouts =
explicitTimeout === undefined
? [DEFAULT_BROWSER_ACTION_TIMEOUT_MS]
: [addTimerTimeoutGraceMs(explicitTimeout, BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS) ?? 1];
if (req.kind === "wait") {
const waitDuration = normalizePositiveTimeoutMs(req.timeMs);
if (waitDuration !== undefined) {
candidateTimeouts.push(
addTimerTimeoutGraceMs(waitDuration, BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS) ?? 1,
);
}
}
return Math.max(...candidateTimeouts);
}
/** Navigate a browser tab through the control server. */
export async function browserNavigate(
baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
profile?: string;
},
): Promise<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
});
}
/** Arm a one-shot browser dialog handler. */
export async function browserArmDialog(
baseUrl: string | undefined,
opts: {
accept: boolean;
promptText?: string;
dialogId?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
dialogId: opts.dialogId,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
/** Arm or execute a browser file chooser upload. */
export async function browserArmFileChooser(
baseUrl: string | undefined,
opts: {
paths: string[];
ref?: string;
inputRef?: string;
element?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
/** Execute one normalized browser action request. */
export async function browserAct(
baseUrl: string | undefined,
req: BrowserActRequest,
opts?: { profile?: string; timeoutMs?: number },
): Promise<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
timeoutMs: resolveTimerTimeoutMs(opts?.timeoutMs, resolveBrowserActRequestTimeoutMs(req)),
});
}
/** Capture a screenshot through the browser control server. */
export async function browserScreenshotAction(
baseUrl: string | undefined,
opts: {
targetId?: string;
fullPage?: boolean;
ref?: string;
element?: string;
type?: "png" | "jpeg";
labels?: boolean;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
const timeoutMs = clampPositiveTimerTimeoutMs(opts.timeoutMs);
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS;
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
fullPage: opts.fullPage,
ref: opts.ref,
element: opts.element,
type: opts.type,
labels: opts.labels,
timeoutMs: effectiveTimeoutMs,
}),
timeoutMs: effectiveTimeoutMs,
});
}

View File

@@ -0,0 +1,57 @@
/**
* Browser client observation helpers.
*
* Wraps browser-control endpoints that read console/debug data or save page
* output without directly mutating page state.
*/
import type { BrowserActionPathResult } from "./client-actions-types.js";
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type { BrowserConsoleMessage } from "./pw-session.js";
function buildQuerySuffix(params: Array<[string, string | boolean | undefined]>): string {
const query = new URLSearchParams();
for (const [key, value] of params) {
if (typeof value === "boolean") {
query.set(key, String(value));
continue;
}
if (typeof value === "string" && value.length > 0) {
query.set(key, value);
}
}
const encoded = query.toString();
return encoded.length > 0 ? `?${encoded}` : "";
}
/** Read browser console messages for a tab. */
export async function browserConsoleMessages(
baseUrl: string | undefined,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string; url?: string }> {
const suffix = buildQuerySuffix([
["level", opts.level],
["targetId", opts.targetId],
["profile", opts.profile],
]);
return await fetchBrowserJson<{
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
url?: string;
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}
/** Save the current page as PDF through browser control. */
export async function browserPdfSave(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
});
}

View File

@@ -0,0 +1,31 @@
/**
* Shared result types for browser client action helpers.
*/
import type { AnnotationItem } from "./screenshot-annotate.js";
/** Generic success result for action endpoints. */
export type BrowserActionOk = { ok: true };
/** Success result carrying the affected tab and optional URL. */
export type BrowserActionTabResult = {
ok: true;
targetId: string;
url?: string;
};
/** Success result carrying a filesystem output path. */
export type BrowserActionPathResult = {
ok: true;
path: string;
targetId: string;
url?: string;
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
/**
* Per-ref bounding boxes when labels=true. Coordinates are in the
* captured image's space (viewport / fullpage / element-relative).
* Omitted when empty.
*/
annotations?: AnnotationItem[];
};

View File

@@ -0,0 +1,16 @@
/**
* URL helpers for browser client action requests.
*/
/** Build a query string for profile-scoped browser requests. */
export function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
/** Prefix a browser-control path with an optional base URL. */
export function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}

View File

@@ -0,0 +1,13 @@
/**
* Public browser action client barrel.
*
* Re-exports the action helpers used by Browser tool registration and tests.
*/
export {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserNavigate,
browserScreenshotAction,
} from "./client-actions-core.js";
export { browserConsoleMessages, browserPdfSave } from "./client-actions-observe.js";

View File

@@ -0,0 +1,105 @@
/**
* Browser action request types.
*
* Defines the closed action union accepted by browser-control `/act` routes and
* reused by the Browser agent tool.
*/
/** Form field descriptor used by fill actions. */
export type BrowserFormField = {
ref: string;
type: string;
value?: string | number | boolean;
};
/** Normalized browser action request sent to the control server. */
export type BrowserActRequest =
| {
kind: "click";
ref?: string;
selector?: string;
targetId?: string;
doubleClick?: boolean;
button?: string;
modifiers?: string[];
delayMs?: number;
timeoutMs?: number;
}
| {
kind: "clickCoords";
x: number;
y: number;
targetId?: string;
doubleClick?: boolean;
button?: string;
delayMs?: number;
timeoutMs?: number;
}
| {
kind: "type";
ref?: string;
selector?: string;
text: string;
targetId?: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}
| { kind: "press"; key: string; targetId?: string; delayMs?: number }
| {
kind: "hover";
ref?: string;
selector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "scrollIntoView";
ref?: string;
selector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "drag";
startRef?: string;
startSelector?: string;
endRef?: string;
endSelector?: string;
targetId?: string;
timeoutMs?: number;
}
| {
kind: "select";
ref?: string;
selector?: string;
values: string[];
targetId?: string;
timeoutMs?: number;
}
| {
kind: "fill";
fields: BrowserFormField[];
targetId?: string;
timeoutMs?: number;
}
| { kind: "resize"; width: number; height: number; targetId?: string }
| {
kind: "wait";
timeMs?: number;
text?: string;
textGone?: string;
selector?: string;
url?: string;
loadState?: "load" | "domcontentloaded" | "networkidle";
fn?: string;
targetId?: string;
timeoutMs?: number;
}
| { kind: "evaluate"; fn: string; ref?: string; targetId?: string; timeoutMs?: number }
| { kind: "close"; targetId?: string }
| {
kind: "batch";
actions: BrowserActRequest[];
targetId?: string;
stopOnError?: boolean;
};

View File

@@ -0,0 +1,89 @@
// Browser tests cover client fetch.attach only plugin behavior.
import fs from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempHomeEnv } from "../../test-support.js";
import { stopBrowserControlService } from "../control-service.js";
import { fetchBrowserJson } from "./client-fetch.js";
type TempHome = {
home: string;
restore: () => Promise<void>;
};
describe("browser client fetch attachOnly diagnostics", () => {
let tempHome: TempHome | undefined;
beforeEach(async () => {
vi.useRealTimers();
await stopBrowserControlService();
clearRuntimeConfigSnapshot();
});
afterEach(async () => {
vi.useRealTimers();
await stopBrowserControlService();
clearRuntimeConfigSnapshot();
await tempHome?.restore();
tempHome = undefined;
});
it("does not suggest gateway restart when an attachOnly CDP endpoint hangs", async () => {
tempHome = await createTempHomeEnv("openclaw-browser-client-fetch-live-");
const sockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as { port: number }).port;
const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify(
{
browser: {
enabled: true,
defaultProfile: "hung",
attachOnly: true,
profiles: {
hung: {
cdpUrl: `http://127.0.0.1:${port}`,
attachOnly: true,
color: "#00AA00",
},
},
},
},
null,
2,
),
);
process.env.OPENCLAW_CONFIG_PATH = configPath;
clearRuntimeConfigSnapshot();
try {
const thrown = await fetchBrowserJson("/tabs?profile=hung", { timeoutMs: 200 }).catch(
(err: unknown) => err,
);
expect(thrown).toBeInstanceOf(Error);
const message = thrown instanceof Error ? thrown.message : String(thrown);
expect(message).toContain("browser profile is external to OpenClaw");
expect(message).toContain("Restarting the OpenClaw gateway will not launch it");
expect(message).not.toContain("Restart the OpenClaw gateway");
expect(message).not.toContain("Do NOT retry the browser tool");
} finally {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});

View File

@@ -0,0 +1,123 @@
import http from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const authMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
resolveBrowserControlAuth: vi.fn(() => ({})),
getBridgeAuthForPort: vi.fn(() => undefined),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return { ...actual, getRuntimeConfig: authMocks.loadConfig, loadConfig: authMocks.loadConfig };
});
vi.mock("./control-auth.js", () => ({
resolveBrowserControlAuth: authMocks.resolveBrowserControlAuth,
}));
vi.mock("./bridge-auth-registry.js", () => ({
getBridgeAuthForPort: authMocks.getBridgeAuthForPort,
}));
const { fetchBrowserJson } = await import("./client-fetch.js");
const STREAM_CHUNK = Buffer.alloc(4 * 1024, "x");
const STREAM_BODY_BYTES = 1024 * 1024;
describe("fetchHttpJson error body boundary", () => {
let server: http.Server;
let baseUrl: string;
let streamClosed: Promise<void>;
let resolveStreamClosed: () => void;
let smallConnectionClosed: Promise<void>;
let resolveSmallConnectionClosed: () => void;
let streamCompleted: boolean;
beforeEach(async () => {
for (const key of [
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
]) {
vi.stubEnv(key, "");
}
streamClosed = new Promise<void>((resolve) => {
resolveStreamClosed = resolve;
});
smallConnectionClosed = new Promise<void>((resolve) => {
resolveSmallConnectionClosed = resolve;
});
streamCompleted = false;
server = http.createServer((req, res) => {
if (req.url === "/small") {
req.socket.once("close", () => resolveSmallConnectionClosed());
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("session expired");
return;
}
res.writeHead(500, { "Content-Type": "text/plain" });
let written = 0;
let closed = false;
res.once("close", () => {
closed = true;
resolveStreamClosed();
});
const writeNext = () => {
if (closed) {
return;
}
if (written >= STREAM_BODY_BYTES) {
streamCompleted = true;
res.end();
return;
}
written += STREAM_CHUNK.byteLength;
const writeMore = () => setTimeout(writeNext, 2);
if (res.write(STREAM_CHUNK)) {
writeMore();
} else {
res.once("drain", writeMore);
}
};
writeNext();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected loopback server address");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
vi.unstubAllEnvs();
server.closeAllConnections();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
it("cancels an overflowing stream and releases the guarded fetch", async () => {
const error = await fetchBrowserJson(`${baseUrl}/large`).catch((err: unknown) => err);
expect(error).toMatchObject({ name: "BrowserServiceError", message: "HTTP 500" });
await expect(streamClosed).resolves.toBeUndefined();
expect(streamCompleted).toBe(false);
});
it("preserves a complete diagnostic body within the limit", async () => {
const error = await fetchBrowserJson(`${baseUrl}/small`).catch((err: unknown) => err);
expect(error).toMatchObject({
name: "BrowserServiceError",
message: "session expired",
});
await expect(smallConnectionClosed).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,622 @@
// Browser tests cover client fetch.loopback auth plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../test-support/browser-security.mock.js";
import type { OpenClawConfig } from "../config/config.js";
import type { BrowserControlAuth } from "./control-auth.js";
import type { BrowserDispatchResponse } from "./routes/dispatcher.js";
type BridgeAuth = NonNullable<
ReturnType<typeof import("./bridge-auth-registry.js").getBridgeAuthForPort>
>;
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: async (params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
}) => ({
response: await fetch(params.url, {
...params.init,
signal: params.signal,
}),
finalUrl: params.url,
release: async () => {},
}),
};
});
function okDispatchResponse(): BrowserDispatchResponse {
return { status: 200, body: { ok: true } };
}
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn<() => OpenClawConfig>(() => ({
gateway: {
auth: {
token: "loopback-token",
},
},
})),
resolveBrowserControlAuth: vi.fn<() => BrowserControlAuth>(() => ({
token: "loopback-token",
})),
getBridgeAuthForPort: vi.fn<(port: number) => BridgeAuth | undefined>(() => undefined),
startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })),
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
getRuntimeConfig: mocks.loadConfig,
loadConfig: mocks.loadConfig,
};
});
vi.mock("./control-service.js", () => ({
createBrowserControlContext: vi.fn(() => ({})),
startBrowserControlServiceFromConfig: mocks.startBrowserControlServiceFromConfig,
}));
vi.mock("./control-auth.js", () => ({
resolveBrowserControlAuth: mocks.resolveBrowserControlAuth,
}));
vi.mock("./bridge-auth-registry.js", () => ({
getBridgeAuthForPort: mocks.getBridgeAuthForPort,
}));
vi.mock("./routes/dispatcher.js", () => ({
createBrowserRouteDispatcher: vi.fn(() => ({
dispatch: mocks.dispatch,
})),
}));
const { fetchBrowserJson } = await import("./client-fetch.js");
function stubJsonFetchOk() {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function requireFetchInit(fetchMock: ReturnType<typeof stubJsonFetchOk>) {
const [call] = fetchMock.mock.calls;
if (!call) {
throw new Error("expected browser fetch call");
}
const [, init] = call;
return init;
}
async function expectThrownBrowserFetchError(
request: () => Promise<unknown>,
params: {
contains: string[];
omits?: string[];
},
) {
const thrown = await request().catch((err: unknown) => err);
expect(thrown).toBeInstanceOf(Error);
if (!(thrown instanceof Error)) {
throw new Error(`Expected Error, got ${String(thrown)}`);
}
for (const snippet of params.contains) {
expect(thrown.message).toContain(snippet);
}
for (const snippet of params.omits ?? []) {
expect(thrown.message).not.toContain(snippet);
}
return thrown;
}
describe("fetchBrowserJson loopback auth", () => {
beforeEach(() => {
vi.restoreAllMocks();
for (const key of [
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
]) {
vi.stubEnv(key, "");
}
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "loopback-token");
mocks.loadConfig.mockClear();
mocks.loadConfig.mockReturnValue({
gateway: {
auth: {
token: "loopback-token",
},
},
});
mocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue({ ok: true });
mocks.dispatch.mockReset().mockResolvedValue(okDispatchResponse());
mocks.resolveBrowserControlAuth.mockReset().mockReturnValue({
token: "loopback-token",
});
mocks.getBridgeAuthForPort.mockReset().mockReturnValue(undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("adds bearer auth for loopback absolute HTTP URLs", async () => {
const fetchMock = stubJsonFetchOk();
const res = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/");
expect(res.ok).toBe(true);
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
it("does not inject auth for non-loopback absolute URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://example.com/");
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBeNull();
});
it("keeps caller-supplied auth header", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://localhost:18888/", {
headers: {
Authorization: "Bearer caller-token",
},
});
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer caller-token");
});
it("injects auth for IPv6 loopback absolute URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://[::1]:18888/");
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
it("injects auth for IPv4-mapped IPv6 loopback URLs", async () => {
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://[::ffff:127.0.0.1]:18888/");
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer loopback-token");
});
it("does not treat explicit port zero as the default loopback bridge port", async () => {
mocks.resolveBrowserControlAuth.mockReturnValueOnce({});
mocks.getBridgeAuthForPort.mockReturnValueOnce({ token: "bridge-token" });
const fetchMock = stubJsonFetchOk();
await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:0/");
const init = requireFetchInit(fetchMock);
const headers = new Headers(init?.headers);
expect(mocks.getBridgeAuthForPort).not.toHaveBeenCalled();
expect(headers.get("authorization")).toBeNull();
});
it("preserves dispatcher timeout context without no-retry hint", async () => {
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"],
omits: ["Can't reach the OpenClaw browser control service", "Do NOT retry the browser tool"],
});
});
it("preserves dispatcher abort context without no-retry hint", async () => {
mocks.dispatch.mockRejectedValueOnce(new DOMException("operation aborted", "AbortError"));
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: ["operation aborted", "Restart the OpenClaw gateway"],
omits: ["Do NOT retry the browser tool"],
});
});
it("avoids restart-gateway guidance for attachOnly dispatcher timeouts", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
attachOnly: true,
defaultProfile: "manual",
profiles: {
manual: {
cdpUrl: "http://127.0.0.1:9222",
attachOnly: true,
color: "#00AA00",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"),
{
contains: [
"Chrome CDP handshake timeout",
"browser profile is external to OpenClaw",
"Restarting the OpenClaw gateway will not launch it",
],
omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"],
},
);
});
it("avoids restart-gateway guidance for existing-session dispatcher timeouts", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
defaultProfile: "user",
profiles: {
user: {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new DOMException("operation aborted", "AbortError"));
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: [
"operation aborted",
"browser profile is external to OpenClaw",
"Restarting the OpenClaw gateway will not launch it",
],
omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"],
});
});
it("avoids restart-gateway guidance for remote CDP dispatcher timeouts", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
defaultProfile: "remote",
profiles: {
remote: {
cdpUrl: "https://browserless.example/chrome?token=test",
color: "#00AA00",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("timed out"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=remote"),
{
contains: [
"timed out",
"browser profile is external to OpenClaw",
"Restarting the OpenClaw gateway will not launch it",
],
omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"],
},
);
});
it("keeps restart-gateway guidance for managed local dispatcher timeouts", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
defaultProfile: "openclaw",
profiles: {
openclaw: {
cdpPort: 18800,
color: "#FF4500",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=openclaw"),
{
contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"],
omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"],
},
);
});
it("keeps restart-gateway guidance when dispatcher profile resolution fails", async () => {
mocks.loadConfig.mockImplementation(() => {
throw new Error("config unavailable");
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"),
{
contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"],
omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"],
},
);
});
it("keeps restart-gateway guidance for unknown dispatcher profiles", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
defaultProfile: "openclaw",
profiles: {
openclaw: {
cdpPort: 18800,
color: "#FF4500",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=missing"),
{
contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"],
omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"],
},
);
});
it("uses the default external profile when dispatcher request omits profile", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
defaultProfile: "manual",
profiles: {
manual: {
cdpUrl: "http://127.0.0.1:9222",
attachOnly: true,
color: "#00AA00",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: [
"Chrome CDP handshake timeout",
"browser profile is external to OpenClaw",
"Restarting the OpenClaw gateway will not launch it",
],
omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"],
});
});
it("keeps no-retry hint but not restart guidance for persistent external profile failures", async () => {
mocks.loadConfig.mockReturnValue({
browser: {
attachOnly: true,
defaultProfile: "manual",
profiles: {
manual: {
cdpUrl: "http://127.0.0.1:9222",
attachOnly: true,
color: "#00AA00",
},
},
},
});
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused"));
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"),
{
contains: [
"Chrome CDP connection refused",
"browser profile is external to OpenClaw",
"Do NOT retry the browser tool",
],
omits: ["Restart the OpenClaw gateway"],
},
);
});
it("keeps no-retry hint for persistent dispatcher failures", async () => {
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused"));
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: ["Chrome CDP connection refused", "Do NOT retry the browser tool"],
omits: ["Can't reach the OpenClaw browser control service"],
});
});
it("surfaces 429 from HTTP URL as rate-limit error with no-retry hint", async () => {
const response = new Response("max concurrent sessions exceeded", { status: 429 });
const text = vi.spyOn(response, "text");
const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
vi.stubGlobal(
"fetch",
vi.fn(async () => response),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/"),
{
contains: ["Browser service rate limit reached", "Do NOT retry the browser tool"],
omits: ["max concurrent sessions exceeded"],
},
);
expect(text).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledOnce();
});
it("surfaces 429 from HTTP URL without body detail when empty", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 429 })),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/"),
{
contains: ["rate limit reached", "Do NOT retry the browser tool"],
},
);
});
it("keeps Browserbase-specific wording for Browserbase 429 responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("max concurrent sessions exceeded", { status: 429 })),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("https://connect.browserbase.com/session"),
{
contains: ["Browserbase rate limit reached", "upgrade your plan"],
omits: ["max concurrent sessions exceeded"],
},
);
});
it("non-429 errors still produce generic messages", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("internal error", { status: 500 })),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/"),
{
contains: ["internal error"],
omits: ["rate limit"],
},
);
});
it("surfaces 429 from dispatcher path as rate-limit error", async () => {
mocks.dispatch.mockResolvedValueOnce({
status: 429,
body: { error: "too many sessions" },
});
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
contains: ["Browser service rate limit reached", "Do NOT retry the browser tool"],
omits: ["too many sessions"],
});
});
it("keeps absolute URL failures wrapped as reachability errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("socket hang up");
}),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/"),
{
contains: [
"Can't reach the OpenClaw browser control service",
"Do NOT retry the browser tool",
],
},
);
});
it("omits no-retry hint for absolute HTTP timeout failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("timed out");
}),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/", { timeoutMs: 1234 }),
{
contains: ["timed out after 1234ms"],
omits: ["Do NOT retry the browser tool"],
},
);
});
it("uses the default timeout for non-finite absolute HTTP timeout failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("timed out");
}),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/", { timeoutMs: Number.NaN }),
{
contains: ["timed out after 5000ms"],
omits: ["NaNms", "Do NOT retry the browser tool"],
},
);
});
it("caps oversized absolute HTTP timeouts before arming the watchdog", async () => {
const timeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("timed out");
}),
);
await expectThrownBrowserFetchError(
() =>
fetchBrowserJson<{ ok: boolean }>("http://example.com/", {
timeoutMs: Number.MAX_SAFE_INTEGER,
}),
{
contains: [`timed out after ${MAX_TIMER_TIMEOUT_MS}ms`],
omits: ["Do NOT retry the browser tool"],
},
);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
});
it("omits no-retry hint for absolute HTTP abort failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new DOMException("operation aborted", "AbortError");
}),
);
await expectThrownBrowserFetchError(
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/"),
{
contains: ["Browser control request was cancelled"],
omits: ["Do NOT retry the browser tool"],
},
);
});
});

View File

@@ -0,0 +1,421 @@
/**
* Browser control client transport.
*
* Sends requests to either an absolute HTTP browser-control URL or the local
* in-process dispatcher, adding loopback auth and operator-facing diagnostics.
*/
import { parseBrowserHttpUrl } from "openclaw/plugin-sdk/browser-config";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatCliCommand } from "../cli/command-format.js";
import { getRuntimeConfig } from "../config/config.js";
import { isLoopbackHost } from "../gateway/net.js";
import { getBridgeAuthForPort } from "./bridge-auth-registry.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { resolveBrowserControlAuth } from "./control-auth.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
// Application-level error from the browser control service (service is reachable
// but returned an error response). Must NOT be wrapped with "Can't reach ..." messaging.
class BrowserServiceError extends Error {
constructor(message: string) {
super(message);
this.name = "BrowserServiceError";
}
}
type LoopbackBrowserAuthDeps = {
getRuntimeConfig: typeof getRuntimeConfig;
resolveBrowserControlAuth: typeof resolveBrowserControlAuth;
getBridgeAuthForPort: typeof getBridgeAuthForPort;
};
function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
function isLoopbackHttpUrl(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
}
function withLoopbackBrowserAuthImpl(
url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined,
deps: LoopbackBrowserAuthDeps,
): RequestInit & { timeoutMs?: number } {
const headers = new Headers(init?.headers ?? {});
if (headers.has("authorization") || headers.has("x-openclaw-password")) {
return { ...init, headers };
}
if (!isLoopbackHttpUrl(url)) {
return { ...init, headers };
}
try {
const cfg = deps.getRuntimeConfig();
const auth = deps.resolveBrowserControlAuth(cfg);
if (auth.token) {
headers.set("Authorization", `Bearer ${auth.token}`);
return { ...init, headers };
}
if (auth.password) {
headers.set("x-openclaw-password", auth.password);
return { ...init, headers };
}
} catch {
// ignore config/auth lookup failures and continue without auth headers
}
// Sandbox bridge servers can run with per-process ephemeral auth on dynamic ports.
// Fall back to the in-memory registry if config auth is not available.
try {
const { port } = parseBrowserHttpUrl(url, "browser control URL");
const bridgeAuth = deps.getBridgeAuthForPort(port);
if (bridgeAuth?.token) {
headers.set("Authorization", `Bearer ${bridgeAuth.token}`);
} else if (bridgeAuth?.password) {
headers.set("x-openclaw-password", bridgeAuth.password);
}
} catch {
// ignore
}
return { ...init, headers };
}
function withLoopbackBrowserAuth(
url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined,
): RequestInit & { timeoutMs?: number } {
return withLoopbackBrowserAuthImpl(url, init, {
getRuntimeConfig,
resolveBrowserControlAuth,
getBridgeAuthForPort,
});
}
const BROWSER_TOOL_MODEL_HINT =
"Do NOT retry the browser tool — it will keep failing. " +
"Use an alternative approach or inform the user that the browser is currently unavailable.";
const BROWSER_ERROR_BODY_LIMIT_BYTES = 16 * 1024;
function isRateLimitStatus(status: number): boolean {
return status === 429;
}
type BrowserControlOwnership = "local-managed" | "external-browser" | "unknown";
function resolveDispatcherBrowserControlOwnership(url: string): BrowserControlOwnership {
if (isAbsoluteHttp(url)) {
return "unknown";
}
try {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg?.browser, cfg);
const parsed = new URL(url, "http://localhost");
const requestedProfile = parsed.searchParams.get("profile")?.trim();
const profile = resolveProfile(resolved, requestedProfile || resolved.defaultProfile);
if (!profile) {
return "unknown";
}
return profile.driver === "openclaw" && profile.cdpIsLoopback && !profile.attachOnly
? "local-managed"
: "external-browser";
} catch {
return "unknown";
}
}
function resolveBrowserFetchOperatorHint(
url: string,
opts?: { ownership?: BrowserControlOwnership },
): string {
if (opts?.ownership === "external-browser") {
return (
"The browser profile is external to OpenClaw; make sure its browser/CDP endpoint " +
"is running and reachable. Restarting the OpenClaw gateway will not launch it."
);
}
const isLocal = !isAbsoluteHttp(url);
return isLocal
? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).`
: "If this is a sandboxed session, ensure the sandbox browser is running.";
}
function normalizeErrorMessage(err: unknown): string {
const message = err instanceof Error ? normalizeOptionalString(err.message) : undefined;
if (message) {
return message;
}
return String(err);
}
function appendBrowserToolModelHint(message: string): string {
if (message.includes(BROWSER_TOOL_MODEL_HINT)) {
return message;
}
return `${message} ${BROWSER_TOOL_MODEL_HINT}`;
}
type BrowserFetchFailureKind = "timeout" | "aborted" | "persistent";
function resolveBrowserFetchTimeoutMs(timeoutMs: number | undefined): number {
return resolveTimerTimeoutMs(timeoutMs, 5000);
}
function classifyBrowserFetchFailure(err: unknown): BrowserFetchFailureKind {
const msg = normalizeErrorMessage(err);
const msgLower = normalizeLowercaseStringOrEmpty(msg);
const nameLower = err instanceof Error ? normalizeLowercaseStringOrEmpty(err.name) : "";
const looksLikeTimeout =
nameLower.includes("timeout") || msgLower.includes("timed out") || msgLower.includes("timeout");
if (looksLikeTimeout) {
return "timeout";
}
const looksLikeAbort =
nameLower === "aborterror" ||
msgLower.includes("aborterror") ||
msgLower.includes("aborted") ||
msgLower.includes("abort") ||
msgLower.includes("cancelled") ||
msgLower.includes("canceled");
return looksLikeAbort ? "aborted" : "persistent";
}
async function discardResponseBody(res: Response): Promise<void> {
try {
await res.body?.cancel();
} catch {
// Best effort only; we're already returning a stable error message.
}
}
function enhanceDispatcherPathError(url: string, err: unknown): Error {
const msg = normalizeErrorMessage(err);
const kind = classifyBrowserFetchFailure(err);
const ownership = resolveDispatcherBrowserControlOwnership(url);
const operatorHint = resolveBrowserFetchOperatorHint(url, { ownership });
const suffix =
kind === "persistent" ? `${operatorHint} ${BROWSER_TOOL_MODEL_HINT}` : operatorHint;
const normalized = msg.endsWith(".") ? msg : `${msg}.`;
return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined);
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const operatorHint = resolveBrowserFetchOperatorHint(url);
const msg = normalizeErrorMessage(err);
const kind = classifyBrowserFetchFailure(err);
if (kind === "timeout") {
return new Error(
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`,
err instanceof Error ? { cause: err } : undefined,
);
}
if (kind === "aborted") {
return new Error(
`Browser control request was cancelled. ${operatorHint}`,
err instanceof Error ? { cause: err } : undefined,
);
}
return new Error(
appendBrowserToolModelHint(
`Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`,
),
err instanceof Error ? { cause: err } : undefined,
);
}
async function fetchHttpJson<T>(
url: string,
init: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = resolveBrowserFetchTimeoutMs(init.timeoutMs);
const ctrl = new AbortController();
const upstreamSignal = init.signal;
let upstreamAbortListener: (() => void) | undefined;
if (upstreamSignal) {
if (upstreamSignal.aborted) {
ctrl.abort(upstreamSignal.reason);
} else {
upstreamAbortListener = () => ctrl.abort(upstreamSignal.reason);
upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true });
}
}
const t = setTimeout(() => ctrl.abort(new Error("timed out")), timeoutMs);
let release: (() => Promise<void>) | undefined;
try {
const guarded = await fetchWithSsrFGuard({
url,
init,
signal: ctrl.signal,
policy: { allowPrivateNetwork: true },
auditContext: "browser-control-client",
});
release = guarded.release;
const res = guarded.response;
if (!res.ok) {
if (isRateLimitStatus(res.status)) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
await discardResponseBody(res);
throw new BrowserServiceError(
`${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
);
}
// Overflow cancels the stream and releases its reader lock before the guarded fetch below.
const body = await readResponseWithLimit(res, BROWSER_ERROR_BODY_LIMIT_BYTES).catch(
() => undefined,
);
const text = body ? new TextDecoder().decode(body) : "";
throw new BrowserServiceError(text || `HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(t);
await release?.();
if (upstreamSignal && upstreamAbortListener) {
upstreamSignal.removeEventListener("abort", upstreamAbortListener);
}
}
}
/** Fetch JSON from browser control over HTTP or local dispatcher transport. */
export async function fetchBrowserJson<T>(
url: string,
init?: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = resolveBrowserFetchTimeoutMs(init?.timeoutMs);
let isDispatcherPath = false;
try {
if (isAbsoluteHttp(url)) {
const httpInit = withLoopbackBrowserAuth(url, init);
return await fetchHttpJson<T>(url, { ...httpInit, timeoutMs });
}
isDispatcherPath = true;
const { dispatchBrowserControlRequest } = await import("./local-dispatch.runtime.js");
const parsed = new URL(url, "http://localhost");
const query: Record<string, unknown> = {};
for (const [key, value] of parsed.searchParams.entries()) {
query[key] = value;
}
let body = init?.body;
if (typeof body === "string") {
try {
body = JSON.parse(body);
} catch {
// keep as string
}
}
const abortCtrl = new AbortController();
const upstreamSignal = init?.signal;
let upstreamAbortListener: (() => void) | undefined;
if (upstreamSignal) {
if (upstreamSignal.aborted) {
abortCtrl.abort(upstreamSignal.reason);
} else {
upstreamAbortListener = () => abortCtrl.abort(upstreamSignal.reason);
upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true });
}
}
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = abortCtrl.signal.aborted
? Promise.reject(
toLintErrorObject(abortCtrl.signal.reason ?? new Error("aborted"), "Non-Error rejection"),
)
: new Promise((_, reject) => {
abortListener = () =>
reject(
toLintErrorObject(
abortCtrl.signal.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
abortCtrl.signal.addEventListener("abort", abortListener, { once: true });
});
let timer: ReturnType<typeof setTimeout> | undefined;
if (timeoutMs) {
timer = setTimeout(() => abortCtrl.abort(new Error("timed out")), timeoutMs);
}
const dispatchPromise = dispatchBrowserControlRequest({
method:
init?.method?.toUpperCase() === "DELETE"
? "DELETE"
: init?.method?.toUpperCase() === "POST"
? "POST"
: "GET",
path: parsed.pathname,
query,
body,
signal: abortCtrl.signal,
});
const result = await Promise.race([dispatchPromise, abortPromise]).finally(() => {
if (timer) {
clearTimeout(timer);
}
if (abortListener) {
abortCtrl.signal.removeEventListener("abort", abortListener);
}
if (upstreamSignal && upstreamAbortListener) {
upstreamSignal.removeEventListener("abort", upstreamAbortListener);
}
});
if (result.status >= 400) {
if (isRateLimitStatus(result.status)) {
// Do not reflect upstream response text into the error surface (log/agent injection risk)
throw new BrowserServiceError(
`${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
);
}
const message =
result.body && typeof result.body === "object" && "error" in result.body
? String((result.body as { error?: unknown }).error)
: `HTTP ${result.status}`;
throw new BrowserServiceError(message);
}
return result.body as T;
} catch (err) {
if (err instanceof BrowserServiceError) {
throw err;
}
// Dispatcher-path failures are service-operation failures, not network
// reachability failures. Keep the original context, but retain anti-retry hints.
if (isDispatcherPath) {
throw enhanceDispatcherPathError(url, err);
}
throw enhanceBrowserFetchError(url, err, timeoutMs);
}
}
/** Focused test hooks for browser client transport internals. */
export const testApi = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};
export { testApi as __test };
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,458 @@
// Browser tests cover client plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "./client-actions.js";
import {
browserDoctor,
browserOpenTab,
browserSnapshot,
browserStatus,
browserTabs,
} from "./client.js";
describe("browser client", () => {
function requireSnapshotCall(calls: string[]): string {
const call = calls.find((url) => url.includes("/snapshot?"));
if (!call) {
throw new Error("expected browser snapshot request");
}
return call;
}
function stubSnapshotFetch(calls: string[]) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return {
ok: true,
json: async () => ({
ok: true,
format: "ai",
targetId: "t1",
url: "https://x",
snapshot: "ok",
}),
} as unknown as Response;
}),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("wraps connection failures with a sandbox hint", async () => {
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
code: "ECONNREFUSED",
});
const fetchFailed = Object.assign(new TypeError("fetch failed"), {
cause: refused,
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
});
it("adds useful cancellation messaging for abort-like failures", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/cancelled/i);
});
it("surfaces non-2xx responses with body text", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("conflict", { status: 409 })));
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
).rejects.toThrow(/conflict/i);
});
it("adds labels + efficient mode query params to snapshots", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
const snapshot = await browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
labels: true,
mode: "efficient",
});
expect(snapshot.ok).toBe(true);
expect(snapshot.format).toBe("ai");
const parsed = new URL(requireSnapshotCall(calls));
expect(parsed.searchParams.get("labels")).toBe("1");
expect(parsed.searchParams.get("mode")).toBe("efficient");
});
it("adds refs=aria to snapshots when requested", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
refs: "aria",
});
const parsed = new URL(requireSnapshotCall(calls));
expect(parsed.searchParams.get("refs")).toBe("aria");
});
it("forwards an explicit snapshot timeoutMs into the query string", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
timeoutMs: 4321,
});
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
expect(snapshotCall).toBeTruthy();
const parsed = new URL(snapshotCall as string);
expect(parsed.searchParams.get("timeoutMs")).toBe("4321");
});
it("clamps oversized snapshot timeoutMs before forwarding", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
format: "ai",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
const parsed = new URL(requireSnapshotCall(calls));
expect(parsed.searchParams.get("timeoutMs")).toBe(String(MAX_TIMER_TIMEOUT_MS));
});
it("falls back to the default snapshot timeout when none is supplied", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", { format: "ai" });
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
expect(snapshotCall).toBeTruthy();
const parsed = new URL(snapshotCall as string);
expect(parsed.searchParams.get("timeoutMs")).toBe("20000");
});
it("omits format when the caller wants server-side snapshot capability defaults", async () => {
const calls: string[] = [];
stubSnapshotFetch(calls);
await browserSnapshot("http://127.0.0.1:18791", {
profile: "chrome",
});
const parsed = new URL(requireSnapshotCall(calls));
expect(parsed.searchParams.get("format")).toBeNull();
expect(parsed.searchParams.get("profile")).toBe("chrome");
});
it("uses the expected endpoints + methods for common calls", async () => {
const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => {
calls.push({ url, init });
if (url.endsWith("/tabs") && (!init || init.method === undefined)) {
return {
ok: true,
json: async () => ({
running: true,
tabs: [{ targetId: "t1", title: "T", url: "https://x" }],
}),
} as unknown as Response;
}
if (url.endsWith("/tabs/open")) {
return {
ok: true,
json: async () => ({
targetId: "t2",
title: "N",
url: "https://y",
}),
} as unknown as Response;
}
if (url.endsWith("/navigate")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
url: "https://y",
}),
} as unknown as Response;
}
if (url.endsWith("/act")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
url: "https://x",
result: 1,
results: [{ ok: true }],
}),
} as unknown as Response;
}
if (url.endsWith("/hooks/file-chooser")) {
return {
ok: true,
json: async () => ({ ok: true }),
} as unknown as Response;
}
if (url.endsWith("/hooks/dialog")) {
return {
ok: true,
json: async () => ({ ok: true }),
} as unknown as Response;
}
if (url.includes("/console?")) {
return {
ok: true,
json: async () => ({
ok: true,
targetId: "t1",
messages: [],
}),
} as unknown as Response;
}
if (url.endsWith("/pdf")) {
return {
ok: true,
json: async () => ({
ok: true,
path: "/tmp/a.pdf",
targetId: "t1",
url: "https://x",
}),
} as unknown as Response;
}
if (url.endsWith("/screenshot")) {
return {
ok: true,
json: async () => ({
ok: true,
path: "/tmp/a.png",
targetId: "t1",
url: "https://x",
}),
} as unknown as Response;
}
if (url.includes("/snapshot?")) {
return {
ok: true,
json: async () => ({
ok: true,
format: "aria",
targetId: "t1",
url: "https://x",
nodes: [],
}),
} as unknown as Response;
}
if (url.includes("/doctor")) {
return {
ok: true,
json: async () => ({
ok: true,
profile: "openclaw",
transport: "cdp",
checks: [],
status: {
enabled: true,
running: true,
cdpPort: 18792,
},
}),
} as unknown as Response;
}
return {
ok: true,
json: async () => ({
enabled: true,
running: true,
pid: 1,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome",
userDataDir: "/tmp",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
}),
} as unknown as Response;
}),
);
const statusResult = await browserStatus("http://127.0.0.1:18791");
expect(statusResult.running).toBe(true);
expect(statusResult.cdpPort).toBe(18792);
const doctorResult = await browserDoctor("http://127.0.0.1:18791");
expect(doctorResult.ok).toBe(true);
expect(doctorResult.profile).toBe("openclaw");
const deepDoctorResult = await browserDoctor("http://127.0.0.1:18791", {
profile: "openclaw",
deep: true,
});
expect(deepDoctorResult.ok).toBe(true);
expect(deepDoctorResult.profile).toBe("openclaw");
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(1);
const openedTab = await browserOpenTab("http://127.0.0.1:18791", "https://example.com");
expect(openedTab.targetId).toBe("t2");
const snapshot = await browserSnapshot("http://127.0.0.1:18791", {
format: "aria",
limit: 1,
});
expect(snapshot.ok).toBe(true);
expect(snapshot.format).toBe("aria");
const navigation = await browserNavigate("http://127.0.0.1:18791", {
url: "https://example.com",
});
expect(navigation.ok).toBe(true);
expect(navigation.targetId).toBe("t1");
const act = await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" });
expect(act.ok).toBe(true);
expect(act.targetId).toBe("t1");
expect(act.results).toEqual([{ ok: true }]);
const fileChooser = await browserArmFileChooser("http://127.0.0.1:18791", {
paths: ["/tmp/a.txt"],
});
expect(fileChooser.ok).toBe(true);
const dialog = await browserArmDialog("http://127.0.0.1:18791", { accept: true });
expect(dialog.ok).toBe(true);
const consoleMessages = await browserConsoleMessages("http://127.0.0.1:18791", {
level: "error",
});
expect(consoleMessages.ok).toBe(true);
expect(consoleMessages.targetId).toBe("t1");
const pdf = await browserPdfSave("http://127.0.0.1:18791");
expect(pdf.ok).toBe(true);
expect(pdf.path).toBe("/tmp/a.pdf");
const screenshotResult = await browserScreenshotAction("http://127.0.0.1:18791", {
fullPage: true,
timeoutMs: 12_345,
});
expect(screenshotResult.ok).toBe(true);
expect(screenshotResult.path).toBe("/tmp/a.png");
const defaultScreenshotResult = await browserScreenshotAction("http://127.0.0.1:18791", {
targetId: "t-default",
});
expect(defaultScreenshotResult.ok).toBe(true);
expect(defaultScreenshotResult.path).toBe("/tmp/a.png");
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"));
expect(deepDoctor?.init?.timeoutMs).toBe(10_000);
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
expect(open?.init?.method).toBe("POST");
const screenshotCalls = calls.filter((c) => c.url.endsWith("/screenshot"));
const screenshot = screenshotCalls[0];
expect(screenshot?.init?.method).toBe("POST");
expect(screenshot?.init?.timeoutMs).toBe(12_345);
const screenshotBody = JSON.parse(
typeof screenshot?.init?.body === "string" ? screenshot.init.body : "{}",
) as { fullPage?: unknown; timeoutMs?: unknown };
expect(screenshotBody.fullPage).toBe(true);
expect(screenshotBody.timeoutMs).toBe(12_345);
const defaultScreenshot = screenshotCalls[1];
expect(defaultScreenshot?.init?.timeoutMs).toBe(20_000);
const defaultScreenshotBody = JSON.parse(
typeof defaultScreenshot?.init?.body === "string" ? defaultScreenshot.init.body : "{}",
) as { targetId?: unknown; timeoutMs?: unknown };
expect(defaultScreenshotBody.targetId).toBe("t-default");
expect(defaultScreenshotBody.timeoutMs).toBe(20_000);
});
it("gives browser act requests enough client timeout for long waits", async () => {
const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => {
calls.push({ url, init });
return {
ok: true,
json: async () => ({ ok: true, targetId: "t1" }),
} as unknown as Response;
}),
);
await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" });
await browserAct("http://127.0.0.1:18791", {
kind: "wait",
timeMs: 70_000,
});
await browserAct("http://127.0.0.1:18791", {
kind: "wait",
timeoutMs: 45_000,
});
expect(calls.map((call) => call.init?.timeoutMs)).toEqual([60_000, 75_000, 50_000]);
});
it("clamps oversized browser action timeouts before forwarding", async () => {
const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => {
calls.push({ url, init });
return {
ok: true,
json: async () => ({ ok: true, targetId: "t1", path: "/tmp/a.png" }),
} as unknown as Response;
}),
);
await browserAct("http://127.0.0.1:18791", {
kind: "wait",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
await browserScreenshotAction("http://127.0.0.1:18791", {
timeoutMs: Number.MAX_SAFE_INTEGER,
});
const act = calls.find((call) => call.url.endsWith("/act"));
expect(act?.init?.timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
const screenshot = calls.find((call) => call.url.endsWith("/screenshot"));
expect(screenshot?.init?.timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
const screenshotBody = JSON.parse(
typeof screenshot?.init?.body === "string" ? screenshot.init.body : "{}",
) as { timeoutMs?: unknown };
expect(screenshotBody.timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,416 @@
/**
* Browser control client API.
*
* 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 { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type {
BrowserStatus,
BrowserTab,
BrowserTransport,
SnapshotAriaNode,
} from "./client.types.js";
import { DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS } from "./constants.js";
import type { BrowserDoctorReport } from "./doctor.js";
import type { AnnotationItem } from "./screenshot-annotate.js";
export type { BrowserStatus, BrowserTab, BrowserTransport } from "./client.types.js";
export type { BrowserDoctorCheck, BrowserDoctorReport } from "./doctor.js";
const BROWSER_STATUS_REQUEST_TIMEOUT_MS = 7_500;
const BROWSER_DOCTOR_REQUEST_TIMEOUT_MS = 7_500;
const BROWSER_DEEP_DOCTOR_REQUEST_TIMEOUT_MS = 10_000;
const JSON_HEADERS = { "Content-Type": "application/json" };
type BrowserClientTimeoutOptions = {
timeoutMs?: number;
};
type BrowserClientProfileOptions = BrowserClientTimeoutOptions & {
profile?: string;
};
function resolveBrowserClientTimeoutMs(
opts: BrowserClientTimeoutOptions | undefined,
fallbackMs: number,
): number {
return resolveTimerTimeoutMs(opts?.timeoutMs, fallbackMs);
}
function withProfilePath(baseUrl: string | undefined, path: string, profile?: string): string {
return withBaseUrl(baseUrl, `${path}${buildProfileQuery(profile)}`);
}
async function sendProfilePost(
baseUrl: string | undefined,
path: string,
opts: BrowserClientProfileOptions | undefined,
fallbackTimeoutMs: number,
): Promise<void> {
await fetchBrowserJson(withProfilePath(baseUrl, path, opts?.profile), {
method: "POST",
timeoutMs: resolveBrowserClientTimeoutMs(opts, fallbackTimeoutMs),
});
}
async function sendTabTargetRequest(params: {
baseUrl: string | undefined;
path: string;
method: "POST" | "DELETE";
opts: BrowserClientProfileOptions | undefined;
body?: object;
}): Promise<void> {
await fetchBrowserJson(withProfilePath(params.baseUrl, params.path, params.opts?.profile), {
method: params.method,
...(params.body ? { headers: JSON_HEADERS, body: JSON.stringify(params.body) } : {}),
timeoutMs: resolveBrowserClientTimeoutMs(params.opts, 5000),
});
}
/** Profile status record returned by browser profile listing. */
export type ProfileStatus = {
name: string;
transport?: BrowserTransport;
cdpPort: number | null;
cdpUrl: string | null;
color: string;
driver: "openclaw" | "existing-session";
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
/** Result returned when a managed browser profile directory is reset. */
export type BrowserResetProfileResult = {
ok: true;
moved: boolean;
from: string;
to?: string;
};
/** Snapshot response returned by browserSnapshot. */
export type SnapshotResult =
| {
ok: true;
format: "aria";
targetId: string;
url: string;
nodes: SnapshotAriaNode[];
blockedByDialog?: boolean;
browserState?: unknown;
}
| {
ok: true;
format: "ai";
targetId: string;
url: string;
snapshot: string;
truncated?: boolean;
refs?: Record<string, { role: string; name?: string; nth?: number }>;
stats?: {
lines: number;
chars: number;
refs: number;
interactive: number;
};
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
/**
* Per-ref bounding boxes when labels=true. Coordinates are in the
* captured image's space. Omitted when empty.
*/
annotations?: AnnotationItem[];
imagePath?: string;
imageType?: "png" | "jpeg";
blockedByDialog?: boolean;
browserState?: unknown;
};
/** Read browser-control status for the selected profile. */
export async function browserStatus(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<BrowserStatus> {
return await fetchBrowserJson<BrowserStatus>(withProfilePath(baseUrl, "/", opts?.profile), {
timeoutMs: resolveBrowserClientTimeoutMs(opts, BROWSER_STATUS_REQUEST_TIMEOUT_MS),
});
}
/** Run browser doctor checks for the selected profile. */
export async function browserDoctor(
baseUrl?: string,
opts?: { profile?: string; deep?: boolean },
): 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,
});
}
/** List configured browser profiles and their current status. */
export async function browserProfiles(
baseUrl?: string,
opts?: { timeoutMs?: number },
): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000),
},
);
return res.profiles ?? [];
}
/** Start the selected browser profile. */
export async function browserStart(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
await sendProfilePost(baseUrl, "/start", opts, 15000);
}
/** Stop the selected browser profile. */
export async function browserStop(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
await sendProfilePost(baseUrl, "/stop", opts, 15000);
}
/** Reset the selected managed browser profile directory. */
export async function browserResetProfile(
baseUrl?: string,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
withBaseUrl(baseUrl, `/reset-profile${q}`),
{
method: "POST",
timeoutMs: 20000,
},
);
}
/** Result returned after creating a browser profile. */
export type BrowserCreateProfileResult = {
ok: true;
profile: string;
transport?: BrowserTransport;
cdpPort: number | null;
cdpUrl: string | null;
userDataDir: string | null;
color: string;
isRemote: boolean;
};
/** Create and persist a browser profile. */
export async function browserCreateProfile(
baseUrl: string | undefined,
opts: {
name: string;
color?: string;
cdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session";
},
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(
withBaseUrl(baseUrl, `/profiles/create`),
{
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
userDataDir: opts.userDataDir,
driver: opts.driver,
}),
timeoutMs: 10000,
},
);
}
/** Result returned after deleting a browser profile. */
export type BrowserDeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
/** Delete a configured browser profile. */
export async function browserDeleteProfile(
baseUrl: string | undefined,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
{
method: "DELETE",
timeoutMs: 20000,
},
);
}
/** List tabs for the selected browser profile. */
export async function browserTabs(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<BrowserTab[]> {
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
withProfilePath(baseUrl, "/tabs", opts?.profile),
{
timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000),
},
);
return res.tabs ?? [];
}
/** Open a new tab in the selected browser profile. */
export async function browserOpenTab(
baseUrl: string | undefined,
url: string,
opts?: { profile?: string; label?: string; timeoutMs?: number },
): Promise<BrowserTab> {
return await fetchBrowserJson<BrowserTab>(withProfilePath(baseUrl, "/tabs/open", opts?.profile), {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }),
timeoutMs: resolveBrowserClientTimeoutMs(opts, 15000),
});
}
/** Focus an existing browser tab. */
export async function browserFocusTab(
baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
const body = { targetId };
await sendTabTargetRequest({ baseUrl, path: "/tabs/focus", method: "POST", opts, body });
}
/** Close an existing browser tab. */
export async function browserCloseTab(
baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
const path = `/tabs/${encodeURIComponent(targetId)}`;
await sendTabTargetRequest({ baseUrl, path, method: "DELETE", opts });
}
/** Execute legacy index-based tab actions. */
export async function browserTabAction(
baseUrl: string | undefined,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
profile?: string;
},
): Promise<unknown> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: opts.action,
index: opts.index,
}),
timeoutMs: 10_000,
});
}
/** Capture an ARIA or AI snapshot for the selected tab. */
export async function browserSnapshot(
baseUrl: string | undefined,
opts: {
format?: "aria" | "ai";
targetId?: string;
limit?: number;
maxChars?: number;
refs?: "role" | "aria";
interactive?: boolean;
compact?: boolean;
depth?: number;
selector?: string;
frame?: string;
labels?: boolean;
urls?: boolean;
mode?: "efficient";
profile?: string;
timeoutMs?: number;
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
if (opts.format) {
q.set("format", opts.format);
}
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (typeof opts.limit === "number") {
q.set("limit", String(opts.limit));
}
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
}
if (opts.refs === "aria" || opts.refs === "role") {
q.set("refs", opts.refs);
}
if (typeof opts.interactive === "boolean") {
q.set("interactive", String(opts.interactive));
}
if (typeof opts.compact === "boolean") {
q.set("compact", String(opts.compact));
}
if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) {
q.set("depth", String(opts.depth));
}
if (opts.selector?.trim()) {
q.set("selector", opts.selector.trim());
}
if (opts.frame?.trim()) {
q.set("frame", opts.frame.trim());
}
if (opts.labels === true) {
q.set("labels", "1");
}
if (opts.urls === true) {
q.set("urls", "1");
}
if (opts.mode) {
q.set("mode", opts.mode);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const resolvedTimeoutMs =
clampPositiveTimerTimeoutMs(opts.timeoutMs) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS;
q.set("timeoutMs", String(resolvedTimeoutMs));
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: resolvedTimeoutMs,
});
}
// Actions beyond the basic read-only commands live in client-actions.ts.

View File

@@ -0,0 +1,72 @@
/**
* Browser client response types.
*
* Shared by the browser control client, CLI, and Browser agent tool.
*/
/** Browser transport backing the selected profile. */
export type BrowserTransport = "cdp" | "chrome-mcp";
type BrowserHeadlessSource =
| "request"
| "env"
| "profile"
| "config"
| "linux-display-fallback"
| "default";
/** Browser status response returned by the control server. */
export type BrowserStatus = {
enabled: boolean;
profile?: string;
driver?: "openclaw" | "existing-session";
transport?: BrowserTransport;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
/**
* For Chrome MCP existing-session profiles, true only if a page-level tool
* round-trip (`list_pages`) completes; for managed CDP profiles, mirrors
* `cdpReady`. Distinguishes "transport handshake passed" from "page tools
* are actually usable".
*/
pageReady?: boolean;
pid: number | null;
cdpPort: number | null;
cdpUrl?: string | null;
chosenBrowser: string | null;
detectedBrowser?: string | null;
detectedExecutablePath?: string | null;
detectError?: string | null;
userDataDir: string | null;
color: string;
headless: boolean;
headlessSource?: BrowserHeadlessSource;
noSandbox?: boolean;
executablePath?: string | null;
attachOnly: boolean;
};
/** Browser tab record exposed by tab listing and tab mutation endpoints. */
export type BrowserTab = {
/** Best handle for agents to pass back as targetId: label, then tabId, then raw targetId. */
suggestedTargetId?: string;
targetId: string;
/** Stable, human-friendly tab handle for this profile runtime (for example t1). */
tabId?: string;
/** Optional user-assigned tab label. */
label?: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
};
/** ARIA snapshot node exposed in structured snapshot responses. */
export type SnapshotAriaNode = {
ref: string;
role: string;
name: string;
value?: string;
description?: string;
backendDOMNodeId?: number;
depth: number;
};

View File

@@ -0,0 +1,181 @@
/**
* Browser config mutation helpers.
*
* Persists browser-control credentials and profile config changes through the
* canonical config writer while preserving port/color allocation rules.
*/
import { mutateConfigFile } from "../config/config.js";
import type { BrowserProfileConfig } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { assertCdpEndpointAllowed } from "./cdp.helpers.js";
import { resolveBrowserConfig, type ResolvedBrowserConfig } from "./config.js";
import {
BrowserConflictError,
BrowserResourceExhaustedError,
BrowserValidationError,
} from "./errors.js";
import { allocateCdpPort, allocateColor, getUsedColors, getUsedPorts } from "./profiles.js";
type BrowserControlCredential =
| {
kind: "token";
value: string;
}
| {
kind: "password";
value: string;
};
const cdpPortRange = (resolved: {
controlPort: number;
cdpPortRangeStart?: number;
cdpPortRangeEnd?: number;
}): { start: number; end: number } => {
const start = resolved.cdpPortRangeStart;
const end = resolved.cdpPortRangeEnd;
if (
typeof start === "number" &&
Number.isFinite(start) &&
Number.isInteger(start) &&
typeof end === "number" &&
Number.isFinite(end) &&
Number.isInteger(end) &&
start > 0 &&
end >= start &&
end <= 65535
) {
return { start, end };
}
return deriveDefaultBrowserCdpPortRange(resolved.controlPort);
};
/** Persist the generated browser-control token or password in gateway auth config. */
export async function persistBrowserControlCredential(
credential: BrowserControlCredential,
): Promise<void> {
await mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
draft.gateway = {
...draft.gateway,
auth: {
...draft.gateway?.auth,
[credential.kind]: credential.value,
},
};
},
});
}
/** Create and persist a browser profile config with allocated color and CDP port. */
export async function createBrowserProfileConfig(params: {
name: string;
resolved: ResolvedBrowserConfig;
color?: string;
parsedCdpUrl?: string;
userDataDir?: string;
driver?: "openclaw" | "existing-session";
}): Promise<BrowserProfileConfig | undefined> {
const mutation = await mutateConfigFile<BrowserProfileConfig>({
afterWrite: { mode: "auto" },
mutate: async (draft) => {
const rawDraftBrowser = draft.browser as
| (NonNullable<typeof draft.browser> & { cdpPortRangeEnd?: unknown })
| undefined;
const draftCdpPortRangeEnd =
typeof rawDraftBrowser?.cdpPortRangeEnd === "number"
? rawDraftBrowser.cdpPortRangeEnd
: undefined;
const useRebasedPortRange =
draft.gateway?.port !== undefined ||
draft.browser?.cdpPortRangeStart !== undefined ||
draftCdpPortRangeEnd !== undefined;
const latestResolved = resolveBrowserConfig(
{
...params.resolved,
...draft.browser,
profiles: draft.browser?.profiles ?? params.resolved.profiles,
},
draft,
);
const latestRootResolved = resolveBrowserConfig(draft.browser, draft);
const latestProfileSource = useRebasedPortRange ? latestRootResolved : latestResolved;
const latestProfiles = draft.browser?.profiles ?? {};
if (params.name in latestProfiles || params.name in latestProfileSource.profiles) {
throw new BrowserConflictError(`profile "${params.name}" already exists`);
}
const profileColor =
params.color ?? allocateColor(getUsedColors(latestProfileSource.profiles));
let nextProfileConfig: BrowserProfileConfig;
if (params.parsedCdpUrl) {
try {
await assertCdpEndpointAllowed(params.parsedCdpUrl, latestResolved.ssrfPolicy);
} catch (err) {
throw new BrowserValidationError(formatErrorMessage(err));
}
nextProfileConfig = {
cdpUrl: params.parsedCdpUrl,
...(params.driver ? { driver: params.driver } : {}),
...(params.driver === "existing-session" ? { attachOnly: true } : {}),
color: profileColor,
};
} else if (params.driver === "existing-session") {
nextProfileConfig = {
driver: params.driver,
attachOnly: true,
...(params.userDataDir ? { userDataDir: params.userDataDir } : {}),
color: profileColor,
};
} else {
const usedPorts = getUsedPorts(latestProfileSource.profiles);
const rangeSource = useRebasedPortRange ? latestRootResolved : params.resolved;
const range = cdpPortRange({
controlPort: rangeSource.controlPort,
cdpPortRangeStart: rangeSource.cdpPortRangeStart,
cdpPortRangeEnd: draftCdpPortRangeEnd ?? rangeSource.cdpPortRangeEnd,
});
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new BrowserResourceExhaustedError("no available CDP ports in range");
}
nextProfileConfig = {
cdpPort,
...(params.driver ? { driver: params.driver } : {}),
color: profileColor,
};
}
draft.browser = {
...draft.browser,
profiles: {
...draft.browser?.profiles,
[params.name]: nextProfileConfig,
},
};
return nextProfileConfig;
},
});
return mutation.result;
}
/** Delete a persisted browser profile config by name. */
export async function deleteBrowserProfileConfig(name: string): Promise<void> {
await mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const { [name]: _removed, ...remainingProfiles } = draft.browser?.profiles ?? {};
const nextBrowser = {
...draft.browser,
profiles: remainingProfiles,
};
if (nextBrowser.defaultProfile === name) {
delete nextBrowser.defaultProfile;
}
draft.browser = nextBrowser;
},
});
}

View File

@@ -0,0 +1,16 @@
/**
* Browser runtime config refresh source.
*
* Loads the source-backed runtime config snapshot when available so long-lived
* browser routes can refresh from disk without changing config ownership.
*/
import {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
type OpenClawConfig,
} from "../config/config.js";
/** Load the best available config object for browser route runtime refresh. */
export function loadBrowserConfigForRuntimeRefresh(): OpenClawConfig {
return getRuntimeConfigSourceSnapshot() ?? getRuntimeConfig();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
/**
* Browser config resolution.
*
* Normalizes raw browser config into resolved runtime defaults, profile
* records, SSRF policy, timeouts, headless mode, and managed Chrome settings.
*/
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import {
normalizeOptionalString,
normalizeOptionalTrimmedStringList,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/paths.js";
import {
DEFAULT_BROWSER_CONTROL_PORT,
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
} from "../config/port-defaults.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolveUserPath } from "../utils.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION,
DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
parseBrowserHttpUrl,
redactCdpUrl,
};
export { parseBrowserHttpUrl as parseHttpUrl };
type BrowserSsrFPolicyCompat = NonNullable<BrowserConfig["ssrfPolicy"]> & {
/**
* Legacy raw-config alias. Keep it out of the public BrowserConfig type while
* still accepting old user files until doctor rewrites them.
*/
allowPrivateNetwork?: boolean;
};
/** Browser config after defaults, derived ports, and profile defaults are applied. */
export type ResolvedBrowserConfig = {
enabled: boolean;
evaluateEnabled: boolean;
controlPort: number;
cdpPortRangeStart: number;
cdpPortRangeEnd: number;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
remoteCdpTimeoutMs: number;
remoteCdpHandshakeTimeoutMs: number;
localLaunchTimeoutMs: number;
localCdpReadyTimeoutMs: number;
actionTimeoutMs: number;
color: string;
executablePath?: string;
headless: boolean;
headlessSource?: "config" | "default";
noSandbox: boolean;
attachOnly: boolean;
defaultProfile: string;
profiles: Record<string, BrowserProfileConfig>;
tabCleanup: ResolvedBrowserTabCleanupConfig;
ssrfPolicy?: SsrFPolicy;
extraArgs: string[];
};
/** Normalized tab-cleanup settings for session-owned browser tabs. */
export type ResolvedBrowserTabCleanupConfig = {
enabled: boolean;
idleMinutes: number;
maxTabsPerSession: number;
sweepMinutes: number;
};
/** Runtime browser profile settings resolved from global and profile config. */
export type ResolvedBrowserProfile = {
name: string;
cdpPort: number;
cdpUrl: string;
cdpHost: string;
cdpIsLoopback: boolean;
userDataDir?: string;
mcpCommand?: string;
mcpArgs?: string[];
color: string;
driver: "openclaw" | "existing-session";
executablePath?: string;
headless: boolean;
headlessSource?: "profile" | "config" | "default";
attachOnly: boolean;
};
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
const MAX_BROWSER_STARTUP_TIMEOUT_MS = 120_000;
/** Environment variable that overrides managed Chrome headless mode. */
export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS";
/** Source that determined managed Chrome headless mode. */
export type ManagedBrowserHeadlessSource =
| "request"
| "env"
| "profile"
| "config"
| "linux-display-fallback"
| "default";
type ManagedBrowserHeadlessMode = {
headless: boolean;
source: ManagedBrowserHeadlessSource;
};
/** Inputs used to resolve managed Chrome headless mode. */
export type ManagedBrowserHeadlessOptions = {
headlessOverride?: boolean;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
};
function normalizeHexColor(raw: string | undefined): string {
const value = (raw ?? "").trim();
if (!value) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
const normalized = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
return normalized.toUpperCase();
}
function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function normalizeStartupTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
if (value <= 0) {
return fallback;
}
return Math.min(value, MAX_BROWSER_STARTUP_TIMEOUT_MS);
}
function normalizeNonNegativeInteger(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function normalizePositiveInteger(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value <= 0 ? fallback : value;
}
const MAX_BROWSER_TIMER_MINUTES = Math.floor(MAX_TIMER_TIMEOUT_MS / 60_000);
function normalizeNonNegativeTimerMinutes(raw: number | undefined, fallback: number): number {
return Math.min(normalizeNonNegativeInteger(raw, fallback), MAX_BROWSER_TIMER_MINUTES);
}
function normalizePositiveTimerMinutes(raw: number | undefined, fallback: number): number {
return Math.min(normalizePositiveInteger(raw, fallback), MAX_BROWSER_TIMER_MINUTES);
}
function normalizeExecutablePath(raw: string | undefined): string | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
return undefined;
}
if (!/^~(?=$|[\\/])/.test(value)) {
return value;
}
return path.resolve(value.replace(/^~(?=$|[\\/])/, os.homedir()));
}
function normalizeExistingSessionCdpUrl(
raw: string | undefined,
profileName: string,
): { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean } | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`browser.profiles.${profileName}.cdpUrl must be a valid URL.`);
}
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`browser.profiles.${profileName}.cdpUrl must use http, https, ws, or wss.`);
}
const normalized =
parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString().replace(/\/$/, "")
: parsed.toString();
return {
cdpUrl: normalized,
cdpHost: parsed.hostname,
cdpIsLoopback: isLoopbackHost(parsed.hostname),
};
}
function hasLinuxDisplay(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim());
}
function isLocalManagedProfile(profile: ResolvedBrowserProfile): boolean {
return profile.driver === "openclaw" && profile.cdpIsLoopback && !profile.attachOnly;
}
function resolveBrowserTabCleanupConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserTabCleanupConfig {
const raw = cfg?.tabCleanup;
return {
enabled: raw?.enabled ?? true,
idleMinutes: normalizeNonNegativeTimerMinutes(
raw?.idleMinutes,
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
),
maxTabsPerSession: normalizeNonNegativeInteger(
raw?.maxTabsPerSession,
DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION,
),
sweepMinutes: normalizePositiveTimerMinutes(
raw?.sweepMinutes,
DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES,
),
};
}
function resolveCdpPortRangeStart(
rawStart: number | undefined,
fallbackStart: number,
rangeSpan: number,
): number {
const start =
typeof rawStart === "number" && Number.isFinite(rawStart)
? Math.floor(rawStart)
: fallbackStart;
if (start < 1 || start > 65535) {
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
}
const maxStart = 65535 - rangeSpan;
if (start > maxStart) {
throw new Error(
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
);
}
return start;
}
const normalizeStringList = normalizeOptionalTrimmedStringList;
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const rawPolicy = cfg?.ssrfPolicy as BrowserSsrFPolicyCompat | undefined;
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist);
const hasExplicitPrivateSetting =
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
const resolvedAllowPrivateNetwork =
dangerouslyAllowPrivateNetwork === true || allowPrivateNetwork === true;
if (
!resolvedAllowPrivateNetwork &&
!hasExplicitPrivateSetting &&
!allowedHostnames &&
!hostnameAllowlist
) {
// Keep the default policy object present so CDP guards still enforce
// fail-closed private-network checks on unconfigured installs.
return {};
}
return {
...(resolvedAllowPrivateNetwork ||
dangerouslyAllowPrivateNetwork === false ||
allowPrivateNetwork === false
? { dangerouslyAllowPrivateNetwork: resolvedAllowPrivateNetwork }
: {}),
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
};
}
function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
derivedDefaultCdpPort?: number,
legacyCdpUrl?: string,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START,
color: defaultColor,
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
};
}
return result;
}
function ensureDefaultUserBrowserProfile(
profiles: Record<string, BrowserProfileConfig>,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.user) {
return result;
}
result.user = {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
};
return result;
}
function applyLegacyCdpUrlToExistingSessionDefaultProfile(
profiles: Record<string, BrowserProfileConfig>,
defaultProfile: string,
legacyCdpUrl: string | undefined,
): Record<string, BrowserProfileConfig> {
if (!legacyCdpUrl) {
return profiles;
}
const profile = profiles[defaultProfile];
if (
!profile ||
profile.driver !== "existing-session" ||
normalizeOptionalString(profile.cdpUrl)
) {
return profiles;
}
return {
...profiles,
[defaultProfile]: {
...profile,
cdpUrl: legacyCdpUrl,
},
};
}
/** Resolve raw browser config into runtime browser defaults. */
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED;
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const localLaunchTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localLaunchTimeoutMs,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
);
const localCdpReadyTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localCdpReadyTimeoutMs,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
);
const actionTimeoutMs = normalizeTimeoutMs(
cfg?.actionTimeoutMs,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
const cdpPortRangeStart = resolveCdpPortRangeStart(
cfg?.cdpPortRangeStart,
derivedCdpRange.start,
cdpRangeSpan,
);
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
| {
parsed: URL;
port: number;
normalized: string;
}
| undefined;
if (rawCdpUrl) {
cdpInfo = parseBrowserHttpUrl(rawCdpUrl, "browser.cdpUrl");
} else {
const derivedPort = controlPort + 1;
if (derivedPort > 65535) {
throw new Error(
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
);
}
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
cdpInfo = {
parsed: derived,
port: derivedPort,
normalized: derived.toString().replace(/\/$/, ""),
};
}
const headless = cfg?.headless === true;
const headlessSource = typeof cfg?.headless === "boolean" ? "config" : "default";
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = normalizeExecutablePath(cfg?.executablePath);
const defaultProfileFromConfig = normalizeOptionalString(cfg?.defaultProfile);
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
let profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
const defaultProfile =
defaultProfileFromConfig ??
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
: "user");
profiles = applyLegacyCdpUrlToExistingSessionDefaultProfile(
profiles,
defaultProfile,
rawCdpUrl ? cdpInfo.normalized : undefined,
);
const extraArgs = Array.isArray(cfg?.extraArgs)
? cfg.extraArgs.filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
)
: [];
return {
enabled,
evaluateEnabled,
controlPort,
cdpPortRangeStart,
cdpPortRangeEnd,
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
localLaunchTimeoutMs,
localCdpReadyTimeoutMs,
actionTimeoutMs,
color: defaultColor,
executablePath,
headless,
headlessSource,
noSandbox,
attachOnly,
defaultProfile,
profiles,
tabCleanup: resolveBrowserTabCleanupConfig(cfg),
ssrfPolicy: resolveBrowserSsrFPolicy(cfg),
extraArgs,
};
}
/** Resolve one configured browser profile by name. */
export function resolveProfile(
resolved: ResolvedBrowserConfig,
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) {
return null;
}
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl;
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
const headlessSource =
typeof profile.headless === "boolean" ? "profile" : resolved.headlessSource;
const executablePath = normalizeExecutablePath(profile.executablePath) ?? resolved.executablePath;
if (driver === "existing-session") {
const existingSessionCdp = normalizeExistingSessionCdpUrl(rawProfileUrl, profileName);
return {
name: profileName,
cdpPort: 0,
cdpUrl: existingSessionCdp?.cdpUrl ?? "",
cdpHost: existingSessionCdp?.cdpHost ?? "",
cdpIsLoopback: existingSessionCdp?.cdpIsLoopback ?? true,
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
mcpCommand: normalizeOptionalString(profile.mcpCommand),
mcpArgs: normalizeStringList(profile.mcpArgs) ?? undefined,
color: profile.color,
driver,
executablePath,
headless,
headlessSource,
attachOnly: true,
};
}
const hasStaleWsPath =
rawProfileUrl !== "" &&
cdpPort > 0 &&
/^wss?:\/\//i.test(rawProfileUrl) &&
/\/devtools\/browser\//i.test(rawProfileUrl);
if (hasStaleWsPath) {
const parsed = new URL(rawProfileUrl);
cdpHost = parsed.hostname;
cdpUrl = `${resolved.cdpProtocol}://${cdpHost}:${cdpPort}`;
} else if (rawProfileUrl) {
const parsed = parseBrowserHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname;
// Port precedence: explicit URL port > configured cdpPort > protocol default.
if (parsed.hasExplicitPort) {
cdpPort = parsed.port;
cdpUrl = parsed.normalizedWithPort;
} else if (cdpPort) {
// URL omitted the port but we have an explicit cdpPort — inject it while
// preserving the rest of the URL (path, query, credentials, etc.).
const rebuilt = new URL(rawProfileUrl);
rebuilt.port = String(cdpPort);
cdpUrl = rebuilt.toString().replace(/\/$/, "");
} else {
cdpPort = parsed.port;
cdpUrl = parsed.normalized;
}
} else if (cdpPort) {
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
} else {
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
}
return {
name: profileName,
cdpPort,
cdpUrl,
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
executablePath,
headless,
headlessSource,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
}
/** Resolve effective headless mode for a managed browser profile. */
export function resolveManagedBrowserHeadlessMode(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
params: ManagedBrowserHeadlessOptions = {},
): ManagedBrowserHeadlessMode {
if (!isLocalManagedProfile(profile)) {
return { headless: profile.headless, source: profile.headlessSource ?? "default" };
}
if (typeof params.headlessOverride === "boolean") {
return { headless: params.headlessOverride, source: "request" };
}
const env = params.env ?? process.env;
const platform = params.platform ?? process.platform;
const envHeadless = parseBooleanValue(env[OPENCLAW_BROWSER_HEADLESS_ENV]);
if (envHeadless !== undefined) {
return { headless: envHeadless, source: "env" };
}
const profileHeadlessSource = profile.headlessSource ?? "default";
if (profileHeadlessSource !== "default") {
return { headless: profile.headless, source: profileHeadlessSource };
}
if (platform === "linux" && !hasLinuxDisplay(env)) {
return { headless: true, source: "linux-display-fallback" };
}
return { headless: resolved.headless, source: "default" };
}
/** Return a Linux display error for headed managed Chrome when no display exists. */
export function getManagedBrowserMissingDisplayError(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
params: ManagedBrowserHeadlessOptions = {},
): string | null {
if (!isLocalManagedProfile(profile)) {
return null;
}
const env = params.env ?? process.env;
const platform = params.platform ?? process.platform;
if (platform !== "linux" || hasLinuxDisplay(env)) {
return null;
}
const mode = resolveManagedBrowserHeadlessMode(resolved, profile, { env, platform });
if (mode.headless) {
return null;
}
const sourceHint =
mode.source === "request"
? "request override"
: mode.source === "env"
? `${OPENCLAW_BROWSER_HEADLESS_ENV}=0`
: mode.source === "profile"
? `browser.profiles.${profile.name}.headless=false`
: "browser.headless=false";
return (
`Headed browser start requested for profile "${profile.name}" via ${sourceHint}, ` +
"but no Linux display server was detected ($DISPLAY/$WAYLAND_DISPLAY unset). " +
`Set ${OPENCLAW_BROWSER_HEADLESS_ENV}=1, remove the headed override, or launch under Xvfb.`
);
}

View File

@@ -0,0 +1,38 @@
/**
* Browser default configuration constants.
*
* Shared defaults for config resolution, tool schemas, managed Chrome launch,
* tab cleanup, screenshots, and AI snapshot sizing.
*/
/** Default enabled state for the browser plugin. */
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
/** Default JavaScript evaluation permission for managed browser actions. */
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
/** Default color for the managed OpenClaw browser profile. */
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
/** Default managed profile name shown to users. */
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
/** Default browser profile selected when no profile is requested. */
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
/** Default timeout for browser action execution. */
export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000;
/** Default launch readiness window for managed local Chrome. */
export const DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS = 15_000;
/** Default CDP readiness window after managed Chrome launch. */
export const DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS = 8_000;
/** Default timeout for screenshot capture. */
export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000;
/** Default timeout for snapshot capture. */
export const DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS = 20_000;
/** Default idle age before session tab cleanup can close tabs. */
export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120;
/** Default maximum tracked tabs kept per session. */
export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8;
/** Default interval for tab cleanup sweeps. */
export const DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES = 5;
/** Default maximum AI snapshot text size. */
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 40_000;
/** Default maximum AI snapshot text size in efficient mode. */
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 8_000;
/** Default maximum AI snapshot depth in efficient mode. */
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;

View File

@@ -0,0 +1,477 @@
// Browser tests cover control auth.auto token plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../../test-support.js";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn<() => OpenClawConfig>(),
writeConfigFile: vi.fn<(cfg: OpenClawConfig) => Promise<void>>(async (_cfg) => {}),
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => {
await mocks.writeConfigFile(nextConfig);
}),
mutateConfigFile: vi.fn(
async (params: {
mutate: (draft: OpenClawConfig, context: { snapshot: { path: string } }) => unknown;
}) => {
const draft = structuredClone(mocks.getRuntimeConfig());
const result = await params.mutate(draft, { snapshot: { path: "/tmp/openclaw.json" } });
await mocks.writeConfigFile(draft);
return {
path: "/tmp/openclaw.json",
previousHash: "test-hash",
persistedHash: "test-hash",
snapshot: { path: "/tmp/openclaw.json" },
nextConfig: draft,
result,
attempts: 1,
afterWrite: { mode: "auto" },
followUp: { action: "none" },
};
},
),
resolveGatewayAuth: vi.fn(
({
authConfig,
}: {
authConfig?: NonNullable<NonNullable<OpenClawConfig["gateway"]>["auth"]>;
}) => {
const token =
typeof authConfig?.token === "string"
? authConfig.token
: typeof authConfig?.token === "object"
? undefined
: undefined;
const password = typeof authConfig?.password === "string" ? authConfig.password : undefined;
const mode = authConfig?.mode ?? (password ? "password" : token ? "token" : "token");
return {
mode,
token,
password,
};
},
),
ensureGatewayStartupAuth: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token" as const,
token: "a".repeat(48),
},
},
},
auth: {
mode: "token" as const,
token: "a".repeat(48),
},
generatedToken: "a".repeat(48),
persistedGeneratedToken: true,
})),
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: mocks.getRuntimeConfig,
replaceConfigFile: mocks.replaceConfigFile,
mutateConfigFile: mocks.mutateConfigFile,
}));
vi.mock("../gateway/startup-auth.js", () => ({
ensureGatewayStartupAuth: mocks.ensureGatewayStartupAuth,
}));
vi.mock("../gateway/auth.js", () => ({
resolveGatewayAuth: mocks.resolveGatewayAuth,
}));
function readPersistedConfig(): OpenClawConfig {
const [call] = mocks.writeConfigFile.mock.calls;
if (!call) {
throw new Error("expected persisted config write");
}
const [persistedCfg] = call;
if (!persistedCfg) {
throw new Error("expected persisted config");
}
return persistedCfg;
}
async function expectGeneratedBrowserAuthPersistence(params: {
cfg: OpenClawConfig;
mode: "none" | "trusted-proxy";
generatedAuthField: "token" | "password";
}) {
mocks.getRuntimeConfig.mockReturnValue(params.cfg);
const result = await ensureBrowserControlAuth({ cfg: params.cfg, env: {} as NodeJS.ProcessEnv });
expect(result.generatedToken).toMatch(/^[a-f0-9]{48}$/);
expect(result.auth[params.generatedAuthField]).toBe(result.generatedToken);
expect(result.auth[params.generatedAuthField === "token" ? "password" : "token"]).toBeUndefined();
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persistedCfg = readPersistedConfig();
expect(persistedCfg?.gateway?.auth?.mode).toBe(params.mode);
expect(persistedCfg?.gateway?.auth?.[params.generatedAuthField]).toBe(result.generatedToken);
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
}
async function expectUnresolvedBrowserSecretRefSkipsPersistence(cfg: OpenClawConfig) {
mocks.getRuntimeConfig.mockReturnValue(cfg);
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
}
let ensureBrowserControlAuth: typeof import("./control-auth.js").ensureBrowserControlAuth;
let resolveBrowserControlAuth: typeof import("./control-auth.js").resolveBrowserControlAuth;
describe("ensureBrowserControlAuth", () => {
const expectExplicitModeSkipsAutoAuth = async (mode: "password") => {
const cfg: OpenClawConfig = {
gateway: {
auth: { mode },
},
browser: {
enabled: true,
},
};
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
};
const expectGeneratedTokenPersisted = async (result: {
generatedToken?: string;
auth: { token?: string };
}) => {
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
const ensured = await mocks.ensureGatewayStartupAuth.mock.results[0]?.value;
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: ensured?.cfg,
});
};
beforeAll(async () => {
({ ensureBrowserControlAuth, resolveBrowserControlAuth } = await import("./control-auth.js"));
});
beforeEach(() => {
vi.restoreAllMocks();
mocks.getRuntimeConfig.mockClear();
mocks.writeConfigFile.mockClear();
mocks.mutateConfigFile.mockClear();
mocks.resolveGatewayAuth.mockClear();
mocks.ensureGatewayStartupAuth.mockClear();
});
it("returns existing auth and skips writes", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "already-set",
},
},
};
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "already-set" } });
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("returns only the active credential in password mode", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
token: "inactive-token",
password: "active-password",
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({
password: "active-password",
});
});
it("returns only the resolved active credential when mode is inferred", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "inactive-token",
password: "active-password",
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({
password: "active-password",
});
});
it("returns only the browser token in none mode", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
token: "browser-token",
password: "inactive-password",
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({
token: "browser-token",
});
});
it("returns only the active token in token mode", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "active-token",
password: "inactive-password",
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({
token: "active-token",
});
});
it("returns only the browser password in trusted-proxy mode", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
token: "inactive-token",
password: "browser-password",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({
password: "browser-password",
});
});
it("does not accept an inactive token in trusted-proxy mode", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
token: "inactive-token",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
};
expect(resolveBrowserControlAuth(cfg, {} as NodeJS.ProcessEnv)).toEqual({});
});
it("auto-generates and persists a token when auth is missing", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
mocks.getRuntimeConfig.mockReturnValue({
browser: {
enabled: true,
},
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
await expectGeneratedTokenPersisted(result);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("skips auto-generation in test env", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { NODE_ENV: "test" } as NodeJS.ProcessEnv,
});
expect(result).toEqual({ auth: {} });
expect(mocks.getRuntimeConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("respects explicit password mode", async () => {
await expectExplicitModeSkipsAutoAuth("password");
});
it("auto-generates and persists browser auth token in none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: { mode: "none" },
},
browser: {
enabled: true,
},
};
await expectGeneratedBrowserAuthPersistence({
cfg,
mode: "none",
generatedAuthField: "token",
});
});
it("does not persist over unresolved token SecretRef in none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
token: { source: "env", provider: "default", id: "BROWSER_TOKEN" },
},
},
browser: {
enabled: true,
},
};
await expectUnresolvedBrowserSecretRefSkipsPersistence(cfg);
});
it("still auto-generates in none mode when only password SecretRef is set", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
password: { source: "env", provider: "default", id: "INACTIVE_PASSWORD" },
},
},
browser: {
enabled: true,
},
};
await expectGeneratedBrowserAuthPersistence({
cfg,
mode: "none",
generatedAuthField: "token",
});
});
it("auto-generates in trusted-proxy mode and persists browser auth password", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: { mode: "trusted-proxy", trustedProxy: { userHeader: "x-forwarded-user" } },
},
browser: {
enabled: true,
},
};
await expectGeneratedBrowserAuthPersistence({
cfg,
mode: "trusted-proxy",
generatedAuthField: "password",
});
});
it("still auto-generates in trusted-proxy mode when only token SecretRef is set", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
token: { source: "env", provider: "default", id: "INACTIVE_TOKEN" },
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
browser: {
enabled: true,
},
};
await expectGeneratedBrowserAuthPersistence({
cfg,
mode: "trusted-proxy",
generatedAuthField: "password",
});
});
it("does not persist over unresolved password SecretRef in trusted-proxy mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
password: { source: "env", provider: "default", id: "BROWSER_PASSWORD" },
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
browser: {
enabled: true,
},
};
await expectUnresolvedBrowserSecretRefSkipsPersistence(cfg);
});
it("reuses auth from latest config snapshot", async () => {
const cfg: OpenClawConfig = {
browser: {
enabled: true,
},
};
mocks.getRuntimeConfig.mockReturnValue({
gateway: {
auth: {
token: "latest-token",
},
},
browser: {
enabled: true,
},
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is unresolved", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
browser: {
enabled: true,
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
mocks.getRuntimeConfig.mockReturnValue(cfg);
mocks.ensureGatewayStartupAuth.mockRejectedValueOnce(new Error("MISSING_GW_TOKEN"));
await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
/MISSING_GW_TOKEN/i,
);
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,86 @@
// Browser tests cover control auth plugin behavior.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../test-support.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
describe("ensureBrowserControlAuth", () => {
async function expectNoAutoGeneratedAuth(cfg: OpenClawConfig): Promise<void> {
const result = await ensureBrowserControlAuth({
cfg,
env: { NODE_ENV: "test" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
expect(result.auth.password).toBeUndefined();
}
it.each([
{
name: "trusted-proxy",
cfg: {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["192.168.1.1"],
},
} satisfies OpenClawConfig,
},
{
name: "password",
cfg: {
gateway: {
auth: {
mode: "password",
},
},
} satisfies OpenClawConfig,
},
{
name: "none",
cfg: {
gateway: {
auth: {
mode: "none",
},
},
} satisfies OpenClawConfig,
},
{
name: "token",
cfg: {
gateway: {
auth: {
mode: "token",
},
},
} satisfies OpenClawConfig,
},
])("skips auto-generation in test mode for $name mode", async ({ cfg }) => {
await expectNoAutoGeneratedAuth(cfg);
});
describe("token mode", () => {
it("should return existing token if configured", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "existing-token-123",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBe("existing-token-123");
});
});
});

View File

@@ -0,0 +1,191 @@
/**
* Browser control authentication helpers.
*
* Resolves browser-control auth from Gateway auth config and auto-generates a
* token/password for local control when safe to persist one.
*/
import crypto from "node:crypto";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
import { persistBrowserControlCredential } from "./config-mutations.js";
/** Auth material accepted by browser-control HTTP middleware and clients. */
export type BrowserControlAuth = {
token?: string;
password?: string;
};
/** Resolve browser-control auth material from config and environment. */
export function resolveBrowserControlAuth(
cfg?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): BrowserControlAuth {
const auth = resolveGatewayAuth({
authConfig: cfg?.gateway?.auth,
env,
tailscaleMode: cfg?.gateway?.tailscale?.mode,
});
const token = normalizeOptionalString(auth.token) ?? "";
const password = normalizeOptionalString(auth.password) ?? "";
const mode = auth.mode;
switch (mode) {
case "password":
case "trusted-proxy":
return { password: password || undefined };
case "token":
case "none":
return { token: token || undefined };
default:
return {};
}
}
/** Return true when startup may auto-generate browser-control auth. */
export function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV);
if (nodeEnv === "test") {
return false;
}
const vitest = normalizeLowercaseStringOrEmpty(env.VITEST);
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
return false;
}
return true;
}
function hasExplicitNonStringGatewayCredentialForMode(params: {
cfg?: OpenClawConfig;
mode: "none" | "trusted-proxy";
}): boolean {
const { cfg, mode } = params;
const auth = cfg?.gateway?.auth;
if (!auth) {
return false;
}
if (mode === "none") {
return auth.token != null && typeof auth.token !== "string";
}
return auth.password != null && typeof auth.password !== "string";
}
function generateBrowserControlToken(): string {
return crypto.randomBytes(24).toString("hex");
}
async function generateAndPersistBrowserControlToken(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const token = generateBrowserControlToken();
await persistBrowserControlCredential({ kind: "token", value: token });
// Re-read to stay consistent with any concurrent config writer.
const persistedAuth = resolveBrowserControlAuth(getRuntimeConfig(), params.env);
if (persistedAuth.token || persistedAuth.password) {
return {
auth: persistedAuth,
generatedToken: persistedAuth.token === token ? token : undefined,
};
}
return { auth: { token }, generatedToken: token };
}
async function generateAndPersistBrowserControlPassword(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const password = generateBrowserControlToken();
await persistBrowserControlCredential({ kind: "password", value: password });
// Re-read to stay consistent with any concurrent config writer.
const persistedAuth = resolveBrowserControlAuth(getRuntimeConfig(), params.env);
if (persistedAuth.token || persistedAuth.password) {
return {
auth: persistedAuth,
generatedToken: persistedAuth.password === password ? password : undefined,
};
}
return { auth: { password }, generatedToken: password };
}
/** Ensure browser-control auth exists, generating and persisting it when allowed. */
export async function ensureBrowserControlAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const env = params.env ?? process.env;
const auth = resolveBrowserControlAuth(params.cfg, env);
if (auth.token || auth.password) {
return { auth };
}
if (!shouldAutoGenerateBrowserAuth(env)) {
return { auth };
}
// Respect explicit password mode even if currently unset.
if (params.cfg.gateway?.auth?.mode === "password") {
return { auth };
}
// Re-read latest config to avoid racing with concurrent config writers.
const latestCfg = getRuntimeConfig();
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
if (latestAuth.token || latestAuth.password) {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "password") {
return { auth: latestAuth };
}
const latestMode = latestCfg.gateway?.auth?.mode;
if (latestMode === "none" || latestMode === "trusted-proxy") {
if (
hasExplicitNonStringGatewayCredentialForMode({
cfg: latestCfg,
mode: latestMode,
})
) {
// Avoid silently overwriting SecretRef-style gateway auth inputs with generated plaintext.
// Startup will fail closed if no resolved browser auth is available.
return { auth: latestAuth };
}
if (latestMode === "trusted-proxy") {
// gateway.auth.mode=trusted-proxy must never be persisted with gateway.auth.token.
// Persist a browser-only shared secret through gateway.auth.password instead so
// out-of-process loopback clients can resolve it from config/env.
return await generateAndPersistBrowserControlPassword({ cfg: latestCfg, env });
}
return await generateAndPersistBrowserControlToken({ cfg: latestCfg, env });
}
const ensured = await ensureGatewayStartupAuth({
cfg: latestCfg,
env,
persist: true,
});
const ensuredAuth = {
token: ensured.auth.token,
password: ensured.auth.password,
};
return {
auth: ensuredAuth,
generatedToken: ensured.generatedToken,
};
}

View File

@@ -0,0 +1,68 @@
// Browser tests cover control service.plugin disabled plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ generatedToken: false })),
createBrowserRuntimeState: vi.fn(async () => ({ ok: true })),
loadConfig: vi.fn(() => ({
browser: {
enabled: true,
},
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
})),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
getRuntimeConfig: mocks.loadConfig,
loadConfig: mocks.loadConfig,
};
});
vi.mock("./config.js", () => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlPort: 18791,
profiles: { openclaw: { cdpPort: 18800 } },
})),
}));
vi.mock("./control-auth.js", () => ({
ensureBrowserControlAuth: mocks.ensureBrowserControlAuth,
}));
vi.mock("./runtime-lifecycle.js", () => ({
createBrowserRuntimeState: mocks.createBrowserRuntimeState,
stopBrowserRuntime: vi.fn(async () => {}),
}));
vi.mock("./server-context.js", () => ({
createBrowserRouteContext: vi.fn(),
}));
const { startBrowserControlServiceFromConfig } = await import("../control-service.js");
vi.doUnmock("./server-context.js");
describe("startBrowserControlServiceFromConfig", () => {
beforeEach(() => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.createBrowserRuntimeState.mockClear();
mocks.loadConfig.mockClear();
});
it("does not start the default service when the browser plugin is disabled", async () => {
const started = await startBrowserControlServiceFromConfig();
expect(started).toBeNull();
expect(mocks.ensureBrowserControlAuth).not.toHaveBeenCalled();
expect(mocks.createBrowserRuntimeState).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,10 @@
/**
* Browser control service barrel.
*
* Re-exports the background control service and shared control-state helpers
* used by the plugin entrypoint, Gateway proxy, and tests.
*/
export {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "../control-service.js";

View File

@@ -0,0 +1,96 @@
/**
* Browser mutation CSRF guard.
*
* Blocks browser-control mutation requests from browser-like cross-site
* contexts while allowing CLI, Gateway, and local service clients.
*/
import type { NextFunction, Request, Response } from "express";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isLoopbackHost } from "../gateway/net.js";
function firstHeader(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
function isMutatingMethod(method: string): boolean {
const m = (method || "").trim().toUpperCase();
return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE";
}
function isLoopbackUrl(value: string): boolean {
const v = value.trim();
if (!v || v === "null") {
return false;
}
try {
const parsed = new URL(v);
return isLoopbackHost(parsed.hostname);
} catch {
return false;
}
}
/** Return true when a request should be rejected as browser-originated CSRF. */
export function shouldRejectBrowserMutation(params: {
method: string;
origin?: string;
referer?: string;
secFetchSite?: string;
}): boolean {
if (!isMutatingMethod(params.method)) {
return false;
}
// Strong signal when present: browser says this is cross-site.
// Avoid being overly clever with "same-site" since localhost vs 127.0.0.1 may differ.
const secFetchSite = normalizeLowercaseStringOrEmpty(params.secFetchSite);
if (secFetchSite === "cross-site") {
return true;
}
const origin = (params.origin ?? "").trim();
if (origin) {
return !isLoopbackUrl(origin);
}
const referer = (params.referer ?? "").trim();
if (referer) {
return !isLoopbackUrl(referer);
}
// Non-browser clients (curl/undici/Node) typically send no Origin/Referer.
return false;
}
/** Create middleware that rejects unsafe browser-control mutations. */
export function browserMutationGuardMiddleware(): (
req: Request,
res: Response,
next: NextFunction,
) => void {
return (req: Request, res: Response, next: NextFunction) => {
// OPTIONS is used for CORS preflight. Even if cross-origin, the preflight isn't mutating.
const method = (req.method || "").trim().toUpperCase();
if (method === "OPTIONS") {
return next();
}
const origin = firstHeader(req.headers.origin);
const referer = firstHeader(req.headers.referer);
const secFetchSite = firstHeader(req.headers["sec-fetch-site"]);
if (
shouldRejectBrowserMutation({
method,
origin,
referer,
secFetchSite,
})
) {
res.status(403).send("Forbidden");
return;
}
next();
};
}

View File

@@ -0,0 +1,158 @@
// Browser tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { buildBrowserDoctorReport } from "./doctor.js";
function collectWarningCheckIds(checks: readonly { id: string; status: string }[]): string[] {
const ids: string[] = [];
for (const check of checks) {
if (check.status === "warn") {
ids.push(check.id);
}
}
return ids;
}
describe("buildBrowserDoctorReport", () => {
it("reports stopped managed browsers as launchable diagnostics", () => {
const report = buildBrowserDoctorReport({
platform: "linux",
env: { DISPLAY: ":99" },
uid: 1000,
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: 18800,
cdpUrl: "http://127.0.0.1:18800",
chosenBrowser: null,
detectedBrowser: "chromium",
detectedExecutablePath: "/usr/bin/chromium",
detectError: null,
userDataDir: "/tmp/openclaw",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
},
});
expect(report.ok).toBe(true);
const websocketCheck = report.checks.find((check) => check.id === "cdp-websocket");
expect(websocketCheck?.status).toBe("info");
expect(websocketCheck?.summary).toBe("Browser is launchable but not running");
});
it("fails when Chrome MCP attach is not ready", () => {
const report = buildBrowserDoctorReport({
status: {
enabled: true,
profile: "user",
driver: "existing-session",
transport: "chrome-mcp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: null,
cdpUrl: null,
chosenBrowser: null,
detectedBrowser: null,
detectedExecutablePath: null,
detectError: null,
userDataDir: null,
color: "#00AA00",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: true,
},
});
expect(report.ok).toBe(false);
const attachCheck = report.checks.find((check) => check.id === "attach-target");
expect(attachCheck?.status).toBe("fail");
});
it("keeps managed launch warnings non-fatal", () => {
const report = buildBrowserDoctorReport({
platform: "linux",
env: {},
uid: 0,
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: 18800,
cdpUrl: "http://127.0.0.1:18800",
chosenBrowser: null,
detectedBrowser: null,
detectedExecutablePath: null,
detectError: null,
userDataDir: "/tmp/openclaw",
color: "#FF4500",
headless: false,
headlessSource: "config",
noSandbox: false,
executablePath: null,
attachOnly: false,
},
});
expect(report.ok).toBe(true);
expect(collectWarningCheckIds(report.checks)).toEqual([
"managed-executable",
"display",
"linux-sandbox",
]);
const displayCheck = report.checks.find((check) => check.id === "display");
expect(displayCheck?.summary).toBe(
"No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (config)",
);
});
it("reports Linux no-display fallback without a display warning", () => {
const report = buildBrowserDoctorReport({
platform: "linux",
env: {},
uid: 1000,
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: 18800,
cdpUrl: "http://127.0.0.1:18800",
chosenBrowser: null,
detectedBrowser: "chrome",
detectedExecutablePath: "/usr/bin/google-chrome-stable",
detectError: null,
userDataDir: "/tmp/openclaw",
color: "#FF4500",
headless: true,
headlessSource: "linux-display-fallback",
noSandbox: false,
executablePath: null,
attachOnly: false,
},
});
const headlessCheck = report.checks.find((check) => check.id === "headless-mode");
expect(headlessCheck?.status).toBe("pass");
expect(report.checks.find((check) => check.id === "display")).toBeUndefined();
});
});

View File

@@ -0,0 +1,156 @@
/**
* Browser doctor report builder.
*
* Turns BrowserStatus into profile-aware diagnostic checks and fix hints for
* CLI, tool, and HTTP doctor responses.
*/
import type { BrowserStatus, BrowserTransport } from "./client.types.js";
type BrowserDoctorCheckStatus = "pass" | "warn" | "fail" | "info";
/** One browser doctor check result. */
export type BrowserDoctorCheck = {
id: string;
label: string;
status: BrowserDoctorCheckStatus;
summary: string;
fixHint?: string;
};
/** Browser doctor report returned by browser-control clients. */
export type BrowserDoctorReport = {
ok: boolean;
profile: string;
transport: BrowserTransport;
checks: BrowserDoctorCheck[];
status: BrowserStatus;
};
/** Build a browser doctor report from a status response and environment facts. */
export function buildBrowserDoctorReport(params: {
status: BrowserStatus;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
uid?: number;
}): BrowserDoctorReport {
const status = params.status;
const checks: BrowserDoctorCheck[] = [];
const transport: BrowserTransport = status.transport === "chrome-mcp" ? "chrome-mcp" : "cdp";
checks.push({
id: "plugin-enabled",
label: "Browser plugin",
status: status.enabled ? "pass" : "fail",
summary: status.enabled ? "enabled" : "disabled",
...(status.enabled ? {} : { fixHint: "Enable the browser plugin and restart the Gateway." }),
});
checks.push({
id: "profile",
label: "Profile",
status: "pass",
summary: `${status.profile ?? "openclaw"} via ${transport}`,
});
if (transport === "chrome-mcp") {
checks.push({
id: "attach-target",
label: "Existing browser attach",
status: status.running ? "pass" : "fail",
summary: status.running
? "Chrome MCP target is reachable"
: "Chrome MCP target is not reachable",
...(status.running
? {}
: {
fixHint:
"Keep the matching Chromium browser running, enable remote debugging in chrome://inspect, and accept the attach prompt.",
}),
});
} else {
checks.push({
id: "managed-executable",
label: "Chromium executable",
status: status.detectError ? "fail" : status.detectedExecutablePath ? "pass" : "warn",
summary: status.detectError
? status.detectError
: status.detectedExecutablePath
? `${status.detectedBrowser ?? "chromium"} at ${status.detectedExecutablePath}`
: "No Chromium executable detected",
...(status.detectedExecutablePath || status.detectError
? {}
: { fixHint: "Install Chrome/Chromium/Brave/Edge or set browser.executablePath." }),
});
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const uid = params.uid ?? process.getuid?.();
const missingDisplay =
platform === "linux" && !status.headless && !env.DISPLAY && !env.WAYLAND_DISPLAY;
if (status.headlessSource === "linux-display-fallback") {
checks.push({
id: "headless-mode",
label: "Headless mode",
status: "pass",
summary: "Linux no-display fallback selected headless mode",
});
}
if (missingDisplay) {
checks.push({
id: "display",
label: "Display",
status: "warn",
summary: `No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (${status.headlessSource ?? "unknown"})`,
fixHint:
"Use a desktop session, Xvfb, set OPENCLAW_BROWSER_HEADLESS=1, or remove the headed override.",
});
}
if (platform === "linux" && uid === 0 && !status.noSandbox) {
checks.push({
id: "linux-sandbox",
label: "Linux sandbox",
status: "warn",
summary: "Gateway is running as root while browser.noSandbox is false",
fixHint: "Set browser.noSandbox: true for container/root Chromium runtimes.",
});
}
checks.push({
id: "cdp-http",
label: "CDP HTTP",
status: status.cdpHttp ? "pass" : status.running ? "fail" : "info",
summary: status.cdpHttp
? "CDP HTTP endpoint is reachable"
: status.running
? "CDP HTTP endpoint is not reachable"
: "Browser is not currently running",
...(status.cdpHttp || !status.running
? {}
: {
fixHint: "Run openclaw browser start or inspect browser.cdpUrl/CDP port reachability.",
}),
});
checks.push({
id: "cdp-websocket",
label: "CDP WebSocket",
status: status.cdpReady ? "pass" : status.running ? "fail" : "info",
summary: status.cdpReady
? "CDP WebSocket is reachable"
: status.running
? "CDP WebSocket is not reachable"
: "Browser is launchable but not running",
...(status.cdpReady || !status.running
? {}
: { fixHint: "Check Chrome launch logs, stale locks, proxy env, and port conflicts." }),
});
}
return {
ok: checks.every((check) => check.status !== "fail"),
profile: status.profile ?? "openclaw",
transport,
checks,
status,
};
}

View File

@@ -0,0 +1,13 @@
// Browser tests cover errors plugin behavior.
import { describe, expect, it } from "vitest";
import { BrowserTabNotFoundError } from "./errors.js";
describe("BrowserTabNotFoundError", () => {
it("teaches agents that bare numbers are not stable tab targets", () => {
const err = new BrowserTabNotFoundError({ input: "2" });
expect(err.message).toBe(
'tab not found: browser tab "2" not found. Numeric values are not tab targets; use a stable tab id like "t1", a label, or a raw targetId. For positional selection, use "openclaw browser tab select 2".',
);
});
});

View File

@@ -0,0 +1,121 @@
/**
* Browser domain errors.
*
* Provides HTTP-mappable error classes and stable blocked-policy messages used
* by route handlers, clients, and Gateway proxy code.
*/
/** Stable message for blocked CDP endpoint configuration. */
export const BROWSER_ENDPOINT_BLOCKED_MESSAGE = "browser endpoint blocked by policy";
/** Stable message for blocked page navigation targets. */
export const BROWSER_NAVIGATION_BLOCKED_MESSAGE = "browser navigation blocked by policy";
/** Base browser error carrying an HTTP status code. */
export class BrowserError extends Error {
status: number;
constructor(message: string, status = 500, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
this.status = status;
}
}
/**
* Raised when a browser CDP endpoint (the cdpUrl itself) fails the
* configured SSRF policy. Distinct from a blocked navigation target so
* callers see "fix your browser endpoint config" rather than "fix your
* navigation URL".
*/
export class BrowserCdpEndpointBlockedError extends BrowserError {
constructor(options?: ErrorOptions) {
super(BROWSER_ENDPOINT_BLOCKED_MESSAGE, 400, options);
}
}
/** Validation failure for browser route or config input. */
export class BrowserValidationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
/** Raised when a target id prefix matches multiple tabs. */
export class BrowserTargetAmbiguousError extends BrowserError {
constructor(message = "ambiguous target id prefix", options?: ErrorOptions) {
super(message, 409, options);
}
}
/** Raised when a requested browser tab cannot be resolved. */
export class BrowserTabNotFoundError extends BrowserError {
constructor(inputOrMessage?: string | { input?: string }, options?: ErrorOptions) {
const input =
typeof inputOrMessage === "object" ? inputOrMessage.input?.trim() : inputOrMessage?.trim();
const message = input
? /^\d+$/.test(input)
? `tab not found: browser tab "${input}" not found. Numeric values are not tab targets; use a stable tab id like "t1", a label, or a raw targetId. For positional selection, use "openclaw browser tab select ${input}".`
: `tab not found: browser tab "${input}" not found. Use action=tabs and pass suggestedTargetId, tabId, label, or raw targetId.`
: "tab not found";
super(message, 404, options);
}
}
/** Raised when a requested browser profile does not exist. */
export class BrowserProfileNotFoundError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 404, options);
}
}
/** Raised when a browser config mutation conflicts with existing state. */
export class BrowserConflictError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
/** Raised when a browser profile cannot be reset by the current driver. */
export class BrowserResetUnsupportedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
/** Raised when a profile is configured but not currently reachable. */
export class BrowserProfileUnavailableError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
/** Raised when browser resource allocation, such as CDP ports, is exhausted. */
export class BrowserResourceExhaustedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 507, options);
}
}
/** Map browser-domain errors to HTTP response details. */
export function toBrowserErrorResponse(err: unknown): {
status: number;
message: string;
} | null {
if (err instanceof BrowserError) {
return { status: err.status, message: err.message };
}
if (err instanceof Error && err.name === "BlockedBrowserTargetError") {
return { status: 409, message: err.message };
}
if (err instanceof Error && err.name === "SsrFBlockedError") {
// SsrFBlockedError from this point is from a navigation-target check
// (assertBrowserNavigationAllowed / resolvePinnedHostnameWithPolicy on a
// requested URL). CDP endpoint blocks are rethrown as
// BrowserCdpEndpointBlockedError by assertCdpEndpointAllowed and handled
// by the BrowserError branch above.
return { status: 400, message: BROWSER_NAVIGATION_BLOCKED_MESSAGE };
}
if (err instanceof Error && err.name === "InvalidBrowserNavigationUrlError") {
return { status: 400, message: err.message };
}
return null;
}

View File

@@ -0,0 +1,63 @@
// Browser tests cover evaluate source normalization.
import { describe, expect, it } from "vitest";
import { normalizeBrowserEvaluateFunctionSource } from "./evaluate-source.js";
describe("normalizeBrowserEvaluateFunctionSource", () => {
it("preserves function sources", () => {
expect(normalizeBrowserEvaluateFunctionSource("() => document.title")).toBe(
"() => document.title",
);
expect(normalizeBrowserEvaluateFunctionSource("async (el) => el.textContent")).toBe(
"async (el) => el.textContent",
);
});
it("wraps expressions as page functions", () => {
expect(normalizeBrowserEvaluateFunctionSource("document.title")).toBe(
[
"() => {",
"const __openclawEvaluateExpressionResult = (document.title);",
'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult() : __openclawEvaluateExpressionResult;',
"}",
].join("\n"),
);
});
it("preserves function-valued expression invocation", () => {
expect(normalizeBrowserEvaluateFunctionSource("extractTitle")).toBe(
[
"() => {",
"const __openclawEvaluateExpressionResult = (extractTitle);",
'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult() : __openclawEvaluateExpressionResult;',
"}",
].join("\n"),
);
expect(normalizeBrowserEvaluateFunctionSource("extractText", { argumentName: "el" })).toBe(
[
"(el) => {",
"const __openclawEvaluateExpressionResult = (extractText);",
'return typeof __openclawEvaluateExpressionResult === "function" ? __openclawEvaluateExpressionResult(el) : __openclawEvaluateExpressionResult;',
"}",
].join("\n"),
);
});
it("wraps statement bodies as async page functions", () => {
expect(normalizeBrowserEvaluateFunctionSource("const x = 41; return x + 1;")).toBe(
"async () => {\nconst x = 41; return x + 1;\n}",
);
expect(
normalizeBrowserEvaluateFunctionSource(
"function helper() { return 41; }\nreturn helper() + 1;",
),
).toBe("async () => {\nfunction helper() { return 41; }\nreturn helper() + 1;\n}");
});
it("wraps statement bodies as async element functions when a ref is present", () => {
expect(
normalizeBrowserEvaluateFunctionSource("const text = el.textContent; return text;", {
argumentName: "el",
}),
).toBe("async (el) => {\nconst text = el.textContent; return text;\n}");
});
});

View File

@@ -0,0 +1,42 @@
// Normalizes browser evaluate input while preserving the public `fn` string API.
import { Script } from "node:vm";
const FUNCTION_SOURCE_PATTERN = /^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/;
const EXPRESSION_RESULT_NAME = "__openclawEvaluateExpressionResult";
function canParseAsExpression(source: string): boolean {
try {
// Parse only. Browser evaluate input is intentionally executable, but the
// Gateway should not run caller-provided page JavaScript while routing.
const parseExpression = new Script(`"use strict";\n(${source});`);
void parseExpression;
return true;
} catch {
return false;
}
}
export function normalizeBrowserEvaluateFunctionSource(
source: string,
params: { argumentName?: string } = {},
): string {
const trimmed = source.trim();
if (!trimmed) {
return "";
}
if (FUNCTION_SOURCE_PATTERN.test(trimmed) && canParseAsExpression(trimmed)) {
return trimmed;
}
const argumentName = params.argumentName;
const args = argumentName ? `(${argumentName})` : "()";
if (canParseAsExpression(trimmed)) {
const invokeArgs = argumentName ? argumentName : "";
return [
`${args} => {`,
`const ${EXPRESSION_RESULT_NAME} = (${trimmed});`,
`return typeof ${EXPRESSION_RESULT_NAME} === "function" ? ${EXPRESSION_RESULT_NAME}(${invokeArgs}) : ${EXPRESSION_RESULT_NAME};`,
"}",
].join("\n");
}
return `async ${args} => {\n${trimmed}\n}`;
}

View File

@@ -0,0 +1,42 @@
/**
* Browser form field normalization.
*
* Converts model/client fill field payloads into the compact field shape used
* by Playwright and Chrome MCP fill actions.
*/
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { BrowserFormField } from "./client-actions.types.js";
/** Default field type for fill actions when no type is provided. */
export const DEFAULT_FILL_FIELD_TYPE = "text";
type BrowserFormFieldValue = NonNullable<BrowserFormField["value"]>;
function normalizeBrowserFormFieldRef(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function normalizeBrowserFormFieldType(value: unknown): string {
const type = normalizeOptionalString(value) ?? "";
return type || DEFAULT_FILL_FIELD_TYPE;
}
/** Normalize a form field value to the types accepted by fill actions. */
export function normalizeBrowserFormFieldValue(value: unknown): BrowserFormFieldValue | undefined {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? value
: undefined;
}
/** Normalize one form field descriptor from untrusted route/tool input. */
export function normalizeBrowserFormField(
record: Record<string, unknown>,
): BrowserFormField | null {
const ref = normalizeBrowserFormFieldRef(record.ref);
if (!ref) {
return null;
}
const type = normalizeBrowserFormFieldType(record.type);
const value = normalizeBrowserFormFieldValue(record.value);
return value === undefined ? { ref, type } : { ref, type, value };
}

View File

@@ -0,0 +1,71 @@
/**
* Browser HTTP auth helpers.
*
* Validates browser-control bearer token or password headers with constant-time
* comparison against resolved control auth.
*/
import type { IncomingMessage } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function firstHeaderValue(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
function parseBearerToken(authorization: string): string | undefined {
if (!normalizeLowercaseStringOrEmpty(authorization).startsWith("bearer ")) {
return undefined;
}
const token = authorization.slice(7).trim();
return token || undefined;
}
function parseBasicPassword(authorization: string): string | undefined {
if (!normalizeLowercaseStringOrEmpty(authorization).startsWith("basic ")) {
return undefined;
}
const encoded = authorization.slice(6).trim();
if (!encoded) {
return undefined;
}
try {
const decoded = Buffer.from(encoded, "base64").toString("utf8");
const sep = decoded.indexOf(":");
if (sep < 0) {
return undefined;
}
const password = decoded.slice(sep + 1).trim();
return password || undefined;
} catch {
return undefined;
}
}
/** Return true when request headers satisfy browser-control auth. */
export function isAuthorizedBrowserRequest(
req: IncomingMessage,
auth: { token?: string; password?: string },
): boolean {
const authorization = firstHeaderValue(req.headers.authorization).trim();
if (auth.token) {
const bearer = parseBearerToken(authorization);
if (bearer && safeEqualSecret(bearer, auth.token)) {
return true;
}
}
if (auth.password) {
const passwordHeader = firstHeaderValue(req.headers["x-openclaw-password"]).trim();
if (passwordHeader && safeEqualSecret(passwordHeader, auth.password)) {
return true;
}
const basicPassword = parseBasicPassword(authorization);
if (basicPassword && safeEqualSecret(basicPassword, auth.password)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,27 @@
/**
* Local browser control dispatch bridge.
*
* Starts the browser control service when needed and dispatches requests
* through the in-process route dispatcher for local Browser tool calls.
*/
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "./control-service.js";
import {
createBrowserRouteDispatcher,
type BrowserDispatchRequest,
type BrowserDispatchResponse,
} from "./routes/dispatcher.js";
/** Dispatch one browser-control request through the local in-process router. */
export async function dispatchBrowserControlRequest(
req: BrowserDispatchRequest,
): Promise<BrowserDispatchResponse> {
const started = await startBrowserControlServiceFromConfig();
if (!started) {
throw new Error("browser control disabled");
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
return await dispatcher.dispatch(req);
}

View File

@@ -0,0 +1,338 @@
// Browser tests cover navigation guard plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationRedirectChainAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
requiresInspectableBrowserNavigationRedirects,
} from "./navigation-guard.js";
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;
return vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
}
const PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] as const;
describe("browser navigation guard", () => {
beforeEach(() => {
for (const key of PROXY_ENV_KEYS) {
vi.stubEnv(key, "");
}
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("blocks private loopback URLs by default", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows about:blank", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "about:blank",
}),
).resolves.toBeUndefined();
});
it("blocks file URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "file:///etc/passwd",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks data URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "data:text/html,<h1>owned</h1>",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks javascript URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "javascript:alert(1)",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks non-blank about URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "about:srcdoc",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("allows blocked hostnames when explicitly allowed", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationAllowed({
url: "http://agent.internal:3000",
ssrfPolicy: {
allowedHostnames: ["agent.internal"],
},
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("agent.internal", { all: true });
});
it("blocks hostnames that resolve to private addresses by default", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows hostnames that resolve to public addresses", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
});
it("blocks hostname navigation when strict SSRF policy is explicitly configured", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
}),
).rejects.toThrow(/dns rebinding protections are unavailable/i);
expect(lookupFn).not.toHaveBeenCalled();
});
it("allows hostname navigation when the default strict policy object is present", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
ssrfPolicy: {},
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
});
it("allows explicitly allowed hostnames in strict mode", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://agent.internal",
lookupFn,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["agent.internal"],
},
}),
).resolves.toBeUndefined();
});
it("allows wildcard-allowlisted hostnames in strict mode", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://sub.example.com",
lookupFn,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.example.com"],
},
}),
).resolves.toBeUndefined();
});
it("does not treat the bare suffix as matching a wildcard allowlist entry", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.example.com"],
},
}),
).rejects.toThrow(/dns rebinding protections are unavailable/i);
expect(lookupFn).not.toHaveBeenCalled();
});
it("does not match sibling domains against wildcard allowlist entries", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://evil-example.com",
lookupFn,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.example.com"],
},
}),
).rejects.toThrow(/dns rebinding protections are unavailable/i);
expect(lookupFn).not.toHaveBeenCalled();
});
it("treats bracketed IPv6 URL hostnames as IP literals in strict mode", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "https://[2606:4700:4700::1111]/",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
}),
).resolves.toBeUndefined();
});
it("allows public navigation when only Gateway env proxy is configured", async () => {
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
});
it("blocks explicit browser proxy routing in strict SSRF mode", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
browserProxyMode: "explicit-browser-proxy",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
expect(lookupFn).not.toHaveBeenCalled();
});
it("allows explicit browser proxy routing when private-network mode is enabled", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
browserProxyMode: "explicit-browser-proxy",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
}),
).resolves.toBeUndefined();
});
it("rejects invalid URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "not a url",
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("validates final network URLs after navigation", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationResultAllowed({
url: "http://private.test",
lookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("ignores non-network browser-internal final URLs", async () => {
await expect(
assertBrowserNavigationResultAllowed({
url: "chrome-error://chromewebdata/",
}),
).resolves.toBeUndefined();
});
it("blocks final hostname URLs in strict mode after navigation", async () => {
await expect(
assertBrowserNavigationResultAllowed({
url: "https://example.com/final",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
}),
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
});
it("blocks private intermediate redirect hops", async () => {
const publicLookup = createLookupFn("93.184.216.34");
const privateLookup = createLookupFn("127.0.0.1");
const finalRequest = {
url: () => "https://public.example/final",
redirectedFrom: () => ({
url: () => "http://private.example/internal",
redirectedFrom: () => ({
url: () => "https://public.example/start",
redirectedFrom: () => null,
}),
}),
};
await expect(
assertBrowserNavigationRedirectChainAllowed({
request: finalRequest,
lookupFn: vi.fn(async (hostname: string) =>
hostname === "private.example"
? privateLookup(hostname, { all: true })
: publicLookup(hostname, { all: true }),
) as unknown as LookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows redirect chains when every hop is public", async () => {
const lookupFn = createLookupFn("93.184.216.34");
const finalRequest = {
url: () => "https://public.example/final",
redirectedFrom: () => ({
url: () => "https://public.example/middle",
redirectedFrom: () => ({
url: () => "https://public.example/start",
redirectedFrom: () => null,
}),
}),
};
await expect(
assertBrowserNavigationRedirectChainAllowed({
request: finalRequest,
lookupFn,
}),
).resolves.toBeUndefined();
});
it("requires redirect-hop inspection only in explicit strict mode", () => {
expect(requiresInspectableBrowserNavigationRedirects()).toBe(false);
expect(
requiresInspectableBrowserNavigationRedirects({ dangerouslyAllowPrivateNetwork: false }),
).toBe(true);
expect(requiresInspectableBrowserNavigationRedirects({ allowPrivateNetwork: true })).toBe(
false,
);
});
});

View File

@@ -0,0 +1,216 @@
/**
* Browser navigation SSRF guard.
*
* Validates page navigation URLs and redirect chains before or after browser
* navigation while accounting for browser proxy routing.
*/
import { isIP } from "node:net";
import {
isPrivateNetworkAllowedByPolicy,
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js";
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]);
function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean {
// Keep non-network navigation explicit; about:blank is the only allowed bootstrap URL.
return SAFE_NON_NETWORK_URLS.has(parsed.href);
}
function normalizeNavigationUrl(url: string): string {
return url.trim();
}
/** Raised when a browser navigation URL fails syntax or policy validation. */
export class InvalidBrowserNavigationUrlError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidBrowserNavigationUrlError";
}
}
/** Policy inputs applied to browser page navigation checks. */
export type BrowserNavigationPolicyOptions = {
ssrfPolicy?: SsrFPolicy;
browserProxyMode?: BrowserNavigationProxyMode;
};
/** Describes whether the browser itself is routing page traffic through a proxy. */
export type BrowserNavigationProxyMode = "direct" | "explicit-browser-proxy";
/** Minimal request shape used to walk browser redirect chains. */
export type BrowserNavigationRequestLike = {
url(): string;
redirectedFrom(): BrowserNavigationRequestLike | null;
};
/** Build a navigation-policy object while omitting default direct proxy mode. */
export function withBrowserNavigationPolicy(
ssrfPolicy?: SsrFPolicy,
opts?: { browserProxyMode?: BrowserNavigationProxyMode },
): BrowserNavigationPolicyOptions {
return {
...(ssrfPolicy ? { ssrfPolicy } : {}),
...(opts?.browserProxyMode && opts.browserProxyMode !== "direct"
? { browserProxyMode: opts.browserProxyMode }
: {}),
};
}
/** Return true when strict policy requires redirect-chain inspection. */
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
return ssrfPolicy?.dangerouslyAllowPrivateNetwork === false;
}
/** Return true when a URL needs redirect inspection under strict policy. */
export function requiresInspectableBrowserNavigationRedirectsForUrl(
url: string,
ssrfPolicy?: SsrFPolicy,
): boolean {
if (!requiresInspectableBrowserNavigationRedirects(ssrfPolicy)) {
return false;
}
try {
const parsed = new URL(url);
return NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol);
} catch {
return false;
}
}
function isIpLiteralHostname(hostname: string): boolean {
return isIP(normalizeHostname(hostname)) !== 0;
}
function isExplicitlyAllowedBrowserHostname(hostname: string, ssrfPolicy?: SsrFPolicy): boolean {
const normalizedHostname = normalizeHostname(hostname);
const exactMatches = ssrfPolicy?.allowedHostnames ?? [];
if (exactMatches.some((value) => normalizeHostname(value) === normalizedHostname)) {
return true;
}
const hostnameAllowlist = (ssrfPolicy?.hostnameAllowlist ?? [])
.map((pattern) => normalizeHostname(pattern))
.filter(Boolean);
return hostnameAllowlist.length > 0
? matchesHostnameAllowlist(normalizedHostname, hostnameAllowlist)
: false;
}
/** Assert that a requested browser navigation URL is policy-allowed. */
export async function assertBrowserNavigationAllowed(
opts: {
url: string;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const rawUrl = normalizeNavigationUrl(opts.url);
if (!rawUrl) {
throw new InvalidBrowserNavigationUrlError("url is required");
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`);
}
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
if (isAllowedNonNetworkNavigationUrl(parsed)) {
return;
}
throw new InvalidBrowserNavigationUrlError(
`Navigation blocked: unsupported protocol "${parsed.protocol}"`,
);
}
// Browser proxy routing hides the final connect target from this process.
// Only block when the browser profile is known to be proxy-routed; Gateway
// provider proxy env alone is not proof of browser page proxy behavior.
if (
opts.browserProxyMode === "explicit-browser-proxy" &&
!isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)
) {
throw new InvalidBrowserNavigationUrlError(
"Navigation blocked: strict browser SSRF policy cannot be enforced while this browser profile is proxy-routed",
);
}
// Browser navigations happen in Chromium's network stack, not Node's. In
// strict mode, a hostname-based URL would be resolved twice by different
// resolvers, so Node-side pinning cannot guarantee the browser connects to
// the same address that passed policy checks.
if (
opts.ssrfPolicy &&
opts.ssrfPolicy.dangerouslyAllowPrivateNetwork === false &&
!isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) &&
!isIpLiteralHostname(parsed.hostname) &&
!isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy)
) {
throw new InvalidBrowserNavigationUrlError(
"Navigation blocked: strict browser SSRF policy requires an IP-literal URL because browser DNS rebinding protections are unavailable for hostname-based navigation",
);
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
lookupFn: opts.lookupFn,
policy: opts.ssrfPolicy,
});
}
/**
* Best-effort post-navigation guard for final page URLs.
* Only validates network URLs (http/https) and about:blank to avoid false
* positives on browser-internal error pages (e.g. chrome-error://). In strict
* mode this intentionally re-applies the hostname gate after redirects.
*/
export async function assertBrowserNavigationResultAllowed(
opts: {
url: string;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const rawUrl = normalizeNavigationUrl(opts.url);
if (!rawUrl) {
return;
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return;
}
if (
NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) ||
isAllowedNonNetworkNavigationUrl(parsed)
) {
await assertBrowserNavigationAllowed(opts);
}
}
/** Assert that every URL in a browser redirect chain is policy-allowed. */
export async function assertBrowserNavigationRedirectChainAllowed(
opts: {
request?: BrowserNavigationRequestLike | null;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const chain: string[] = [];
let current = opts.request ?? null;
while (current) {
chain.push(current.url());
current = current.redirectedFrom();
}
for (const url of chain.toReversed()) {
await assertBrowserNavigationAllowed({
url,
lookupFn: opts.lookupFn,
ssrfPolicy: opts.ssrfPolicy,
browserProxyMode: opts.browserProxyMode,
});
}
}

View File

@@ -0,0 +1,22 @@
/**
* Atomic output write helper.
*
* Ensures browser-generated files are written through a sibling temp path under
* an allowed output root before becoming visible at the target path.
*/
import { writeExternalFileWithinRoot } from "../sdk-security-runtime.js";
import { ensureOutputDirectory } from "./output-directories.js";
/** Write a file inside an output root via a caller-provided temp writer. */
export async function writeViaSiblingTempPath(params: {
rootDir: string;
targetPath: string;
writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> {
await ensureOutputDirectory(params.rootDir);
await writeExternalFileWithinRoot({
rootDir: params.rootDir,
path: params.targetPath,
write: params.writeTemp,
});
}

View File

@@ -0,0 +1,56 @@
// Browser tests cover output directories plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureOutputDirectory } from "./output-directories.js";
async function withTempDir<T>(run: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-output-dir-test-"));
try {
return await run(tempDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function expectPathMissing(targetPath: string): Promise<void> {
let error: unknown;
try {
await fs.access(targetPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
describe("ensureOutputDirectory", () => {
it("creates nested missing output directories", async () => {
await withTempDir(async (tempDir) => {
const outputDir = path.join(tempDir, "reports", "downloads");
await ensureOutputDirectory(outputDir);
const stat = await fs.stat(outputDir);
expect(stat.isDirectory()).toBe(true);
});
});
it.runIf(process.platform !== "win32")(
"rejects symlinked output directory ancestors",
async () => {
await withTempDir(async (tempDir) => {
const outsideDir = path.join(tempDir, "outside");
await fs.mkdir(outsideDir);
const symlinkDir = path.join(tempDir, "downloads");
await fs.symlink(outsideDir, symlinkDir);
await expect(ensureOutputDirectory(path.join(symlinkDir, "nested"))).rejects.toThrow(
/symlink|output directory/i,
);
await expectPathMissing(path.join(outsideDir, "nested"));
});
},
);
});

View File

@@ -0,0 +1,42 @@
/**
* Browser output directory helper.
*
* Creates absolute output directories while handling macOS system symlink
* aliases such as /tmp and /var safely.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { ensureAbsoluteDirectory } from "../sdk-security-runtime.js";
async function resolveSystemDirectoryAlias(dirPath: string): Promise<string> {
// macOS exposes /tmp and /var as fixed system symlinks into /private.
// Canonicalize only those roots before rejecting symlinks below them.
for (const aliasRoot of ["/tmp", "/var"]) {
if (dirPath !== aliasRoot && !dirPath.startsWith(`${aliasRoot}${path.sep}`)) {
continue;
}
try {
const stat = await fs.lstat(aliasRoot);
if (!stat.isSymbolicLink()) {
return dirPath;
}
return path.join(await fs.realpath(aliasRoot), path.relative(aliasRoot, dirPath));
} catch {
return dirPath;
}
}
return dirPath;
}
/** Ensure an absolute browser output directory exists and is safe to use. */
export async function ensureOutputDirectory(dirPath: string): Promise<void> {
const result = await ensureAbsoluteDirectory(
await resolveSystemDirectoryAlias(path.resolve(dirPath)),
{
scopeLabel: "output directory",
},
);
if (!result.ok) {
throw result.error;
}
}

Some files were not shown because too many files have changed in this diff Show More