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
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:
6
extensions/browser/browser-bridge.ts
Normal file
6
extensions/browser/browser-bridge.ts
Normal 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";
|
||||
5
extensions/browser/browser-cdp.ts
Normal file
5
extensions/browser/browser-cdp.ts
Normal 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";
|
||||
20
extensions/browser/browser-config.ts
Normal file
20
extensions/browser/browser-config.ts
Normal 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";
|
||||
10
extensions/browser/browser-control-auth.ts
Normal file
10
extensions/browser/browser-control-auth.ts
Normal 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";
|
||||
10
extensions/browser/browser-doctor.ts
Normal file
10
extensions/browser/browser-doctor.ts
Normal 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";
|
||||
10
extensions/browser/browser-host-inspection.ts
Normal file
10
extensions/browser/browser-host-inspection.ts
Normal 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";
|
||||
6
extensions/browser/browser-maintenance.ts
Normal file
6
extensions/browser/browser-maintenance.ts
Normal 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";
|
||||
19
extensions/browser/browser-profiles.ts
Normal file
19
extensions/browser/browser-profiles.ts
Normal 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";
|
||||
21
extensions/browser/cli-metadata.ts
Normal file
21
extensions/browser/cli-metadata.ts
Normal 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"] },
|
||||
);
|
||||
},
|
||||
});
|
||||
336
extensions/browser/index.test.ts
Normal file
336
extensions/browser/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
22
extensions/browser/index.ts
Normal file
22
extensions/browser/index.ts
Normal 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,
|
||||
});
|
||||
18
extensions/browser/openclaw.plugin.json
Normal file
18
extensions/browser/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
24
extensions/browser/package.json
Normal file
24
extensions/browser/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
217
extensions/browser/plugin-registration.ts
Normal file
217
extensions/browser/plugin-registration.ts
Normal 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());
|
||||
}
|
||||
9
extensions/browser/register.runtime.ts
Normal file
9
extensions/browser/register.runtime.ts
Normal 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";
|
||||
95
extensions/browser/runtime-api.ts
Normal file
95
extensions/browser/runtime-api.ts
Normal 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";
|
||||
58
extensions/browser/setup-api.ts
Normal file
58
extensions/browser/setup-api.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
});
|
||||
89
extensions/browser/skills/browser-automation/SKILL.md
Normal file
89
extensions/browser/skills/browser-automation/SKILL.md
Normal 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.
|
||||
83
extensions/browser/src/browser-control-state.ts
Normal file
83
extensions/browser/src/browser-control-state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
11
extensions/browser/src/browser-gateway-contract.ts
Normal file
11
extensions/browser/src/browser-gateway-contract.ts
Normal 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;
|
||||
100
extensions/browser/src/browser-runtime.ts
Normal file
100
extensions/browser/src/browser-runtime.ts
Normal 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";
|
||||
650
extensions/browser/src/browser-tool.actions.ts
Normal file
650
extensions/browser/src/browser-tool.actions.ts
Normal 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 };
|
||||
67
extensions/browser/src/browser-tool.runtime.ts
Normal file
67
extensions/browser/src/browser-tool.runtime.ts
Normal 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";
|
||||
33
extensions/browser/src/browser-tool.schema.test.ts
Normal file
33
extensions/browser/src/browser-tool.schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
164
extensions/browser/src/browser-tool.schema.ts
Normal file
164
extensions/browser/src/browser-tool.schema.ts
Normal 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),
|
||||
});
|
||||
1962
extensions/browser/src/browser-tool.test.ts
Normal file
1962
extensions/browser/src/browser-tool.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1044
extensions/browser/src/browser-tool.ts
Normal file
1044
extensions/browser/src/browser-tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
58
extensions/browser/src/browser/act-policy.ts
Normal file
58
extensions/browser/src/browser/act-policy.ts
Normal 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));
|
||||
}
|
||||
43
extensions/browser/src/browser/bridge-auth-registry.ts
Normal file
43
extensions/browser/src/browser/bridge-auth-registry.ts
Normal 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);
|
||||
}
|
||||
125
extensions/browser/src/browser/bridge-server.auth.test.ts
Normal file
125
extensions/browser/src/browser/bridge-server.auth.test.ts
Normal 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=");
|
||||
});
|
||||
});
|
||||
168
extensions/browser/src/browser/bridge-server.ts
Normal file
168
extensions/browser/src/browser/bridge-server.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
54
extensions/browser/src/browser/browser-proxy-mode.test.ts
Normal file
54
extensions/browser/src/browser/browser-proxy-mode.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
65
extensions/browser/src/browser/browser-proxy-mode.ts
Normal file
65
extensions/browser/src/browser/browser-proxy-mode.ts
Normal 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";
|
||||
}
|
||||
268
extensions/browser/src/browser/browser-utils.test.ts
Normal file
268
extensions/browser/src/browser/browser-utils.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
524
extensions/browser/src/browser/cdp-proxy-bypass.test.ts
Normal file
524
extensions/browser/src/browser/cdp-proxy-bypass.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
200
extensions/browser/src/browser/cdp-proxy-bypass.ts
Normal file
200
extensions/browser/src/browser/cdp-proxy-bypass.ts
Normal 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);
|
||||
}
|
||||
40
extensions/browser/src/browser/cdp-reachability-policy.ts
Normal file
40
extensions/browser/src/browser/cdp-reachability-policy.ts
Normal 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;
|
||||
30
extensions/browser/src/browser/cdp-target-filter.ts
Normal file
30
extensions/browser/src/browser/cdp-target-filter.ts
Normal 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);
|
||||
}
|
||||
100
extensions/browser/src/browser/cdp-timeouts.ts
Normal file
100
extensions/browser/src/browser/cdp-timeouts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
445
extensions/browser/src/browser/cdp.helpers.fuzz.test.ts
Normal file
445
extensions/browser/src/browser/cdp.helpers.fuzz.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
590
extensions/browser/src/browser/cdp.helpers.internal.test.ts
Normal file
590
extensions/browser/src/browser/cdp.helpers.internal.test.ts
Normal 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;
|
||||
}
|
||||
328
extensions/browser/src/browser/cdp.helpers.test.ts
Normal file
328
extensions/browser/src/browser/cdp.helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
539
extensions/browser/src/browser/cdp.helpers.ts
Normal file
539
extensions/browser/src/browser/cdp.helpers.ts
Normal 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");
|
||||
}
|
||||
948
extensions/browser/src/browser/cdp.internal.test.ts
Normal file
948
extensions/browser/src/browser/cdp.internal.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
226
extensions/browser/src/browser/cdp.screenshot-params.test.ts
Normal file
226
extensions/browser/src/browser/cdp.screenshot-params.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
774
extensions/browser/src/browser/cdp.test.ts
Normal file
774
extensions/browser/src/browser/cdp.test.ts
Normal 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, "");
|
||||
}
|
||||
});
|
||||
928
extensions/browser/src/browser/cdp.ts
Normal file
928
extensions/browser/src/browser/cdp.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
12
extensions/browser/src/browser/chrome-mcp.runtime.ts
Normal file
12
extensions/browser/src/browser/chrome-mcp.runtime.ts
Normal 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");
|
||||
}
|
||||
69
extensions/browser/src/browser/chrome-mcp.snapshot.test.ts
Normal file
69
extensions/browser/src/browser/chrome-mcp.snapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
193
extensions/browser/src/browser/chrome-mcp.snapshot.ts
Normal file
193
extensions/browser/src/browser/chrome-mcp.snapshot.ts
Normal 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 };
|
||||
}
|
||||
1552
extensions/browser/src/browser/chrome-mcp.test.ts
Normal file
1552
extensions/browser/src/browser/chrome-mcp.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1798
extensions/browser/src/browser/chrome-mcp.ts
Normal file
1798
extensions/browser/src/browser/chrome-mcp.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
});
|
||||
}
|
||||
165
extensions/browser/src/browser/chrome.default-browser.test.ts
Normal file
165
extensions/browser/src/browser/chrome.default-browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
429
extensions/browser/src/browser/chrome.diagnostics.ts
Normal file
429
extensions/browser/src/browser/chrome.diagnostics.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
822
extensions/browser/src/browser/chrome.executables.ts
Normal file
822
extensions/browser/src/browser/chrome.executables.ts
Normal 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;
|
||||
}
|
||||
1946
extensions/browser/src/browser/chrome.internal.test.ts
Normal file
1946
extensions/browser/src/browser/chrome.internal.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
194
extensions/browser/src/browser/chrome.profile-decoration.ts
Normal file
194
extensions/browser/src/browser/chrome.profile-decoration.ts
Normal 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);
|
||||
}
|
||||
1051
extensions/browser/src/browser/chrome.test.ts
Normal file
1051
extensions/browser/src/browser/chrome.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1183
extensions/browser/src/browser/chrome.ts
Normal file
1183
extensions/browser/src/browser/chrome.ts
Normal file
File diff suppressed because it is too large
Load Diff
87
extensions/browser/src/browser/chrome.version.test.ts
Normal file
87
extensions/browser/src/browser/chrome.version.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
180
extensions/browser/src/browser/client-actions-core.ts
Normal file
180
extensions/browser/src/browser/client-actions-core.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
57
extensions/browser/src/browser/client-actions-observe.ts
Normal file
57
extensions/browser/src/browser/client-actions-observe.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
31
extensions/browser/src/browser/client-actions-types.ts
Normal file
31
extensions/browser/src/browser/client-actions-types.ts
Normal 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[];
|
||||
};
|
||||
16
extensions/browser/src/browser/client-actions-url.ts
Normal file
16
extensions/browser/src/browser/client-actions-url.ts
Normal 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}`;
|
||||
}
|
||||
13
extensions/browser/src/browser/client-actions.ts
Normal file
13
extensions/browser/src/browser/client-actions.ts
Normal 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";
|
||||
105
extensions/browser/src/browser/client-actions.types.ts
Normal file
105
extensions/browser/src/browser/client-actions.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
421
extensions/browser/src/browser/client-fetch.ts
Normal file
421
extensions/browser/src/browser/client-fetch.ts
Normal 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;
|
||||
}
|
||||
458
extensions/browser/src/browser/client.test.ts
Normal file
458
extensions/browser/src/browser/client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
416
extensions/browser/src/browser/client.ts
Normal file
416
extensions/browser/src/browser/client.ts
Normal 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.
|
||||
72
extensions/browser/src/browser/client.types.ts
Normal file
72
extensions/browser/src/browser/client.types.ts
Normal 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;
|
||||
};
|
||||
181
extensions/browser/src/browser/config-mutations.ts
Normal file
181
extensions/browser/src/browser/config-mutations.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
16
extensions/browser/src/browser/config-refresh-source.ts
Normal file
16
extensions/browser/src/browser/config-refresh-source.ts
Normal 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();
|
||||
}
|
||||
1102
extensions/browser/src/browser/config.test.ts
Normal file
1102
extensions/browser/src/browser/config.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
657
extensions/browser/src/browser/config.ts
Normal file
657
extensions/browser/src/browser/config.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
38
extensions/browser/src/browser/constants.ts
Normal file
38
extensions/browser/src/browser/constants.ts
Normal 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;
|
||||
477
extensions/browser/src/browser/control-auth.auto-token.test.ts
Normal file
477
extensions/browser/src/browser/control-auth.auto-token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
extensions/browser/src/browser/control-auth.test.ts
Normal file
86
extensions/browser/src/browser/control-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
191
extensions/browser/src/browser/control-auth.ts
Normal file
191
extensions/browser/src/browser/control-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
10
extensions/browser/src/browser/control-service.ts
Normal file
10
extensions/browser/src/browser/control-service.ts
Normal 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";
|
||||
96
extensions/browser/src/browser/csrf.ts
Normal file
96
extensions/browser/src/browser/csrf.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
158
extensions/browser/src/browser/doctor.test.ts
Normal file
158
extensions/browser/src/browser/doctor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
156
extensions/browser/src/browser/doctor.ts
Normal file
156
extensions/browser/src/browser/doctor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
extensions/browser/src/browser/errors.test.ts
Normal file
13
extensions/browser/src/browser/errors.test.ts
Normal 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".',
|
||||
);
|
||||
});
|
||||
});
|
||||
121
extensions/browser/src/browser/errors.ts
Normal file
121
extensions/browser/src/browser/errors.ts
Normal 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;
|
||||
}
|
||||
63
extensions/browser/src/browser/evaluate-source.test.ts
Normal file
63
extensions/browser/src/browser/evaluate-source.test.ts
Normal 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}");
|
||||
});
|
||||
});
|
||||
42
extensions/browser/src/browser/evaluate-source.ts
Normal file
42
extensions/browser/src/browser/evaluate-source.ts
Normal 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}`;
|
||||
}
|
||||
42
extensions/browser/src/browser/form-fields.ts
Normal file
42
extensions/browser/src/browser/form-fields.ts
Normal 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 };
|
||||
}
|
||||
71
extensions/browser/src/browser/http-auth.ts
Normal file
71
extensions/browser/src/browser/http-auth.ts
Normal 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;
|
||||
}
|
||||
27
extensions/browser/src/browser/local-dispatch.runtime.ts
Normal file
27
extensions/browser/src/browser/local-dispatch.runtime.ts
Normal 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);
|
||||
}
|
||||
338
extensions/browser/src/browser/navigation-guard.test.ts
Normal file
338
extensions/browser/src/browser/navigation-guard.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
216
extensions/browser/src/browser/navigation-guard.ts
Normal file
216
extensions/browser/src/browser/navigation-guard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
extensions/browser/src/browser/output-atomic.ts
Normal file
22
extensions/browser/src/browser/output-atomic.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
56
extensions/browser/src/browser/output-directories.test.ts
Normal file
56
extensions/browser/src/browser/output-directories.test.ts
Normal 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"));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
42
extensions/browser/src/browser/output-directories.ts
Normal file
42
extensions/browser/src/browser/output-directories.ts
Normal 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
Reference in New Issue
Block a user