mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
refactor: switch browser ownership to bundled plugin
This commit is contained in:
@@ -42,6 +42,44 @@ openclaw browser --browser-profile openclaw snapshot
|
||||
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||
Gateway.
|
||||
|
||||
## Plugin control
|
||||
|
||||
The default `browser` tool is now a bundled plugin that ships enabled by
|
||||
default. That means you can disable or replace it without removing the rest of
|
||||
OpenClaw's plugin system:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
browser: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Disable the bundled plugin before installing another plugin that provides the
|
||||
same `browser` tool name. The default browser experience needs both:
|
||||
|
||||
- `plugins.entries.browser.enabled` not disabled
|
||||
- `browser.enabled=true`
|
||||
|
||||
If you turn off only the plugin, the bundled browser CLI (`openclaw browser`),
|
||||
gateway method (`browser.request`), agent tool, and default browser control
|
||||
service all disappear together. Your `browser.*` config stays intact for a
|
||||
replacement plugin to reuse.
|
||||
|
||||
The bundled browser plugin also owns the browser runtime implementation now.
|
||||
Core keeps only shared Plugin SDK helpers plus compatibility re-exports for
|
||||
older internal import paths. In practice, removing or replacing
|
||||
`extensions/browser` removes the browser feature set instead of leaving a
|
||||
second core-owned runtime behind.
|
||||
|
||||
Browser config changes still require a Gateway restart so the bundled plugin
|
||||
can re-register its browser service with the new settings.
|
||||
|
||||
## Profiles: `openclaw` vs `user`
|
||||
|
||||
- `openclaw`: managed, isolated browser (no extension required).
|
||||
|
||||
@@ -104,6 +104,7 @@ and the [Plugin SDK Overview](/plugins/sdk-overview).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Other">
|
||||
- `browser` — bundled browser plugin for the browser tool, `openclaw browser` CLI, `browser.request` gateway method, browser runtime, and default browser control service (enabled by default; disable before replacing it)
|
||||
- `copilot-proxy` — VS Code Copilot Proxy bridge (disabled by default)
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
34
extensions/browser/src/browser/bridge-auth-registry.ts
Normal file
34
extensions/browser/src/browser/bridge-auth-registry.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type BridgeAuth = {
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
// In-process registry for loopback-only bridge servers that require auth, but
|
||||
// are addressed via dynamic ephemeral ports (e.g. sandbox browser bridge).
|
||||
const authByPort = new Map<number, BridgeAuth>();
|
||||
|
||||
export function setBridgeAuthForPort(port: number, auth: BridgeAuth): void {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return;
|
||||
}
|
||||
const token = typeof auth.token === "string" ? auth.token.trim() : "";
|
||||
const password = typeof auth.password === "string" ? auth.password.trim() : "";
|
||||
authByPort.set(port, {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBridgeAuthForPort(port: number): BridgeAuth | undefined {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return authByPort.get(port);
|
||||
}
|
||||
|
||||
export function deleteBridgeAuthForPort(port: number): void {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return;
|
||||
}
|
||||
authByPort.delete(port);
|
||||
}
|
||||
146
extensions/browser/src/browser/bridge-server.ts
Normal file
146
extensions/browser/src/browser/bridge-server.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
type ProfileContext,
|
||||
} from "./server-context.js";
|
||||
import {
|
||||
installBrowserAuthMiddleware,
|
||||
installBrowserCommonMiddleware,
|
||||
} from "./server-middleware.js";
|
||||
|
||||
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",
|
||||
});
|
||||
if (params.password?.trim()) {
|
||||
hash.set("password", params.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>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}): 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);
|
||||
|
||||
if (params.resolveSandboxNoVncToken) {
|
||||
app.get("/sandbox/novnc", (req, res) => {
|
||||
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 = typeof req.query?.token === "string" ? req.query.token.trim() : "";
|
||||
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 authToken = params.authToken?.trim() || undefined;
|
||||
const authPassword = params.authPassword?.trim() || undefined;
|
||||
if (!authToken && !authPassword) {
|
||||
throw new Error("bridge server requires auth (authToken/authPassword missing)");
|
||||
}
|
||||
installBrowserAuthMiddleware(app, { token: authToken, password: authPassword });
|
||||
|
||||
const state: BrowserServerState = {
|
||||
server: null as unknown as Server,
|
||||
port,
|
||||
resolved: params.resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
}
|
||||
151
extensions/browser/src/browser/cdp-proxy-bypass.ts
Normal file
151
extensions/browser/src/browser/cdp-proxy-bypass.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 { 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 noProxyAlreadyCoversLocalhost(): boolean {
|
||||
const current = process.env.NO_PROXY || process.env.no_proxy || "";
|
||||
return (
|
||||
current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]")
|
||||
);
|
||||
}
|
||||
|
||||
export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await withNoProxyForCdpUrl("http://127.0.0.1", fn);
|
||||
}
|
||||
|
||||
function isLoopbackCdpUrl(url: string): boolean {
|
||||
try {
|
||||
return isLoopbackHost(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type NoProxySnapshot = {
|
||||
noProxy: string | undefined;
|
||||
noProxyLower: string | undefined;
|
||||
applied: 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 current = noProxy || noProxyLower || "";
|
||||
const applied = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES;
|
||||
process.env.NO_PROXY = applied;
|
||||
process.env.no_proxy = applied;
|
||||
this.snapshot = { noProxy, noProxyLower, applied };
|
||||
}
|
||||
|
||||
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, applied } = this.snapshot;
|
||||
const currentNoProxy = process.env.NO_PROXY;
|
||||
const currentNoProxyLower = process.env.no_proxy;
|
||||
const untouched =
|
||||
currentNoProxy === applied &&
|
||||
(currentNoProxyLower === applied || currentNoProxyLower === undefined);
|
||||
if (untouched) {
|
||||
if (noProxy !== undefined) {
|
||||
process.env.NO_PROXY = noProxy;
|
||||
} else {
|
||||
delete process.env.NO_PROXY;
|
||||
}
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
56
extensions/browser/src/browser/cdp-timeouts.ts
Normal file
56
extensions/browser/src/browser/cdp-timeouts.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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_EXIT_TIMEOUT_MS = 5000;
|
||||
export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000;
|
||||
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;
|
||||
|
||||
export const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
|
||||
export const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
|
||||
export 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;
|
||||
|
||||
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
export function resolveCdpReachabilityTimeouts(params: {
|
||||
profileIsLoopback: boolean;
|
||||
timeoutMs?: number;
|
||||
remoteHttpTimeoutMs: number;
|
||||
remoteHandshakeTimeoutMs: number;
|
||||
}): { httpTimeoutMs: number; wsTimeoutMs: number } {
|
||||
const normalized = normalizeTimeoutMs(params.timeoutMs);
|
||||
if (params.profileIsLoopback) {
|
||||
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) {
|
||||
return {
|
||||
httpTimeoutMs: Math.max(normalized, params.remoteHttpTimeoutMs),
|
||||
wsTimeoutMs: Math.max(normalized * 2, params.remoteHandshakeTimeoutMs),
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpTimeoutMs: params.remoteHttpTimeoutMs,
|
||||
wsTimeoutMs: params.remoteHandshakeTimeoutMs,
|
||||
};
|
||||
}
|
||||
280
extensions/browser/src/browser/cdp.helpers.ts
Normal file
280
extensions/browser/src/browser/cdp.helpers.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import WebSocket from "ws";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
|
||||
|
||||
export { isLoopbackHost };
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
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(":", "")}`);
|
||||
}
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
|
||||
if (typeof cdpUrl !== "string") {
|
||||
return cdpUrl;
|
||||
}
|
||||
const trimmed = cdpUrl.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
|
||||
} catch {
|
||||
return redactSensitiveText(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
export type CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
sessionId?: string,
|
||||
) => Promise<unknown>;
|
||||
|
||||
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.toLowerCase() === "authorization",
|
||||
);
|
||||
if (hasAuthHeader) {
|
||||
return mergedHeaders;
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||||
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return mergedHeaders;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
const send: CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
sessionId?: string,
|
||||
) => {
|
||||
const id = nextId++;
|
||||
const msg = { id, method, params, sessionId };
|
||||
ws.send(JSON.stringify(msg));
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const closeWithError = (err: Error) => {
|
||||
for (const [, p] of pending) {
|
||||
p.reject(err);
|
||||
}
|
||||
pending.clear();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("error", (err) => {
|
||||
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
|
||||
if (typeof parsed.id !== "number") {
|
||||
return;
|
||||
}
|
||||
const p = pending.get(parsed.id);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
pending.delete(parsed.id);
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetchCdpChecked(url, timeoutMs, init);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchCdpChecked(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||
try {
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await withNoProxyForCdpUrl(url, () =>
|
||||
fetch(url, { ...init, headers, signal: ctrl.signal }),
|
||||
);
|
||||
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 res;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOk(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
): Promise<void> {
|
||||
await fetchCdpChecked(url, timeoutMs, init);
|
||||
}
|
||||
|
||||
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);
|
||||
return new WebSocket(wsUrl, {
|
||||
handshakeTimeout: handshakeTimeoutMs,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function withCdpSocket<T>(
|
||||
wsUrl: string,
|
||||
fn: (send: CdpSendFn) => Promise<T>,
|
||||
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const ws = openCdpWebSocket(wsUrl, opts);
|
||||
const { send, closeWithError } = createCdpSender(ws);
|
||||
|
||||
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) {
|
||||
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(send);
|
||||
} catch (err) {
|
||||
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
485
extensions/browser/src/browser/cdp.ts
Normal file
485
extensions/browser/src/browser/cdp.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
isLoopbackHost,
|
||||
isWebSocketUrl,
|
||||
withCdpSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||
|
||||
export {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
fetchOk,
|
||||
getHeadersWithAuth,
|
||||
isWebSocketUrl,
|
||||
} from "./cdp.helpers.js";
|
||||
|
||||
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");
|
||||
if (cdpPort) {
|
||||
ws.port = cdpPort;
|
||||
}
|
||||
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
export async function captureScreenshotPng(opts: {
|
||||
wsUrl: string;
|
||||
fullPage?: boolean;
|
||||
}): Promise<Buffer> {
|
||||
return await captureScreenshot({
|
||||
wsUrl: opts.wsUrl,
|
||||
fullPage: opts.fullPage,
|
||||
format: "png",
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureScreenshot(opts: {
|
||||
wsUrl: string;
|
||||
fullPage?: boolean;
|
||||
format?: "png" | "jpeg";
|
||||
quality?: number; // jpeg only (0..100)
|
||||
}): Promise<Buffer> {
|
||||
return await withCdpSocket(opts.wsUrl, async (send) => {
|
||||
await send("Page.enable");
|
||||
|
||||
let clip: { x: number; y: number; width: number; height: number; scale: 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 width = Number(size?.width ?? 0);
|
||||
const height = Number(size?.height ?? 0);
|
||||
if (width > 0 && height > 0) {
|
||||
clip = { x: 0, y: 0, width, height, scale: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const format = opts.format ?? "png";
|
||||
const quality =
|
||||
format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined;
|
||||
|
||||
const result = (await send("Page.captureScreenshot", {
|
||||
format,
|
||||
...(quality !== undefined ? { quality } : {}),
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
...(clip ? { clip } : {}),
|
||||
})) as { data?: string };
|
||||
|
||||
const base64 = result?.data;
|
||||
if (!base64) {
|
||||
throw new Error("Screenshot failed: missing data");
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTargetViaCdp(opts: {
|
||||
cdpUrl: string;
|
||||
url: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ targetId: string }> {
|
||||
await assertBrowserNavigationAllowed({
|
||||
url: opts.url,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
|
||||
let wsUrl: string;
|
||||
if (isWebSocketUrl(opts.cdpUrl)) {
|
||||
// Direct WebSocket URL — skip /json/version discovery.
|
||||
wsUrl = opts.cdpUrl;
|
||||
} else {
|
||||
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
||||
1500,
|
||||
);
|
||||
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||
if (!wsUrl) {
|
||||
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
||||
}
|
||||
}
|
||||
|
||||
return await withCdpSocket(wsUrl, async (send) => {
|
||||
const created = (await send("Target.createTarget", { url: opts.url })) as {
|
||||
targetId?: string;
|
||||
};
|
||||
const targetId = String(created?.targetId ?? "").trim();
|
||||
if (!targetId) {
|
||||
throw new Error("CDP Target.createTarget returned no targetId");
|
||||
}
|
||||
return { targetId };
|
||||
});
|
||||
}
|
||||
|
||||
export type CdpRemoteObject = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
unserializableValue?: string;
|
||||
preview?: unknown;
|
||||
};
|
||||
|
||||
export type CdpExceptionDetails = {
|
||||
text?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
exception?: CdpRemoteObject;
|
||||
stackTrace?: unknown;
|
||||
};
|
||||
|
||||
export async function evaluateJavaScript(opts: {
|
||||
wsUrl: string;
|
||||
expression: string;
|
||||
awaitPromise?: boolean;
|
||||
returnByValue?: boolean;
|
||||
}): Promise<{
|
||||
result: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
}> {
|
||||
return await withCdpSocket(opts.wsUrl, async (send) => {
|
||||
await send("Runtime.enable").catch(() => {});
|
||||
const evaluated = (await send("Runtime.evaluate", {
|
||||
expression: opts.expression,
|
||||
awaitPromise: Boolean(opts.awaitPromise),
|
||||
returnByValue: opts.returnByValue ?? true,
|
||||
userGesture: true,
|
||||
includeCommandLineAPI: true,
|
||||
})) as {
|
||||
result?: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
};
|
||||
|
||||
const result = evaluated?.result;
|
||||
if (!result) {
|
||||
throw new Error("CDP Runtime.evaluate returned no result");
|
||||
}
|
||||
return { result, exceptionDetails: evaluated.exceptionDetails };
|
||||
});
|
||||
}
|
||||
|
||||
export type AriaSnapshotNode = {
|
||||
ref: string;
|
||||
role: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
backendDOMNodeId?: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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();
|
||||
if (!popped) {
|
||||
break;
|
||||
}
|
||||
const { id, depth } = popped;
|
||||
const n = byId.get(id);
|
||||
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${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];
|
||||
if (child) {
|
||||
stack.push({ id: child, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function snapshotAria(opts: {
|
||||
wsUrl: string;
|
||||
limit?: number;
|
||||
}): Promise<{ nodes: AriaSnapshotNode[] }> {
|
||||
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
|
||||
return await withCdpSocket(opts.wsUrl, async (send) => {
|
||||
await send("Accessibility.enable").catch(() => {});
|
||||
const res = (await send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function snapshotDom(opts: {
|
||||
wsUrl: string;
|
||||
limit?: number;
|
||||
maxTextChars?: number;
|
||||
}): Promise<{
|
||||
nodes: DomSnapshotNode[];
|
||||
}> {
|
||||
const limit = Math.max(1, Math.min(5000, Math.floor(opts.limit ?? 800)));
|
||||
const maxTextChars = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 220)));
|
||||
|
||||
const expression = `(() => {
|
||||
const maxNodes = ${JSON.stringify(limit)};
|
||||
const maxText = ${JSON.stringify(maxTextChars)};
|
||||
const nodes = [];
|
||||
const root = document.documentElement;
|
||||
if (!root) return { nodes };
|
||||
const stack = [{ el: root, depth: 0, parentRef: null }];
|
||||
while (stack.length && nodes.length < maxNodes) {
|
||||
const cur = stack.pop();
|
||||
const el = cur.el;
|
||||
if (!el || el.nodeType !== 1) continue;
|
||||
const ref = "n" + String(nodes.length + 1);
|
||||
const tag = (el.tagName || "").toLowerCase();
|
||||
const id = el.id ? String(el.id) : undefined;
|
||||
const className = el.className ? String(el.className).slice(0, 300) : undefined;
|
||||
const role = el.getAttribute && el.getAttribute("role") ? String(el.getAttribute("role")) : undefined;
|
||||
const name = el.getAttribute && el.getAttribute("aria-label") ? String(el.getAttribute("aria-label")) : undefined;
|
||||
let text = "";
|
||||
try { text = String(el.innerText || "").trim(); } catch {}
|
||||
if (maxText && text.length > maxText) text = text.slice(0, maxText) + "…";
|
||||
const href = (el.href !== undefined && el.href !== null) ? String(el.href) : undefined;
|
||||
const type = (el.type !== undefined && el.type !== null) ? String(el.type) : undefined;
|
||||
const value = (el.value !== undefined && el.value !== null) ? String(el.value).slice(0, 500) : undefined;
|
||||
nodes.push({
|
||||
ref,
|
||||
parentRef: cur.parentRef,
|
||||
depth: cur.depth,
|
||||
tag,
|
||||
...(id ? { id } : {}),
|
||||
...(className ? { className } : {}),
|
||||
...(role ? { role } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(href ? { href } : {}),
|
||||
...(type ? { type } : {}),
|
||||
...(value ? { value } : {}),
|
||||
});
|
||||
const children = el.children ? Array.from(el.children) : [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ el: children[i], depth: cur.depth + 1, parentRef: ref });
|
||||
}
|
||||
}
|
||||
return { nodes };
|
||||
})()`;
|
||||
|
||||
const evaluated = await evaluateJavaScript({
|
||||
wsUrl: opts.wsUrl,
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
const value = evaluated.result?.value;
|
||||
if (!value || typeof value !== "object") {
|
||||
return { nodes: [] };
|
||||
}
|
||||
const nodes = (value as { nodes?: unknown }).nodes;
|
||||
return { nodes: Array.isArray(nodes) ? (nodes as DomSnapshotNode[]) : [] };
|
||||
}
|
||||
|
||||
export type DomSnapshotNode = {
|
||||
ref: string;
|
||||
parentRef: string | null;
|
||||
depth: number;
|
||||
tag: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
role?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export async function getDomText(opts: {
|
||||
wsUrl: string;
|
||||
format: "html" | "text";
|
||||
maxChars?: number;
|
||||
selector?: string;
|
||||
}): Promise<{ text: string }> {
|
||||
const maxChars = Math.max(0, Math.min(5_000_000, Math.floor(opts.maxChars ?? 200_000)));
|
||||
const selectorExpr = opts.selector ? JSON.stringify(opts.selector) : "null";
|
||||
const expression = `(() => {
|
||||
const fmt = ${JSON.stringify(opts.format)};
|
||||
const max = ${JSON.stringify(maxChars)};
|
||||
const sel = ${selectorExpr};
|
||||
const pick = sel ? document.querySelector(sel) : null;
|
||||
let out = "";
|
||||
if (fmt === "text") {
|
||||
const el = pick || document.body || document.documentElement;
|
||||
try { out = String(el && el.innerText ? el.innerText : ""); } catch { out = ""; }
|
||||
} else {
|
||||
const el = pick || document.documentElement;
|
||||
try { out = String(el && el.outerHTML ? el.outerHTML : ""); } catch { out = ""; }
|
||||
}
|
||||
if (max && out.length > max) out = out.slice(0, max) + "\\n<!-- …truncated… -->";
|
||||
return out;
|
||||
})()`;
|
||||
|
||||
const evaluated = await evaluateJavaScript({
|
||||
wsUrl: opts.wsUrl,
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
const textValue = (evaluated.result?.value ?? "") as unknown;
|
||||
const text =
|
||||
typeof textValue === "string"
|
||||
? textValue
|
||||
: typeof textValue === "number" || typeof textValue === "boolean"
|
||||
? String(textValue)
|
||||
: "";
|
||||
return { text };
|
||||
}
|
||||
|
||||
export async function querySelector(opts: {
|
||||
wsUrl: string;
|
||||
selector: string;
|
||||
limit?: number;
|
||||
maxTextChars?: number;
|
||||
maxHtmlChars?: number;
|
||||
}): Promise<{
|
||||
matches: QueryMatch[];
|
||||
}> {
|
||||
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 20)));
|
||||
const maxText = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 500)));
|
||||
const maxHtml = Math.max(0, Math.min(20000, Math.floor(opts.maxHtmlChars ?? 1500)));
|
||||
|
||||
const expression = `(() => {
|
||||
const sel = ${JSON.stringify(opts.selector)};
|
||||
const lim = ${JSON.stringify(limit)};
|
||||
const maxText = ${JSON.stringify(maxText)};
|
||||
const maxHtml = ${JSON.stringify(maxHtml)};
|
||||
const els = Array.from(document.querySelectorAll(sel)).slice(0, lim);
|
||||
return els.map((el, i) => {
|
||||
const tag = (el.tagName || "").toLowerCase();
|
||||
const id = el.id ? String(el.id) : undefined;
|
||||
const className = el.className ? String(el.className).slice(0, 300) : undefined;
|
||||
let text = "";
|
||||
try { text = String(el.innerText || "").trim(); } catch {}
|
||||
if (maxText && text.length > maxText) text = text.slice(0, maxText) + "…";
|
||||
const value = (el.value !== undefined && el.value !== null) ? String(el.value).slice(0, 500) : undefined;
|
||||
const href = (el.href !== undefined && el.href !== null) ? String(el.href) : undefined;
|
||||
let outerHTML = "";
|
||||
try { outerHTML = String(el.outerHTML || ""); } catch {}
|
||||
if (maxHtml && outerHTML.length > maxHtml) outerHTML = outerHTML.slice(0, maxHtml) + "…";
|
||||
return {
|
||||
index: i + 1,
|
||||
tag,
|
||||
...(id ? { id } : {}),
|
||||
...(className ? { className } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(value ? { value } : {}),
|
||||
...(href ? { href } : {}),
|
||||
...(outerHTML ? { outerHTML } : {}),
|
||||
};
|
||||
});
|
||||
})()`;
|
||||
|
||||
const evaluated = await evaluateJavaScript({
|
||||
wsUrl: opts.wsUrl,
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
const matches = evaluated.result?.value;
|
||||
return { matches: Array.isArray(matches) ? (matches as QueryMatch[]) : [] };
|
||||
}
|
||||
|
||||
export type QueryMatch = {
|
||||
index: number;
|
||||
tag: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
href?: string;
|
||||
outerHTML?: string;
|
||||
};
|
||||
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 @@
|
||||
import type { SnapshotAriaNode } from "./client.js";
|
||||
import {
|
||||
getRoleSnapshotStats,
|
||||
type RoleRefMap,
|
||||
type RoleSnapshotOptions,
|
||||
} from "./pw-role-snapshot.js";
|
||||
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
|
||||
|
||||
export type ChromeMcpSnapshotNode = {
|
||||
id?: string;
|
||||
role?: string;
|
||||
name?: string;
|
||||
value?: string | number | boolean;
|
||||
description?: string;
|
||||
children?: ChromeMcpSnapshotNode[];
|
||||
};
|
||||
|
||||
function normalizeRole(node: ChromeMcpSnapshotNode): string {
|
||||
const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : "";
|
||||
return role || "generic";
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
650
extensions/browser/src/browser/chrome-mcp.ts
Normal file
650
extensions/browser/src/browser/chrome-mcp.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
|
||||
|
||||
type ChromeMcpStructuredPage = {
|
||||
id: number;
|
||||
url?: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
type ChromeMcpToolResult = {
|
||||
structuredContent?: Record<string, unknown>;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ChromeMcpSession = {
|
||||
client: Client;
|
||||
transport: StdioClientTransport;
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
type ChromeMcpSessionFactory = (
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
) => Promise<ChromeMcpSession>;
|
||||
|
||||
const DEFAULT_CHROME_MCP_COMMAND = "npx";
|
||||
const DEFAULT_CHROME_MCP_ARGS = [
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
];
|
||||
|
||||
const sessions = new Map<string, ChromeMcpSession>();
|
||||
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||
let sessionFactory: ChromeMcpSessionFactory | null = null;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asPages(value: unknown): ChromeMcpStructuredPage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const out: ChromeMcpStructuredPage[] = [];
|
||||
for (const entry of value) {
|
||||
const record = asRecord(entry);
|
||||
if (!record || typeof record.id !== "number") {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
id: record.id,
|
||||
url: typeof record.url === "string" ? record.url : undefined,
|
||||
selected: record.selected === true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePageId(targetId: string): number {
|
||||
const parsed = Number.parseInt(targetId.trim(), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toBrowserTabs(pages: ChromeMcpStructuredPage[]): BrowserTab[] {
|
||||
return pages.map((page) => ({
|
||||
targetId: String(page.id),
|
||||
title: "",
|
||||
url: page.url ?? "",
|
||||
type: "page",
|
||||
}));
|
||||
}
|
||||
|
||||
function extractStructuredContent(result: ChromeMcpToolResult): Record<string, unknown> {
|
||||
return asRecord(result.structuredContent) ?? {};
|
||||
}
|
||||
|
||||
function extractTextContent(result: ChromeMcpToolResult): string[] {
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
return content
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return record && typeof record.text === "string" ? record.text : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] {
|
||||
const pages: ChromeMcpStructuredPage[] = [];
|
||||
for (const block of extractTextContent(result)) {
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
pages.push({
|
||||
id: Number.parseInt(match[1] ?? "", 10),
|
||||
url: match[2]?.trim() || undefined,
|
||||
selected: Boolean(match[3]),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] {
|
||||
const structured = asPages(extractStructuredContent(result).pages);
|
||||
return structured.length > 0 ? structured : extractTextPages(result);
|
||||
}
|
||||
|
||||
function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode {
|
||||
const structured = extractStructuredContent(result);
|
||||
const snapshot = asRecord(structured.snapshot);
|
||||
if (!snapshot) {
|
||||
throw new Error("Chrome MCP snapshot response was missing structured snapshot data.");
|
||||
}
|
||||
return snapshot as unknown as ChromeMcpSnapshotNode;
|
||||
}
|
||||
|
||||
function extractJsonBlock(text: string): unknown {
|
||||
const match = text.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
const raw = match?.[1]?.trim() || text.trim();
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
function extractMessageText(result: ChromeMcpToolResult): string {
|
||||
const message = extractStructuredContent(result).message;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
const blocks = extractTextContent(result);
|
||||
return blocks.find((block) => block.trim()) ?? "";
|
||||
}
|
||||
|
||||
function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string {
|
||||
const message = extractMessageText(result).trim();
|
||||
return message || `Chrome MCP tool "${name}" failed.`;
|
||||
}
|
||||
|
||||
function extractJsonMessage(result: ChromeMcpToolResult): unknown {
|
||||
const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) =>
|
||||
text.trim(),
|
||||
);
|
||||
let lastError: unknown;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return extractJsonBlock(candidate);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined {
|
||||
const trimmed = userDataDir?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string {
|
||||
return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]);
|
||||
}
|
||||
|
||||
function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(cacheKey);
|
||||
return Array.isArray(parsed) && parsed[0] === profileName;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeChromeMcpSessionsForProfile(
|
||||
profileName: string,
|
||||
keepKey?: string,
|
||||
): Promise<boolean> {
|
||||
let closed = false;
|
||||
|
||||
for (const key of Array.from(pendingSessions.keys())) {
|
||||
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
|
||||
pendingSessions.delete(key);
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, session] of Array.from(sessions.entries())) {
|
||||
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
|
||||
sessions.delete(key);
|
||||
closed = true;
|
||||
await session.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return closed;
|
||||
}
|
||||
|
||||
export function buildChromeMcpArgs(userDataDir?: string): string[] {
|
||||
const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir);
|
||||
return normalizedUserDataDir
|
||||
? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir]
|
||||
: [...DEFAULT_CHROME_MCP_ARGS];
|
||||
}
|
||||
|
||||
async function createRealSession(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
): Promise<ChromeMcpSession> {
|
||||
const transport = new StdioClientTransport({
|
||||
command: DEFAULT_CHROME_MCP_COMMAND,
|
||||
args: buildChromeMcpArgs(userDataDir),
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: "openclaw-browser",
|
||||
version: "0.0.0",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const ready = (async () => {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
if (!tools.tools.some((tool) => tool.name === "list_pages")) {
|
||||
throw new Error("Chrome MCP server did not expose the expected navigation tools.");
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const targetLabel = userDataDir
|
||||
? `the configured Chromium user data dir (${userDataDir})`
|
||||
: "Google Chrome's default profile";
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
||||
`Make sure ${targetLabel} is running locally with remote debugging enabled. ` +
|
||||
`Details: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
ready,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSession(profileName: string, userDataDir?: string): Promise<ChromeMcpSession> {
|
||||
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
|
||||
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
|
||||
|
||||
let session = sessions.get(cacheKey);
|
||||
if (session && session.transport.pid === null) {
|
||||
sessions.delete(cacheKey);
|
||||
session = undefined;
|
||||
}
|
||||
if (!session) {
|
||||
let pending = pendingSessions.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
|
||||
if (pendingSessions.get(cacheKey) === pending) {
|
||||
sessions.set(cacheKey, created);
|
||||
} else {
|
||||
await created.client.close().catch(() => {});
|
||||
}
|
||||
return created;
|
||||
})();
|
||||
pendingSessions.set(cacheKey, pending);
|
||||
}
|
||||
try {
|
||||
session = await pending;
|
||||
} finally {
|
||||
if (pendingSessions.get(cacheKey) === pending) {
|
||||
pendingSessions.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await session.ready;
|
||||
return session;
|
||||
} catch (err) {
|
||||
const current = sessions.get(cacheKey);
|
||||
if (current?.transport === session.transport) {
|
||||
sessions.delete(cacheKey);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(
|
||||
profileName: string,
|
||||
userDataDir: string | undefined,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<ChromeMcpToolResult> {
|
||||
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
|
||||
const session = await getSession(profileName, userDataDir);
|
||||
let result: ChromeMcpToolResult;
|
||||
try {
|
||||
result = (await session.client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
})) as ChromeMcpToolResult;
|
||||
} catch (err) {
|
||||
// Transport/connection error — tear down session so it reconnects on next call
|
||||
sessions.delete(cacheKey);
|
||||
await session.client.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
// Tool-level errors (element not found, script error, etc.) don't indicate a
|
||||
// broken connection — don't tear down the session for these.
|
||||
if (result.isError) {
|
||||
throw new Error(extractToolErrorMessage(result, name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-"));
|
||||
const filePath = path.join(dir, randomUUID());
|
||||
try {
|
||||
return await fn(filePath);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function findPageById(
|
||||
profileName: string,
|
||||
pageId: number,
|
||||
userDataDir?: string,
|
||||
): Promise<ChromeMcpStructuredPage> {
|
||||
const pages = await listChromeMcpPages(profileName, userDataDir);
|
||||
const page = pages.find((entry) => entry.id === pageId);
|
||||
if (!page) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function ensureChromeMcpAvailable(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
): Promise<void> {
|
||||
await getSession(profileName, userDataDir);
|
||||
}
|
||||
|
||||
export function getChromeMcpPid(profileName: string): number | null {
|
||||
for (const [key, session] of sessions.entries()) {
|
||||
if (cacheKeyMatchesProfileName(key, profileName)) {
|
||||
return session.transport.pid ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function closeChromeMcpSession(profileName: string): Promise<boolean> {
|
||||
return await closeChromeMcpSessionsForProfile(profileName);
|
||||
}
|
||||
|
||||
export async function stopAllChromeMcpSessions(): Promise<void> {
|
||||
const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))];
|
||||
for (const name of names) {
|
||||
await closeChromeMcpSession(name).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChromeMcpPages(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
): Promise<ChromeMcpStructuredPage[]> {
|
||||
const result = await callTool(profileName, userDataDir, "list_pages");
|
||||
return extractStructuredPages(result);
|
||||
}
|
||||
|
||||
export async function listChromeMcpTabs(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
): Promise<BrowserTab[]> {
|
||||
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir));
|
||||
}
|
||||
|
||||
export async function openChromeMcpTab(
|
||||
profileName: string,
|
||||
url: string,
|
||||
userDataDir?: string,
|
||||
): Promise<BrowserTab> {
|
||||
const result = await callTool(profileName, userDataDir, "new_page", { url });
|
||||
const pages = extractStructuredPages(result);
|
||||
const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
|
||||
if (!chosen) {
|
||||
throw new Error("Chrome MCP did not return the created page.");
|
||||
}
|
||||
return {
|
||||
targetId: String(chosen.id),
|
||||
title: "",
|
||||
url: chosen.url ?? url,
|
||||
type: "page",
|
||||
};
|
||||
}
|
||||
|
||||
export async function focusChromeMcpTab(
|
||||
profileName: string,
|
||||
targetId: string,
|
||||
userDataDir?: string,
|
||||
): Promise<void> {
|
||||
await callTool(profileName, userDataDir, "select_page", {
|
||||
pageId: parsePageId(targetId),
|
||||
bringToFront: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeChromeMcpTab(
|
||||
profileName: string,
|
||||
targetId: string,
|
||||
userDataDir?: string,
|
||||
): Promise<void> {
|
||||
await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) });
|
||||
}
|
||||
|
||||
export async function navigateChromeMcpPage(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ url: string }> {
|
||||
await callTool(params.profileName, params.userDataDir, "navigate_page", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
type: "url",
|
||||
url: params.url,
|
||||
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),
|
||||
});
|
||||
const page = await findPageById(
|
||||
params.profileName,
|
||||
parsePageId(params.targetId),
|
||||
params.userDataDir,
|
||||
);
|
||||
return { url: page.url ?? params.url };
|
||||
}
|
||||
|
||||
export async function takeChromeMcpSnapshot(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<ChromeMcpSnapshotNode> {
|
||||
const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
});
|
||||
return extractSnapshot(result);
|
||||
}
|
||||
|
||||
export async function takeChromeMcpScreenshot(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid?: string;
|
||||
fullPage?: boolean;
|
||||
format?: "png" | "jpeg";
|
||||
}): Promise<Buffer> {
|
||||
return await withTempFile(async (filePath) => {
|
||||
await callTool(params.profileName, params.userDataDir, "take_screenshot", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
filePath,
|
||||
format: params.format ?? "png",
|
||||
...(params.uid ? { uid: params.uid } : {}),
|
||||
...(params.fullPage ? { fullPage: true } : {}),
|
||||
});
|
||||
return await fs.readFile(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
doubleClick?: boolean;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "click", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
...(params.doubleClick ? { dblClick: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fillChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "fill", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
value: params.value,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fillChromeMcpForm(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
elements: Array<{ uid: string; value: string }>;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "fill_form", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
elements: params.elements,
|
||||
});
|
||||
}
|
||||
|
||||
export async function hoverChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "hover", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
});
|
||||
}
|
||||
|
||||
export async function dragChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
fromUid: string;
|
||||
toUid: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "drag", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
from_uid: params.fromUid,
|
||||
to_uid: params.toUid,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadChromeMcpFile(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
uid: string;
|
||||
filePath: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "upload_file", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
uid: params.uid,
|
||||
filePath: params.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pressChromeMcpKey(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "press_key", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
key: params.key,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resizeChromeMcpPage(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "resize_page", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleChromeMcpDialog(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
action: "accept" | "dismiss";
|
||||
promptText?: string;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "handle_dialog", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
action: params.action,
|
||||
...(params.promptText ? { promptText: params.promptText } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluateChromeMcpScript(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
fn: string;
|
||||
args?: string[];
|
||||
}): Promise<unknown> {
|
||||
const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
function: params.fn,
|
||||
...(params.args?.length ? { args: params.args } : {}),
|
||||
});
|
||||
return extractJsonMessage(result);
|
||||
}
|
||||
|
||||
export async function waitForChromeMcpText(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
text: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
await callTool(params.profileName, params.userDataDir, "wait_for", {
|
||||
pageId: parsePageId(params.targetId),
|
||||
text: params.text,
|
||||
...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
|
||||
sessionFactory = factory;
|
||||
}
|
||||
|
||||
export async function resetChromeMcpSessionsForTest(): Promise<void> {
|
||||
sessionFactory = null;
|
||||
pendingSessions.clear();
|
||||
await stopAllChromeMcpSessions();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
721
extensions/browser/src/browser/chrome.executables.ts
Normal file
721
extensions/browser/src/browser/chrome.executables.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserExecutable = {
|
||||
kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge";
|
||||
path: string;
|
||||
};
|
||||
|
||||
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
|
||||
|
||||
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 String(output ?? "").trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
|
||||
const id = identifier.toLowerCase();
|
||||
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 = name.toLowerCase();
|
||||
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.trim().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.trim());
|
||||
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 = path.posix.basename(resolved).toLowerCase();
|
||||
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 = path.win32.basename(exePath).toLowerCase();
|
||||
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 (let i = 0; i < line.length; i += 1) {
|
||||
const ch = line[i];
|
||||
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 match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function expandWindowsEnvVars(value: string): string {
|
||||
return value.replace(/%([^%]+)%/g, (_match, name) => {
|
||||
const key = String(name ?? "").trim();
|
||||
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)) {
|
||||
return {
|
||||
kind:
|
||||
candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary")
|
||||
? "canary"
|
||||
: "chrome",
|
||||
path: candidate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export 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",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
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: "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: "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: "/snap/bin/chromium" },
|
||||
];
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export 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",
|
||||
"/snap/bin/google-chrome",
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function readBrowserVersion(executablePath: string): string | null {
|
||||
const output = execText(executablePath, ["--version"], 2000);
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
return output.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
|
||||
const match = String(rawVersion ?? "").match(CHROME_VERSION_RE);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
198
extensions/browser/src/browser/chrome.profile-decoration.ts
Normal file
198
extensions/browser/src/browser/chrome.profile-decoration.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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 {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function isProfileDecorated(
|
||||
userDataDir: string,
|
||||
desiredName: string,
|
||||
desiredColorHex: 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 infoCache =
|
||||
typeof profile === "object" && profile !== null && !Array.isArray(profile)
|
||||
? (profile as Record<string, unknown>).info_cache
|
||||
: null;
|
||||
const info =
|
||||
typeof infoCache === "object" &&
|
||||
infoCache !== null &&
|
||||
!Array.isArray(infoCache) &&
|
||||
typeof (infoCache as Record<string, unknown>).Default === "object" &&
|
||||
(infoCache as Record<string, unknown>).Default !== null &&
|
||||
!Array.isArray((infoCache as Record<string, unknown>).Default)
|
||||
? ((infoCache as Record<string, unknown>).Default as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const prefs = safeReadJson(preferencesPath);
|
||||
const browserTheme = (() => {
|
||||
const browser = prefs?.browser;
|
||||
const theme =
|
||||
typeof browser === "object" && browser !== null && !Array.isArray(browser)
|
||||
? (browser as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const autogeneratedTheme = (() => {
|
||||
const autogenerated = prefs?.autogenerated;
|
||||
const theme =
|
||||
typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated)
|
||||
? (autogenerated as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const nameOk = typeof info?.name === "string" ? info.name === desiredName : true;
|
||||
|
||||
if (desiredColorInt == null) {
|
||||
// If the user provided a non-#RRGGBB value, we can only do best-effort.
|
||||
return nameOk;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
) {
|
||||
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);
|
||||
}
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
477
extensions/browser/src/browser/chrome.ts
Normal file
477
extensions/browser/src/browser/chrome.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import {
|
||||
CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS,
|
||||
CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS,
|
||||
CHROME_LAUNCH_READY_POLL_MS,
|
||||
CHROME_LAUNCH_READY_WINDOW_MS,
|
||||
CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
CHROME_STDERR_HINT_MAX_CHARS,
|
||||
CHROME_STOP_PROBE_TIMEOUT_MS,
|
||||
CHROME_STOP_TIMEOUT_MS,
|
||||
CHROME_WS_READY_TIMEOUT_MS,
|
||||
} from "./cdp-timeouts.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
assertCdpEndpointAllowed,
|
||||
fetchCdpChecked,
|
||||
isWebSocketUrl,
|
||||
openCdpWebSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
type BrowserExecutable,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
import {
|
||||
decorateOpenClawProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
|
||||
const log = createSubsystemLogger("browser").child("chrome");
|
||||
|
||||
export type { BrowserExecutable } from "./chrome.executables.js";
|
||||
export {
|
||||
findChromeExecutableLinux,
|
||||
findChromeExecutableMac,
|
||||
findChromeExecutableWindows,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
export {
|
||||
decorateOpenClawProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type RunningChrome = {
|
||||
pid: number;
|
||||
exe: BrowserExecutable;
|
||||
userDataDir: string;
|
||||
cdpPort: number;
|
||||
startedAt: number;
|
||||
proc: ChildProcess;
|
||||
};
|
||||
|
||||
function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null {
|
||||
return resolveBrowserExecutableForPlatform(resolved, process.platform);
|
||||
}
|
||||
|
||||
export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) {
|
||||
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
|
||||
}
|
||||
|
||||
function cdpUrlForPort(cdpPort: number) {
|
||||
return `http://127.0.0.1:${cdpPort}`;
|
||||
}
|
||||
|
||||
export function buildOpenClawChromeLaunchArgs(params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profile: ResolvedBrowserProfile;
|
||||
userDataDir: string;
|
||||
}): string[] {
|
||||
const { resolved, profile, userDataDir } = params;
|
||||
const args: string[] = [
|
||||
`--remote-debugging-port=${profile.cdpPort}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-sync",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,MediaRouter",
|
||||
"--disable-session-crashed-bubble",
|
||||
"--hide-crash-restore-bubble",
|
||||
"--password-store=basic",
|
||||
];
|
||||
|
||||
if (resolved.headless) {
|
||||
args.push("--headless=new");
|
||||
args.push("--disable-gpu");
|
||||
}
|
||||
if (resolved.noSandbox) {
|
||||
args.push("--no-sandbox");
|
||||
args.push("--disable-setuid-sandbox");
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
args.push("--disable-dev-shm-usage");
|
||||
}
|
||||
if (resolved.extraArgs.length > 0) {
|
||||
args.push(...resolved.extraArgs);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
|
||||
ws.once("open", () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
ws.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
export async function isChromeReachable(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
if (isWebSocketUrl(cdpUrl)) {
|
||||
// Direct WebSocket endpoint — probe via WS handshake.
|
||||
return await canOpenWebSocket(cdpUrl, timeoutMs);
|
||||
}
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
|
||||
return Boolean(version);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ChromeVersion = {
|
||||
webSocketDebuggerUrl?: string;
|
||||
Browser?: string;
|
||||
"User-Agent"?: string;
|
||||
};
|
||||
|
||||
async function fetchChromeVersion(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<ChromeVersion | null> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||
try {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
||||
const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal });
|
||||
const data = (await res.json()) as ChromeVersion;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChromeWebSocketUrl(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<string | null> {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
if (isWebSocketUrl(cdpUrl)) {
|
||||
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
|
||||
return cdpUrl;
|
||||
}
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
|
||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
if (!wsUrl) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCdpWsUrl(wsUrl, cdpUrl);
|
||||
}
|
||||
|
||||
async function canRunCdpHealthCommand(
|
||||
wsUrl: string,
|
||||
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const ws = openCdpWebSocket(wsUrl, {
|
||||
handshakeTimeoutMs: timeoutMs,
|
||||
});
|
||||
let settled = false;
|
||||
const onMessage = (raw: Parameters<typeof rawDataToString>[0]) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
let parsed: { id?: unknown; result?: unknown } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed?.id !== 1) {
|
||||
return;
|
||||
}
|
||||
finish(Boolean(parsed.result && typeof parsed.result === "object"));
|
||||
};
|
||||
|
||||
const finish = (value: boolean) => {
|
||||
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(false);
|
||||
},
|
||||
Math.max(50, timeoutMs + 25),
|
||||
);
|
||||
|
||||
ws.once("open", () => {
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: "Browser.getVersion",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("message", onMessage);
|
||||
|
||||
ws.once("error", () => {
|
||||
finish(false);
|
||||
});
|
||||
ws.once("close", () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function isChromeCdpReady(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<boolean> {
|
||||
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null);
|
||||
if (!wsUrl) {
|
||||
return false;
|
||||
}
|
||||
return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs);
|
||||
}
|
||||
|
||||
export async function launchOpenClawChrome(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
profile: ResolvedBrowserProfile,
|
||||
): Promise<RunningChrome> {
|
||||
if (!profile.cdpIsLoopback) {
|
||||
throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`);
|
||||
}
|
||||
await ensurePortAvailable(profile.cdpPort);
|
||||
|
||||
const exe = resolveBrowserExecutable(resolved);
|
||||
if (!exe) {
|
||||
throw new Error(
|
||||
"No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).",
|
||||
);
|
||||
}
|
||||
|
||||
const userDataDir = resolveOpenClawUserDataDir(profile.name);
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
const needsDecorate = !isProfileDecorated(
|
||||
userDataDir,
|
||||
profile.name,
|
||||
(profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(),
|
||||
);
|
||||
|
||||
// First launch to create preference files if missing, then decorate and relaunch.
|
||||
const spawnOnce = () => {
|
||||
const args = buildOpenClawChromeLaunchArgs({
|
||||
resolved,
|
||||
profile,
|
||||
userDataDir,
|
||||
});
|
||||
// stdio tuple: discard stdout to prevent buffer saturation in constrained
|
||||
// environments (e.g. Docker), while keeping stderr piped for diagnostics.
|
||||
// Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely;
|
||||
// the tuple overload resolution varies across @types/node versions.
|
||||
return spawn(exe.path, args, {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
// Reduce accidental sharing with the user's env.
|
||||
HOME: os.homedir(),
|
||||
},
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
const localStatePath = path.join(userDataDir, "Local State");
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath);
|
||||
|
||||
// If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults.
|
||||
// Then decorate (if needed) before the "real" run.
|
||||
if (needsBootstrap) {
|
||||
const bootstrap = spawnOnce();
|
||||
const deadline = Date.now() + CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (exists(localStatePath) && exists(preferencesPath)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
try {
|
||||
bootstrap.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const exitDeadline = Date.now() + CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS;
|
||||
while (Date.now() < exitDeadline) {
|
||||
if (bootstrap.exitCode != null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
if (needsDecorate) {
|
||||
try {
|
||||
decorateOpenClawProfile(userDataDir, {
|
||||
name: profile.name,
|
||||
color: profile.color,
|
||||
});
|
||||
log.info(`🦞 openclaw browser profile decorated (${profile.color})`);
|
||||
} catch (err) {
|
||||
log.warn(`openclaw browser profile decoration failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ensureProfileCleanExit(userDataDir);
|
||||
} catch (err) {
|
||||
log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const proc = spawnOnce();
|
||||
|
||||
// Collect stderr for diagnostics in case Chrome fails to start.
|
||||
// The listener is removed on success to avoid unbounded memory growth
|
||||
// from a long-lived Chrome process that emits periodic warnings.
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
};
|
||||
proc.stderr?.on("data", onStderr);
|
||||
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
|
||||
while (Date.now() < readyDeadline) {
|
||||
if (await isChromeReachable(profile.cdpUrl)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
|
||||
}
|
||||
|
||||
if (!(await isChromeReachable(profile.cdpUrl))) {
|
||||
const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim();
|
||||
const stderrHint = stderrOutput
|
||||
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
|
||||
: "";
|
||||
const sandboxHint =
|
||||
process.platform === "linux" && !resolved.noSandbox
|
||||
? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
|
||||
: "";
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Chrome started successfully — detach the stderr listener and release the buffer.
|
||||
proc.stderr?.off("data", onStderr);
|
||||
stderrChunks.length = 0;
|
||||
|
||||
const pid = proc.pid ?? -1;
|
||||
log.info(
|
||||
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
|
||||
);
|
||||
|
||||
return {
|
||||
pid,
|
||||
exe,
|
||||
userDataDir,
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt,
|
||||
proc,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopOpenClawChrome(
|
||||
running: RunningChrome,
|
||||
timeoutMs = CHROME_STOP_TIMEOUT_MS,
|
||||
) {
|
||||
const proc = running.proc;
|
||||
if (proc.killed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!proc.exitCode && proc.killed) {
|
||||
break;
|
||||
}
|
||||
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), CHROME_STOP_PROBE_TIMEOUT_MS))) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
279
extensions/browser/src/browser/client-actions-core.ts
Normal file
279
extensions/browser/src/browser/client-actions-core.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type {
|
||||
BrowserActionOk,
|
||||
BrowserActionPathResult,
|
||||
BrowserActionTabResult,
|
||||
} from "./client-actions-types.js";
|
||||
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
export type BrowserFormField = {
|
||||
ref: string;
|
||||
type: string;
|
||||
value?: string | number | boolean;
|
||||
};
|
||||
|
||||
export type BrowserActRequest =
|
||||
| {
|
||||
kind: "click";
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
targetId?: string;
|
||||
doubleClick?: boolean;
|
||||
button?: string;
|
||||
modifiers?: 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;
|
||||
};
|
||||
|
||||
export type BrowserActResponse = {
|
||||
ok: true;
|
||||
targetId: string;
|
||||
url?: string;
|
||||
result?: unknown;
|
||||
results?: Array<{ ok: boolean; error?: string }>;
|
||||
};
|
||||
|
||||
export type BrowserDownloadPayload = {
|
||||
url: string;
|
||||
suggestedFilename: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload };
|
||||
|
||||
async function postDownloadRequest(
|
||||
baseUrl: string | undefined,
|
||||
route: "/wait/download" | "/download",
|
||||
body: Record<string, unknown>,
|
||||
profile?: string,
|
||||
): Promise<BrowserDownloadResult> {
|
||||
const q = buildProfileQuery(profile);
|
||||
return await fetchBrowserJson<BrowserDownloadResult>(withBaseUrl(baseUrl, `${route}${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserArmDialog(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
accept: boolean;
|
||||
promptText?: 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,
|
||||
targetId: opts.targetId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserWaitForDownload(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
path?: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserDownloadResult> {
|
||||
return await postDownloadRequest(
|
||||
baseUrl,
|
||||
"/wait/download",
|
||||
{
|
||||
targetId: opts.targetId,
|
||||
path: opts.path,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
},
|
||||
opts.profile,
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserDownload(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
ref: string;
|
||||
path: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserDownloadResult> {
|
||||
return await postDownloadRequest(
|
||||
baseUrl,
|
||||
"/download",
|
||||
{
|
||||
targetId: opts.targetId,
|
||||
ref: opts.ref,
|
||||
path: opts.path,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
},
|
||||
opts.profile,
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserAct(
|
||||
baseUrl: string | undefined,
|
||||
req: BrowserActRequest,
|
||||
opts?: { profile?: string },
|
||||
): 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: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserScreenshotAction(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
fullPage?: boolean;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
type?: "png" | "jpeg";
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
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,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
184
extensions/browser/src/browser/client-actions-observe.ts
Normal file
184
extensions/browser/src/browser/client-actions-observe.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { BrowserActionPathResult, BrowserActionTargetOk } from "./client-actions-types.js";
|
||||
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import type {
|
||||
BrowserConsoleMessage,
|
||||
BrowserNetworkRequest,
|
||||
BrowserPageError,
|
||||
} 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}` : "";
|
||||
}
|
||||
|
||||
export async function browserConsoleMessages(
|
||||
baseUrl: string | undefined,
|
||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||
const suffix = buildQuerySuffix([
|
||||
["level", opts.level],
|
||||
["targetId", opts.targetId],
|
||||
["profile", opts.profile],
|
||||
]);
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
messages: BrowserConsoleMessage[];
|
||||
targetId: string;
|
||||
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserPageErrors(
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||
const suffix = buildQuerySuffix([
|
||||
["targetId", opts.targetId],
|
||||
["clear", typeof opts.clear === "boolean" ? opts.clear : undefined],
|
||||
["profile", opts.profile],
|
||||
]);
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
errors: BrowserPageError[];
|
||||
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserRequests(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
filter?: string;
|
||||
clear?: boolean;
|
||||
profile?: string;
|
||||
} = {},
|
||||
): Promise<{ ok: true; targetId: string; requests: BrowserNetworkRequest[] }> {
|
||||
const suffix = buildQuerySuffix([
|
||||
["targetId", opts.targetId],
|
||||
["filter", opts.filter],
|
||||
["clear", typeof opts.clear === "boolean" ? opts.clear : undefined],
|
||||
["profile", opts.profile],
|
||||
]);
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
requests: BrowserNetworkRequest[];
|
||||
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserTraceStart(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
screenshots?: boolean;
|
||||
snapshots?: boolean;
|
||||
sources?: boolean;
|
||||
profile?: string;
|
||||
} = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
screenshots: opts.screenshots,
|
||||
snapshots: opts.snapshots,
|
||||
sources: opts.sources,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserTraceStop(
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionPathResult> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserHighlight(
|
||||
baseUrl: string | undefined,
|
||||
opts: { ref: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserResponseBody(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
url: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
maxChars?: number;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
response: {
|
||||
url: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
body: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}> {
|
||||
const q = buildProfileQuery(opts.profile);
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
response: {
|
||||
url: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
body: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetId: opts.targetId,
|
||||
url: opts.url,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxChars: opts.maxChars,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
278
extensions/browser/src/browser/client-actions-state.ts
Normal file
278
extensions/browser/src/browser/client-actions-state.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js";
|
||||
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
type TargetedProfileOptions = {
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
};
|
||||
|
||||
type HttpCredentialsOptions = TargetedProfileOptions & {
|
||||
username?: string;
|
||||
password?: string;
|
||||
clear?: boolean;
|
||||
};
|
||||
|
||||
type GeolocationOptions = TargetedProfileOptions & {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
accuracy?: number;
|
||||
origin?: string;
|
||||
clear?: boolean;
|
||||
};
|
||||
|
||||
function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string {
|
||||
const query = new URLSearchParams();
|
||||
if (params.targetId) {
|
||||
query.set("targetId", params.targetId);
|
||||
}
|
||||
if (params.key) {
|
||||
query.set("key", params.key);
|
||||
}
|
||||
if (params.profile) {
|
||||
query.set("profile", params.profile);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
return suffix ? `?${suffix}` : "";
|
||||
}
|
||||
|
||||
async function postProfileJson<T>(
|
||||
baseUrl: string | undefined,
|
||||
params: { path: string; profile?: string; body: unknown },
|
||||
): Promise<T> {
|
||||
const query = buildProfileQuery(params.profile);
|
||||
return await fetchBrowserJson<T>(withBaseUrl(baseUrl, `${params.path}${query}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params.body),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function postTargetedProfileJson(
|
||||
baseUrl: string | undefined,
|
||||
params: {
|
||||
path: string;
|
||||
opts: { targetId?: string; profile?: string };
|
||||
body: Record<string, unknown>;
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: params.path,
|
||||
profile: params.opts.profile,
|
||||
body: {
|
||||
targetId: params.opts.targetId,
|
||||
...params.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserCookies(
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||
const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile });
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
cookies: unknown[];
|
||||
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserCookiesSet(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
cookie: Record<string, unknown>;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/cookies/set",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, cookie: opts.cookie },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserCookiesClear(
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/cookies/clear",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStorageGet(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key?: string;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
|
||||
const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile });
|
||||
return await fetchBrowserJson<{
|
||||
ok: true;
|
||||
targetId: string;
|
||||
values: Record<string, string>;
|
||||
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
export async function browserStorageSet(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
kind: "local" | "session";
|
||||
key: string;
|
||||
value: string;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: `/storage/${opts.kind}/set`,
|
||||
profile: opts.profile,
|
||||
body: {
|
||||
targetId: opts.targetId,
|
||||
key: opts.key,
|
||||
value: opts.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStorageClear(
|
||||
baseUrl: string | undefined,
|
||||
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: `/storage/${opts.kind}/clear`,
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetOffline(
|
||||
baseUrl: string | undefined,
|
||||
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/offline",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, offline: opts.offline },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetHeaders(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
headers: Record<string, string>;
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/headers",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, headers: opts.headers },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetHttpCredentials(
|
||||
baseUrl: string | undefined,
|
||||
opts: HttpCredentialsOptions = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postTargetedProfileJson(baseUrl, {
|
||||
path: "/set/credentials",
|
||||
opts,
|
||||
body: {
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
clear: opts.clear,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetGeolocation(
|
||||
baseUrl: string | undefined,
|
||||
opts: GeolocationOptions = {},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postTargetedProfileJson(baseUrl, {
|
||||
path: "/set/geolocation",
|
||||
opts,
|
||||
body: {
|
||||
latitude: opts.latitude,
|
||||
longitude: opts.longitude,
|
||||
accuracy: opts.accuracy,
|
||||
origin: opts.origin,
|
||||
clear: opts.clear,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetMedia(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||
targetId?: string;
|
||||
profile?: string;
|
||||
},
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/media",
|
||||
profile: opts.profile,
|
||||
body: {
|
||||
targetId: opts.targetId,
|
||||
colorScheme: opts.colorScheme,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetTimezone(
|
||||
baseUrl: string | undefined,
|
||||
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/timezone",
|
||||
profile: opts.profile,
|
||||
body: {
|
||||
targetId: opts.targetId,
|
||||
timezoneId: opts.timezoneId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetLocale(
|
||||
baseUrl: string | undefined,
|
||||
opts: { locale: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/locale",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, locale: opts.locale },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSetDevice(
|
||||
baseUrl: string | undefined,
|
||||
opts: { name: string; targetId?: string; profile?: string },
|
||||
): Promise<BrowserActionTargetOk> {
|
||||
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
|
||||
path: "/set/device",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, name: opts.name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserClearPermissions(
|
||||
baseUrl: string | undefined,
|
||||
opts: { targetId?: string; profile?: string } = {},
|
||||
): Promise<BrowserActionOk> {
|
||||
return await postProfileJson<BrowserActionOk>(baseUrl, {
|
||||
path: "/set/geolocation",
|
||||
profile: opts.profile,
|
||||
body: { targetId: opts.targetId, clear: true },
|
||||
});
|
||||
}
|
||||
16
extensions/browser/src/browser/client-actions-types.ts
Normal file
16
extensions/browser/src/browser/client-actions-types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type BrowserActionOk = { ok: true };
|
||||
|
||||
export type BrowserActionTabResult = {
|
||||
ok: true;
|
||||
targetId: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type BrowserActionPathResult = {
|
||||
ok: true;
|
||||
path: string;
|
||||
targetId: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type BrowserActionTargetOk = { ok: true; targetId: string };
|
||||
11
extensions/browser/src/browser/client-actions-url.ts
Normal file
11
extensions/browser/src/browser/client-actions-url.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
export function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return path;
|
||||
}
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
4
extensions/browser/src/browser/client-actions.ts
Normal file
4
extensions/browser/src/browser/client-actions.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./client-actions-core.js";
|
||||
export * from "./client-actions-observe.js";
|
||||
export * from "./client-actions-state.js";
|
||||
export * from "./client-actions-types.js";
|
||||
345
extensions/browser/src/browser/client-fetch.ts
Normal file
345
extensions/browser/src/browser/client-fetch.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { getBridgeAuthForPort } from "./bridge-auth-registry.js";
|
||||
import { resolveBrowserControlAuth } from "./control-auth.js";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "./control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "./routes/dispatcher.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 = {
|
||||
loadConfig: typeof loadConfig;
|
||||
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.loadConfig();
|
||||
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 parsed = new URL(url);
|
||||
const port =
|
||||
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
||||
? Number.parseInt(parsed.port, 10)
|
||||
: parsed.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
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, {
|
||||
loadConfig,
|
||||
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_SERVICE_RATE_LIMIT_MESSAGE =
|
||||
"Browser service rate limit reached. " +
|
||||
"Wait for the current session to complete, or retry later.";
|
||||
|
||||
const BROWSERBASE_RATE_LIMIT_MESSAGE =
|
||||
"Browserbase rate limit reached (max concurrent sessions). " +
|
||||
"Wait for the current session to complete, or upgrade your plan.";
|
||||
|
||||
function isRateLimitStatus(status: number): boolean {
|
||||
return status === 429;
|
||||
}
|
||||
|
||||
function isBrowserbaseUrl(url: string): boolean {
|
||||
if (!isAbsoluteHttp(url)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return host === "browserbase.com" || host.endsWith(".browserbase.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBrowserRateLimitMessage(url: string): string {
|
||||
return isBrowserbaseUrl(url)
|
||||
? BROWSERBASE_RATE_LIMIT_MESSAGE
|
||||
: BROWSER_SERVICE_RATE_LIMIT_MESSAGE;
|
||||
}
|
||||
|
||||
function resolveBrowserFetchOperatorHint(url: string): string {
|
||||
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 {
|
||||
if (err instanceof Error && err.message.trim().length > 0) {
|
||||
return err.message.trim();
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function appendBrowserToolModelHint(message: string): string {
|
||||
if (message.includes(BROWSER_TOOL_MODEL_HINT)) {
|
||||
return message;
|
||||
}
|
||||
return `${message} ${BROWSER_TOOL_MODEL_HINT}`;
|
||||
}
|
||||
|
||||
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 suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`;
|
||||
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 = String(err);
|
||||
const msgLower = msg.toLowerCase();
|
||||
const looksLikeTimeout =
|
||||
msgLower.includes("timed out") ||
|
||||
msgLower.includes("timeout") ||
|
||||
msgLower.includes("aborted") ||
|
||||
msgLower.includes("abort") ||
|
||||
msgLower.includes("aborterror");
|
||||
if (looksLikeTimeout) {
|
||||
return new Error(
|
||||
appendBrowserToolModelHint(
|
||||
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
appendBrowserToolModelHint(
|
||||
`Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHttpJson<T>(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init.timeoutMs ?? 5000;
|
||||
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);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new BrowserServiceError(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
if (upstreamSignal && upstreamAbortListener) {
|
||||
upstreamSignal.removeEventListener("abort", upstreamAbortListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBrowserJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit & { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
let isDispatcherPath = false;
|
||||
try {
|
||||
if (isAbsoluteHttp(url)) {
|
||||
const httpInit = withLoopbackBrowserAuth(url, init);
|
||||
return await fetchHttpJson<T>(url, { ...httpInit, timeoutMs });
|
||||
}
|
||||
isDispatcherPath = true;
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) {
|
||||
throw new Error("browser control disabled");
|
||||
}
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
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(abortCtrl.signal.reason ?? new Error("aborted"))
|
||||
: new Promise((_, reject) => {
|
||||
abortListener = () => reject(abortCtrl.signal.reason ?? new Error("aborted"));
|
||||
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 = dispatcher.dispatch({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
|
||||
};
|
||||
351
extensions/browser/src/browser/client.ts
Normal file
351
extensions/browser/src/browser/client.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
export type BrowserTransport = "cdp" | "chrome-mcp";
|
||||
|
||||
export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
profile?: string;
|
||||
driver?: "openclaw" | "existing-session";
|
||||
transport?: BrowserTransport;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
cdpHttp?: 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;
|
||||
noSandbox?: boolean;
|
||||
executablePath?: string | null;
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type BrowserResetProfileResult = {
|
||||
ok: true;
|
||||
moved: boolean;
|
||||
from: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type BrowserTab = {
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
wsUrl?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type SnapshotAriaNode = {
|
||||
ref: string;
|
||||
role: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
backendDOMNodeId?: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type SnapshotResult =
|
||||
| {
|
||||
ok: true;
|
||||
format: "aria";
|
||||
targetId: string;
|
||||
url: string;
|
||||
nodes: SnapshotAriaNode[];
|
||||
}
|
||||
| {
|
||||
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;
|
||||
imagePath?: string;
|
||||
imageType?: "png" | "jpeg";
|
||||
};
|
||||
|
||||
function buildProfileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return path;
|
||||
}
|
||||
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
export async function browserStatus(
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserStatus> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
|
||||
timeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
|
||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
|
||||
withBaseUrl(baseUrl, `/profiles`),
|
||||
{
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
);
|
||||
return res.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
|
||||
method: "POST",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserCreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
transport?: BrowserTransport;
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
userDataDir: string | null;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
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: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: opts.name,
|
||||
color: opts.color,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
userDataDir: opts.userDataDir,
|
||||
driver: opts.driver,
|
||||
}),
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDeleteProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export async function browserDeleteProfile(
|
||||
baseUrl: string | undefined,
|
||||
profile: string,
|
||||
): Promise<BrowserDeleteProfileResult> {
|
||||
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
||||
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserTabs(
|
||||
baseUrl?: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab[]> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
withBaseUrl(baseUrl, `/tabs${q}`),
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
return res.tabs ?? [];
|
||||
}
|
||||
|
||||
export async function browserOpenTab(
|
||||
baseUrl: string | undefined,
|
||||
url: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<BrowserTab> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserFocusTab(
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId }),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserCloseTab(
|
||||
baseUrl: string | undefined,
|
||||
targetId: string,
|
||||
opts?: { profile?: string },
|
||||
): Promise<void> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
|
||||
method: "DELETE",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
mode?: "efficient";
|
||||
profile?: string;
|
||||
},
|
||||
): 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.mode) {
|
||||
q.set("mode", opts.mode);
|
||||
}
|
||||
if (opts.profile) {
|
||||
q.set("profile", opts.profile);
|
||||
}
|
||||
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
// Actions beyond the basic read-only commands live in client-actions.ts.
|
||||
365
extensions/browser/src/browser/config.ts
Normal file
365
extensions/browser/src/browser/config.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import {
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
DEFAULT_BROWSER_CONTROL_PORT,
|
||||
} from "../config/port-defaults.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
import { CDP_PORT_RANGE_START } from "./profiles.js";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
cdpPortRangeStart: number;
|
||||
cdpPortRangeEnd: number;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
noSandbox: boolean;
|
||||
attachOnly: boolean;
|
||||
defaultProfile: string;
|
||||
profiles: Record<string, BrowserProfileConfig>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
extraArgs: string[];
|
||||
};
|
||||
|
||||
export type ResolvedBrowserProfile = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
userDataDir?: string;
|
||||
color: string;
|
||||
driver: "openclaw" | "existing-session";
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
function normalizeHexColor(raw: string | undefined) {
|
||||
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) {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
|
||||
return value < 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function resolveCdpPortRangeStart(
|
||||
rawStart: number | undefined,
|
||||
fallbackStart: number,
|
||||
rangeSpan: 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;
|
||||
}
|
||||
|
||||
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const values = raw
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is string => value.length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
||||
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
|
||||
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
|
||||
const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist);
|
||||
const hasExplicitPrivateSetting =
|
||||
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
||||
// Browser defaults to trusted-network mode unless explicitly disabled by policy.
|
||||
const resolvedAllowPrivateNetwork =
|
||||
dangerouslyAllowPrivateNetwork === true ||
|
||||
allowPrivateNetwork === true ||
|
||||
!hasExplicitPrivateSetting;
|
||||
|
||||
if (
|
||||
!resolvedAllowPrivateNetwork &&
|
||||
!hasExplicitPrivateSetting &&
|
||||
!allowedHostnames &&
|
||||
!hostnameAllowlist
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHttpUrl(raw: string, label: string) {
|
||||
const trimmed = raw.trim();
|
||||
const parsed = new URL(trimmed);
|
||||
const allowed = ["http:", "https:", "ws:", "wss:"];
|
||||
if (!allowed.includes(parsed.protocol)) {
|
||||
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
|
||||
}
|
||||
|
||||
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
||||
const port =
|
||||
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
||||
? Number.parseInt(parsed.port, 10)
|
||||
: isSecure
|
||||
? 443
|
||||
: 80;
|
||||
|
||||
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`${label} has invalid port: ${parsed.port}`);
|
||||
}
|
||||
|
||||
return {
|
||||
parsed,
|
||||
port,
|
||||
normalized: parsed.toString().replace(/\/$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the default "openclaw" profile exists in the profiles map.
|
||||
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
|
||||
*/
|
||||
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 ?? CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
// Preserve the full cdpUrl for ws/wss endpoints so resolveProfile()
|
||||
// doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops
|
||||
// the WebSocket protocol and query params like API keys).
|
||||
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a built-in "user" profile exists for Chrome's existing-session attach flow.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 = parseHttpUrl(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 noSandbox = cfg?.noSandbox === true;
|
||||
const attachOnly = cfg?.attachOnly === true;
|
||||
const executablePath = cfg?.executablePath?.trim() || undefined;
|
||||
|
||||
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
||||
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
|
||||
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
|
||||
const 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");
|
||||
|
||||
const extraArgs = Array.isArray(cfg?.extraArgs)
|
||||
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
||||
: [];
|
||||
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
|
||||
return {
|
||||
enabled,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
cdpPortRangeStart,
|
||||
cdpPortRangeEnd,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
noSandbox,
|
||||
attachOnly,
|
||||
defaultProfile,
|
||||
profiles,
|
||||
ssrfPolicy,
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile by name from the config.
|
||||
* Returns null if the profile doesn't exist.
|
||||
*/
|
||||
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";
|
||||
|
||||
if (driver === "existing-session") {
|
||||
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
cdpHost: "",
|
||||
cdpIsLoopback: true,
|
||||
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (rawProfileUrl) {
|
||||
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
|
||||
cdpHost = parsed.parsed.hostname;
|
||||
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,
|
||||
attachOnly: profile.attachOnly ?? resolved.attachOnly,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
|
||||
return true;
|
||||
}
|
||||
8
extensions/browser/src/browser/constants.ts
Normal file
8
extensions/browser/src/browser/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
|
||||
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;
|
||||
98
extensions/browser/src/browser/control-auth.ts
Normal file
98
extensions/browser/src/browser/control-auth.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
|
||||
|
||||
export type BrowserControlAuth = {
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export function resolveBrowserControlAuth(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): BrowserControlAuth {
|
||||
const auth = resolveGatewayAuth({
|
||||
authConfig: cfg?.gateway?.auth,
|
||||
env,
|
||||
tailscaleMode: cfg?.gateway?.tailscale?.mode,
|
||||
});
|
||||
const token = typeof auth.token === "string" ? auth.token.trim() : "";
|
||||
const password = typeof auth.password === "string" ? auth.password.trim() : "";
|
||||
return {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
|
||||
const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
|
||||
if (nodeEnv === "test") {
|
||||
return false;
|
||||
}
|
||||
const vitest = (env.VITEST ?? "").trim().toLowerCase();
|
||||
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
if (params.cfg.gateway?.auth?.mode === "none") {
|
||||
return { auth };
|
||||
}
|
||||
|
||||
if (params.cfg.gateway?.auth?.mode === "trusted-proxy") {
|
||||
return { auth };
|
||||
}
|
||||
|
||||
// Re-read latest config to avoid racing with concurrent config writers.
|
||||
const latestCfg = loadConfig();
|
||||
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
|
||||
if (latestAuth.token || latestAuth.password) {
|
||||
return { auth: latestAuth };
|
||||
}
|
||||
if (latestCfg.gateway?.auth?.mode === "password") {
|
||||
return { auth: latestAuth };
|
||||
}
|
||||
if (latestCfg.gateway?.auth?.mode === "none") {
|
||||
return { auth: latestAuth };
|
||||
}
|
||||
if (latestCfg.gateway?.auth?.mode === "trusted-proxy") {
|
||||
return { auth: latestAuth };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
1
extensions/browser/src/browser/control-service.ts
Normal file
1
extensions/browser/src/browser/control-service.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../control-service.js";
|
||||
87
extensions/browser/src/browser/csrf.ts
Normal file
87
extensions/browser/src/browser/csrf.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = (params.secFetchSite ?? "").trim().toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
}
|
||||
85
extensions/browser/src/browser/errors.ts
Normal file
85
extensions/browser/src/browser/errors.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserValidationError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 400, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserConfigurationError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 400, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserTargetAmbiguousError extends BrowserError {
|
||||
constructor(message = "ambiguous target id prefix", options?: ErrorOptions) {
|
||||
super(message, 409, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserTabNotFoundError extends BrowserError {
|
||||
constructor(message = "tab not found", options?: ErrorOptions) {
|
||||
super(message, 404, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserProfileNotFoundError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 404, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserConflictError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 409, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserResetUnsupportedError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 400, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserProfileUnavailableError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 409, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserResourceExhaustedError extends BrowserError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, 507, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function toBrowserErrorResponse(err: unknown): {
|
||||
status: number;
|
||||
message: string;
|
||||
} | null {
|
||||
if (err instanceof BrowserError) {
|
||||
return { status: err.status, message: err.message };
|
||||
}
|
||||
if (err instanceof SsrFBlockedError) {
|
||||
return { status: 400, message: err.message };
|
||||
}
|
||||
if (
|
||||
err instanceof InvalidBrowserNavigationUrlError ||
|
||||
(err instanceof Error && err.name === "InvalidBrowserNavigationUrlError")
|
||||
) {
|
||||
return { status: 400, message: err.message };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
32
extensions/browser/src/browser/form-fields.ts
Normal file
32
extensions/browser/src/browser/form-fields.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { BrowserFormField } from "./client-actions-core.js";
|
||||
|
||||
export const DEFAULT_FILL_FIELD_TYPE = "text";
|
||||
|
||||
type BrowserFormFieldValue = NonNullable<BrowserFormField["value"]>;
|
||||
|
||||
export function normalizeBrowserFormFieldRef(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeBrowserFormFieldType(value: unknown): string {
|
||||
const type = typeof value === "string" ? value.trim() : "";
|
||||
return type || DEFAULT_FILL_FIELD_TYPE;
|
||||
}
|
||||
|
||||
export function normalizeBrowserFormFieldValue(value: unknown): BrowserFormFieldValue | undefined {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
63
extensions/browser/src/browser/http-auth.ts
Normal file
63
extensions/browser/src/browser/http-auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
function parseBearerToken(authorization: string): string | undefined {
|
||||
if (!authorization || !authorization.toLowerCase().startsWith("bearer ")) {
|
||||
return undefined;
|
||||
}
|
||||
const token = authorization.slice(7).trim();
|
||||
return token || undefined;
|
||||
}
|
||||
|
||||
function parseBasicPassword(authorization: string): string | undefined {
|
||||
if (!authorization || !authorization.toLowerCase().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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
134
extensions/browser/src/browser/navigation-guard.ts
Normal file
134
extensions/browser/src/browser/navigation-guard.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
|
||||
import {
|
||||
isPrivateNetworkAllowedByPolicy,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "../infra/net/ssrf.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);
|
||||
}
|
||||
|
||||
export class InvalidBrowserNavigationUrlError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidBrowserNavigationUrlError";
|
||||
}
|
||||
}
|
||||
|
||||
export type BrowserNavigationPolicyOptions = {
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
};
|
||||
|
||||
export type BrowserNavigationRequestLike = {
|
||||
url(): string;
|
||||
redirectedFrom(): BrowserNavigationRequestLike | null;
|
||||
};
|
||||
|
||||
export function withBrowserNavigationPolicy(
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): BrowserNavigationPolicyOptions {
|
||||
return ssrfPolicy ? { ssrfPolicy } : {};
|
||||
}
|
||||
|
||||
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
|
||||
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
||||
}
|
||||
|
||||
export async function assertBrowserNavigationAllowed(
|
||||
opts: {
|
||||
url: string;
|
||||
lookupFn?: LookupFn;
|
||||
} & BrowserNavigationPolicyOptions,
|
||||
): Promise<void> {
|
||||
const rawUrl = String(opts.url ?? "").trim();
|
||||
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 network stacks may apply env proxy routing at connect-time, which
|
||||
// can bypass strict destination-binding intent from pre-navigation DNS checks.
|
||||
// In strict mode, fail closed unless private-network navigation is explicitly
|
||||
// enabled by policy.
|
||||
if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) {
|
||||
throw new InvalidBrowserNavigationUrlError(
|
||||
"Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set",
|
||||
);
|
||||
}
|
||||
|
||||
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://).
|
||||
*/
|
||||
export async function assertBrowserNavigationResultAllowed(
|
||||
opts: {
|
||||
url: string;
|
||||
lookupFn?: LookupFn;
|
||||
} & BrowserNavigationPolicyOptions,
|
||||
): Promise<void> {
|
||||
const rawUrl = String(opts.url ?? "").trim();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
51
extensions/browser/src/browser/output-atomic.ts
Normal file
51
extensions/browser/src/browser/output-atomic.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js";
|
||||
import { sanitizeUntrustedFileName } from "./safe-filename.js";
|
||||
|
||||
function buildSiblingTempPath(targetPath: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
|
||||
return path.join(path.dirname(targetPath), `.openclaw-output-${id}-${safeTail}.part`);
|
||||
}
|
||||
|
||||
export async function writeViaSiblingTempPath(params: {
|
||||
rootDir: string;
|
||||
targetPath: string;
|
||||
writeTemp: (tempPath: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const rootDir = await fs
|
||||
.realpath(path.resolve(params.rootDir))
|
||||
.catch(() => path.resolve(params.rootDir));
|
||||
const requestedTargetPath = path.resolve(params.targetPath);
|
||||
const targetPath = await fs
|
||||
.realpath(path.dirname(requestedTargetPath))
|
||||
.then((realDir) => path.join(realDir, path.basename(requestedTargetPath)))
|
||||
.catch(() => requestedTargetPath);
|
||||
const relativeTargetPath = path.relative(rootDir, targetPath);
|
||||
if (
|
||||
!relativeTargetPath ||
|
||||
relativeTargetPath === ".." ||
|
||||
relativeTargetPath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativeTargetPath)
|
||||
) {
|
||||
throw new Error("Target path is outside the allowed root");
|
||||
}
|
||||
const tempPath = buildSiblingTempPath(targetPath);
|
||||
let renameSucceeded = false;
|
||||
try {
|
||||
await params.writeTemp(tempPath);
|
||||
await writeFileFromPathWithinRoot({
|
||||
rootDir,
|
||||
relativePath: relativeTargetPath,
|
||||
sourcePath: tempPath,
|
||||
mkdir: false,
|
||||
});
|
||||
renameSucceeded = true;
|
||||
} finally {
|
||||
if (!renameSucceeded) {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
256
extensions/browser/src/browser/paths.ts
Normal file
256
extensions/browser/src/browser/paths.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir();
|
||||
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
|
||||
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
|
||||
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
|
||||
|
||||
type InvalidPathResult = { ok: false; error: string };
|
||||
|
||||
function invalidPath(scopeLabel: string): InvalidPathResult {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid path: must stay within ${scopeLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRealPathIfExists(targetPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.realpath(targetPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTrustedRootRealPath(rootDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const rootLstat = await fs.lstat(rootDir);
|
||||
if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
return await fs.realpath(rootDir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCanonicalPathWithinRoot(params: {
|
||||
rootRealPath: string;
|
||||
candidatePath: string;
|
||||
expect: "directory" | "file";
|
||||
}): Promise<"ok" | "not-found" | "invalid"> {
|
||||
try {
|
||||
const candidateLstat = await fs.lstat(params.candidatePath);
|
||||
if (candidateLstat.isSymbolicLink()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "directory" && !candidateLstat.isDirectory()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "file" && !candidateLstat.isFile()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "file" && candidateLstat.nlink > 1) {
|
||||
return "invalid";
|
||||
}
|
||||
const candidateRealPath = await fs.realpath(params.candidatePath);
|
||||
return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid";
|
||||
} catch (err) {
|
||||
return isNotFoundPathError(err) ? "not-found" : "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePathWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPath: string;
|
||||
scopeLabel: string;
|
||||
defaultFileName?: string;
|
||||
}): { ok: true; path: string } | { ok: false; error: string } {
|
||||
const root = path.resolve(params.rootDir);
|
||||
const raw = params.requestedPath.trim();
|
||||
if (!raw) {
|
||||
if (!params.defaultFileName) {
|
||||
return { ok: false, error: "path is required" };
|
||||
}
|
||||
return { ok: true, path: path.join(root, params.defaultFileName) };
|
||||
}
|
||||
const resolved = path.resolve(root, raw);
|
||||
const rel = path.relative(root, resolved);
|
||||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
|
||||
}
|
||||
return { ok: true, path: resolved };
|
||||
}
|
||||
|
||||
export async function resolveWritablePathWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPath: string;
|
||||
scopeLabel: string;
|
||||
defaultFileName?: string;
|
||||
}): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
|
||||
const lexical = resolvePathWithinRoot(params);
|
||||
if (!lexical.ok) {
|
||||
return lexical;
|
||||
}
|
||||
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
const rootRealPath = await resolveTrustedRootRealPath(rootDir);
|
||||
if (!rootRealPath) {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
const requestedPath = lexical.path;
|
||||
const parentDir = path.dirname(requestedPath);
|
||||
const parentStatus = await validateCanonicalPathWithinRoot({
|
||||
rootRealPath,
|
||||
candidatePath: parentDir,
|
||||
expect: "directory",
|
||||
});
|
||||
if (parentStatus !== "ok") {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
const targetStatus = await validateCanonicalPathWithinRoot({
|
||||
rootRealPath,
|
||||
candidatePath: requestedPath,
|
||||
expect: "file",
|
||||
});
|
||||
if (targetStatus === "invalid") {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
return lexical;
|
||||
}
|
||||
|
||||
export function resolvePathsWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPaths: string[];
|
||||
scopeLabel: string;
|
||||
}): { ok: true; paths: string[] } | { ok: false; error: string } {
|
||||
const resolvedPaths: string[] = [];
|
||||
for (const raw of params.requestedPaths) {
|
||||
const pathResult = resolvePathWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
requestedPath: raw,
|
||||
scopeLabel: params.scopeLabel,
|
||||
});
|
||||
if (!pathResult.ok) {
|
||||
return { ok: false, error: pathResult.error };
|
||||
}
|
||||
resolvedPaths.push(pathResult.path);
|
||||
}
|
||||
return { ok: true, paths: resolvedPaths };
|
||||
}
|
||||
|
||||
export async function resolveExistingPathsWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPaths: string[];
|
||||
scopeLabel: string;
|
||||
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
|
||||
return await resolveCheckedPathsWithinRoot({
|
||||
...params,
|
||||
allowMissingFallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveStrictExistingPathsWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPaths: string[];
|
||||
scopeLabel: string;
|
||||
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
|
||||
return await resolveCheckedPathsWithinRoot({
|
||||
...params,
|
||||
allowMissingFallback: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveCheckedPathsWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPaths: string[];
|
||||
scopeLabel: string;
|
||||
allowMissingFallback: boolean;
|
||||
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
// Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks.
|
||||
const rootRealPath = await resolveRealPathIfExists(rootDir);
|
||||
|
||||
const isInRoot = (relativePath: string) =>
|
||||
Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
|
||||
const resolveExistingRelativePath = async (
|
||||
requestedPath: string,
|
||||
): Promise<
|
||||
{ ok: true; relativePath: string; fallbackPath: string } | { ok: false; error: string }
|
||||
> => {
|
||||
const raw = requestedPath.trim();
|
||||
const lexicalPathResult = resolvePathWithinRoot({
|
||||
rootDir,
|
||||
requestedPath,
|
||||
scopeLabel: params.scopeLabel,
|
||||
});
|
||||
if (lexicalPathResult.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
relativePath: path.relative(rootDir, lexicalPathResult.path),
|
||||
fallbackPath: lexicalPathResult.path,
|
||||
};
|
||||
}
|
||||
if (!rootRealPath || !raw || !path.isAbsolute(raw)) {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
try {
|
||||
const resolvedExistingPath = await fs.realpath(raw);
|
||||
const relativePath = path.relative(rootRealPath, resolvedExistingPath);
|
||||
if (!isInRoot(relativePath)) {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
relativePath,
|
||||
fallbackPath: resolvedExistingPath,
|
||||
};
|
||||
} catch {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedPaths: string[] = [];
|
||||
for (const raw of params.requestedPaths) {
|
||||
const pathResult = await resolveExistingRelativePath(raw);
|
||||
if (!pathResult.ok) {
|
||||
return { ok: false, error: pathResult.error };
|
||||
}
|
||||
|
||||
let opened: Awaited<ReturnType<typeof openFileWithinRoot>> | undefined;
|
||||
try {
|
||||
opened = await openFileWithinRoot({
|
||||
rootDir,
|
||||
relativePath: pathResult.relativePath,
|
||||
});
|
||||
resolvedPaths.push(opened.realPath);
|
||||
} catch (err) {
|
||||
if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") {
|
||||
// Preserve historical behavior for paths that do not exist yet.
|
||||
resolvedPaths.push(pathResult.fallbackPath);
|
||||
continue;
|
||||
}
|
||||
if (err instanceof SafeOpenError && err.code === "outside-workspace") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File is outside ${params.scopeLabel}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`,
|
||||
};
|
||||
} finally {
|
||||
await opened?.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
return { ok: true, paths: resolvedPaths };
|
||||
}
|
||||
1
extensions/browser/src/browser/plugin-enabled.ts
Normal file
1
extensions/browser/src/browser/plugin-enabled.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../plugin-enabled.js";
|
||||
1
extensions/browser/src/browser/plugin-service.ts
Normal file
1
extensions/browser/src/browser/plugin-service.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../plugin-service.js";
|
||||
93
extensions/browser/src/browser/profile-capabilities.ts
Normal file
93
extensions/browser/src/browser/profile-capabilities.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
|
||||
export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp";
|
||||
|
||||
export type BrowserProfileCapabilities = {
|
||||
mode: BrowserProfileMode;
|
||||
isRemote: boolean;
|
||||
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
|
||||
usesChromeMcp: boolean;
|
||||
usesPersistentPlaywright: boolean;
|
||||
supportsPerTabWs: boolean;
|
||||
supportsJsonTabEndpoints: boolean;
|
||||
supportsReset: boolean;
|
||||
supportsManagedTabLimit: boolean;
|
||||
};
|
||||
|
||||
export function getBrowserProfileCapabilities(
|
||||
profile: ResolvedBrowserProfile,
|
||||
): BrowserProfileCapabilities {
|
||||
if (profile.driver === "existing-session") {
|
||||
return {
|
||||
mode: "local-existing-session",
|
||||
isRemote: false,
|
||||
usesChromeMcp: true,
|
||||
usesPersistentPlaywright: false,
|
||||
supportsPerTabWs: false,
|
||||
supportsJsonTabEndpoints: false,
|
||||
supportsReset: false,
|
||||
supportsManagedTabLimit: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!profile.cdpIsLoopback) {
|
||||
return {
|
||||
mode: "remote-cdp",
|
||||
isRemote: true,
|
||||
usesChromeMcp: false,
|
||||
usesPersistentPlaywright: true,
|
||||
supportsPerTabWs: false,
|
||||
supportsJsonTabEndpoints: false,
|
||||
supportsReset: false,
|
||||
supportsManagedTabLimit: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "local-managed",
|
||||
isRemote: false,
|
||||
usesChromeMcp: false,
|
||||
usesPersistentPlaywright: false,
|
||||
supportsPerTabWs: true,
|
||||
supportsJsonTabEndpoints: true,
|
||||
supportsReset: true,
|
||||
supportsManagedTabLimit: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultSnapshotFormat(params: {
|
||||
profile: ResolvedBrowserProfile;
|
||||
hasPlaywright: boolean;
|
||||
explicitFormat?: "ai" | "aria";
|
||||
mode?: "efficient";
|
||||
}): "ai" | "aria" {
|
||||
if (params.explicitFormat) {
|
||||
return params.explicitFormat;
|
||||
}
|
||||
if (params.mode === "efficient") {
|
||||
return "ai";
|
||||
}
|
||||
|
||||
const capabilities = getBrowserProfileCapabilities(params.profile);
|
||||
if (capabilities.mode === "local-existing-session") {
|
||||
return "ai";
|
||||
}
|
||||
|
||||
return params.hasPlaywright ? "ai" : "aria";
|
||||
}
|
||||
|
||||
export function shouldUsePlaywrightForScreenshot(params: {
|
||||
profile: ResolvedBrowserProfile;
|
||||
wsUrl?: string;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
}): boolean {
|
||||
return !params.wsUrl || Boolean(params.ref) || Boolean(params.element);
|
||||
}
|
||||
|
||||
export function shouldUsePlaywrightForAriaSnapshot(params: {
|
||||
profile: ResolvedBrowserProfile;
|
||||
wsUrl?: string;
|
||||
}): boolean {
|
||||
return !params.wsUrl;
|
||||
}
|
||||
258
extensions/browser/src/browser/profiles-service.ts
Normal file
258
extensions/browser/src/browser/profiles-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||
import {
|
||||
BrowserConflictError,
|
||||
BrowserProfileNotFoundError,
|
||||
BrowserResourceExhaustedError,
|
||||
BrowserValidationError,
|
||||
} from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import {
|
||||
allocateCdpPort,
|
||||
allocateColor,
|
||||
getUsedColors,
|
||||
getUsedPorts,
|
||||
isValidProfileName,
|
||||
} from "./profiles.js";
|
||||
import type { BrowserRouteContext, ProfileStatus } from "./server-context.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
export type CreateProfileParams = {
|
||||
name: string;
|
||||
color?: string;
|
||||
cdpUrl?: string;
|
||||
userDataDir?: string;
|
||||
driver?: "openclaw" | "existing-session";
|
||||
};
|
||||
|
||||
export type CreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
transport: "cdp" | "chrome-mcp";
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
userDataDir: string | null;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
export type DeleteProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const listProfiles = async (): Promise<ProfileStatus[]> => {
|
||||
return await ctx.listProfiles();
|
||||
};
|
||||
|
||||
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
|
||||
const name = params.name.trim();
|
||||
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
|
||||
const rawUserDataDir = params.userDataDir?.trim() || undefined;
|
||||
const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined;
|
||||
const driver = params.driver === "existing-session" ? "existing-session" : undefined;
|
||||
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new BrowserValidationError(
|
||||
"invalid profile name: use lowercase letters, numbers, and hyphens only",
|
||||
);
|
||||
}
|
||||
|
||||
const state = ctx.state();
|
||||
const resolvedProfiles = state.resolved.profiles;
|
||||
if (name in resolvedProfiles) {
|
||||
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const rawProfiles = cfg.browser?.profiles ?? {};
|
||||
if (name in rawProfiles) {
|
||||
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const usedColors = getUsedColors(resolvedProfiles);
|
||||
const profileColor =
|
||||
params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors);
|
||||
|
||||
let profileConfig: BrowserProfileConfig;
|
||||
if (normalizedUserDataDir && driver !== "existing-session") {
|
||||
throw new BrowserValidationError(
|
||||
"driver=existing-session is required when userDataDir is provided",
|
||||
);
|
||||
}
|
||||
if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) {
|
||||
throw new BrowserValidationError(
|
||||
`browser user data directory not found: ${normalizedUserDataDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rawCdpUrl) {
|
||||
let parsed: ReturnType<typeof parseHttpUrl>;
|
||||
try {
|
||||
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
||||
} catch (err) {
|
||||
throw new BrowserValidationError(String(err));
|
||||
}
|
||||
if (driver === "existing-session") {
|
||||
throw new BrowserValidationError(
|
||||
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
|
||||
);
|
||||
}
|
||||
profileConfig = {
|
||||
cdpUrl: parsed.normalized,
|
||||
...(driver ? { driver } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
} else {
|
||||
if (driver === "existing-session") {
|
||||
// existing-session uses Chrome MCP auto-connect; no CDP port needed
|
||||
profileConfig = {
|
||||
driver,
|
||||
attachOnly: true,
|
||||
...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
} else {
|
||||
const usedPorts = getUsedPorts(resolvedProfiles);
|
||||
const range = cdpPortRange(state.resolved);
|
||||
const cdpPort = allocateCdpPort(usedPorts, range);
|
||||
if (cdpPort === null) {
|
||||
throw new BrowserResourceExhaustedError("no available CDP ports in range");
|
||||
}
|
||||
profileConfig = {
|
||||
cdpPort,
|
||||
...(driver ? { driver } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfig: OpenClawConfig = {
|
||||
...cfg,
|
||||
browser: {
|
||||
...cfg.browser,
|
||||
profiles: {
|
||||
...rawProfiles,
|
||||
[name]: profileConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
state.resolved.profiles[name] = profileConfig;
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
if (!resolved) {
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
|
||||
}
|
||||
const capabilities = getBrowserProfileCapabilities(resolved);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile: name,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl,
|
||||
userDataDir: resolved.userDataDir ?? null,
|
||||
color: resolved.color,
|
||||
isRemote: !resolved.cdpIsLoopback,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
|
||||
const name = nameRaw.trim();
|
||||
if (!name) {
|
||||
throw new BrowserValidationError("profile name is required");
|
||||
}
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new BrowserValidationError("invalid profile name");
|
||||
}
|
||||
|
||||
const state = ctx.state();
|
||||
const cfg = loadConfig();
|
||||
const profiles = cfg.browser?.profiles ?? {};
|
||||
const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile;
|
||||
if (name === defaultProfile) {
|
||||
throw new BrowserValidationError(
|
||||
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
||||
);
|
||||
}
|
||||
if (!(name in profiles)) {
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
|
||||
}
|
||||
|
||||
let deleted = false;
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
|
||||
if (resolved?.cdpIsLoopback && resolved.driver === "openclaw") {
|
||||
try {
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const userDataDir = resolveOpenClawUserDataDir(name);
|
||||
const profileDir = path.dirname(userDataDir);
|
||||
if (fs.existsSync(profileDir)) {
|
||||
await movePathToTrash(profileDir);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const { [name]: _removed, ...remainingProfiles } = profiles;
|
||||
const nextConfig: OpenClawConfig = {
|
||||
...cfg,
|
||||
browser: {
|
||||
...cfg.browser,
|
||||
profiles: remainingProfiles,
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
delete state.resolved.profiles[name];
|
||||
state.profiles.delete(name);
|
||||
|
||||
return { ok: true, profile: name, deleted };
|
||||
};
|
||||
|
||||
return {
|
||||
listProfiles,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
};
|
||||
}
|
||||
113
extensions/browser/src/browser/profiles.ts
Normal file
113
extensions/browser/src/browser/profiles.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* CDP port allocation for browser profiles.
|
||||
*
|
||||
* Default port range: 18800-18899 (100 profiles max)
|
||||
* Ports are allocated once at profile creation and persisted in config.
|
||||
* Multi-instance: callers may pass an explicit range to avoid collisions.
|
||||
*
|
||||
* Reserved ports (do not use for CDP):
|
||||
* 18789 - Gateway WebSocket
|
||||
* 18790 - Bridge
|
||||
* 18791 - Browser control server
|
||||
* 18792-18799 - Reserved for future one-off services (canvas at 18793)
|
||||
*/
|
||||
|
||||
export const CDP_PORT_RANGE_START = 18800;
|
||||
export const CDP_PORT_RANGE_END = 18899;
|
||||
|
||||
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export function isValidProfileName(name: string): boolean {
|
||||
if (!name || name.length > 64) {
|
||||
return false;
|
||||
}
|
||||
return PROFILE_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
export function allocateCdpPort(
|
||||
usedPorts: Set<number>,
|
||||
range?: { start: number; end: number },
|
||||
): number | null {
|
||||
const start = range?.start ?? CDP_PORT_RANGE_START;
|
||||
const end = range?.end ?? CDP_PORT_RANGE_END;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (start > end) {
|
||||
return null;
|
||||
}
|
||||
for (let port = start; port <= end; port++) {
|
||||
if (!usedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUsedPorts(
|
||||
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
|
||||
): Set<number> {
|
||||
if (!profiles) {
|
||||
return new Set();
|
||||
}
|
||||
const used = new Set<number>();
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (typeof profile.cdpPort === "number") {
|
||||
used.add(profile.cdpPort);
|
||||
continue;
|
||||
}
|
||||
const rawUrl = profile.cdpUrl?.trim();
|
||||
if (!rawUrl) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const port =
|
||||
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
||||
? Number.parseInt(parsed.port, 10)
|
||||
: parsed.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
|
||||
used.add(port);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
export const PROFILE_COLORS = [
|
||||
"#FF4500", // Orange-red (openclaw default)
|
||||
"#0066CC", // Blue
|
||||
"#00AA00", // Green
|
||||
"#9933FF", // Purple
|
||||
"#FF6699", // Pink
|
||||
"#00CCCC", // Cyan
|
||||
"#FF9900", // Orange
|
||||
"#6666FF", // Indigo
|
||||
"#CC3366", // Magenta
|
||||
"#339966", // Teal
|
||||
];
|
||||
|
||||
export function allocateColor(usedColors: Set<string>): string {
|
||||
// Find first unused color from palette
|
||||
for (const color of PROFILE_COLORS) {
|
||||
if (!usedColors.has(color.toUpperCase())) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
// All colors used, cycle based on count
|
||||
const index = usedColors.size % PROFILE_COLORS.length;
|
||||
return PROFILE_COLORS[index] ?? PROFILE_COLORS[0];
|
||||
}
|
||||
|
||||
export function getUsedColors(
|
||||
profiles: Record<string, { color: string }> | undefined,
|
||||
): Set<string> {
|
||||
if (!profiles) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
|
||||
}
|
||||
40
extensions/browser/src/browser/proxy-files.ts
Normal file
40
extensions/browser/src/browser/proxy-files.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
|
||||
export type BrowserProxyFile = {
|
||||
path: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
if (!files || files.length === 0) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const mapping = new Map<string, string>();
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(file.base64, "base64");
|
||||
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser");
|
||||
mapping.set(file.path, saved.path);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function applyBrowserProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
if (!result || typeof result !== "object") {
|
||||
return;
|
||||
}
|
||||
const obj = result as Record<string, unknown>;
|
||||
if (typeof obj.path === "string" && mapping.has(obj.path)) {
|
||||
obj.path = mapping.get(obj.path);
|
||||
}
|
||||
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
|
||||
obj.imagePath = mapping.get(obj.imagePath);
|
||||
}
|
||||
const download = obj.download;
|
||||
if (download && typeof download === "object") {
|
||||
const d = download as Record<string, unknown>;
|
||||
if (typeof d.path === "string" && mapping.has(d.path)) {
|
||||
d.path = mapping.get(d.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
extensions/browser/src/browser/pw-ai-module.ts
Normal file
51
extensions/browser/src/browser/pw-ai-module.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
|
||||
export type PwAiModule = typeof import("./pw-ai.js");
|
||||
|
||||
type PwAiLoadMode = "soft" | "strict";
|
||||
|
||||
let pwAiModuleSoft: Promise<PwAiModule | null> | null = null;
|
||||
let pwAiModuleStrict: Promise<PwAiModule | null> | null = null;
|
||||
|
||||
function isModuleNotFoundError(err: unknown): boolean {
|
||||
const code = extractErrorCode(err);
|
||||
if (code === "ERR_MODULE_NOT_FOUND") {
|
||||
return true;
|
||||
}
|
||||
const msg = formatErrorMessage(err);
|
||||
return (
|
||||
msg.includes("Cannot find module") ||
|
||||
msg.includes("Cannot find package") ||
|
||||
msg.includes("Failed to resolve import") ||
|
||||
msg.includes("Failed to resolve entry for package") ||
|
||||
msg.includes("Failed to load url")
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
|
||||
try {
|
||||
return await import("./pw-ai.js");
|
||||
} catch (err) {
|
||||
if (mode === "soft") {
|
||||
return null;
|
||||
}
|
||||
if (isModuleNotFoundError(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPwAiModule(opts?: { mode?: PwAiLoadMode }): Promise<PwAiModule | null> {
|
||||
const mode: PwAiLoadMode = opts?.mode ?? "soft";
|
||||
if (mode === "soft") {
|
||||
if (!pwAiModuleSoft) {
|
||||
pwAiModuleSoft = loadPwAiModule("soft");
|
||||
}
|
||||
return await pwAiModuleSoft;
|
||||
}
|
||||
if (!pwAiModuleStrict) {
|
||||
pwAiModuleStrict = loadPwAiModule("strict");
|
||||
}
|
||||
return await pwAiModuleStrict;
|
||||
}
|
||||
9
extensions/browser/src/browser/pw-ai-state.ts
Normal file
9
extensions/browser/src/browser/pw-ai-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let pwAiLoaded = false;
|
||||
|
||||
export function markPwAiLoaded(): void {
|
||||
pwAiLoaded = true;
|
||||
}
|
||||
|
||||
export function isPwAiLoaded(): boolean {
|
||||
return pwAiLoaded;
|
||||
}
|
||||
66
extensions/browser/src/browser/pw-ai.ts
Normal file
66
extensions/browser/src/browser/pw-ai.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { markPwAiLoaded } from "./pw-ai-state.js";
|
||||
|
||||
markPwAiLoaded();
|
||||
|
||||
export {
|
||||
type BrowserConsoleMessage,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
ensurePageState,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
refLocator,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
|
||||
export {
|
||||
armDialogViaPlaywright,
|
||||
armFileUploadViaPlaywright,
|
||||
batchViaPlaywright,
|
||||
clickViaPlaywright,
|
||||
closePageViaPlaywright,
|
||||
cookiesClearViaPlaywright,
|
||||
cookiesGetViaPlaywright,
|
||||
cookiesSetViaPlaywright,
|
||||
downloadViaPlaywright,
|
||||
dragViaPlaywright,
|
||||
emulateMediaViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
getConsoleMessagesViaPlaywright,
|
||||
getNetworkRequestsViaPlaywright,
|
||||
getPageErrorsViaPlaywright,
|
||||
highlightViaPlaywright,
|
||||
hoverViaPlaywright,
|
||||
navigateViaPlaywright,
|
||||
pdfViaPlaywright,
|
||||
pressKeyViaPlaywright,
|
||||
resizeViewportViaPlaywright,
|
||||
responseBodyViaPlaywright,
|
||||
scrollIntoViewViaPlaywright,
|
||||
selectOptionViaPlaywright,
|
||||
setDeviceViaPlaywright,
|
||||
setExtraHTTPHeadersViaPlaywright,
|
||||
setGeolocationViaPlaywright,
|
||||
setHttpCredentialsViaPlaywright,
|
||||
setInputFilesViaPlaywright,
|
||||
setLocaleViaPlaywright,
|
||||
setOfflineViaPlaywright,
|
||||
setTimezoneViaPlaywright,
|
||||
snapshotAiViaPlaywright,
|
||||
snapshotAriaViaPlaywright,
|
||||
snapshotRoleViaPlaywright,
|
||||
screenshotWithLabelsViaPlaywright,
|
||||
storageClearViaPlaywright,
|
||||
storageGetViaPlaywright,
|
||||
storageSetViaPlaywright,
|
||||
takeScreenshotViaPlaywright,
|
||||
traceStartViaPlaywright,
|
||||
traceStopViaPlaywright,
|
||||
typeViaPlaywright,
|
||||
waitForDownloadViaPlaywright,
|
||||
waitForViaPlaywright,
|
||||
} from "./pw-tools-core.js";
|
||||
402
extensions/browser/src/browser/pw-role-snapshot.ts
Normal file
402
extensions/browser/src/browser/pw-role-snapshot.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
|
||||
|
||||
export type RoleRef = {
|
||||
role: string;
|
||||
name?: string;
|
||||
/** Index used only when role+name duplicates exist. */
|
||||
nth?: number;
|
||||
};
|
||||
|
||||
export type RoleRefMap = Record<string, RoleRef>;
|
||||
|
||||
export type RoleSnapshotStats = {
|
||||
lines: number;
|
||||
chars: number;
|
||||
refs: number;
|
||||
interactive: number;
|
||||
};
|
||||
|
||||
export type RoleSnapshotOptions = {
|
||||
/** Only include interactive elements (buttons, links, inputs, etc.). */
|
||||
interactive?: boolean;
|
||||
/** Maximum depth to include (0 = root only). */
|
||||
maxDepth?: number;
|
||||
/** Remove unnamed structural elements and empty branches. */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
|
||||
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
|
||||
return {
|
||||
lines: snapshot.split("\n").length,
|
||||
chars: snapshot.length,
|
||||
refs: Object.keys(refs).length,
|
||||
interactive,
|
||||
};
|
||||
}
|
||||
|
||||
function getIndentLevel(line: string): number {
|
||||
const match = line.match(/^(\s*)/);
|
||||
return match ? Math.floor(match[1].length / 2) : 0;
|
||||
}
|
||||
|
||||
function matchInteractiveSnapshotLine(
|
||||
line: string,
|
||||
options: RoleSnapshotOptions,
|
||||
): { roleRaw: string; role: string; name?: string; suffix: string } | null {
|
||||
const depth = getIndentLevel(line);
|
||||
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
||||
return null;
|
||||
}
|
||||
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, , roleRaw, name, suffix] = match;
|
||||
if (roleRaw.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const role = roleRaw.toLowerCase();
|
||||
return {
|
||||
roleRaw,
|
||||
role,
|
||||
...(name ? { name } : {}),
|
||||
suffix,
|
||||
};
|
||||
}
|
||||
|
||||
type RoleNameTracker = {
|
||||
counts: Map<string, number>;
|
||||
refsByKey: Map<string, string[]>;
|
||||
getKey: (role: string, name?: string) => string;
|
||||
getNextIndex: (role: string, name?: string) => number;
|
||||
trackRef: (role: string, name: string | undefined, ref: string) => void;
|
||||
getDuplicateKeys: () => Set<string>;
|
||||
};
|
||||
|
||||
function createRoleNameTracker(): RoleNameTracker {
|
||||
const counts = new Map<string, number>();
|
||||
const refsByKey = new Map<string, string[]>();
|
||||
return {
|
||||
counts,
|
||||
refsByKey,
|
||||
getKey(role: string, name?: string) {
|
||||
return `${role}:${name ?? ""}`;
|
||||
},
|
||||
getNextIndex(role: string, name?: string) {
|
||||
const key = this.getKey(role, name);
|
||||
const current = counts.get(key) ?? 0;
|
||||
counts.set(key, current + 1);
|
||||
return current;
|
||||
},
|
||||
trackRef(role: string, name: string | undefined, ref: string) {
|
||||
const key = this.getKey(role, name);
|
||||
const list = refsByKey.get(key) ?? [];
|
||||
list.push(ref);
|
||||
refsByKey.set(key, list);
|
||||
},
|
||||
getDuplicateKeys() {
|
||||
const out = new Set<string>();
|
||||
for (const [key, refs] of refsByKey) {
|
||||
if (refs.length > 1) {
|
||||
out.add(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker) {
|
||||
const duplicates = tracker.getDuplicateKeys();
|
||||
for (const [ref, data] of Object.entries(refs)) {
|
||||
const key = tracker.getKey(data.role, data.name);
|
||||
if (!duplicates.has(key)) {
|
||||
delete refs[ref]?.nth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compactTree(tree: string) {
|
||||
const lines = tree.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.includes("[ref=")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
if (line.includes(":") && !line.trimEnd().endsWith(":")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentIndent = getIndentLevel(line);
|
||||
let hasRelevantChildren = false;
|
||||
for (let j = i + 1; j < lines.length; j += 1) {
|
||||
const childIndent = getIndentLevel(lines[j]);
|
||||
if (childIndent <= currentIndent) {
|
||||
break;
|
||||
}
|
||||
if (lines[j]?.includes("[ref=")) {
|
||||
hasRelevantChildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasRelevantChildren) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
function processLine(
|
||||
line: string,
|
||||
refs: RoleRefMap,
|
||||
options: RoleSnapshotOptions,
|
||||
tracker: RoleNameTracker,
|
||||
nextRef: () => string,
|
||||
): string | null {
|
||||
const depth = getIndentLevel(line);
|
||||
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||
if (!match) {
|
||||
return options.interactive ? null : line;
|
||||
}
|
||||
|
||||
const [, prefix, roleRaw, name, suffix] = match;
|
||||
if (roleRaw.startsWith("/")) {
|
||||
return options.interactive ? null : line;
|
||||
}
|
||||
|
||||
const role = roleRaw.toLowerCase();
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role);
|
||||
const isContent = CONTENT_ROLES.has(role);
|
||||
const isStructural = STRUCTURAL_ROLES.has(role);
|
||||
|
||||
if (options.interactive && !isInteractive) {
|
||||
return null;
|
||||
}
|
||||
if (options.compact && isStructural && !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldHaveRef = isInteractive || (isContent && name);
|
||||
if (!shouldHaveRef) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const ref = nextRef();
|
||||
const nth = tracker.getNextIndex(role, name);
|
||||
tracker.trackRef(role, name, ref);
|
||||
refs[ref] = {
|
||||
role,
|
||||
name,
|
||||
nth,
|
||||
};
|
||||
|
||||
let enhanced = `${prefix}${roleRaw}`;
|
||||
if (name) {
|
||||
enhanced += ` "${name}"`;
|
||||
}
|
||||
enhanced += ` [ref=${ref}]`;
|
||||
if (nth > 0) {
|
||||
enhanced += ` [nth=${nth}]`;
|
||||
}
|
||||
if (suffix) {
|
||||
enhanced += suffix;
|
||||
}
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
type InteractiveSnapshotLine = NonNullable<ReturnType<typeof matchInteractiveSnapshotLine>>;
|
||||
|
||||
function buildInteractiveSnapshotLines(params: {
|
||||
lines: string[];
|
||||
options: RoleSnapshotOptions;
|
||||
resolveRef: (parsed: InteractiveSnapshotLine) => { ref: string; nth?: number } | null;
|
||||
recordRef: (parsed: InteractiveSnapshotLine, ref: string, nth?: number) => void;
|
||||
includeSuffix: (suffix: string) => boolean;
|
||||
}): string[] {
|
||||
const out: string[] = [];
|
||||
for (const line of params.lines) {
|
||||
const parsed = matchInteractiveSnapshotLine(line, params.options);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!INTERACTIVE_ROLES.has(parsed.role)) {
|
||||
continue;
|
||||
}
|
||||
const resolved = params.resolveRef(parsed);
|
||||
if (!resolved?.ref) {
|
||||
continue;
|
||||
}
|
||||
params.recordRef(parsed, resolved.ref, resolved.nth);
|
||||
|
||||
let enhanced = `- ${parsed.roleRaw}`;
|
||||
if (parsed.name) {
|
||||
enhanced += ` "${parsed.name}"`;
|
||||
}
|
||||
enhanced += ` [ref=${resolved.ref}]`;
|
||||
if ((resolved.nth ?? 0) > 0) {
|
||||
enhanced += ` [nth=${resolved.nth}]`;
|
||||
}
|
||||
if (params.includeSuffix(parsed.suffix)) {
|
||||
enhanced += parsed.suffix;
|
||||
}
|
||||
out.push(enhanced);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseRoleRef(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.startsWith("@")
|
||||
? trimmed.slice(1)
|
||||
: trimmed.startsWith("ref=")
|
||||
? trimmed.slice(4)
|
||||
: trimmed;
|
||||
return /^e\d+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export function buildRoleSnapshotFromAriaSnapshot(
|
||||
ariaSnapshot: string,
|
||||
options: RoleSnapshotOptions = {},
|
||||
): { snapshot: string; refs: RoleRefMap } {
|
||||
const lines = ariaSnapshot.split("\n");
|
||||
const refs: RoleRefMap = {};
|
||||
const tracker = createRoleNameTracker();
|
||||
|
||||
let counter = 0;
|
||||
const nextRef = () => {
|
||||
counter += 1;
|
||||
return `e${counter}`;
|
||||
};
|
||||
|
||||
if (options.interactive) {
|
||||
const result = buildInteractiveSnapshotLines({
|
||||
lines,
|
||||
options,
|
||||
resolveRef: ({ role, name }) => {
|
||||
const ref = nextRef();
|
||||
const nth = tracker.getNextIndex(role, name);
|
||||
tracker.trackRef(role, name, ref);
|
||||
return { ref, nth };
|
||||
},
|
||||
recordRef: ({ role, name }, ref, nth) => {
|
||||
refs[ref] = {
|
||||
role,
|
||||
name,
|
||||
nth,
|
||||
};
|
||||
},
|
||||
includeSuffix: (suffix) => suffix.includes("["),
|
||||
});
|
||||
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
return {
|
||||
snapshot: result.join("\n") || "(no interactive elements)",
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
for (const line of lines) {
|
||||
const processed = processLine(line, refs, options, tracker, nextRef);
|
||||
if (processed !== null) {
|
||||
result.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
const tree = result.join("\n") || "(empty)";
|
||||
return {
|
||||
snapshot: options.compact ? compactTree(tree) : tree,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAiSnapshotRef(suffix: string): string | null {
|
||||
const match = suffix.match(/\[ref=(e\d+)\]/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a role snapshot from Playwright's AI snapshot output while preserving Playwright's own
|
||||
* aria-ref ids (e.g. ref=e13). This makes the refs self-resolving across calls.
|
||||
*/
|
||||
export function buildRoleSnapshotFromAiSnapshot(
|
||||
aiSnapshot: string,
|
||||
options: RoleSnapshotOptions = {},
|
||||
): { snapshot: string; refs: RoleRefMap } {
|
||||
const lines = String(aiSnapshot ?? "").split("\n");
|
||||
const refs: RoleRefMap = {};
|
||||
|
||||
if (options.interactive) {
|
||||
const out = buildInteractiveSnapshotLines({
|
||||
lines,
|
||||
options,
|
||||
resolveRef: ({ suffix }) => {
|
||||
const ref = parseAiSnapshotRef(suffix);
|
||||
return ref ? { ref } : null;
|
||||
},
|
||||
recordRef: ({ role, name }, ref) => {
|
||||
refs[ref] = { role, ...(name ? { name } : {}) };
|
||||
},
|
||||
includeSuffix: () => true,
|
||||
});
|
||||
return {
|
||||
snapshot: out.join("\n") || "(no interactive elements)",
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const line of lines) {
|
||||
const depth = getIndentLevel(line);
|
||||
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||
if (!match) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const [, , roleRaw, name, suffix] = match;
|
||||
if (roleRaw.startsWith("/")) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = roleRaw.toLowerCase();
|
||||
const isStructural = STRUCTURAL_ROLES.has(role);
|
||||
|
||||
if (options.compact && isStructural && !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ref = parseAiSnapshotRef(suffix);
|
||||
if (ref) {
|
||||
refs[ref] = { role, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const tree = out.join("\n") || "(empty)";
|
||||
return {
|
||||
snapshot: options.compact ? compactTree(tree) : tree,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
15
extensions/browser/src/browser/pw-session.mock-setup.ts
Normal file
15
extensions/browser/src/browser/pw-session.mock-setup.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { vi } from "vitest";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
|
||||
export const connectOverCdpMock: MockFn = vi.fn();
|
||||
export const getChromeWebSocketUrlMock: MockFn = vi.fn();
|
||||
|
||||
vi.mock("playwright-core", () => ({
|
||||
chromium: {
|
||||
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args),
|
||||
}));
|
||||
33
extensions/browser/src/browser/pw-session.page-cdp.ts
Normal file
33
extensions/browser/src/browser/pw-session.page-cdp.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CDPSession, Page } from "playwright-core";
|
||||
|
||||
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
async function withPlaywrightPageCdpSession<T>(
|
||||
page: Page,
|
||||
fn: (session: CDPSession) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function withPageScopedCdpClient<T>(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
targetId?: string;
|
||||
fn: (send: PageCdpSend) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
|
||||
return await opts.fn((method, params) =>
|
||||
(
|
||||
session.send as unknown as (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<unknown>
|
||||
)(method, params),
|
||||
);
|
||||
});
|
||||
}
|
||||
845
extensions/browser/src/browser/pw-session.ts
Normal file
845
extensions/browser/src/browser/pw-session.ts
Normal file
@@ -0,0 +1,845 @@
|
||||
import type {
|
||||
Browser,
|
||||
BrowserContext,
|
||||
ConsoleMessage,
|
||||
Page,
|
||||
Request,
|
||||
Response,
|
||||
} from "playwright-core";
|
||||
import { chromium } from "playwright-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
getHeadersWithAuth,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
withCdpSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
import { BrowserTabNotFoundError } from "./errors.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export type BrowserConsoleMessage = {
|
||||
type: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
location?: { url?: string; lineNumber?: number; columnNumber?: number };
|
||||
};
|
||||
|
||||
export type BrowserPageError = {
|
||||
message: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type BrowserNetworkRequest = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
method: string;
|
||||
url: string;
|
||||
resourceType?: string;
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
failureText?: string;
|
||||
};
|
||||
|
||||
type SnapshotForAIResult = { full: string; incremental?: string };
|
||||
type SnapshotForAIOptions = { timeout?: number; track?: string };
|
||||
|
||||
export type WithSnapshotForAI = {
|
||||
_snapshotForAI?: (options?: SnapshotForAIOptions) => Promise<SnapshotForAIResult>;
|
||||
};
|
||||
|
||||
type TargetInfoResponse = {
|
||||
targetInfo?: {
|
||||
targetId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectedBrowser = {
|
||||
browser: Browser;
|
||||
cdpUrl: string;
|
||||
onDisconnected?: () => void;
|
||||
};
|
||||
|
||||
type PageState = {
|
||||
console: BrowserConsoleMessage[];
|
||||
errors: BrowserPageError[];
|
||||
requests: BrowserNetworkRequest[];
|
||||
requestIds: WeakMap<Request, string>;
|
||||
nextRequestId: number;
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
/**
|
||||
* Role-based refs from the last role snapshot (e.g. e1/e2).
|
||||
* Mode "role" refs are generated from ariaSnapshot and resolved via getByRole.
|
||||
* Mode "aria" refs are Playwright aria-ref ids and resolved via `aria-ref=...`.
|
||||
*/
|
||||
roleRefs?: Record<string, { role: string; name?: string; nth?: number }>;
|
||||
roleRefsMode?: "role" | "aria";
|
||||
roleRefsFrameSelector?: string;
|
||||
};
|
||||
|
||||
type RoleRefs = NonNullable<PageState["roleRefs"]>;
|
||||
type RoleRefsCacheEntry = {
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
mode?: NonNullable<PageState["roleRefsMode"]>;
|
||||
};
|
||||
|
||||
type ContextState = {
|
||||
traceActive: boolean;
|
||||
};
|
||||
|
||||
const pageStates = new WeakMap<Page, PageState>();
|
||||
const contextStates = new WeakMap<BrowserContext, ContextState>();
|
||||
const observedContexts = new WeakSet<BrowserContext>();
|
||||
const observedPages = new WeakSet<Page>();
|
||||
|
||||
// Best-effort cache to make role refs stable even if Playwright returns a different Page object
|
||||
// for the same CDP target across requests.
|
||||
const roleRefsByTarget = new Map<string, RoleRefsCacheEntry>();
|
||||
const MAX_ROLE_REFS_CACHE = 50;
|
||||
|
||||
const MAX_CONSOLE_MESSAGES = 500;
|
||||
const MAX_PAGE_ERRORS = 200;
|
||||
const MAX_NETWORK_REQUESTS = 500;
|
||||
|
||||
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
|
||||
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
|
||||
|
||||
function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function findNetworkRequestById(state: PageState, id: string): BrowserNetworkRequest | undefined {
|
||||
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = state.requests[i];
|
||||
if (candidate && candidate.id === id) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function roleRefsKey(cdpUrl: string, targetId: string) {
|
||||
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
|
||||
}
|
||||
|
||||
export function rememberRoleRefsForTarget(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
mode?: NonNullable<PageState["roleRefsMode"]>;
|
||||
}): void {
|
||||
const targetId = opts.targetId.trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
|
||||
refs: opts.refs,
|
||||
...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
|
||||
...(opts.mode ? { mode: opts.mode } : {}),
|
||||
});
|
||||
while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
|
||||
const first = roleRefsByTarget.keys().next();
|
||||
if (first.done) {
|
||||
break;
|
||||
}
|
||||
roleRefsByTarget.delete(first.value);
|
||||
}
|
||||
}
|
||||
|
||||
export function storeRoleRefsForTarget(opts: {
|
||||
page: Page;
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
mode: NonNullable<PageState["roleRefsMode"]>;
|
||||
}): void {
|
||||
const state = ensurePageState(opts.page);
|
||||
state.roleRefs = opts.refs;
|
||||
state.roleRefsFrameSelector = opts.frameSelector;
|
||||
state.roleRefsMode = opts.mode;
|
||||
if (!opts.targetId?.trim()) {
|
||||
return;
|
||||
}
|
||||
rememberRoleRefsForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: opts.refs,
|
||||
frameSelector: opts.frameSelector,
|
||||
mode: opts.mode,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreRoleRefsForTarget(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
page: Page;
|
||||
}): void {
|
||||
const targetId = opts.targetId?.trim() || "";
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
const state = ensurePageState(opts.page);
|
||||
if (state.roleRefs) {
|
||||
return;
|
||||
}
|
||||
state.roleRefs = cached.refs;
|
||||
state.roleRefsFrameSelector = cached.frameSelector;
|
||||
state.roleRefsMode = cached.mode;
|
||||
}
|
||||
|
||||
export function ensurePageState(page: Page): PageState {
|
||||
const existing = pageStates.get(page);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: PageState = {
|
||||
console: [],
|
||||
errors: [],
|
||||
requests: [],
|
||||
requestIds: new WeakMap(),
|
||||
nextRequestId: 0,
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
pageStates.set(page, state);
|
||||
|
||||
if (!observedPages.has(page)) {
|
||||
observedPages.add(page);
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
const entry: BrowserConsoleMessage = {
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
timestamp: new Date().toISOString(),
|
||||
location: msg.location(),
|
||||
};
|
||||
state.console.push(entry);
|
||||
if (state.console.length > MAX_CONSOLE_MESSAGES) {
|
||||
state.console.shift();
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (err: Error) => {
|
||||
state.errors.push({
|
||||
message: err?.message ? String(err.message) : String(err),
|
||||
name: err?.name ? String(err.name) : undefined,
|
||||
stack: err?.stack ? String(err.stack) : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (state.errors.length > MAX_PAGE_ERRORS) {
|
||||
state.errors.shift();
|
||||
}
|
||||
});
|
||||
page.on("request", (req: Request) => {
|
||||
state.nextRequestId += 1;
|
||||
const id = `r${state.nextRequestId}`;
|
||||
state.requestIds.set(req, id);
|
||||
state.requests.push({
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
method: req.method(),
|
||||
url: req.url(),
|
||||
resourceType: req.resourceType(),
|
||||
});
|
||||
if (state.requests.length > MAX_NETWORK_REQUESTS) {
|
||||
state.requests.shift();
|
||||
}
|
||||
});
|
||||
page.on("response", (resp: Response) => {
|
||||
const req = resp.request();
|
||||
const id = state.requestIds.get(req);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const rec = findNetworkRequestById(state, id);
|
||||
if (!rec) {
|
||||
return;
|
||||
}
|
||||
rec.status = resp.status();
|
||||
rec.ok = resp.ok();
|
||||
});
|
||||
page.on("requestfailed", (req: Request) => {
|
||||
const id = state.requestIds.get(req);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const rec = findNetworkRequestById(state, id);
|
||||
if (!rec) {
|
||||
return;
|
||||
}
|
||||
rec.failureText = req.failure()?.errorText;
|
||||
rec.ok = false;
|
||||
});
|
||||
page.on("close", () => {
|
||||
pageStates.delete(page);
|
||||
observedPages.delete(page);
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function observeContext(context: BrowserContext) {
|
||||
if (observedContexts.has(context)) {
|
||||
return;
|
||||
}
|
||||
observedContexts.add(context);
|
||||
ensureContextState(context);
|
||||
|
||||
for (const page of context.pages()) {
|
||||
ensurePageState(page);
|
||||
}
|
||||
context.on("page", (page) => ensurePageState(page));
|
||||
}
|
||||
|
||||
export function ensureContextState(context: BrowserContext): ContextState {
|
||||
const existing = contextStates.get(context);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state: ContextState = { traceActive: false };
|
||||
contextStates.set(context, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function observeBrowser(browser: Browser) {
|
||||
for (const context of browser.contexts()) {
|
||||
observeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
||||
const normalized = normalizeCdpUrl(cdpUrl);
|
||||
const cached = cachedByCdpUrl.get(normalized);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const connecting = connectingByCdpUrl.get(normalized);
|
||||
if (connecting) {
|
||||
return await connecting;
|
||||
}
|
||||
|
||||
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const timeout = 5000 + attempt * 2000;
|
||||
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
||||
const endpoint = wsUrl ?? normalized;
|
||||
const headers = getHeadersWithAuth(endpoint);
|
||||
// Bypass proxy for loopback CDP connections (#31219)
|
||||
const browser = await withNoProxyForCdpUrl(endpoint, () =>
|
||||
chromium.connectOverCDP(endpoint, { timeout, headers }),
|
||||
);
|
||||
const onDisconnected = () => {
|
||||
const current = cachedByCdpUrl.get(normalized);
|
||||
if (current?.browser === browser) {
|
||||
cachedByCdpUrl.delete(normalized);
|
||||
}
|
||||
};
|
||||
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
|
||||
cachedByCdpUrl.set(normalized, connected);
|
||||
browser.on("disconnected", onDisconnected);
|
||||
observeBrowser(browser);
|
||||
return connected;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
// Don't retry rate-limit errors; retrying worsens the 429.
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (errMsg.includes("rate limit")) {
|
||||
break;
|
||||
}
|
||||
const delay = 250 + attempt * 250;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
if (lastErr instanceof Error) {
|
||||
throw lastErr;
|
||||
}
|
||||
const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed";
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const pending = connectWithRetry().finally(() => {
|
||||
connectingByCdpUrl.delete(normalized);
|
||||
});
|
||||
connectingByCdpUrl.set(normalized, pending);
|
||||
|
||||
return await pending;
|
||||
}
|
||||
|
||||
async function getAllPages(browser: Browser): Promise<Page[]> {
|
||||
const contexts = browser.contexts();
|
||||
const pages = contexts.flatMap((c) => c.pages());
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function pageTargetId(page: Page): Promise<string | null> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse;
|
||||
const targetId = String(info?.targetInfo?.targetId ?? "").trim();
|
||||
return targetId || null;
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function matchPageByTargetList(
|
||||
pages: Page[],
|
||||
targets: Array<{ id: string; url: string; title?: string }>,
|
||||
targetId: string,
|
||||
): Page | null {
|
||||
const target = targets.find((entry) => entry.id === targetId);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlMatch = pages.filter((page) => page.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0] ?? null;
|
||||
}
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPageByTargetIdViaTargetList(
|
||||
pages: Page[],
|
||||
targetId: string,
|
||||
cdpUrl: string,
|
||||
): Promise<Page | null> {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
|
||||
const targets = await fetchJson<
|
||||
Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
|
||||
return matchPageByTargetList(pages, targets, targetId);
|
||||
}
|
||||
|
||||
async function findPageByTargetId(
|
||||
browser: Browser,
|
||||
targetId: string,
|
||||
cdpUrl?: string,
|
||||
): Promise<Page | null> {
|
||||
const pages = await getAllPages(browser);
|
||||
let resolvedViaCdp = false;
|
||||
for (const page of pages) {
|
||||
let tid: string | null = null;
|
||||
try {
|
||||
tid = await pageTargetId(page);
|
||||
resolvedViaCdp = true;
|
||||
} catch {
|
||||
tid = null;
|
||||
}
|
||||
if (tid && tid === targetId) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
if (cdpUrl) {
|
||||
try {
|
||||
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to return null.
|
||||
}
|
||||
}
|
||||
if (!resolvedViaCdp && pages.length === 1) {
|
||||
return pages[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePageByTargetIdOrThrow(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
}): Promise<Page> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!page) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function getPageForTargetId(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<Page> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const pages = await getAllPages(browser);
|
||||
if (!pages.length) {
|
||||
throw new Error("No pages available in the connected browser.");
|
||||
}
|
||||
const first = pages[0];
|
||||
if (!opts.targetId) {
|
||||
return first;
|
||||
}
|
||||
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!found) {
|
||||
// If Playwright only exposes a single Page, use it as a best-effort fallback.
|
||||
if (pages.length === 1) {
|
||||
return first;
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function refLocator(page: Page, ref: string) {
|
||||
const normalized = ref.startsWith("@")
|
||||
? ref.slice(1)
|
||||
: ref.startsWith("ref=")
|
||||
? ref.slice(4)
|
||||
: ref;
|
||||
|
||||
if (/^e\d+$/.test(normalized)) {
|
||||
const state = pageStates.get(page);
|
||||
if (state?.roleRefsMode === "aria") {
|
||||
const scope = state.roleRefsFrameSelector
|
||||
? page.frameLocator(state.roleRefsFrameSelector)
|
||||
: page;
|
||||
return scope.locator(`aria-ref=${normalized}`);
|
||||
}
|
||||
const info = state?.roleRefs?.[normalized];
|
||||
if (!info) {
|
||||
throw new Error(
|
||||
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
||||
);
|
||||
}
|
||||
const scope = state?.roleRefsFrameSelector
|
||||
? page.frameLocator(state.roleRefsFrameSelector)
|
||||
: page;
|
||||
const locAny = scope as unknown as {
|
||||
getByRole: (
|
||||
role: never,
|
||||
opts?: { name?: string; exact?: boolean },
|
||||
) => ReturnType<Page["getByRole"]>;
|
||||
};
|
||||
const locator = info.name
|
||||
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
|
||||
: locAny.getByRole(info.role as never);
|
||||
return info.nth !== undefined ? locator.nth(info.nth) : locator;
|
||||
}
|
||||
|
||||
return page.locator(`aria-ref=${normalized}`);
|
||||
}
|
||||
|
||||
export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise<void> {
|
||||
const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null;
|
||||
|
||||
if (normalized) {
|
||||
const cur = cachedByCdpUrl.get(normalized);
|
||||
cachedByCdpUrl.delete(normalized);
|
||||
connectingByCdpUrl.delete(normalized);
|
||||
if (!cur) {
|
||||
return;
|
||||
}
|
||||
if (cur.onDisconnected && typeof cur.browser.off === "function") {
|
||||
cur.browser.off("disconnected", cur.onDisconnected);
|
||||
}
|
||||
await cur.browser.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = Array.from(cachedByCdpUrl.values());
|
||||
cachedByCdpUrl.clear();
|
||||
connectingByCdpUrl.clear();
|
||||
for (const cur of connections) {
|
||||
if (cur.onDisconnected && typeof cur.browser.off === "function") {
|
||||
cur.browser.off("disconnected", cur.onDisconnected);
|
||||
}
|
||||
await cur.browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function cdpSocketNeedsAttach(wsUrl: string): boolean {
|
||||
try {
|
||||
const pathname = new URL(wsUrl).pathname;
|
||||
return (
|
||||
pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryTerminateExecutionViaCdp(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl);
|
||||
const listUrl = appendCdpPath(cdpHttpBase, "/json/list");
|
||||
|
||||
const pages = await fetchJson<
|
||||
Array<{
|
||||
id?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}>
|
||||
>(listUrl, 2000).catch(() => null);
|
||||
if (!pages || pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = pages.find((p) => String(p.id ?? "").trim() === opts.targetId);
|
||||
const wsUrlRaw = String(target?.webSocketDebuggerUrl ?? "").trim();
|
||||
if (!wsUrlRaw) {
|
||||
return;
|
||||
}
|
||||
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase);
|
||||
const needsAttach = cdpSocketNeedsAttach(wsUrl);
|
||||
|
||||
const runWithTimeout = async <T>(work: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("CDP command timed out")), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([work, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await withCdpSocket(
|
||||
wsUrl,
|
||||
async (send) => {
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
if (needsAttach) {
|
||||
const attached = (await runWithTimeout(
|
||||
send("Target.attachToTarget", { targetId: opts.targetId, flatten: true }),
|
||||
1500,
|
||||
)) as { sessionId?: unknown };
|
||||
if (typeof attached?.sessionId === "string" && attached.sessionId.trim()) {
|
||||
sessionId = attached.sessionId;
|
||||
}
|
||||
}
|
||||
await runWithTimeout(send("Runtime.terminateExecution", undefined, sessionId), 1500);
|
||||
if (sessionId) {
|
||||
// Best-effort cleanup; not required for termination to take effect.
|
||||
void send("Target.detachFromTarget", { sessionId }).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; ignore
|
||||
}
|
||||
},
|
||||
{ handshakeTimeoutMs: 2000 },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cancellation for stuck page operations.
|
||||
*
|
||||
* Playwright serializes CDP commands per page; a long-running or stuck operation (notably evaluate)
|
||||
* can block all subsequent commands. We cannot safely "cancel" an individual command, and we do
|
||||
* not want to close the actual Chromium tab. Instead, we disconnect Playwright's CDP connection
|
||||
* so in-flight commands fail fast and the next request reconnects transparently.
|
||||
*
|
||||
* IMPORTANT: We CANNOT call Connection.close() because Playwright shares a single Connection
|
||||
* across all objects (BrowserType, Browser, etc.). Closing it corrupts the entire Playwright
|
||||
* instance, preventing reconnection.
|
||||
*
|
||||
* Instead we:
|
||||
* 1. Null out `cached` so the next call triggers a fresh connectOverCDP
|
||||
* 2. Fire-and-forget browser.close() — it may hang but won't block us
|
||||
* 3. The next connectBrowser() creates a completely new CDP WebSocket connection
|
||||
*
|
||||
* The old browser.close() eventually resolves when the in-browser evaluate timeout fires,
|
||||
* or the old connection gets GC'd. Either way, it doesn't affect the fresh connection.
|
||||
*/
|
||||
export async function forceDisconnectPlaywrightForTarget(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
reason?: string;
|
||||
}): Promise<void> {
|
||||
const normalized = normalizeCdpUrl(opts.cdpUrl);
|
||||
const cur = cachedByCdpUrl.get(normalized);
|
||||
if (!cur) {
|
||||
return;
|
||||
}
|
||||
cachedByCdpUrl.delete(normalized);
|
||||
// Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP
|
||||
// rather than awaiting a stale promise.
|
||||
connectingByCdpUrl.delete(normalized);
|
||||
// Remove the "disconnected" listener to prevent the old browser's teardown
|
||||
// from racing with a fresh connection and nulling the new cached entry.
|
||||
if (cur.onDisconnected && typeof cur.browser.off === "function") {
|
||||
cur.browser.off("disconnected", cur.onDisconnected);
|
||||
}
|
||||
|
||||
// Best-effort: kill any stuck JS to unblock the target's execution context before we
|
||||
// disconnect Playwright's CDP connection.
|
||||
const targetId = opts.targetId?.trim() || "";
|
||||
if (targetId) {
|
||||
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
|
||||
}
|
||||
|
||||
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
|
||||
cur.browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all pages/tabs from the persistent Playwright connection.
|
||||
* Used for remote profiles where HTTP-based /json/list is ephemeral.
|
||||
*/
|
||||
export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
|
||||
Array<{
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
type: string;
|
||||
}>
|
||||
> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const pages = await getAllPages(browser);
|
||||
const results: Array<{
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
type: string;
|
||||
}> = [];
|
||||
|
||||
for (const page of pages) {
|
||||
const tid = await pageTargetId(page).catch(() => null);
|
||||
if (tid) {
|
||||
results.push({
|
||||
targetId: tid,
|
||||
title: await page.title().catch(() => ""),
|
||||
url: page.url(),
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new page/tab using the persistent Playwright connection.
|
||||
* Used for remote profiles where HTTP-based /json/new is ephemeral.
|
||||
* Returns the new page's targetId and metadata.
|
||||
*/
|
||||
export async function createPageViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
url: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
type: string;
|
||||
}> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const context = browser.contexts()[0] ?? (await browser.newContext());
|
||||
ensureContextState(context);
|
||||
|
||||
const page = await context.newPage();
|
||||
ensurePageState(page);
|
||||
|
||||
// Navigate to the URL
|
||||
const targetUrl = opts.url.trim() || "about:blank";
|
||||
if (targetUrl !== "about:blank") {
|
||||
const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
|
||||
await assertBrowserNavigationAllowed({
|
||||
url: targetUrl,
|
||||
...navigationPolicy,
|
||||
});
|
||||
const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||
// Navigation might fail for some URLs, but page is still created
|
||||
return null;
|
||||
});
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: page.url(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the targetId for this page
|
||||
const tid = await pageTargetId(page).catch(() => null);
|
||||
if (!tid) {
|
||||
throw new Error("Failed to get targetId for new page");
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: tid,
|
||||
title: await page.title().catch(() => ""),
|
||||
url: page.url(),
|
||||
type: "page",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a page/tab by targetId using the persistent Playwright connection.
|
||||
* Used for remote profiles where HTTP-based /json/close is ephemeral.
|
||||
*/
|
||||
export async function closePageByTargetIdViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
const page = await resolvePageByTargetIdOrThrow(opts);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a page/tab by targetId using the persistent Playwright connection.
|
||||
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
|
||||
*/
|
||||
export async function focusPageByTargetIdViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
const page = await resolvePageByTargetIdOrThrow(opts);
|
||||
try {
|
||||
await page.bringToFront();
|
||||
} catch (err) {
|
||||
try {
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Page.bringToFront");
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
extensions/browser/src/browser/pw-tools-core.activity.ts
Normal file
68
extensions/browser/src/browser/pw-tools-core.activity.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
BrowserConsoleMessage,
|
||||
BrowserNetworkRequest,
|
||||
BrowserPageError,
|
||||
} from "./pw-session.js";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
|
||||
export async function getPageErrorsViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
clear?: boolean;
|
||||
}): Promise<{ errors: BrowserPageError[] }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
const errors = [...state.errors];
|
||||
if (opts.clear) {
|
||||
state.errors = [];
|
||||
}
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export async function getNetworkRequestsViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
filter?: string;
|
||||
clear?: boolean;
|
||||
}): Promise<{ requests: BrowserNetworkRequest[] }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
const raw = [...state.requests];
|
||||
const filter = typeof opts.filter === "string" ? opts.filter.trim() : "";
|
||||
const requests = filter ? raw.filter((r) => r.url.includes(filter)) : raw;
|
||||
if (opts.clear) {
|
||||
state.requests = [];
|
||||
state.requestIds = new WeakMap();
|
||||
}
|
||||
return { requests };
|
||||
}
|
||||
|
||||
function consolePriority(level: string) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return 3;
|
||||
case "warning":
|
||||
return 2;
|
||||
case "info":
|
||||
case "log":
|
||||
return 1;
|
||||
case "debug":
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConsoleMessagesViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
level?: string;
|
||||
}): Promise<BrowserConsoleMessage[]> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
if (!opts.level) {
|
||||
return [...state.console];
|
||||
}
|
||||
const min = consolePriority(opts.level);
|
||||
return state.console.filter((msg) => consolePriority(msg.type) >= min);
|
||||
}
|
||||
280
extensions/browser/src/browser/pw-tools-core.downloads.ts
Normal file
280
extensions/browser/src/browser/pw-tools-core.downloads.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright-core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { writeViaSiblingTempPath } from "./output-atomic.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
||||
import {
|
||||
ensurePageState,
|
||||
getPageForTargetId,
|
||||
refLocator,
|
||||
restoreRoleRefsForTarget,
|
||||
} from "./pw-session.js";
|
||||
import {
|
||||
bumpDialogArmId,
|
||||
bumpDownloadArmId,
|
||||
bumpUploadArmId,
|
||||
normalizeTimeoutMs,
|
||||
requireRef,
|
||||
toAIFriendlyError,
|
||||
} from "./pw-tools-core.shared.js";
|
||||
import { sanitizeUntrustedFileName } from "./safe-filename.js";
|
||||
|
||||
function buildTempDownloadPath(fileName: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const safeName = sanitizeUntrustedFileName(fileName, "download.bin");
|
||||
return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`);
|
||||
}
|
||||
|
||||
function createPageDownloadWaiter(page: Page, timeoutMs: number) {
|
||||
let done = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let handler: ((download: unknown) => void) | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = undefined;
|
||||
if (handler) {
|
||||
page.off("download", handler as never);
|
||||
handler = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
handler = (download: unknown) => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanup();
|
||||
resolve(download);
|
||||
};
|
||||
|
||||
page.on("download", handler as never);
|
||||
timer = setTimeout(() => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(new Error("Timeout waiting for download"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DownloadPayload = {
|
||||
url?: () => string;
|
||||
suggestedFilename?: () => string;
|
||||
saveAs?: (outPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
|
||||
const suggested = download.suggestedFilename?.() || "download.bin";
|
||||
const requestedPath = outPath?.trim();
|
||||
const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested));
|
||||
await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true });
|
||||
|
||||
if (!requestedPath) {
|
||||
await download.saveAs?.(resolvedOutPath);
|
||||
} else {
|
||||
await writeViaSiblingTempPath({
|
||||
rootDir: path.dirname(resolvedOutPath),
|
||||
targetPath: resolvedOutPath,
|
||||
writeTemp: async (tempPath) => {
|
||||
await download.saveAs?.(tempPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: download.url?.() || "",
|
||||
suggestedFilename: suggested,
|
||||
path: resolvedOutPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function awaitDownloadPayload(params: {
|
||||
waiter: ReturnType<typeof createPageDownloadWaiter>;
|
||||
state: ReturnType<typeof ensurePageState>;
|
||||
armId: number;
|
||||
outPath?: string;
|
||||
}) {
|
||||
try {
|
||||
const download = (await params.waiter.promise) as DownloadPayload;
|
||||
if (params.state.armIdDownload !== params.armId) {
|
||||
throw new Error("Download was superseded by another waiter");
|
||||
}
|
||||
return await saveDownloadPayload(download, params.outPath ?? "");
|
||||
} catch (err) {
|
||||
params.waiter.cancel();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function armFileUploadViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
paths?: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 120_000));
|
||||
|
||||
state.armIdUpload = bumpUploadArmId();
|
||||
const armId = state.armIdUpload;
|
||||
|
||||
void page
|
||||
.waitForEvent("filechooser", { timeout })
|
||||
.then(async (fileChooser) => {
|
||||
if (state.armIdUpload !== armId) {
|
||||
return;
|
||||
}
|
||||
if (!opts.paths?.length) {
|
||||
// Playwright removed `FileChooser.cancel()`; best-effort close the chooser instead.
|
||||
try {
|
||||
await page.keyboard.press("Escape");
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
return;
|
||||
}
|
||||
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
||||
rootDir: DEFAULT_UPLOAD_DIR,
|
||||
requestedPaths: opts.paths,
|
||||
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
||||
});
|
||||
if (!uploadPathsResult.ok) {
|
||||
try {
|
||||
await page.keyboard.press("Escape");
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fileChooser.setFiles(uploadPathsResult.paths);
|
||||
try {
|
||||
const input =
|
||||
typeof fileChooser.element === "function"
|
||||
? await Promise.resolve(fileChooser.element())
|
||||
: null;
|
||||
if (input) {
|
||||
await input.evaluate((el) => {
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort for sites that don't react to setFiles alone.
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore timeouts; the chooser may never appear.
|
||||
});
|
||||
}
|
||||
|
||||
export async function armDialogViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
accept: boolean;
|
||||
promptText?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
|
||||
|
||||
state.armIdDialog = bumpDialogArmId();
|
||||
const armId = state.armIdDialog;
|
||||
|
||||
void page
|
||||
.waitForEvent("dialog", { timeout })
|
||||
.then(async (dialog) => {
|
||||
if (state.armIdDialog !== armId) {
|
||||
return;
|
||||
}
|
||||
if (opts.accept) {
|
||||
await dialog.accept(opts.promptText);
|
||||
} else {
|
||||
await dialog.dismiss();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore timeouts; the dialog may never appear.
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForDownloadViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
path?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{
|
||||
url: string;
|
||||
suggestedFilename: string;
|
||||
path: string;
|
||||
}> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
|
||||
|
||||
state.armIdDownload = bumpDownloadArmId();
|
||||
const armId = state.armIdDownload;
|
||||
|
||||
const waiter = createPageDownloadWaiter(page, timeout);
|
||||
return await awaitDownloadPayload({ waiter, state, armId, outPath: opts.path });
|
||||
}
|
||||
|
||||
export async function downloadViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
path: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{
|
||||
url: string;
|
||||
suggestedFilename: string;
|
||||
path: string;
|
||||
}> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
|
||||
|
||||
const ref = requireRef(opts.ref);
|
||||
const outPath = String(opts.path ?? "").trim();
|
||||
if (!outPath) {
|
||||
throw new Error("path is required");
|
||||
}
|
||||
|
||||
state.armIdDownload = bumpDownloadArmId();
|
||||
const armId = state.armIdDownload;
|
||||
|
||||
const waiter = createPageDownloadWaiter(page, timeout);
|
||||
try {
|
||||
const locator = refLocator(page, ref);
|
||||
try {
|
||||
await locator.click({ timeout });
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, ref);
|
||||
}
|
||||
return await awaitDownloadPayload({ waiter, state, armId, outPath });
|
||||
} catch (err) {
|
||||
waiter.cancel();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
891
extensions/browser/src/browser/pw-tools-core.interactions.ts
Normal file
891
extensions/browser/src/browser/pw-tools-core.interactions.ts
Normal file
@@ -0,0 +1,891 @@
|
||||
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
|
||||
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
||||
import {
|
||||
ensurePageState,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
refLocator,
|
||||
restoreRoleRefsForTarget,
|
||||
} from "./pw-session.js";
|
||||
import {
|
||||
normalizeTimeoutMs,
|
||||
requireRef,
|
||||
requireRefOrSelector,
|
||||
toAIFriendlyError,
|
||||
} from "./pw-tools-core.shared.js";
|
||||
import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js";
|
||||
|
||||
type TargetOpts = {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
const MAX_CLICK_DELAY_MS = 5_000;
|
||||
const MAX_WAIT_TIME_MS = 30_000;
|
||||
const MAX_BATCH_ACTIONS = 100;
|
||||
|
||||
function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number {
|
||||
const normalized = Math.floor(value ?? 0);
|
||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||
throw new Error(`${label} must be >= 0`);
|
||||
}
|
||||
if (normalized > maxMs) {
|
||||
throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function getRestoredPageForTarget(opts: TargetOpts) {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
||||
return page;
|
||||
}
|
||||
|
||||
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
|
||||
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
||||
}
|
||||
|
||||
async function awaitEvalWithAbort<T>(
|
||||
evalPromise: Promise<T>,
|
||||
abortPromise?: Promise<never>,
|
||||
): Promise<T> {
|
||||
if (!abortPromise) {
|
||||
return await evalPromise;
|
||||
}
|
||||
try {
|
||||
return await Promise.race([evalPromise, abortPromise]);
|
||||
} catch (err) {
|
||||
// If abort wins the race, evaluate may reject later; avoid unhandled rejections.
|
||||
void evalPromise.catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
}): Promise<void> {
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const ref = requireRef(opts.ref);
|
||||
try {
|
||||
await refLocator(page, ref).highlight();
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, ref);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clickViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
doubleClick?: boolean;
|
||||
button?: "left" | "right" | "middle";
|
||||
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const label = resolved.ref ?? resolved.selector!;
|
||||
const locator = resolved.ref
|
||||
? refLocator(page, requireRef(resolved.ref))
|
||||
: page.locator(resolved.selector!);
|
||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||
try {
|
||||
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
||||
if (delayMs > 0) {
|
||||
await locator.hover({ timeout });
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
if (opts.doubleClick) {
|
||||
await locator.dblclick({
|
||||
timeout,
|
||||
button: opts.button,
|
||||
modifiers: opts.modifiers,
|
||||
});
|
||||
} else {
|
||||
await locator.click({
|
||||
timeout,
|
||||
button: opts.button,
|
||||
modifiers: opts.modifiers,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hoverViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const label = resolved.ref ?? resolved.selector!;
|
||||
const locator = resolved.ref
|
||||
? refLocator(page, requireRef(resolved.ref))
|
||||
: page.locator(resolved.selector!);
|
||||
try {
|
||||
await locator.hover({
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function dragViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
startRef?: string;
|
||||
startSelector?: string;
|
||||
endRef?: string;
|
||||
endSelector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
|
||||
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const startLocator = resolvedStart.ref
|
||||
? refLocator(page, requireRef(resolvedStart.ref))
|
||||
: page.locator(resolvedStart.selector!);
|
||||
const endLocator = resolvedEnd.ref
|
||||
? refLocator(page, requireRef(resolvedEnd.ref))
|
||||
: page.locator(resolvedEnd.selector!);
|
||||
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
|
||||
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
|
||||
try {
|
||||
await startLocator.dragTo(endLocator, {
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectOptionViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
values: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
if (!opts.values?.length) {
|
||||
throw new Error("values are required");
|
||||
}
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const label = resolved.ref ?? resolved.selector!;
|
||||
const locator = resolved.ref
|
||||
? refLocator(page, requireRef(resolved.ref))
|
||||
: page.locator(resolved.selector!);
|
||||
try {
|
||||
await locator.selectOption(opts.values, {
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pressKeyViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
key: string;
|
||||
delayMs?: number;
|
||||
}): Promise<void> {
|
||||
const key = String(opts.key ?? "").trim();
|
||||
if (!key) {
|
||||
throw new Error("key is required");
|
||||
}
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.keyboard.press(key, {
|
||||
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function typeViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
text: string;
|
||||
submit?: boolean;
|
||||
slowly?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const text = String(opts.text ?? "");
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const label = resolved.ref ?? resolved.selector!;
|
||||
const locator = resolved.ref
|
||||
? refLocator(page, requireRef(resolved.ref))
|
||||
: page.locator(resolved.selector!);
|
||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||
try {
|
||||
if (opts.slowly) {
|
||||
await locator.click({ timeout });
|
||||
await locator.type(text, { timeout, delay: 75 });
|
||||
} else {
|
||||
await locator.fill(text, { timeout });
|
||||
}
|
||||
if (opts.submit) {
|
||||
await locator.press("Enter", { timeout });
|
||||
}
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fillFormViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
fields: BrowserFormField[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||
for (const field of opts.fields) {
|
||||
const ref = field.ref.trim();
|
||||
const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE;
|
||||
const rawValue = field.value;
|
||||
const value =
|
||||
typeof rawValue === "string"
|
||||
? rawValue
|
||||
: typeof rawValue === "number" || typeof rawValue === "boolean"
|
||||
? String(rawValue)
|
||||
: "";
|
||||
if (!ref) {
|
||||
continue;
|
||||
}
|
||||
const locator = refLocator(page, ref);
|
||||
if (type === "checkbox" || type === "radio") {
|
||||
const checked =
|
||||
rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
|
||||
try {
|
||||
await locator.setChecked(checked, { timeout });
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, ref);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await locator.fill(value, { timeout });
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
fn: string;
|
||||
ref?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<unknown> {
|
||||
const fnText = String(opts.fn ?? "").trim();
|
||||
if (!fnText) {
|
||||
throw new Error("function is required");
|
||||
}
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
// Clamp evaluate timeout to prevent permanently blocking Playwright's command queue.
|
||||
// Without this, a long-running async evaluate blocks all subsequent page operations
|
||||
// because Playwright serializes CDP commands per page.
|
||||
//
|
||||
// NOTE: Playwright's { timeout } on evaluate only applies to installing the function,
|
||||
// NOT to its execution time. We must inject a Promise.race timeout into the browser
|
||||
// context itself so async functions are bounded.
|
||||
const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||
// Leave headroom for routing/serialization overhead so the outer request timeout
|
||||
// doesn't fire first and strand a long-running evaluate.
|
||||
let evaluateTimeout = Math.max(1000, Math.min(120_000, outerTimeout - 500));
|
||||
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
|
||||
|
||||
const signal = opts.signal;
|
||||
let abortListener: (() => void) | undefined;
|
||||
let abortReject: ((reason: unknown) => void) | undefined;
|
||||
let abortPromise: Promise<never> | undefined;
|
||||
if (signal) {
|
||||
abortPromise = new Promise((_, reject) => {
|
||||
abortReject = reject;
|
||||
});
|
||||
// Ensure the abort promise never becomes an unhandled rejection if we throw early.
|
||||
void abortPromise.catch(() => {});
|
||||
}
|
||||
if (signal) {
|
||||
const disconnect = () => {
|
||||
void forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
reason: "evaluate aborted",
|
||||
}).catch(() => {});
|
||||
};
|
||||
if (signal.aborted) {
|
||||
disconnect();
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
}
|
||||
abortListener = () => {
|
||||
disconnect();
|
||||
abortReject?.(signal.reason ?? new Error("aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
// If the signal aborted between the initial check and listener registration, handle it.
|
||||
if (signal.aborted) {
|
||||
abortListener();
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (opts.ref) {
|
||||
const locator = refLocator(page, opts.ref);
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
|
||||
const elementEvaluator = new Function(
|
||||
"el",
|
||||
"args",
|
||||
`
|
||||
"use strict";
|
||||
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
|
||||
try {
|
||||
var candidate = eval("(" + fnBody + ")");
|
||||
var result = typeof candidate === "function" ? candidate(el) : candidate;
|
||||
if (result && typeof result.then === "function") {
|
||||
return Promise.race([
|
||||
result,
|
||||
new Promise(function(_, reject) {
|
||||
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
|
||||
})
|
||||
]);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
|
||||
}
|
||||
`,
|
||||
) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown;
|
||||
const evalPromise = locator.evaluate(elementEvaluator, {
|
||||
fnBody: fnText,
|
||||
timeoutMs: evaluateTimeout,
|
||||
});
|
||||
return await awaitEvalWithAbort(evalPromise, abortPromise);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
|
||||
const browserEvaluator = new Function(
|
||||
"args",
|
||||
`
|
||||
"use strict";
|
||||
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
|
||||
try {
|
||||
var candidate = eval("(" + fnBody + ")");
|
||||
var result = typeof candidate === "function" ? candidate() : candidate;
|
||||
if (result && typeof result.then === "function") {
|
||||
return Promise.race([
|
||||
result,
|
||||
new Promise(function(_, reject) {
|
||||
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
|
||||
})
|
||||
]);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
|
||||
}
|
||||
`,
|
||||
) as (args: { fnBody: string; timeoutMs: number }) => unknown;
|
||||
const evalPromise = page.evaluate(browserEvaluator, {
|
||||
fnBody: fnText,
|
||||
timeoutMs: evaluateTimeout,
|
||||
});
|
||||
return await awaitEvalWithAbort(evalPromise, abortPromise);
|
||||
} finally {
|
||||
if (signal && abortListener) {
|
||||
signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollIntoViewViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||
|
||||
const label = resolved.ref ?? resolved.selector!;
|
||||
const locator = resolved.ref
|
||||
? refLocator(page, requireRef(resolved.ref))
|
||||
: page.locator(resolved.selector!);
|
||||
try {
|
||||
await locator.scrollIntoViewIfNeeded({ timeout });
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
timeMs?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
selector?: string;
|
||||
url?: string;
|
||||
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||
fn?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||
|
||||
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
||||
await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
||||
}
|
||||
if (opts.text) {
|
||||
await page.getByText(opts.text).first().waitFor({
|
||||
state: "visible",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
if (opts.textGone) {
|
||||
await page.getByText(opts.textGone).first().waitFor({
|
||||
state: "hidden",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
if (opts.selector) {
|
||||
const selector = String(opts.selector).trim();
|
||||
if (selector) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout });
|
||||
}
|
||||
}
|
||||
if (opts.url) {
|
||||
const url = String(opts.url).trim();
|
||||
if (url) {
|
||||
await page.waitForURL(url, { timeout });
|
||||
}
|
||||
}
|
||||
if (opts.loadState) {
|
||||
await page.waitForLoadState(opts.loadState, { timeout });
|
||||
}
|
||||
if (opts.fn) {
|
||||
const fn = String(opts.fn).trim();
|
||||
if (fn) {
|
||||
await page.waitForFunction(fn, { timeout });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeScreenshotViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
fullPage?: boolean;
|
||||
type?: "png" | "jpeg";
|
||||
}): Promise<{ buffer: Buffer }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
||||
const type = opts.type ?? "png";
|
||||
if (opts.ref) {
|
||||
if (opts.fullPage) {
|
||||
throw new Error("fullPage is not supported for element screenshots");
|
||||
}
|
||||
const locator = refLocator(page, opts.ref);
|
||||
const buffer = await locator.screenshot({ type });
|
||||
return { buffer };
|
||||
}
|
||||
if (opts.element) {
|
||||
if (opts.fullPage) {
|
||||
throw new Error("fullPage is not supported for element screenshots");
|
||||
}
|
||||
const locator = page.locator(opts.element).first();
|
||||
const buffer = await locator.screenshot({ type });
|
||||
return { buffer };
|
||||
}
|
||||
const buffer = await page.screenshot({
|
||||
type,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
});
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
export async function screenshotWithLabelsViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
refs: Record<string, { role: string; name?: string; nth?: number }>;
|
||||
maxLabels?: number;
|
||||
type?: "png" | "jpeg";
|
||||
}): Promise<{ buffer: Buffer; labels: number; skipped: number }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
||||
const type = opts.type ?? "png";
|
||||
const maxLabels =
|
||||
typeof opts.maxLabels === "number" && Number.isFinite(opts.maxLabels)
|
||||
? Math.max(1, Math.floor(opts.maxLabels))
|
||||
: 150;
|
||||
|
||||
const viewport = await page.evaluate(() => ({
|
||||
scrollX: window.scrollX || 0,
|
||||
scrollY: window.scrollY || 0,
|
||||
width: window.innerWidth || 0,
|
||||
height: window.innerHeight || 0,
|
||||
}));
|
||||
|
||||
const refs = Object.keys(opts.refs ?? {});
|
||||
const boxes: Array<{ ref: string; x: number; y: number; w: number; h: number }> = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const ref of refs) {
|
||||
if (boxes.length >= maxLabels) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const box = await refLocator(page, ref).boundingBox();
|
||||
if (!box) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const x0 = box.x;
|
||||
const y0 = box.y;
|
||||
const x1 = box.x + box.width;
|
||||
const y1 = box.y + box.height;
|
||||
const vx0 = viewport.scrollX;
|
||||
const vy0 = viewport.scrollY;
|
||||
const vx1 = viewport.scrollX + viewport.width;
|
||||
const vy1 = viewport.scrollY + viewport.height;
|
||||
if (x1 < vx0 || x0 > vx1 || y1 < vy0 || y0 > vy1) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
boxes.push({
|
||||
ref,
|
||||
x: x0 - viewport.scrollX,
|
||||
y: y0 - viewport.scrollY,
|
||||
w: Math.max(1, box.width),
|
||||
h: Math.max(1, box.height),
|
||||
});
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (boxes.length > 0) {
|
||||
await page.evaluate((labels) => {
|
||||
const existing = document.querySelectorAll("[data-openclaw-labels]");
|
||||
existing.forEach((el) => el.remove());
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-openclaw-labels", "1");
|
||||
root.style.position = "fixed";
|
||||
root.style.left = "0";
|
||||
root.style.top = "0";
|
||||
root.style.zIndex = "2147483647";
|
||||
root.style.pointerEvents = "none";
|
||||
root.style.fontFamily =
|
||||
'"SF Mono","SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace';
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
for (const label of labels) {
|
||||
const box = document.createElement("div");
|
||||
box.setAttribute("data-openclaw-labels", "1");
|
||||
box.style.position = "absolute";
|
||||
box.style.left = `${label.x}px`;
|
||||
box.style.top = `${label.y}px`;
|
||||
box.style.width = `${label.w}px`;
|
||||
box.style.height = `${label.h}px`;
|
||||
box.style.border = "2px solid #ffb020";
|
||||
box.style.boxSizing = "border-box";
|
||||
|
||||
const tag = document.createElement("div");
|
||||
tag.setAttribute("data-openclaw-labels", "1");
|
||||
tag.textContent = label.ref;
|
||||
tag.style.position = "absolute";
|
||||
tag.style.left = `${label.x}px`;
|
||||
tag.style.top = `${clamp(label.y - 18, 0, 20000)}px`;
|
||||
tag.style.background = "#ffb020";
|
||||
tag.style.color = "#1a1a1a";
|
||||
tag.style.fontSize = "12px";
|
||||
tag.style.lineHeight = "14px";
|
||||
tag.style.padding = "1px 4px";
|
||||
tag.style.borderRadius = "3px";
|
||||
tag.style.boxShadow = "0 1px 2px rgba(0,0,0,0.35)";
|
||||
tag.style.whiteSpace = "nowrap";
|
||||
|
||||
root.appendChild(box);
|
||||
root.appendChild(tag);
|
||||
}
|
||||
|
||||
document.documentElement.appendChild(root);
|
||||
}, boxes);
|
||||
}
|
||||
|
||||
const buffer = await page.screenshot({ type });
|
||||
return { buffer, labels: boxes.length, skipped };
|
||||
} finally {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const existing = document.querySelectorAll("[data-openclaw-labels]");
|
||||
existing.forEach((el) => el.remove());
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function setInputFilesViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
inputRef?: string;
|
||||
element?: string;
|
||||
paths: string[];
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
||||
if (!opts.paths.length) {
|
||||
throw new Error("paths are required");
|
||||
}
|
||||
const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
|
||||
const element = typeof opts.element === "string" ? opts.element.trim() : "";
|
||||
if (inputRef && element) {
|
||||
throw new Error("inputRef and element are mutually exclusive");
|
||||
}
|
||||
if (!inputRef && !element) {
|
||||
throw new Error("inputRef or element is required");
|
||||
}
|
||||
|
||||
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
|
||||
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
||||
rootDir: DEFAULT_UPLOAD_DIR,
|
||||
requestedPaths: opts.paths,
|
||||
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
||||
});
|
||||
if (!uploadPathsResult.ok) {
|
||||
throw new Error(uploadPathsResult.error);
|
||||
}
|
||||
const resolvedPaths = uploadPathsResult.paths;
|
||||
|
||||
try {
|
||||
await locator.setInputFiles(resolvedPaths);
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, inputRef || element);
|
||||
}
|
||||
try {
|
||||
const handle = await locator.elementHandle();
|
||||
if (handle) {
|
||||
await handle.evaluate((el) => {
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort for sites that don't react to setInputFiles alone.
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BATCH_DEPTH = 5;
|
||||
|
||||
async function executeSingleAction(
|
||||
action: BrowserActRequest,
|
||||
cdpUrl: string,
|
||||
targetId?: string,
|
||||
evaluateEnabled?: boolean,
|
||||
depth = 0,
|
||||
): Promise<void> {
|
||||
if (depth > MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
||||
}
|
||||
const effectiveTargetId = action.targetId ?? targetId;
|
||||
switch (action.kind) {
|
||||
case "click":
|
||||
await clickViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ref: action.ref,
|
||||
selector: action.selector,
|
||||
doubleClick: action.doubleClick,
|
||||
button: action.button as "left" | "right" | "middle" | undefined,
|
||||
modifiers: action.modifiers as Array<
|
||||
"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"
|
||||
>,
|
||||
delayMs: action.delayMs,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "type":
|
||||
await typeViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ref: action.ref,
|
||||
selector: action.selector,
|
||||
text: action.text,
|
||||
submit: action.submit,
|
||||
slowly: action.slowly,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "press":
|
||||
await pressKeyViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
key: action.key,
|
||||
delayMs: action.delayMs,
|
||||
});
|
||||
break;
|
||||
case "hover":
|
||||
await hoverViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ref: action.ref,
|
||||
selector: action.selector,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "scrollIntoView":
|
||||
await scrollIntoViewViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ref: action.ref,
|
||||
selector: action.selector,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "drag":
|
||||
await dragViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
startRef: action.startRef,
|
||||
startSelector: action.startSelector,
|
||||
endRef: action.endRef,
|
||||
endSelector: action.endSelector,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "select":
|
||||
await selectOptionViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ref: action.ref,
|
||||
selector: action.selector,
|
||||
values: action.values,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "fill":
|
||||
await fillFormViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
fields: action.fields,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "resize":
|
||||
await resizeViewportViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
width: action.width,
|
||||
height: action.height,
|
||||
});
|
||||
break;
|
||||
case "wait":
|
||||
if (action.fn && !evaluateEnabled) {
|
||||
throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)");
|
||||
}
|
||||
await waitForViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
timeMs: action.timeMs,
|
||||
text: action.text,
|
||||
textGone: action.textGone,
|
||||
selector: action.selector,
|
||||
url: action.url,
|
||||
loadState: action.loadState,
|
||||
fn: action.fn,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "evaluate":
|
||||
if (!evaluateEnabled) {
|
||||
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
|
||||
}
|
||||
await evaluateViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
fn: action.fn,
|
||||
ref: action.ref,
|
||||
timeoutMs: action.timeoutMs,
|
||||
});
|
||||
break;
|
||||
case "close":
|
||||
await closePageViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
});
|
||||
break;
|
||||
case "batch":
|
||||
await batchViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
actions: action.actions,
|
||||
stopOnError: action.stopOnError,
|
||||
evaluateEnabled,
|
||||
depth: depth + 1,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function batchViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
actions: BrowserActRequest[];
|
||||
stopOnError?: boolean;
|
||||
evaluateEnabled?: boolean;
|
||||
depth?: number;
|
||||
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
|
||||
const depth = opts.depth ?? 0;
|
||||
if (depth > MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
||||
}
|
||||
if (opts.actions.length > MAX_BATCH_ACTIONS) {
|
||||
throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`);
|
||||
}
|
||||
const results: Array<{ ok: boolean; error?: string }> = [];
|
||||
for (const action of opts.actions) {
|
||||
try {
|
||||
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
|
||||
results.push({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({ ok: false, error: message });
|
||||
if (opts.stopOnError !== false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
}
|
||||
108
extensions/browser/src/browser/pw-tools-core.responses.ts
Normal file
108
extensions/browser/src/browser/pw-tools-core.responses.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
|
||||
import { matchBrowserUrlPattern } from "./url-pattern.js";
|
||||
|
||||
export async function responseBodyViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
maxChars?: number;
|
||||
}): Promise<{
|
||||
url: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
body: string;
|
||||
truncated?: boolean;
|
||||
}> {
|
||||
const pattern = String(opts.url ?? "").trim();
|
||||
if (!pattern) {
|
||||
throw new Error("url is required");
|
||||
}
|
||||
const maxChars =
|
||||
typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)
|
||||
? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars)))
|
||||
: 200_000;
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
let done = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let handler: ((resp: unknown) => void) | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = undefined;
|
||||
if (handler) {
|
||||
page.off("response", handler as never);
|
||||
}
|
||||
};
|
||||
|
||||
handler = (resp: unknown) => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
const r = resp as { url?: () => string };
|
||||
const u = r.url?.() || "";
|
||||
if (!matchBrowserUrlPattern(pattern, u)) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanup();
|
||||
resolve(resp);
|
||||
};
|
||||
|
||||
page.on("response", handler as never);
|
||||
timer = setTimeout(() => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Response not found for url pattern "${pattern}". Run '${formatCliCommand("openclaw browser requests")}' to inspect recent network activity.`,
|
||||
),
|
||||
);
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
const resp = (await promise) as {
|
||||
url?: () => string;
|
||||
status?: () => number;
|
||||
headers?: () => Record<string, string>;
|
||||
body?: () => Promise<Buffer>;
|
||||
text?: () => Promise<string>;
|
||||
};
|
||||
|
||||
const url = resp.url?.() || "";
|
||||
const status = resp.status?.();
|
||||
const headers = resp.headers?.();
|
||||
|
||||
let bodyText = "";
|
||||
try {
|
||||
if (typeof resp.text === "function") {
|
||||
bodyText = await resp.text();
|
||||
} else if (typeof resp.body === "function") {
|
||||
const buf = await resp.body();
|
||||
bodyText = new TextDecoder("utf-8").decode(buf);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read response body for "${url}": ${String(err)}`, { cause: err });
|
||||
}
|
||||
|
||||
const trimmed = bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
|
||||
return {
|
||||
url,
|
||||
status,
|
||||
headers,
|
||||
body: trimmed,
|
||||
truncated: bodyText.length > maxChars ? true : undefined,
|
||||
};
|
||||
}
|
||||
85
extensions/browser/src/browser/pw-tools-core.shared.ts
Normal file
85
extensions/browser/src/browser/pw-tools-core.shared.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { parseRoleRef } from "./pw-role-snapshot.js";
|
||||
|
||||
let nextUploadArmId = 0;
|
||||
let nextDialogArmId = 0;
|
||||
let nextDownloadArmId = 0;
|
||||
|
||||
export function bumpUploadArmId(): number {
|
||||
nextUploadArmId += 1;
|
||||
return nextUploadArmId;
|
||||
}
|
||||
|
||||
export function bumpDialogArmId(): number {
|
||||
nextDialogArmId += 1;
|
||||
return nextDialogArmId;
|
||||
}
|
||||
|
||||
export function bumpDownloadArmId(): number {
|
||||
nextDownloadArmId += 1;
|
||||
return nextDownloadArmId;
|
||||
}
|
||||
|
||||
export function requireRef(value: unknown): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
const roleRef = raw ? parseRoleRef(raw) : null;
|
||||
const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw);
|
||||
if (!ref) {
|
||||
throw new Error("ref is required");
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function requireRefOrSelector(
|
||||
ref: string | undefined,
|
||||
selector: string | undefined,
|
||||
): { ref?: string; selector?: string } {
|
||||
const trimmedRef = typeof ref === "string" ? ref.trim() : "";
|
||||
const trimmedSelector = typeof selector === "string" ? selector.trim() : "";
|
||||
if (!trimmedRef && !trimmedSelector) {
|
||||
throw new Error("ref or selector is required");
|
||||
}
|
||||
return {
|
||||
ref: trimmedRef || undefined,
|
||||
selector: trimmedSelector || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) {
|
||||
return Math.max(500, Math.min(120_000, timeoutMs ?? fallback));
|
||||
}
|
||||
|
||||
export function toAIFriendlyError(error: unknown, selector: string): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.includes("strict mode violation")) {
|
||||
const countMatch = message.match(/resolved to (\d+) elements/);
|
||||
const count = countMatch ? countMatch[1] : "multiple";
|
||||
return new Error(
|
||||
`Selector "${selector}" matched ${count} elements. ` +
|
||||
`Run a new snapshot to get updated refs, or use a different ref.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(message.includes("Timeout") || message.includes("waiting for")) &&
|
||||
(message.includes("to be visible") || message.includes("not visible"))
|
||||
) {
|
||||
return new Error(
|
||||
`Element "${selector}" not found or not visible. ` +
|
||||
`Run a new snapshot to see current page elements.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("intercepts pointer events") ||
|
||||
message.includes("not visible") ||
|
||||
message.includes("not receive pointer events")
|
||||
) {
|
||||
return new Error(
|
||||
`Element "${selector}" is not interactable (hidden or covered). ` +
|
||||
`Try scrolling it into view, closing overlays, or re-snapshotting.`,
|
||||
);
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
262
extensions/browser/src/browser/pw-tools-core.snapshot.ts
Normal file
262
extensions/browser/src/browser/pw-tools-core.snapshot.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import {
|
||||
buildRoleSnapshotFromAiSnapshot,
|
||||
buildRoleSnapshotFromAriaSnapshot,
|
||||
getRoleSnapshotStats,
|
||||
type RoleSnapshotOptions,
|
||||
type RoleRefMap,
|
||||
} from "./pw-role-snapshot.js";
|
||||
import {
|
||||
ensurePageState,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
storeRoleRefsForTarget,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export async function snapshotAriaViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ nodes: AriaSnapshotNode[] }> {
|
||||
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
const res = (await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Accessibility.enable").catch(() => {});
|
||||
return (await send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
},
|
||||
})) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
}
|
||||
|
||||
export async function snapshotAiViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
maxChars?: number;
|
||||
}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
|
||||
const maybe = page as unknown as WithSnapshotForAI;
|
||||
if (!maybe._snapshotForAI) {
|
||||
throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core.");
|
||||
}
|
||||
|
||||
const result = await maybe._snapshotForAI({
|
||||
timeout: Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))),
|
||||
track: "response",
|
||||
});
|
||||
let snapshot = String(result?.full ?? "");
|
||||
const maxChars = opts.maxChars;
|
||||
const limit =
|
||||
typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0
|
||||
? Math.floor(maxChars)
|
||||
: undefined;
|
||||
let truncated = false;
|
||||
if (limit && snapshot.length > limit) {
|
||||
snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`;
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const built = buildRoleSnapshotFromAiSnapshot(snapshot);
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: built.refs,
|
||||
mode: "aria",
|
||||
});
|
||||
return truncated ? { snapshot, truncated, refs: built.refs } : { snapshot, refs: built.refs };
|
||||
}
|
||||
|
||||
export async function snapshotRoleViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
selector?: string;
|
||||
frameSelector?: string;
|
||||
refsMode?: "role" | "aria";
|
||||
options?: RoleSnapshotOptions;
|
||||
}): Promise<{
|
||||
snapshot: string;
|
||||
refs: Record<string, { role: string; name?: string; nth?: number }>;
|
||||
stats: { lines: number; chars: number; refs: number; interactive: number };
|
||||
}> {
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
|
||||
if (opts.refsMode === "aria") {
|
||||
if (opts.selector?.trim() || opts.frameSelector?.trim()) {
|
||||
throw new Error("refs=aria does not support selector/frame snapshots yet.");
|
||||
}
|
||||
const maybe = page as unknown as WithSnapshotForAI;
|
||||
if (!maybe._snapshotForAI) {
|
||||
throw new Error("refs=aria requires Playwright _snapshotForAI support.");
|
||||
}
|
||||
const result = await maybe._snapshotForAI({
|
||||
timeout: 5000,
|
||||
track: "response",
|
||||
});
|
||||
const built = buildRoleSnapshotFromAiSnapshot(String(result?.full ?? ""), opts.options);
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: built.refs,
|
||||
mode: "aria",
|
||||
});
|
||||
return {
|
||||
snapshot: built.snapshot,
|
||||
refs: built.refs,
|
||||
stats: getRoleSnapshotStats(built.snapshot, built.refs),
|
||||
};
|
||||
}
|
||||
|
||||
const frameSelector = opts.frameSelector?.trim() || "";
|
||||
const selector = opts.selector?.trim() || "";
|
||||
const locator = frameSelector
|
||||
? selector
|
||||
? page.frameLocator(frameSelector).locator(selector)
|
||||
: page.frameLocator(frameSelector).locator(":root")
|
||||
: selector
|
||||
? page.locator(selector)
|
||||
: page.locator(":root");
|
||||
|
||||
const ariaSnapshot = await locator.ariaSnapshot();
|
||||
const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ""), opts.options);
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: built.refs,
|
||||
frameSelector: frameSelector || undefined,
|
||||
mode: "role",
|
||||
});
|
||||
return {
|
||||
snapshot: built.snapshot,
|
||||
refs: built.refs,
|
||||
stats: getRoleSnapshotStats(built.snapshot, built.refs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function navigateViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ url: string }> {
|
||||
const isRetryableNavigateError = (err: unknown): boolean => {
|
||||
const msg =
|
||||
typeof err === "string"
|
||||
? err.toLowerCase()
|
||||
: err instanceof Error
|
||||
? err.message.toLowerCase()
|
||||
: "";
|
||||
return (
|
||||
msg.includes("frame has been detached") ||
|
||||
msg.includes("target page, context or browser has been closed")
|
||||
);
|
||||
};
|
||||
|
||||
const url = String(opts.url ?? "").trim();
|
||||
if (!url) {
|
||||
throw new Error("url is required");
|
||||
}
|
||||
await assertBrowserNavigationAllowed({
|
||||
url,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
let page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const navigate = async () => await page.goto(url, { timeout });
|
||||
let response;
|
||||
try {
|
||||
response = await navigate();
|
||||
} catch (err) {
|
||||
if (!isRetryableNavigateError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Extension relays can briefly drop CDP during renderer swaps/navigation.
|
||||
// Force a clean reconnect, then retry once on the refreshed page handle.
|
||||
await forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
reason: "retry navigate after detached frame",
|
||||
}).catch(() => {});
|
||||
page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
response = await navigate();
|
||||
}
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
const finalUrl = page.url();
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: finalUrl,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
return { url: finalUrl };
|
||||
}
|
||||
|
||||
export async function resizeViewportViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.setViewportSize({
|
||||
width: Math.max(1, Math.floor(opts.width)),
|
||||
height: Math.max(1, Math.floor(opts.height)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closePageViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
export async function pdfViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<{ buffer: Buffer }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const buffer = await page.pdf({ printBackground: true });
|
||||
return { buffer };
|
||||
}
|
||||
215
extensions/browser/src/browser/pw-tools-core.state.ts
Normal file
215
extensions/browser/src/browser/pw-tools-core.state.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { devices as playwrightDevices } from "playwright-core";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export async function setOfflineViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
offline: boolean;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.context().setOffline(Boolean(opts.offline));
|
||||
}
|
||||
|
||||
export async function setExtraHTTPHeadersViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.context().setExtraHTTPHeaders(opts.headers);
|
||||
}
|
||||
|
||||
export async function setHttpCredentialsViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
clear?: boolean;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
if (opts.clear) {
|
||||
await page.context().setHTTPCredentials(null);
|
||||
return;
|
||||
}
|
||||
const username = String(opts.username ?? "");
|
||||
const password = String(opts.password ?? "");
|
||||
if (!username) {
|
||||
throw new Error("username is required (or set clear=true)");
|
||||
}
|
||||
await page.context().setHTTPCredentials({ username, password });
|
||||
}
|
||||
|
||||
export async function setGeolocationViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
accuracy?: number;
|
||||
origin?: string;
|
||||
clear?: boolean;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const context = page.context();
|
||||
if (opts.clear) {
|
||||
await context.setGeolocation(null);
|
||||
await context.clearPermissions().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") {
|
||||
throw new Error("latitude and longitude are required (or set clear=true)");
|
||||
}
|
||||
await context.setGeolocation({
|
||||
latitude: opts.latitude,
|
||||
longitude: opts.longitude,
|
||||
accuracy: typeof opts.accuracy === "number" ? opts.accuracy : undefined,
|
||||
});
|
||||
const origin =
|
||||
opts.origin?.trim() ||
|
||||
(() => {
|
||||
try {
|
||||
return new URL(page.url()).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
if (origin) {
|
||||
await context.grantPermissions(["geolocation"], { origin }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function emulateMediaViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
colorScheme: "dark" | "light" | "no-preference" | null;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.emulateMedia({ colorScheme: opts.colorScheme });
|
||||
}
|
||||
|
||||
export async function setLocaleViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
locale: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const locale = String(opts.locale ?? "").trim();
|
||||
if (!locale) {
|
||||
throw new Error("locale is required");
|
||||
}
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setLocaleOverride", { locale });
|
||||
} catch (err) {
|
||||
if (String(err).includes("Another locale override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTimezoneViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
timezoneId: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const timezoneId = String(opts.timezoneId ?? "").trim();
|
||||
if (!timezoneId) {
|
||||
throw new Error("timezoneId is required");
|
||||
}
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setTimezoneOverride", { timezoneId });
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("Timezone override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
if (msg.includes("Invalid timezone")) {
|
||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDeviceViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const name = String(opts.name ?? "").trim();
|
||||
if (!name) {
|
||||
throw new Error("device name is required");
|
||||
}
|
||||
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
|
||||
| {
|
||||
userAgent?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
deviceScaleFactor?: number;
|
||||
isMobile?: boolean;
|
||||
hasTouch?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!descriptor) {
|
||||
throw new Error(`Unknown device "${name}".`);
|
||||
}
|
||||
|
||||
if (descriptor.viewport) {
|
||||
await page.setViewportSize({
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
if (descriptor.userAgent || descriptor.locale) {
|
||||
await send("Emulation.setUserAgentOverride", {
|
||||
userAgent: descriptor.userAgent ?? "",
|
||||
acceptLanguage: descriptor.locale ?? undefined,
|
||||
});
|
||||
}
|
||||
if (descriptor.viewport) {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
mobile: Boolean(descriptor.isMobile),
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||
screenWidth: descriptor.viewport.width,
|
||||
screenHeight: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
if (descriptor.hasTouch) {
|
||||
await send("Emulation.setTouchEmulationEnabled", {
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
128
extensions/browser/src/browser/pw-tools-core.storage.ts
Normal file
128
extensions/browser/src/browser/pw-tools-core.storage.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
|
||||
export async function cookiesGetViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<{ cookies: unknown[] }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const cookies = await page.context().cookies();
|
||||
return { cookies };
|
||||
}
|
||||
|
||||
export async function cookiesSetViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
cookie: {
|
||||
name: string;
|
||||
value: string;
|
||||
url?: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: "Lax" | "None" | "Strict";
|
||||
};
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const cookie = opts.cookie;
|
||||
if (!cookie.name || cookie.value === undefined) {
|
||||
throw new Error("cookie name and value are required");
|
||||
}
|
||||
const hasUrl = typeof cookie.url === "string" && cookie.url.trim();
|
||||
const hasDomainPath =
|
||||
typeof cookie.domain === "string" &&
|
||||
cookie.domain.trim() &&
|
||||
typeof cookie.path === "string" &&
|
||||
cookie.path.trim();
|
||||
if (!hasUrl && !hasDomainPath) {
|
||||
throw new Error("cookie requires url, or domain+path");
|
||||
}
|
||||
await page.context().addCookies([cookie]);
|
||||
}
|
||||
|
||||
export async function cookiesClearViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
||||
type StorageKind = "local" | "session";
|
||||
|
||||
export async function storageGetViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
kind: StorageKind;
|
||||
key?: string;
|
||||
}): Promise<{ values: Record<string, string> }> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const kind = opts.kind;
|
||||
const key = typeof opts.key === "string" ? opts.key : undefined;
|
||||
const values = await page.evaluate(
|
||||
({ kind: kind2, key: key2 }) => {
|
||||
const store = kind2 === "session" ? window.sessionStorage : window.localStorage;
|
||||
if (key2) {
|
||||
const value = store.getItem(key2);
|
||||
return value === null ? {} : { [key2]: value };
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = 0; i < store.length; i += 1) {
|
||||
const k = store.key(i);
|
||||
if (!k) {
|
||||
continue;
|
||||
}
|
||||
const v = store.getItem(k);
|
||||
if (v !== null) {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ kind, key },
|
||||
);
|
||||
return { values: values ?? {} };
|
||||
}
|
||||
|
||||
export async function storageSetViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
kind: StorageKind;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const key = String(opts.key ?? "");
|
||||
if (!key) {
|
||||
throw new Error("key is required");
|
||||
}
|
||||
await page.evaluate(
|
||||
({ kind, key: k, value }) => {
|
||||
const store = kind === "session" ? window.sessionStorage : window.localStorage;
|
||||
store.setItem(k, value);
|
||||
},
|
||||
{ kind: opts.kind, key, value: String(opts.value ?? "") },
|
||||
);
|
||||
}
|
||||
|
||||
export async function storageClearViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
kind: StorageKind;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
await page.evaluate(
|
||||
({ kind }) => {
|
||||
const store = kind === "session" ? window.sessionStorage : window.localStorage;
|
||||
store.clear();
|
||||
},
|
||||
{ kind: opts.kind },
|
||||
);
|
||||
}
|
||||
66
extensions/browser/src/browser/pw-tools-core.test-harness.ts
Normal file
66
extensions/browser/src/browser/pw-tools-core.test-harness.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
} = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) {
|
||||
throw new Error("missing page");
|
||||
}
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
|
||||
restoreRoleRefsForTarget: vi.fn(() => {}),
|
||||
storeRoleRefsForTarget: vi.fn(() => {}),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) {
|
||||
throw new Error("missing locator");
|
||||
}
|
||||
return currentRefLocator;
|
||||
}),
|
||||
rememberRoleRefsForTarget: vi.fn(() => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
export function getPwToolsCoreSessionMocks() {
|
||||
return sessionMocks;
|
||||
}
|
||||
|
||||
export function setPwToolsCoreCurrentPage(page: Record<string, unknown> | null) {
|
||||
currentPage = page;
|
||||
}
|
||||
|
||||
export function setPwToolsCoreCurrentRefLocator(locator: Record<string, unknown> | null) {
|
||||
currentRefLocator = locator;
|
||||
}
|
||||
|
||||
export function installPwToolsCoreTestHooks() {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
|
||||
for (const fn of Object.values(sessionMocks)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
});
|
||||
}
|
||||
45
extensions/browser/src/browser/pw-tools-core.trace.ts
Normal file
45
extensions/browser/src/browser/pw-tools-core.trace.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { writeViaSiblingTempPath } from "./output-atomic.js";
|
||||
import { DEFAULT_TRACE_DIR } from "./paths.js";
|
||||
import { ensureContextState, getPageForTargetId } from "./pw-session.js";
|
||||
|
||||
export async function traceStartViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
screenshots?: boolean;
|
||||
snapshots?: boolean;
|
||||
sources?: boolean;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const context = page.context();
|
||||
const ctxState = ensureContextState(context);
|
||||
if (ctxState.traceActive) {
|
||||
throw new Error("Trace already running. Stop the current trace before starting a new one.");
|
||||
}
|
||||
await context.tracing.start({
|
||||
screenshots: opts.screenshots ?? true,
|
||||
snapshots: opts.snapshots ?? true,
|
||||
sources: opts.sources ?? false,
|
||||
});
|
||||
ctxState.traceActive = true;
|
||||
}
|
||||
|
||||
export async function traceStopViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
path: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const context = page.context();
|
||||
const ctxState = ensureContextState(context);
|
||||
if (!ctxState.traceActive) {
|
||||
throw new Error("No active trace. Start a trace before stopping it.");
|
||||
}
|
||||
await writeViaSiblingTempPath({
|
||||
rootDir: DEFAULT_TRACE_DIR,
|
||||
targetPath: opts.path,
|
||||
writeTemp: async (tempPath) => {
|
||||
await context.tracing.stop({ path: tempPath });
|
||||
},
|
||||
});
|
||||
ctxState.traceActive = false;
|
||||
}
|
||||
8
extensions/browser/src/browser/pw-tools-core.ts
Normal file
8
extensions/browser/src/browser/pw-tools-core.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./pw-tools-core.activity.js";
|
||||
export * from "./pw-tools-core.downloads.js";
|
||||
export * from "./pw-tools-core.interactions.js";
|
||||
export * from "./pw-tools-core.responses.js";
|
||||
export * from "./pw-tools-core.snapshot.js";
|
||||
export * from "./pw-tools-core.state.js";
|
||||
export * from "./pw-tools-core.storage.js";
|
||||
export * from "./pw-tools-core.trace.js";
|
||||
49
extensions/browser/src/browser/request-policy.ts
Normal file
49
extensions/browser/src/browser/request-policy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
type BrowserRequestProfileParams = {
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
profile?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeBrowserRequestPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withLeadingSlash.length <= 1) {
|
||||
return withLeadingSlash;
|
||||
}
|
||||
return withLeadingSlash.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function isPersistentBrowserProfileMutation(method: string, path: string): boolean {
|
||||
const normalizedPath = normalizeBrowserRequestPath(path);
|
||||
if (
|
||||
method === "POST" &&
|
||||
(normalizedPath === "/profiles/create" || normalizedPath === "/reset-profile")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
|
||||
}
|
||||
|
||||
export function resolveRequestedBrowserProfile(
|
||||
params: BrowserRequestProfileParams,
|
||||
): string | undefined {
|
||||
const queryProfile =
|
||||
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
|
||||
if (queryProfile) {
|
||||
return queryProfile;
|
||||
}
|
||||
if (params.body && typeof params.body === "object") {
|
||||
const bodyProfile =
|
||||
"profile" in params.body && typeof params.body.profile === "string"
|
||||
? params.body.profile.trim()
|
||||
: undefined;
|
||||
if (bodyProfile) {
|
||||
return bodyProfile;
|
||||
}
|
||||
}
|
||||
const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined;
|
||||
return explicitProfile || undefined;
|
||||
}
|
||||
107
extensions/browser/src/browser/resolved-config-refresh.ts
Normal file
107
extensions/browser/src/browser/resolved-config-refresh.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createConfigIO, getRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
|
||||
import type { BrowserServerState } from "./server-context.types.js";
|
||||
|
||||
function changedProfileInvariants(
|
||||
current: ResolvedBrowserProfile,
|
||||
next: ResolvedBrowserProfile,
|
||||
): string[] {
|
||||
const changed: string[] = [];
|
||||
if (current.cdpUrl !== next.cdpUrl) {
|
||||
changed.push("cdpUrl");
|
||||
}
|
||||
if (current.cdpPort !== next.cdpPort) {
|
||||
changed.push("cdpPort");
|
||||
}
|
||||
if (current.driver !== next.driver) {
|
||||
changed.push("driver");
|
||||
}
|
||||
if (current.attachOnly !== next.attachOnly) {
|
||||
changed.push("attachOnly");
|
||||
}
|
||||
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
|
||||
changed.push("cdpIsLoopback");
|
||||
}
|
||||
if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) {
|
||||
changed.push("userDataDir");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function applyResolvedConfig(
|
||||
current: BrowserServerState,
|
||||
freshResolved: BrowserServerState["resolved"],
|
||||
) {
|
||||
current.resolved = {
|
||||
...freshResolved,
|
||||
// Keep the runtime evaluate gate stable across request-time profile refreshes.
|
||||
// Security-sensitive behavior should only change via full runtime config reload,
|
||||
// not as a side effect of resolving profiles/tabs during a request.
|
||||
evaluateEnabled: current.resolved.evaluateEnabled,
|
||||
};
|
||||
for (const [name, runtime] of current.profiles) {
|
||||
const nextProfile = resolveProfile(freshResolved, name);
|
||||
if (nextProfile) {
|
||||
const changed = changedProfileInvariants(runtime.profile, nextProfile);
|
||||
if (changed.length > 0) {
|
||||
runtime.reconcile = {
|
||||
previousProfile: runtime.profile,
|
||||
reason: `profile invariants changed: ${changed.join(", ")}`,
|
||||
};
|
||||
runtime.lastTargetId = null;
|
||||
}
|
||||
runtime.profile = nextProfile;
|
||||
continue;
|
||||
}
|
||||
runtime.reconcile = {
|
||||
previousProfile: runtime.profile,
|
||||
reason: "profile removed from config",
|
||||
};
|
||||
runtime.lastTargetId = null;
|
||||
if (!runtime.running) {
|
||||
current.profiles.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshResolvedBrowserConfigFromDisk(params: {
|
||||
current: BrowserServerState;
|
||||
refreshConfigFromDisk: boolean;
|
||||
mode: "cached" | "fresh";
|
||||
}) {
|
||||
if (!params.refreshConfigFromDisk) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route-level browser config hot reload should observe on-disk changes immediately.
|
||||
// The shared loadConfig() helper may return a cached snapshot for the configured TTL,
|
||||
// which can leave request-time browser guards stale (for example evaluateEnabled).
|
||||
const cfg = getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig();
|
||||
const freshResolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
applyResolvedConfig(params.current, freshResolved);
|
||||
}
|
||||
|
||||
export function resolveBrowserProfileWithHotReload(params: {
|
||||
current: BrowserServerState;
|
||||
refreshConfigFromDisk: boolean;
|
||||
name: string;
|
||||
}): ResolvedBrowserProfile | null {
|
||||
refreshResolvedBrowserConfigFromDisk({
|
||||
current: params.current,
|
||||
refreshConfigFromDisk: params.refreshConfigFromDisk,
|
||||
mode: "cached",
|
||||
});
|
||||
let profile = resolveProfile(params.current.resolved, params.name);
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Hot-reload: profile missing; retry with a fresh disk read without flushing the global cache.
|
||||
refreshResolvedBrowserConfigFromDisk({
|
||||
current: params.current,
|
||||
refreshConfigFromDisk: params.refreshConfigFromDisk,
|
||||
mode: "fresh",
|
||||
});
|
||||
profile = resolveProfile(params.current.resolved, params.name);
|
||||
return profile;
|
||||
}
|
||||
123
extensions/browser/src/browser/routes/agent.act.download.ts
Normal file
123
extensions/browser/src/browser/routes/agent.act.download.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
readBody,
|
||||
requirePwAi,
|
||||
resolveTargetIdFromBody,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
|
||||
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
|
||||
return {
|
||||
cdpUrl,
|
||||
targetId,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBrowserAgentActDownloadRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/wait/download", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const out = toStringOrEmpty(body.path) || "";
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"download waiting is not supported for existing-session profiles yet.",
|
||||
);
|
||||
}
|
||||
const pw = await requirePwAi(res, "wait for download");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
|
||||
let downloadPath: string | undefined;
|
||||
if (out.trim()) {
|
||||
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
|
||||
res,
|
||||
rootDir: DEFAULT_DOWNLOAD_DIR,
|
||||
requestedPath: out,
|
||||
scopeLabel: "downloads directory",
|
||||
});
|
||||
if (!resolvedDownloadPath) {
|
||||
return;
|
||||
}
|
||||
downloadPath = resolvedDownloadPath;
|
||||
}
|
||||
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
|
||||
const result = await pw.waitForDownloadViaPlaywright({
|
||||
...requestBase,
|
||||
path: downloadPath,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, download: result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/download", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const ref = toStringOrEmpty(body.ref);
|
||||
const out = toStringOrEmpty(body.path);
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (!ref) {
|
||||
return jsonError(res, 400, "ref is required");
|
||||
}
|
||||
if (!out) {
|
||||
return jsonError(res, 400, "path is required");
|
||||
}
|
||||
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"downloads are not supported for existing-session profiles yet.",
|
||||
);
|
||||
}
|
||||
const pw = await requirePwAi(res, "download");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
|
||||
const downloadPath = await resolveWritableOutputPathOrRespond({
|
||||
res,
|
||||
rootDir: DEFAULT_DOWNLOAD_DIR,
|
||||
requestedPath: out,
|
||||
scopeLabel: "downloads directory",
|
||||
});
|
||||
if (!downloadPath) {
|
||||
return;
|
||||
}
|
||||
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
|
||||
const result = await pw.downloadViaPlaywright({
|
||||
...requestBase,
|
||||
ref,
|
||||
path: downloadPath,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, download: result });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
197
extensions/browser/src/browser/routes/agent.act.hooks.ts
Normal file
197
extensions/browser/src/browser/routes/agent.act.hooks.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js";
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
readBody,
|
||||
requirePwAi,
|
||||
resolveTargetIdFromBody,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserAgentActHookRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/hooks/file-chooser", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
|
||||
const element = toStringOrEmpty(body.element) || undefined;
|
||||
const paths = toStringArray(body.paths) ?? [];
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (!paths.length) {
|
||||
return jsonError(res, 400, "paths are required");
|
||||
}
|
||||
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
const uploadPathsResult = await resolveExistingPathsWithinRoot({
|
||||
rootDir: DEFAULT_UPLOAD_DIR,
|
||||
requestedPaths: paths,
|
||||
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
||||
});
|
||||
if (!uploadPathsResult.ok) {
|
||||
res.status(400).json({ error: uploadPathsResult.error });
|
||||
return;
|
||||
}
|
||||
const resolvedPaths = uploadPathsResult.paths;
|
||||
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (element) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session file uploads do not support element selectors; use ref/inputRef.",
|
||||
);
|
||||
}
|
||||
if (resolvedPaths.length !== 1) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session file uploads currently support one file at a time.",
|
||||
);
|
||||
}
|
||||
const uid = inputRef || ref;
|
||||
if (!uid) {
|
||||
return jsonError(res, 501, "existing-session file uploads require ref or inputRef.");
|
||||
}
|
||||
await uploadChromeMcpFile({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid,
|
||||
filePath: resolvedPaths[0] ?? "",
|
||||
});
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
const pw = await requirePwAi(res, "file chooser hook");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputRef || element) {
|
||||
if (ref) {
|
||||
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
|
||||
}
|
||||
await pw.setInputFilesViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
inputRef,
|
||||
element,
|
||||
paths: resolvedPaths,
|
||||
});
|
||||
} else {
|
||||
await pw.armFileUploadViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
paths: resolvedPaths,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
});
|
||||
if (ref) {
|
||||
await pw.clickViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/hooks/dialog", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const accept = toBoolean(body.accept);
|
||||
const promptText = toStringOrEmpty(body.promptText) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (accept === undefined) {
|
||||
return jsonError(res, 400, "accept is required");
|
||||
}
|
||||
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (timeoutMs) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session dialog handling does not support timeoutMs.",
|
||||
);
|
||||
}
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fn: `() => {
|
||||
const state = (window.__openclawDialogHook ??= {});
|
||||
if (!state.originals) {
|
||||
state.originals = {
|
||||
alert: window.alert.bind(window),
|
||||
confirm: window.confirm.bind(window),
|
||||
prompt: window.prompt.bind(window),
|
||||
};
|
||||
}
|
||||
const originals = state.originals;
|
||||
const restore = () => {
|
||||
window.alert = originals.alert;
|
||||
window.confirm = originals.confirm;
|
||||
window.prompt = originals.prompt;
|
||||
delete window.__openclawDialogHook;
|
||||
};
|
||||
window.alert = (...args) => {
|
||||
try {
|
||||
return undefined;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
};
|
||||
window.confirm = (...args) => {
|
||||
try {
|
||||
return ${accept ? "true" : "false"};
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
};
|
||||
window.prompt = (...args) => {
|
||||
try {
|
||||
return ${accept ? JSON.stringify(promptText ?? "") : "null"};
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
};
|
||||
return true;
|
||||
}`,
|
||||
});
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
const pw = await requirePwAi(res, "dialog hook");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
await pw.armDialogViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
accept,
|
||||
promptText,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
53
extensions/browser/src/browser/routes/agent.act.shared.ts
Normal file
53
extensions/browser/src/browser/routes/agent.act.shared.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const ACT_KINDS = [
|
||||
"batch",
|
||||
"click",
|
||||
"close",
|
||||
"drag",
|
||||
"evaluate",
|
||||
"fill",
|
||||
"hover",
|
||||
"scrollIntoView",
|
||||
"press",
|
||||
"resize",
|
||||
"select",
|
||||
"type",
|
||||
"wait",
|
||||
] as const;
|
||||
|
||||
export type ActKind = (typeof ACT_KINDS)[number];
|
||||
|
||||
export function isActKind(value: unknown): value is ActKind {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
return (ACT_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type ClickButton = "left" | "right" | "middle";
|
||||
export type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
|
||||
|
||||
const ALLOWED_CLICK_MODIFIERS = new Set<ClickModifier>([
|
||||
"Alt",
|
||||
"Control",
|
||||
"ControlOrMeta",
|
||||
"Meta",
|
||||
"Shift",
|
||||
]);
|
||||
|
||||
export function parseClickButton(raw: string): ClickButton | undefined {
|
||||
if (raw === "left" || raw === "right" || raw === "middle") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseClickModifiers(raw: string[]): {
|
||||
modifiers?: ClickModifier[];
|
||||
error?: string;
|
||||
} {
|
||||
const invalid = raw.filter((m) => !ALLOWED_CLICK_MODIFIERS.has(m as ClickModifier));
|
||||
if (invalid.length) {
|
||||
return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" };
|
||||
}
|
||||
return { modifiers: raw.length ? (raw as ClickModifier[]) : undefined };
|
||||
}
|
||||
1211
extensions/browser/src/browser/routes/agent.act.ts
Normal file
1211
extensions/browser/src/browser/routes/agent.act.ts
Normal file
File diff suppressed because it is too large
Load Diff
147
extensions/browser/src/browser/routes/agent.debug.ts
Normal file
147
extensions/browser/src/browser/routes/agent.debug.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
readBody,
|
||||
resolveTargetIdFromBody,
|
||||
resolveTargetIdFromQuery,
|
||||
withPlaywrightRouteContext,
|
||||
} from "./agent.shared.js";
|
||||
import { resolveWritableOutputPathOrRespond } from "./output-paths.js";
|
||||
import { DEFAULT_TRACE_DIR } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserAgentDebugRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/console", async (req, res) => {
|
||||
const targetId = resolveTargetIdFromQuery(req.query);
|
||||
const level = typeof req.query.level === "string" ? req.query.level : "";
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "console messages",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const messages = await pw.getConsoleMessagesViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
level: level.trim() || undefined,
|
||||
});
|
||||
res.json({ ok: true, messages, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/errors", async (req, res) => {
|
||||
const targetId = resolveTargetIdFromQuery(req.query);
|
||||
const clear = toBoolean(req.query.clear) ?? false;
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "page errors",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const result = await pw.getPageErrorsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
clear,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/requests", async (req, res) => {
|
||||
const targetId = resolveTargetIdFromQuery(req.query);
|
||||
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
|
||||
const clear = toBoolean(req.query.clear) ?? false;
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "network requests",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const result = await pw.getNetworkRequestsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
filter: filter.trim() || undefined,
|
||||
clear,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/trace/start", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const screenshots = toBoolean(body.screenshots) ?? undefined;
|
||||
const snapshots = toBoolean(body.snapshots) ?? undefined;
|
||||
const sources = toBoolean(body.sources) ?? undefined;
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "trace start",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.traceStartViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
screenshots,
|
||||
snapshots,
|
||||
sources,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/trace/stop", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const out = toStringOrEmpty(body.path) || "";
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "trace stop",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const id = crypto.randomUUID();
|
||||
const tracePath = await resolveWritableOutputPathOrRespond({
|
||||
res,
|
||||
rootDir: DEFAULT_TRACE_DIR,
|
||||
requestedPath: out,
|
||||
scopeLabel: "trace directory",
|
||||
defaultFileName: `browser-trace-${id}.zip`,
|
||||
ensureRootDir: true,
|
||||
});
|
||||
if (!tracePath) {
|
||||
return;
|
||||
}
|
||||
await pw.traceStopViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
path: tracePath,
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
targetId: tab.targetId,
|
||||
path: path.resolve(tracePath),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
148
extensions/browser/src/browser/routes/agent.shared.ts
Normal file
148
extensions/browser/src/browser/routes/agent.shared.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { toBrowserErrorResponse } from "../errors.js";
|
||||
import type { PwAiModule } from "../pw-ai-module.js";
|
||||
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
import { getProfileContext, jsonError } from "./utils.js";
|
||||
|
||||
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
|
||||
"",
|
||||
"Example workflow:",
|
||||
"1. snapshot action to get page state with refs",
|
||||
'2. act with ref: "e123" to interact with element',
|
||||
"",
|
||||
"This is more reliable for modern SPAs.",
|
||||
].join("\n");
|
||||
|
||||
export function readBody(req: BrowserRequest): Record<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return {};
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function resolveTargetIdFromBody(body: Record<string, unknown>): string | undefined {
|
||||
const targetId = typeof body.targetId === "string" ? body.targetId.trim() : "";
|
||||
return targetId || undefined;
|
||||
}
|
||||
|
||||
export function resolveTargetIdFromQuery(query: Record<string, unknown>): string | undefined {
|
||||
const targetId = typeof query.targetId === "string" ? query.targetId.trim() : "";
|
||||
return targetId || undefined;
|
||||
}
|
||||
|
||||
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
}
|
||||
const browserMapped = toBrowserErrorResponse(err);
|
||||
if (browserMapped) {
|
||||
return jsonError(res, browserMapped.status, browserMapped.message);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
export function resolveProfileContext(
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | null {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
jsonError(res, profileCtx.status, profileCtx.error);
|
||||
return null;
|
||||
}
|
||||
return profileCtx;
|
||||
}
|
||||
|
||||
export async function getPwAiModule(): Promise<PwAiModule | null> {
|
||||
return await getPwAiModuleBase({ mode: "soft" });
|
||||
}
|
||||
|
||||
export async function requirePwAi(
|
||||
res: BrowserResponse,
|
||||
feature: string,
|
||||
): Promise<PwAiModule | null> {
|
||||
const mod = await getPwAiModule();
|
||||
if (mod) {
|
||||
return mod;
|
||||
}
|
||||
jsonError(
|
||||
res,
|
||||
501,
|
||||
[
|
||||
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
|
||||
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
|
||||
"Docs: /tools/browser#playwright-requirement",
|
||||
].join("\n"),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
type RouteTabContext = {
|
||||
profileCtx: ProfileContext;
|
||||
tab: Awaited<ReturnType<ProfileContext["ensureTabAvailable"]>>;
|
||||
cdpUrl: string;
|
||||
};
|
||||
|
||||
type RouteTabPwContext = RouteTabContext & {
|
||||
pw: PwAiModule;
|
||||
};
|
||||
|
||||
type RouteWithTabParams<T> = {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
targetId?: string;
|
||||
run: (ctx: RouteTabContext) => Promise<T>;
|
||||
};
|
||||
|
||||
export async function withRouteTabContext<T>(
|
||||
params: RouteWithTabParams<T>,
|
||||
): Promise<T | undefined> {
|
||||
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
|
||||
if (!profileCtx) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(params.targetId);
|
||||
return await params.run({
|
||||
profileCtx,
|
||||
tab,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
handleRouteError(params.ctx, params.res, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type RouteWithPwParams<T> = {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
targetId?: string;
|
||||
feature: string;
|
||||
run: (ctx: RouteTabPwContext) => Promise<T>;
|
||||
};
|
||||
|
||||
export async function withPlaywrightRouteContext<T>(
|
||||
params: RouteWithPwParams<T>,
|
||||
): Promise<T | undefined> {
|
||||
return await withRouteTabContext({
|
||||
req: params.req,
|
||||
res: params.res,
|
||||
ctx: params.ctx,
|
||||
targetId: params.targetId,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
const pw = await requirePwAi(params.res, params.feature);
|
||||
if (!pw) {
|
||||
return undefined as T | undefined;
|
||||
}
|
||||
return await params.run({ profileCtx, tab, cdpUrl, pw });
|
||||
},
|
||||
});
|
||||
}
|
||||
97
extensions/browser/src/browser/routes/agent.snapshot.plan.ts
Normal file
97
extensions/browser/src/browser/routes/agent.snapshot.plan.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ResolvedBrowserProfile } from "../config.js";
|
||||
import {
|
||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
|
||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
} from "../constants.js";
|
||||
import {
|
||||
resolveDefaultSnapshotFormat,
|
||||
shouldUsePlaywrightForAriaSnapshot,
|
||||
shouldUsePlaywrightForScreenshot,
|
||||
} from "../profile-capabilities.js";
|
||||
import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export type BrowserSnapshotPlan = {
|
||||
format: "ai" | "aria";
|
||||
mode?: "efficient";
|
||||
labels?: boolean;
|
||||
limit?: number;
|
||||
resolvedMaxChars?: number;
|
||||
interactive?: boolean;
|
||||
compact?: boolean;
|
||||
depth?: number;
|
||||
refsMode?: "aria" | "role";
|
||||
selectorValue?: string;
|
||||
frameSelectorValue?: string;
|
||||
wantsRoleSnapshot: boolean;
|
||||
};
|
||||
|
||||
export function resolveSnapshotPlan(params: {
|
||||
profile: ResolvedBrowserProfile;
|
||||
query: Record<string, unknown>;
|
||||
hasPlaywright: boolean;
|
||||
}): BrowserSnapshotPlan {
|
||||
const mode = params.query.mode === "efficient" ? "efficient" : undefined;
|
||||
const labels = toBoolean(params.query.labels) ?? undefined;
|
||||
const explicitFormat =
|
||||
params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined;
|
||||
const format = resolveDefaultSnapshotFormat({
|
||||
profile: params.profile,
|
||||
hasPlaywright: params.hasPlaywright,
|
||||
explicitFormat,
|
||||
mode,
|
||||
});
|
||||
const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined;
|
||||
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
|
||||
const maxCharsRaw =
|
||||
typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined;
|
||||
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||
const maxChars =
|
||||
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
|
||||
? Math.floor(maxCharsRaw)
|
||||
: undefined;
|
||||
const resolvedMaxChars =
|
||||
format === "ai"
|
||||
? hasMaxChars
|
||||
? maxChars
|
||||
: mode === "efficient"
|
||||
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
|
||||
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
|
||||
: undefined;
|
||||
const interactiveRaw = toBoolean(params.query.interactive);
|
||||
const compactRaw = toBoolean(params.query.compact);
|
||||
const depthRaw = toNumber(params.query.depth);
|
||||
const refsModeRaw = toStringOrEmpty(params.query.refs).trim();
|
||||
const refsMode: "aria" | "role" | undefined =
|
||||
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
|
||||
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
|
||||
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
|
||||
const depth =
|
||||
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
|
||||
const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined;
|
||||
const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined;
|
||||
|
||||
return {
|
||||
format,
|
||||
mode,
|
||||
labels,
|
||||
limit,
|
||||
resolvedMaxChars,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
refsMode,
|
||||
selectorValue,
|
||||
frameSelectorValue,
|
||||
wantsRoleSnapshot:
|
||||
labels === true ||
|
||||
mode === "efficient" ||
|
||||
interactive === true ||
|
||||
compact === true ||
|
||||
depth !== undefined ||
|
||||
Boolean(selectorValue) ||
|
||||
Boolean(frameSelectorValue),
|
||||
};
|
||||
}
|
||||
|
||||
export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot };
|
||||
602
extensions/browser/src/browser/routes/agent.snapshot.ts
Normal file
602
extensions/browser/src/browser/routes/agent.snapshot.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import path from "node:path";
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||
import {
|
||||
evaluateChromeMcpScript,
|
||||
navigateChromeMcpPage,
|
||||
takeChromeMcpScreenshot,
|
||||
takeChromeMcpSnapshot,
|
||||
} from "../chrome-mcp.js";
|
||||
import {
|
||||
buildAiSnapshotFromChromeMcpSnapshot,
|
||||
flattenChromeMcpSnapshotToAriaNodes,
|
||||
} from "../chrome-mcp.snapshot.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
} from "../navigation-guard.js";
|
||||
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
getPwAiModule,
|
||||
handleRouteError,
|
||||
readBody,
|
||||
requirePwAi,
|
||||
resolveProfileContext,
|
||||
withPlaywrightRouteContext,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import {
|
||||
resolveSnapshotPlan,
|
||||
shouldUsePlaywrightForAriaSnapshot,
|
||||
shouldUsePlaywrightForScreenshot,
|
||||
} from "./agent.snapshot.plan.js";
|
||||
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
|
||||
|
||||
async function clearChromeMcpOverlay(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
fn: `() => {
|
||||
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
|
||||
return true;
|
||||
}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renderChromeMcpLabels(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
refs: string[];
|
||||
}): Promise<{ labels: number; skipped: number }> {
|
||||
const refList = JSON.stringify(params.refs);
|
||||
const result = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
args: params.refs,
|
||||
fn: `(...elements) => {
|
||||
const refs = ${refList};
|
||||
document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove());
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "labels");
|
||||
root.style.position = "fixed";
|
||||
root.style.inset = "0";
|
||||
root.style.pointerEvents = "none";
|
||||
root.style.zIndex = "2147483647";
|
||||
let labels = 0;
|
||||
let skipped = 0;
|
||||
elements.forEach((el, index) => {
|
||||
if (!(el instanceof Element)) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 && rect.height <= 0) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
labels += 1;
|
||||
const badge = document.createElement("div");
|
||||
badge.setAttribute("${CHROME_MCP_OVERLAY_ATTR}", "label");
|
||||
badge.textContent = refs[index] || String(labels);
|
||||
badge.style.position = "fixed";
|
||||
badge.style.left = \`\${Math.max(0, rect.left)}px\`;
|
||||
badge.style.top = \`\${Math.max(0, rect.top)}px\`;
|
||||
badge.style.transform = "translateY(-100%)";
|
||||
badge.style.padding = "2px 6px";
|
||||
badge.style.borderRadius = "999px";
|
||||
badge.style.background = "#FF4500";
|
||||
badge.style.color = "#fff";
|
||||
badge.style.font = "600 12px ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
badge.style.boxShadow = "0 2px 6px rgba(0,0,0,0.35)";
|
||||
badge.style.whiteSpace = "nowrap";
|
||||
root.appendChild(badge);
|
||||
});
|
||||
document.documentElement.appendChild(root);
|
||||
return { labels, skipped };
|
||||
}`,
|
||||
});
|
||||
const labels =
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
typeof (result as { labels?: unknown }).labels === "number"
|
||||
? (result as { labels: number }).labels
|
||||
: 0;
|
||||
const skipped =
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
typeof (result as { skipped?: unknown }).skipped === "number"
|
||||
? (result as { skipped: number }).skipped
|
||||
: 0;
|
||||
return { labels, skipped };
|
||||
}
|
||||
|
||||
async function saveNormalizedScreenshotResponse(params: {
|
||||
res: BrowserResponse;
|
||||
buffer: Buffer;
|
||||
type: "png" | "jpeg";
|
||||
targetId: string;
|
||||
url: string;
|
||||
}) {
|
||||
const normalized = await normalizeBrowserScreenshot(params.buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await saveBrowserMediaResponse({
|
||||
res: params.res,
|
||||
buffer: normalized.buffer,
|
||||
contentType: normalized.contentType ?? `image/${params.type}`,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
targetId: params.targetId,
|
||||
url: params.url,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveBrowserMediaResponse(params: {
|
||||
res: BrowserResponse;
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
maxBytes: number;
|
||||
targetId: string;
|
||||
url: string;
|
||||
}) {
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
params.buffer,
|
||||
params.contentType,
|
||||
"browser",
|
||||
params.maxBytes,
|
||||
);
|
||||
params.res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: params.targetId,
|
||||
url: params.url,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
|
||||
export async function resolveTargetIdAfterNavigate(opts: {
|
||||
oldTargetId: string;
|
||||
navigatedUrl: string;
|
||||
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
|
||||
}): Promise<string> {
|
||||
let currentTargetId = opts.oldTargetId;
|
||||
try {
|
||||
const pickReplacement = (
|
||||
tabs: Array<{ targetId: string; url: string }>,
|
||||
options?: { allowSingleTabFallback?: boolean },
|
||||
) => {
|
||||
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
|
||||
return opts.oldTargetId;
|
||||
}
|
||||
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
|
||||
if (byUrl.length === 1) {
|
||||
return byUrl[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
|
||||
if (uniqueReplacement.length === 1) {
|
||||
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
if (options?.allowSingleTabFallback && tabs.length === 1) {
|
||||
return tabs[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
return opts.oldTargetId;
|
||||
};
|
||||
|
||||
currentTargetId = pickReplacement(await opts.listTabs());
|
||||
if (currentTargetId === opts.oldTargetId) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
currentTargetId = pickReplacement(await opts.listTabs(), {
|
||||
allowSingleTabFallback: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: fall back to pre-navigation targetId
|
||||
}
|
||||
return currentTargetId;
|
||||
}
|
||||
|
||||
export function registerBrowserAgentSnapshotRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const url = toStringOrEmpty(body.url);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
if (!url) {
|
||||
return jsonError(res, 400, "url is required");
|
||||
}
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
|
||||
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||
const result = await navigateChromeMcpPage({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
|
||||
return res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
}
|
||||
const pw = await requirePwAi(res, "navigate");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
const result = await pw.navigateViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
|
||||
});
|
||||
const currentTargetId = await resolveTargetIdAfterNavigate({
|
||||
oldTargetId: tab.targetId,
|
||||
navigatedUrl: result.url,
|
||||
listTabs: () => profileCtx.listTabs(),
|
||||
});
|
||||
res.json({ ok: true, targetId: currentTargetId, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/pdf", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) {
|
||||
return;
|
||||
}
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
|
||||
);
|
||||
}
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "pdf",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const pdf = await pw.pdfViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await saveBrowserMediaResponse({
|
||||
res,
|
||||
buffer: pdf.buffer,
|
||||
contentType: "application/pdf",
|
||||
maxBytes: pdf.buffer.byteLength,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/screenshot", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const fullPage = toBoolean(body.fullPage) ?? false;
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const element = toStringOrEmpty(body.element) || undefined;
|
||||
const type = body.type === "jpeg" ? "jpeg" : "png";
|
||||
|
||||
if (fullPage && (ref || element)) {
|
||||
return jsonError(res, 400, "fullPage is not supported for element screenshots");
|
||||
}
|
||||
|
||||
await withRouteTabContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (element) {
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
|
||||
);
|
||||
}
|
||||
const buffer = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: ref,
|
||||
fullPage,
|
||||
format: type,
|
||||
});
|
||||
await saveNormalizedScreenshotResponse({
|
||||
res,
|
||||
buffer,
|
||||
type,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
|
||||
profile: profileCtx.profile,
|
||||
wsUrl: tab.wsUrl,
|
||||
ref,
|
||||
element,
|
||||
});
|
||||
if (shouldUsePlaywright) {
|
||||
const pw = await requirePwAi(res, "screenshot");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
const snap = await pw.takeScreenshotViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
fullPage,
|
||||
type,
|
||||
});
|
||||
buffer = snap.buffer;
|
||||
} else {
|
||||
buffer = await captureScreenshot({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
fullPage,
|
||||
format: type,
|
||||
quality: type === "jpeg" ? 85 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
await saveNormalizedScreenshotResponse({
|
||||
res,
|
||||
buffer,
|
||||
type,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/snapshot", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) {
|
||||
return;
|
||||
}
|
||||
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const hasPlaywright = Boolean(await getPwAiModule());
|
||||
const plan = resolveSnapshotPlan({
|
||||
profile: profileCtx.profile,
|
||||
query: req.query,
|
||||
hasPlaywright,
|
||||
});
|
||||
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||
}
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (plan.selectorValue || plan.frameSelectorValue) {
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
|
||||
);
|
||||
}
|
||||
const snapshot = await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
if (plan.format === "aria") {
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: "aria",
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
|
||||
});
|
||||
}
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot({
|
||||
root: snapshot,
|
||||
options: {
|
||||
interactive: plan.interactive ?? undefined,
|
||||
compact: plan.compact ?? undefined,
|
||||
maxDepth: plan.depth ?? undefined,
|
||||
},
|
||||
maxChars: plan.resolvedMaxChars,
|
||||
});
|
||||
if (plan.labels) {
|
||||
const refs = Object.keys(built.refs);
|
||||
const labelResult = await renderChromeMcpLabels({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
refs,
|
||||
});
|
||||
try {
|
||||
const labeled = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
format: "png",
|
||||
});
|
||||
const normalized = await normalizeBrowserScreenshot(labeled, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? "image/png",
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: "ai",
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
labels: true,
|
||||
labelsCount: labelResult.labels,
|
||||
labelsSkipped: labelResult.skipped,
|
||||
imagePath: path.resolve(saved.path),
|
||||
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
|
||||
...built,
|
||||
});
|
||||
} finally {
|
||||
await clearChromeMcpOverlay({
|
||||
profileName: profileCtx.profile.name,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: "ai",
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...built,
|
||||
});
|
||||
}
|
||||
if (plan.format === "ai") {
|
||||
const pw = await requirePwAi(res, "ai snapshot");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
const roleSnapshotArgs = {
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
selector: plan.selectorValue,
|
||||
frameSelector: plan.frameSelectorValue,
|
||||
refsMode: plan.refsMode,
|
||||
options: {
|
||||
interactive: plan.interactive ?? undefined,
|
||||
compact: plan.compact ?? undefined,
|
||||
maxDepth: plan.depth ?? undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const snap = plan.wantsRoleSnapshot
|
||||
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
|
||||
: await pw
|
||||
.snapshotAiViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
...(typeof plan.resolvedMaxChars === "number"
|
||||
? { maxChars: plan.resolvedMaxChars }
|
||||
: {}),
|
||||
})
|
||||
.catch(async (err) => {
|
||||
// Public-API fallback when Playwright's private _snapshotForAI is missing.
|
||||
if (String(err).toLowerCase().includes("_snapshotforai")) {
|
||||
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (plan.labels) {
|
||||
const labeled = await pw.screenshotWithLabelsViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
refs: "refs" in snap ? snap.refs : {},
|
||||
type: "png",
|
||||
});
|
||||
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? "image/png",
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
labels: true,
|
||||
labelsCount: labeled.labels,
|
||||
labelsSkipped: labeled.skipped,
|
||||
imagePath: path.resolve(saved.path),
|
||||
imageType,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
const snap = shouldUsePlaywrightForAriaSnapshot({
|
||||
profile: profileCtx.profile,
|
||||
wsUrl: tab.wsUrl,
|
||||
})
|
||||
? (() => {
|
||||
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
|
||||
// Also covers cases where wsUrl is missing/unusable.
|
||||
return requirePwAi(res, "aria snapshot").then(async (pw) => {
|
||||
if (!pw) {
|
||||
return null;
|
||||
}
|
||||
return await pw.snapshotAriaViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
limit: plan.limit,
|
||||
});
|
||||
});
|
||||
})()
|
||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
|
||||
|
||||
const resolved = await Promise.resolve(snap);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...resolved,
|
||||
});
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
451
extensions/browser/src/browser/routes/agent.storage.ts
Normal file
451
extensions/browser/src/browser/routes/agent.storage.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
readBody,
|
||||
resolveTargetIdFromBody,
|
||||
resolveTargetIdFromQuery,
|
||||
withPlaywrightRouteContext,
|
||||
} from "./agent.shared.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
type StorageKind = "local" | "session";
|
||||
|
||||
export function parseStorageKind(raw: string): StorageKind | null {
|
||||
if (raw === "local" || raw === "session") {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseStorageMutationRequest(
|
||||
kindParam: unknown,
|
||||
body: Record<string, unknown>,
|
||||
): { kind: StorageKind | null; targetId: string | undefined } {
|
||||
return {
|
||||
kind: parseStorageKind(toStringOrEmpty(kindParam)),
|
||||
targetId: resolveTargetIdFromBody(body),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRequiredStorageMutationRequest(
|
||||
kindParam: unknown,
|
||||
body: Record<string, unknown>,
|
||||
): { kind: StorageKind; targetId: string | undefined } | null {
|
||||
const parsed = parseStorageMutationRequest(kindParam, body);
|
||||
if (!parsed.kind) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: parsed.kind,
|
||||
targetId: parsed.targetId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStorageMutationOrRespond(
|
||||
res: BrowserResponse,
|
||||
kindParam: unknown,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const parsed = parseRequiredStorageMutationRequest(kindParam, body);
|
||||
if (!parsed) {
|
||||
jsonError(res, 400, "kind must be local|session");
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseStorageMutationFromRequest(req: BrowserRequest, res: BrowserResponse) {
|
||||
const body = readBody(req);
|
||||
const parsed = parseStorageMutationOrRespond(res, req.params.kind, body);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return { body, parsed };
|
||||
}
|
||||
|
||||
export function registerBrowserAgentStorageRoutes(
|
||||
app: BrowserRouteRegistrar,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/cookies", async (req, res) => {
|
||||
const targetId = resolveTargetIdFromQuery(req.query);
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "cookies",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const result = await pw.cookiesGetViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/cookies/set", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const cookie =
|
||||
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
|
||||
? (body.cookie as Record<string, unknown>)
|
||||
: null;
|
||||
if (!cookie) {
|
||||
return jsonError(res, 400, "cookie is required");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "cookies set",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.cookiesSetViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
cookie: {
|
||||
name: toStringOrEmpty(cookie.name),
|
||||
value: toStringOrEmpty(cookie.value),
|
||||
url: toStringOrEmpty(cookie.url) || undefined,
|
||||
domain: toStringOrEmpty(cookie.domain) || undefined,
|
||||
path: toStringOrEmpty(cookie.path) || undefined,
|
||||
expires: toNumber(cookie.expires) ?? undefined,
|
||||
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
|
||||
secure: toBoolean(cookie.secure) ?? undefined,
|
||||
sameSite:
|
||||
cookie.sameSite === "Lax" ||
|
||||
cookie.sameSite === "None" ||
|
||||
cookie.sameSite === "Strict"
|
||||
? cookie.sameSite
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/cookies/clear", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "cookies clear",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.cookiesClearViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/storage/:kind", async (req, res) => {
|
||||
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
|
||||
if (!kind) {
|
||||
return jsonError(res, 400, "kind must be local|session");
|
||||
}
|
||||
const targetId = resolveTargetIdFromQuery(req.query);
|
||||
const key = toStringOrEmpty(req.query.key);
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "storage get",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
const result = await pw.storageGetViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
kind,
|
||||
key: key.trim() || undefined,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/storage/:kind/set", async (req, res) => {
|
||||
const mutation = parseStorageMutationFromRequest(req, res);
|
||||
if (!mutation) {
|
||||
return;
|
||||
}
|
||||
const key = toStringOrEmpty(mutation.body.key);
|
||||
if (!key) {
|
||||
return jsonError(res, 400, "key is required");
|
||||
}
|
||||
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId: mutation.parsed.targetId,
|
||||
feature: "storage set",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.storageSetViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
kind: mutation.parsed.kind,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/storage/:kind/clear", async (req, res) => {
|
||||
const mutation = parseStorageMutationFromRequest(req, res);
|
||||
if (!mutation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId: mutation.parsed.targetId,
|
||||
feature: "storage clear",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.storageClearViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
kind: mutation.parsed.kind,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/offline", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const offline = toBoolean(body.offline);
|
||||
if (offline === undefined) {
|
||||
return jsonError(res, 400, "offline is required");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "offline",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setOfflineViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
offline,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/headers", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const headers =
|
||||
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
|
||||
? (body.headers as Record<string, unknown>)
|
||||
: null;
|
||||
if (!headers) {
|
||||
return jsonError(res, 400, "headers is required");
|
||||
}
|
||||
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof v === "string") {
|
||||
parsed[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "headers",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setExtraHTTPHeadersViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
headers: parsed,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/credentials", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const clear = toBoolean(body.clear) ?? false;
|
||||
const username = toStringOrEmpty(body.username) || undefined;
|
||||
const password = typeof body.password === "string" ? body.password : undefined;
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "http credentials",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setHttpCredentialsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
username,
|
||||
password,
|
||||
clear,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/geolocation", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const clear = toBoolean(body.clear) ?? false;
|
||||
const latitude = toNumber(body.latitude);
|
||||
const longitude = toNumber(body.longitude);
|
||||
const accuracy = toNumber(body.accuracy) ?? undefined;
|
||||
const origin = toStringOrEmpty(body.origin) || undefined;
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "geolocation",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setGeolocationViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
origin,
|
||||
clear,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/media", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const schemeRaw = toStringOrEmpty(body.colorScheme);
|
||||
const colorScheme =
|
||||
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
|
||||
? schemeRaw
|
||||
: schemeRaw === "none"
|
||||
? null
|
||||
: undefined;
|
||||
if (colorScheme === undefined) {
|
||||
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "media emulation",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.emulateMediaViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
colorScheme,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/timezone", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const timezoneId = toStringOrEmpty(body.timezoneId);
|
||||
if (!timezoneId) {
|
||||
return jsonError(res, 400, "timezoneId is required");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "timezone",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setTimezoneViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
timezoneId,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/locale", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const locale = toStringOrEmpty(body.locale);
|
||||
if (!locale) {
|
||||
return jsonError(res, 400, "locale is required");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "locale",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setLocaleViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
locale,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/set/device", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = resolveTargetIdFromBody(body);
|
||||
const name = toStringOrEmpty(body.name);
|
||||
if (!name) {
|
||||
return jsonError(res, 400, "name is required");
|
||||
}
|
||||
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
feature: "device emulation",
|
||||
run: async ({ cdpUrl, tab, pw }) => {
|
||||
await pw.setDeviceViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
name,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
13
extensions/browser/src/browser/routes/agent.ts
Normal file
13
extensions/browser/src/browser/routes/agent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentActRoutes } from "./agent.act.js";
|
||||
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
|
||||
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
|
||||
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserAgentSnapshotRoutes(app, ctx);
|
||||
registerBrowserAgentActRoutes(app, ctx);
|
||||
registerBrowserAgentDebugRoutes(app, ctx);
|
||||
registerBrowserAgentStorageRoutes(app, ctx);
|
||||
}
|
||||
225
extensions/browser/src/browser/routes/basic.ts
Normal file
225
extensions/browser/src/browser/routes/basic.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { getChromeMcpPid } from "../chrome-mcp.js";
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { toBrowserErrorResponse } from "../errors.js";
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import { resolveProfileContext } from "./agent.shared.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function handleBrowserRouteError(res: BrowserResponse, err: unknown) {
|
||||
const mapped = toBrowserErrorResponse(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
async function withBasicProfileRoute(params: {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
run: (profileCtx: ProfileContext) => Promise<void>;
|
||||
}) {
|
||||
const profileCtx = resolveProfileContext(params.req, params.res, params.ctx);
|
||||
if (!profileCtx) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.run(profileCtx);
|
||||
} catch (err) {
|
||||
return handleBrowserRouteError(params.res, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function withProfilesServiceMutation(params: {
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
run: (service: ReturnType<typeof createBrowserProfilesService>) => Promise<unknown>;
|
||||
}) {
|
||||
try {
|
||||
const service = createBrowserProfilesService(params.ctx);
|
||||
const result = await params.run(service);
|
||||
params.res.json(result);
|
||||
} catch (err) {
|
||||
return handleBrowserRouteError(params.res, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
// List all profiles with their status
|
||||
app.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const profiles = await service.listProfiles();
|
||||
res.json({ profiles });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Get status (profile-aware)
|
||||
app.get("/", async (req, res) => {
|
||||
let current: ReturnType<typeof ctx.state>;
|
||||
try {
|
||||
current = ctx.state();
|
||||
} catch {
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
|
||||
let detectedBrowser: string | null = null;
|
||||
let detectedExecutablePath: string | null = null;
|
||||
let detectError: string | null = null;
|
||||
|
||||
try {
|
||||
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
|
||||
if (detected) {
|
||||
detectedBrowser = detected.kind;
|
||||
detectedExecutablePath = detected.path;
|
||||
}
|
||||
} catch (err) {
|
||||
detectError = String(err);
|
||||
}
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
profile: profileCtx.profile.name,
|
||||
driver: profileCtx.profile.driver,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid: capabilities.usesChromeMcp
|
||||
? getChromeMcpPid(profileCtx.profile.name)
|
||||
: (profileState?.running?.pid ?? null),
|
||||
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
detectedBrowser,
|
||||
detectedExecutablePath,
|
||||
detectError,
|
||||
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
|
||||
color: profileCtx.profile.color,
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
attachOnly: profileCtx.profile.attachOnly,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = toBrowserErrorResponse(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Start browser (profile-aware)
|
||||
app.post("/start", async (req, res) => {
|
||||
await withBasicProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
run: async (profileCtx) => {
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Stop browser (profile-aware)
|
||||
app.post("/stop", async (req, res) => {
|
||||
await withBasicProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
run: async (profileCtx) => {
|
||||
const result = await profileCtx.stopRunningBrowser();
|
||||
res.json({
|
||||
ok: true,
|
||||
stopped: result.stopped,
|
||||
profile: profileCtx.profile.name,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Reset profile (profile-aware)
|
||||
app.post("/reset-profile", async (req, res) => {
|
||||
await withBasicProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
run: async (profileCtx) => {
|
||||
const result = await profileCtx.resetProfile();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Create a new profile
|
||||
app.post("/profiles/create", async (req, res) => {
|
||||
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
|
||||
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
|
||||
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
|
||||
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
|
||||
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
|
||||
|
||||
if (!name) {
|
||||
return jsonError(res, 400, "name is required");
|
||||
}
|
||||
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
|
||||
);
|
||||
}
|
||||
|
||||
await withProfilesServiceMutation({
|
||||
res,
|
||||
ctx,
|
||||
run: async (service) =>
|
||||
await service.createProfile({
|
||||
name,
|
||||
color: color || undefined,
|
||||
cdpUrl: cdpUrl || undefined,
|
||||
userDataDir: userDataDir || undefined,
|
||||
driver:
|
||||
driver === "existing-session"
|
||||
? "existing-session"
|
||||
: driver === "openclaw" || driver === "clawd"
|
||||
? "openclaw"
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Delete a profile
|
||||
app.delete("/profiles/:name", async (req, res) => {
|
||||
const name = toStringOrEmpty(req.params.name);
|
||||
if (!name) {
|
||||
return jsonError(res, 400, "profile name is required");
|
||||
}
|
||||
|
||||
await withProfilesServiceMutation({
|
||||
res,
|
||||
ctx,
|
||||
run: async (service) => await service.deleteProfile(name),
|
||||
});
|
||||
});
|
||||
}
|
||||
133
extensions/browser/src/browser/routes/dispatcher.ts
Normal file
133
extensions/browser/src/browser/routes/dispatcher.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { escapeRegExp } from "../../utils.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserRoutes } from "./index.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
type BrowserDispatchRequest = {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
path: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type BrowserDispatchResponse = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type RouteEntry = {
|
||||
method: BrowserDispatchRequest["method"];
|
||||
path: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
const parts = path.split("/").map((part) => {
|
||||
if (part.startsWith(":")) {
|
||||
const name = part.slice(1);
|
||||
paramNames.push(name);
|
||||
return "([^/]+)";
|
||||
}
|
||||
return escapeRegExp(part);
|
||||
});
|
||||
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
|
||||
}
|
||||
|
||||
function createRegistry() {
|
||||
const routes: RouteEntry[] = [];
|
||||
const register =
|
||||
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
|
||||
const { regex, paramNames } = compileRoute(path);
|
||||
routes.push({ method, path, regex, paramNames, handler });
|
||||
};
|
||||
const router: BrowserRouteRegistrar = {
|
||||
get: register("GET"),
|
||||
post: register("POST"),
|
||||
delete: register("DELETE"),
|
||||
};
|
||||
return { routes, router };
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) {
|
||||
return "/";
|
||||
}
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
|
||||
const registry = createRegistry();
|
||||
registerBrowserRoutes(registry.router, ctx);
|
||||
|
||||
return {
|
||||
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
|
||||
const method = req.method;
|
||||
const path = normalizePath(req.path);
|
||||
const query = req.query ?? {};
|
||||
const body = req.body;
|
||||
const signal = req.signal;
|
||||
|
||||
const match = registry.routes.find((route) => {
|
||||
if (route.method !== method) {
|
||||
return false;
|
||||
}
|
||||
return route.regex.test(path);
|
||||
});
|
||||
if (!match) {
|
||||
return { status: 404, body: { error: "Not Found" } };
|
||||
}
|
||||
|
||||
const exec = match.regex.exec(path);
|
||||
const params: Record<string, string> = {};
|
||||
if (exec) {
|
||||
for (const [idx, name] of match.paramNames.entries()) {
|
||||
const value = exec[idx + 1];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
params[name] = decodeURIComponent(value);
|
||||
} catch {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: `invalid path parameter encoding: ${name}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let payload: unknown = undefined;
|
||||
const res: BrowserResponse = {
|
||||
status(code) {
|
||||
status = code;
|
||||
return res;
|
||||
},
|
||||
json(bodyValue) {
|
||||
payload = bodyValue;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await match.handler(
|
||||
{
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
},
|
||||
res,
|
||||
);
|
||||
} catch (err) {
|
||||
return { status: 500, body: { error: String(err) } };
|
||||
}
|
||||
|
||||
return { status, body: payload };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { BrowserDispatchRequest, BrowserDispatchResponse };
|
||||
11
extensions/browser/src/browser/routes/index.ts
Normal file
11
extensions/browser/src/browser/routes/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserAgentRoutes } from "./agent.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
registerBrowserBasicRoutes(app, ctx);
|
||||
registerBrowserTabRoutes(app, ctx);
|
||||
registerBrowserAgentRoutes(app, ctx);
|
||||
}
|
||||
31
extensions/browser/src/browser/routes/output-paths.ts
Normal file
31
extensions/browser/src/browser/routes/output-paths.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveWritablePathWithinRoot } from "./path-output.js";
|
||||
import type { BrowserResponse } from "./types.js";
|
||||
|
||||
export async function ensureOutputRootDir(rootDir: string): Promise<void> {
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
export async function resolveWritableOutputPathOrRespond(params: {
|
||||
res: BrowserResponse;
|
||||
rootDir: string;
|
||||
requestedPath: string;
|
||||
scopeLabel: string;
|
||||
defaultFileName?: string;
|
||||
ensureRootDir?: boolean;
|
||||
}): Promise<string | null> {
|
||||
if (params.ensureRootDir) {
|
||||
await ensureOutputRootDir(params.rootDir);
|
||||
}
|
||||
const pathResult = await resolveWritablePathWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
requestedPath: params.requestedPath,
|
||||
scopeLabel: params.scopeLabel,
|
||||
defaultFileName: params.defaultFileName,
|
||||
});
|
||||
if (!pathResult.ok) {
|
||||
params.res.status(400).json({ error: pathResult.error });
|
||||
return null;
|
||||
}
|
||||
return pathResult.path;
|
||||
}
|
||||
1
extensions/browser/src/browser/routes/path-output.ts
Normal file
1
extensions/browser/src/browser/routes/path-output.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../paths.js";
|
||||
230
extensions/browser/src/browser/routes/tabs.ts
Normal file
230
extensions/browser/src/browser/routes/tabs.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "../navigation-guard.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function resolveTabsProfileContext(
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
jsonError(res, profileCtx.status, profileCtx.error);
|
||||
return null;
|
||||
}
|
||||
return profileCtx;
|
||||
}
|
||||
|
||||
function handleTabsRouteError(
|
||||
ctx: BrowserRouteContext,
|
||||
res: BrowserResponse,
|
||||
err: unknown,
|
||||
opts?: { mapTabError?: boolean },
|
||||
) {
|
||||
if (opts?.mapTabError) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
}
|
||||
}
|
||||
return jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
async function withTabsProfileRoute(params: {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
mapTabError?: boolean;
|
||||
run: (profileCtx: ProfileContext) => Promise<void>;
|
||||
}) {
|
||||
const profileCtx = resolveTabsProfileContext(params.req, params.res, params.ctx);
|
||||
if (!profileCtx) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.run(profileCtx);
|
||||
} catch (err) {
|
||||
handleTabsRouteError(params.ctx, params.res, err, { mapTabError: params.mapTabError });
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
|
||||
if (!(await profileCtx.isReachable(300))) {
|
||||
jsonError(
|
||||
res,
|
||||
new BrowserProfileUnavailableError("browser not running").status,
|
||||
"browser not running",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveIndexedTab(
|
||||
tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>,
|
||||
index: number | undefined,
|
||||
) {
|
||||
return typeof index === "number" ? tabs[index] : tabs.at(0);
|
||||
}
|
||||
|
||||
function parseRequiredTargetId(res: BrowserResponse, rawTargetId: unknown): string | null {
|
||||
const targetId = toStringOrEmpty(rawTargetId);
|
||||
if (!targetId) {
|
||||
jsonError(res, 400, "targetId is required");
|
||||
return null;
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
async function runTabTargetMutation(params: {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
ctx: BrowserRouteContext;
|
||||
targetId: string;
|
||||
mutate: (profileCtx: ProfileContext, targetId: string) => Promise<void>;
|
||||
}) {
|
||||
await withTabsProfileRoute({
|
||||
req: params.req,
|
||||
res: params.res,
|
||||
ctx: params.ctx,
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
if (!(await ensureBrowserRunning(profileCtx, params.res))) {
|
||||
return;
|
||||
}
|
||||
await params.mutate(profileCtx, params.targetId);
|
||||
params.res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
||||
app.get("/tabs", async (req, res) => {
|
||||
await withTabsProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
run: async (profileCtx) => {
|
||||
const reachable = await profileCtx.isReachable(300);
|
||||
if (!reachable) {
|
||||
return res.json({ running: false, tabs: [] as unknown[] });
|
||||
}
|
||||
const tabs = await profileCtx.listTabs();
|
||||
res.json({ running: true, tabs });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/tabs/open", async (req, res) => {
|
||||
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
|
||||
if (!url) {
|
||||
return jsonError(res, 400, "url is required");
|
||||
}
|
||||
|
||||
await withTabsProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
await assertBrowserNavigationAllowed({
|
||||
url,
|
||||
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
|
||||
});
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
const tab = await profileCtx.openTab(url);
|
||||
res.json(tab);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/tabs/focus", async (req, res) => {
|
||||
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
await runTabTargetMutation({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
mutate: async (profileCtx, id) => {
|
||||
await profileCtx.focusTab(id);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.delete("/tabs/:targetId", async (req, res) => {
|
||||
const targetId = parseRequiredTargetId(res, req.params.targetId);
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
await runTabTargetMutation({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
targetId,
|
||||
mutate: async (profileCtx, id) => {
|
||||
await profileCtx.closeTab(id);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/tabs/action", async (req, res) => {
|
||||
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
|
||||
const index = toNumber((req.body as { index?: unknown })?.index);
|
||||
|
||||
await withTabsProfileRoute({
|
||||
req,
|
||||
res,
|
||||
ctx,
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
if (action === "list") {
|
||||
const reachable = await profileCtx.isReachable(300);
|
||||
if (!reachable) {
|
||||
return res.json({ ok: true, tabs: [] as unknown[] });
|
||||
}
|
||||
const tabs = await profileCtx.listTabs();
|
||||
return res.json({ ok: true, tabs });
|
||||
}
|
||||
|
||||
if (action === "new") {
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
const tab = await profileCtx.openTab("about:blank");
|
||||
return res.json({ ok: true, tab });
|
||||
}
|
||||
|
||||
if (action === "close") {
|
||||
const tabs = await profileCtx.listTabs();
|
||||
const target = resolveIndexedTab(tabs, index);
|
||||
if (!target) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
await profileCtx.closeTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
}
|
||||
|
||||
if (action === "select") {
|
||||
if (typeof index !== "number") {
|
||||
return jsonError(res, 400, "index is required");
|
||||
}
|
||||
const tabs = await profileCtx.listTabs();
|
||||
const target = tabs[index];
|
||||
if (!target) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
await profileCtx.focusTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
}
|
||||
|
||||
return jsonError(res, 400, "unknown tab action");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
36
extensions/browser/src/browser/routes/test-helpers.ts
Normal file
36
extensions/browser/src/browser/routes/test-helpers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { BrowserResponse, BrowserRouteHandler, BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
export function createBrowserRouteApp() {
|
||||
const getHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const postHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const deleteHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const app: BrowserRouteRegistrar = {
|
||||
get: (path, handler) => void getHandlers.set(path, handler),
|
||||
post: (path, handler) => void postHandlers.set(path, handler),
|
||||
delete: (path, handler) => void deleteHandlers.set(path, handler),
|
||||
};
|
||||
return { app, getHandlers, postHandlers, deleteHandlers };
|
||||
}
|
||||
|
||||
export function createBrowserRouteResponse() {
|
||||
let statusCode = 200;
|
||||
let jsonBody: unknown;
|
||||
const res: BrowserResponse = {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body) {
|
||||
jsonBody = body;
|
||||
},
|
||||
};
|
||||
return {
|
||||
res,
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
get body() {
|
||||
return jsonBody;
|
||||
},
|
||||
};
|
||||
}
|
||||
26
extensions/browser/src/browser/routes/types.ts
Normal file
26
extensions/browser/src/browser/routes/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type BrowserRequest = {
|
||||
params: Record<string, string>;
|
||||
query: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
/**
|
||||
* Optional abort signal for in-process dispatch. This lets callers enforce
|
||||
* timeouts and (where supported) cancel long-running operations.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type BrowserResponse = {
|
||||
status: (code: number) => BrowserResponse;
|
||||
json: (body: unknown) => void;
|
||||
};
|
||||
|
||||
export type BrowserRouteHandler = (
|
||||
req: BrowserRequest,
|
||||
res: BrowserResponse,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type BrowserRouteRegistrar = {
|
||||
get: (path: string, handler: BrowserRouteHandler) => void;
|
||||
post: (path: string, handler: BrowserRouteHandler) => void;
|
||||
delete: (path: string, handler: BrowserRouteHandler) => void;
|
||||
};
|
||||
73
extensions/browser/src/browser/routes/utils.ts
Normal file
73
extensions/browser/src/browser/routes/utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
* Query string takes precedence over body for consistency with GET routes.
|
||||
*/
|
||||
export function getProfileContext(
|
||||
req: BrowserRequest,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | { error: string; status: number } {
|
||||
let profileName: string | undefined;
|
||||
|
||||
// Check query string first (works for GET and POST)
|
||||
if (typeof req.query.profile === "string") {
|
||||
profileName = req.query.profile.trim() || undefined;
|
||||
}
|
||||
|
||||
// Fall back to body for POST requests
|
||||
if (!profileName && req.body && typeof req.body === "object") {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (typeof body.profile === "string") {
|
||||
profileName = body.profile.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return ctx.forProfile(profileName);
|
||||
} catch (err) {
|
||||
return { error: String(err), status: 404 };
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(res: BrowserResponse, status: number, message: string) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
export function toStringOrEmpty(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toBoolean(value: unknown) {
|
||||
return parseBooleanValue(value, {
|
||||
truthy: ["true", "1", "yes"],
|
||||
falsy: ["false", "0", "no"],
|
||||
});
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
|
||||
return strings.length ? strings : undefined;
|
||||
}
|
||||
60
extensions/browser/src/browser/runtime-lifecycle.ts
Normal file
60
extensions/browser/src/browser/runtime-lifecycle.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Server } from "node:http";
|
||||
import { isPwAiLoaded } from "./pw-ai-state.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
|
||||
export async function createBrowserRuntimeState(params: {
|
||||
resolved: BrowserServerState["resolved"];
|
||||
port: number;
|
||||
server?: Server | null;
|
||||
onWarn: (message: string) => void;
|
||||
}): Promise<BrowserServerState> {
|
||||
const state: BrowserServerState = {
|
||||
server: params.server ?? null,
|
||||
port: params.port,
|
||||
resolved: params.resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved: params.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function stopBrowserRuntime(params: {
|
||||
current: BrowserServerState | null;
|
||||
getState: () => BrowserServerState | null;
|
||||
clearState: () => void;
|
||||
closeServer?: boolean;
|
||||
onWarn: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!params.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
getState: params.getState,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
if (params.closeServer && params.current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
params.current?.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
params.clearState();
|
||||
|
||||
if (!isPwAiLoaded()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
26
extensions/browser/src/browser/safe-filename.ts
Normal file
26
extensions/browser/src/browser/safe-filename.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string {
|
||||
const trimmed = String(fileName ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return fallbackName;
|
||||
}
|
||||
let base = path.posix.basename(trimmed);
|
||||
base = path.win32.basename(base);
|
||||
let cleaned = "";
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const code = base.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
continue;
|
||||
}
|
||||
cleaned += base[i];
|
||||
}
|
||||
base = cleaned.trim();
|
||||
if (!base || base === "." || base === "..") {
|
||||
return fallbackName;
|
||||
}
|
||||
if (base.length > 200) {
|
||||
base = base.slice(0, 200);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
58
extensions/browser/src/browser/screenshot.ts
Normal file
58
extensions/browser/src/browser/screenshot.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
buildImageResizeSideGrid,
|
||||
getImageMetadata,
|
||||
IMAGE_REDUCE_QUALITY_STEPS,
|
||||
resizeToJpeg,
|
||||
} from "../media/image-ops.js";
|
||||
|
||||
export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000;
|
||||
export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export async function normalizeBrowserScreenshot(
|
||||
buffer: Buffer,
|
||||
opts?: {
|
||||
maxSide?: number;
|
||||
maxBytes?: number;
|
||||
},
|
||||
): Promise<{ buffer: Buffer; contentType?: "image/jpeg" }> {
|
||||
const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE));
|
||||
const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES));
|
||||
|
||||
const meta = await getImageMetadata(buffer);
|
||||
const width = Number(meta?.width ?? 0);
|
||||
const height = Number(meta?.height ?? 0);
|
||||
const maxDim = Math.max(width, height);
|
||||
|
||||
if (buffer.byteLength <= maxBytes && (maxDim === 0 || (width <= maxSide && height <= maxSide))) {
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
const sideStart = maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide;
|
||||
const sideGrid = buildImageResizeSideGrid(maxSide, sideStart);
|
||||
|
||||
let smallest: { buffer: Buffer; size: number } | null = null;
|
||||
|
||||
for (const side of sideGrid) {
|
||||
for (const quality of IMAGE_REDUCE_QUALITY_STEPS) {
|
||||
const out = await resizeToJpeg({
|
||||
buffer,
|
||||
maxSide: side,
|
||||
quality,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (!smallest || out.byteLength < smallest.size) {
|
||||
smallest = { buffer: out, size: out.byteLength };
|
||||
}
|
||||
|
||||
if (out.byteLength <= maxBytes) {
|
||||
return { buffer: out, contentType: "image/jpeg" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const best = smallest?.buffer ?? buffer;
|
||||
throw new Error(
|
||||
`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
);
|
||||
}
|
||||
293
extensions/browser/src/browser/server-context.availability.ts
Normal file
293
extensions/browser/src/browser/server-context.availability.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
CHROME_MCP_ATTACH_READY_POLL_MS,
|
||||
CHROME_MCP_ATTACH_READY_WINDOW_MS,
|
||||
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
|
||||
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
|
||||
resolveCdpReachabilityTimeouts,
|
||||
} from "./cdp-timeouts.js";
|
||||
import {
|
||||
closeChromeMcpSession,
|
||||
ensureChromeMcpAvailable,
|
||||
listChromeMcpTabs,
|
||||
} from "./chrome-mcp.js";
|
||||
import {
|
||||
isChromeCdpReady,
|
||||
isChromeReachable,
|
||||
launchOpenClawChrome,
|
||||
stopOpenClawChrome,
|
||||
} from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserProfileUnavailableError } from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import {
|
||||
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
||||
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
|
||||
CDP_READY_AFTER_LAUNCH_POLL_MS,
|
||||
CDP_READY_AFTER_LAUNCH_WINDOW_MS,
|
||||
} from "./server-context.constants.js";
|
||||
import type {
|
||||
BrowserServerState,
|
||||
ContextOptions,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
|
||||
type AvailabilityDeps = {
|
||||
opts: ContextOptions;
|
||||
profile: ResolvedBrowserProfile;
|
||||
state: () => BrowserServerState;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
setProfileRunning: (running: ProfileRuntimeState["running"]) => void;
|
||||
};
|
||||
|
||||
type AvailabilityOps = {
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
};
|
||||
|
||||
export function createProfileAvailability({
|
||||
opts,
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
}: AvailabilityDeps): AvailabilityOps {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
profileIsLoopback: profile.cdpIsLoopback,
|
||||
timeoutMs,
|
||||
remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs,
|
||||
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
|
||||
});
|
||||
|
||||
const isReachable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
|
||||
await listChromeMcpTabs(profile.name, profile.userDataDir);
|
||||
return true;
|
||||
}
|
||||
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeCdpReady(
|
||||
profile.cdpUrl,
|
||||
httpTimeoutMs,
|
||||
wsTimeoutMs,
|
||||
state().resolved.ssrfPolicy,
|
||||
);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
return await isReachable(timeoutMs);
|
||||
}
|
||||
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
|
||||
};
|
||||
|
||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||
setProfileRunning(running);
|
||||
running.proc.on("exit", () => {
|
||||
// Guard against server teardown (e.g., SIGUSR1 restart)
|
||||
if (!opts.getState()) {
|
||||
return;
|
||||
}
|
||||
const profileState = getProfileState();
|
||||
if (profileState.running?.pid === running.pid) {
|
||||
setProfileRunning(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileProfileRuntime = async (): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
const reconcile = profileState.reconcile;
|
||||
if (!reconcile) {
|
||||
return;
|
||||
}
|
||||
profileState.reconcile = null;
|
||||
profileState.lastTargetId = null;
|
||||
|
||||
const previousProfile = reconcile.previousProfile;
|
||||
if (profileState.running) {
|
||||
await stopOpenClawChrome(profileState.running).catch(() => {});
|
||||
setProfileRunning(null);
|
||||
}
|
||||
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
|
||||
await closeChromeMcpSession(previousProfile.name).catch(() => false);
|
||||
}
|
||||
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
|
||||
if (previousProfile.cdpUrl !== profile.cdpUrl) {
|
||||
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
|
||||
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
|
||||
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
|
||||
const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS;
|
||||
while (Date.now() < deadlineMs) {
|
||||
const remainingMs = Math.max(0, deadlineMs - Date.now());
|
||||
// Keep each attempt short; loopback profiles derive a WS timeout from this value.
|
||||
const attemptTimeoutMs = Math.max(
|
||||
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
|
||||
Math.min(CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, remainingMs),
|
||||
);
|
||||
if (await isReachable(attemptTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
|
||||
}
|
||||
throw new Error(
|
||||
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`,
|
||||
);
|
||||
};
|
||||
|
||||
const waitForChromeMcpReadyAfterAttach = async (): Promise<void> => {
|
||||
const deadlineMs = Date.now() + CHROME_MCP_ATTACH_READY_WINDOW_MS;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadlineMs) {
|
||||
try {
|
||||
await listChromeMcpTabs(profile.name, profile.userDataDir);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
|
||||
}
|
||||
const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Chrome MCP existing-session attach for profile "${profile.name}" timed out waiting for tabs to become available.` +
|
||||
` Approve the browser attach prompt, keep the browser open, and retry.${detail}`,
|
||||
);
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`,
|
||||
);
|
||||
}
|
||||
await ensureChromeMcpAvailable(profile.name, profile.userDataDir);
|
||||
await waitForChromeMcpReadyAfterAttach();
|
||||
return;
|
||||
}
|
||||
const current = state();
|
||||
const remoteCdp = capabilities.isRemote;
|
||||
const attachOnly = profile.attachOnly;
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable();
|
||||
|
||||
if (!httpReachable) {
|
||||
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Browser control service can restart while a loopback OpenClaw browser is still
|
||||
// alive. Give that pre-existing browser one longer probe window before falling
|
||||
// back to local executable resolution.
|
||||
if (!attachOnly && !remoteCdp && profile.cdpIsLoopback && !profileState.running) {
|
||||
if (
|
||||
(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) &&
|
||||
(await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
);
|
||||
}
|
||||
const launched = await launchOpenClawChrome(current.resolved, profile);
|
||||
attachRunning(launched);
|
||||
try {
|
||||
await waitForCdpReadyAfterLaunch();
|
||||
} catch (err) {
|
||||
await stopOpenClawChrome(launched).catch(() => {});
|
||||
setProfileRunning(null);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Port is reachable - check if we own it.
|
||||
if (await isReachable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform
|
||||
// local ownership/restart handling; just run attach retries and surface attach errors.
|
||||
if (attachOnly || remoteCdp) {
|
||||
if (opts.onEnsureAttachTarget) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
|
||||
);
|
||||
}
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else.
|
||||
if (!profileState.running) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||
);
|
||||
}
|
||||
|
||||
await stopOpenClawChrome(profileState.running);
|
||||
setProfileRunning(null);
|
||||
|
||||
const relaunched = await launchOpenClawChrome(current.resolved, profile);
|
||||
attachRunning(relaunched);
|
||||
|
||||
if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) {
|
||||
throw new Error(
|
||||
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const stopped = await closeChromeMcpSession(profile.name);
|
||||
return { stopped };
|
||||
}
|
||||
const profileState = getProfileState();
|
||||
if (!profileState.running) {
|
||||
return { stopped: false };
|
||||
}
|
||||
await stopOpenClawChrome(profileState.running);
|
||||
setProfileRunning(null);
|
||||
return { stopped: true };
|
||||
};
|
||||
|
||||
return {
|
||||
isHttpReachable,
|
||||
isReachable,
|
||||
ensureBrowserAvailable,
|
||||
stopRunningBrowser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { vi } from "vitest";
|
||||
import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js";
|
||||
|
||||
const chromeUserDataDir = { dir: "/tmp/openclaw" };
|
||||
installChromeUserDataDirHooks(chromeUserDataDir);
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => true),
|
||||
isChromeReachable: vi.fn(async () => true),
|
||||
launchOpenClawChrome: vi.fn(async () => {
|
||||
throw new Error("unexpected launch");
|
||||
}),
|
||||
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
|
||||
stopOpenClawChrome: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8;
|
||||
|
||||
export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000;
|
||||
export const OPEN_TAB_DISCOVERY_POLL_MS = 100;
|
||||
|
||||
export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000;
|
||||
export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100;
|
||||
export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75;
|
||||
export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
export const originalFetch = globalThis.fetch;
|
||||
|
||||
export function makeState(
|
||||
profile: "remote" | "openclaw",
|
||||
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
server: null as any,
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18899,
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||
cdpIsLoopback: profile !== "remote",
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
evaluateEnabled: false,
|
||||
extraArgs: [],
|
||||
color: "#FF4500",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: false,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
defaultProfile: profile,
|
||||
profiles: {
|
||||
remote: {
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
cdpPort: 443,
|
||||
color: "#00AA00",
|
||||
},
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
profiles: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeUnexpectedFetchMock() {
|
||||
return vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
}
|
||||
|
||||
export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise<Response>) {
|
||||
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
|
||||
global.fetch = withFetchPreconnect(activeFetchMock);
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
|
||||
}
|
||||
|
||||
export function createSequentialPageLister<T>(responses: T[]) {
|
||||
return async () => {
|
||||
const next = responses.shift();
|
||||
if (!next) {
|
||||
throw new Error("no more responses");
|
||||
}
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
type JsonListEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
type: "page";
|
||||
};
|
||||
|
||||
export function createJsonListFetchMock(entries: JsonListEntry[]) {
|
||||
return async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => entries,
|
||||
} as unknown as Response;
|
||||
};
|
||||
}
|
||||
|
||||
function makeManagedTab(id: string, ordinal: number): JsonListEntry {
|
||||
return {
|
||||
id,
|
||||
title: String(ordinal),
|
||||
url: `http://127.0.0.1:300${ordinal}`,
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`,
|
||||
type: "page",
|
||||
};
|
||||
}
|
||||
|
||||
export function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] {
|
||||
const oldTabs = Array.from({ length: 8 }, (_, index) =>
|
||||
makeManagedTab(`OLD${index + 1}`, index + 1),
|
||||
);
|
||||
const newTab = makeManagedTab("NEW", 9);
|
||||
return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab];
|
||||
}
|
||||
67
extensions/browser/src/browser/server-context.reset.ts
Normal file
67
extensions/browser/src/browser/server-context.reset.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserResetUnsupportedError } from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { ProfileRuntimeState } from "./server-context.types.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
type ResetDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
resolveOpenClawUserDataDir: (profileName: string) => string;
|
||||
};
|
||||
|
||||
type ResetOps = {
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise<void> {
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function createProfileResetOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
stopRunningBrowser,
|
||||
isHttpReachable,
|
||||
resolveOpenClawUserDataDir,
|
||||
}: ResetDeps): ResetOps {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resetProfile = async () => {
|
||||
if (!capabilities.supportsReset) {
|
||||
throw new BrowserResetUnsupportedError(
|
||||
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
||||
);
|
||||
}
|
||||
|
||||
const userDataDir = resolveOpenClawUserDataDir(profile.name);
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable(300);
|
||||
if (httpReachable && !profileState.running) {
|
||||
// Port in use but not by us - kill it.
|
||||
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
|
||||
}
|
||||
|
||||
if (profileState.running) {
|
||||
await stopRunningBrowser();
|
||||
}
|
||||
|
||||
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
|
||||
|
||||
if (!fs.existsSync(userDataDir)) {
|
||||
return { moved: false, from: userDataDir };
|
||||
}
|
||||
|
||||
const moved = await movePathToTrash(userDataDir);
|
||||
return { moved: true, from: userDataDir, to: moved };
|
||||
};
|
||||
|
||||
return { resetProfile };
|
||||
}
|
||||
153
extensions/browser/src/browser/server-context.selection.ts
Normal file
153
extensions/browser/src/browser/server-context.selection.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath } from "./cdp.js";
|
||||
import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
|
||||
type SelectionDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
};
|
||||
|
||||
type SelectionOps = {
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createProfileSelectionOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
}: SelectionDeps): SelectionOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
await ensureBrowserAvailable();
|
||||
const profileState = getProfileState();
|
||||
const tabs1 = await listTabs();
|
||||
if (tabs1.length === 0) {
|
||||
await openTab("about:blank");
|
||||
}
|
||||
|
||||
const tabs = await listTabs();
|
||||
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||
|
||||
const resolveById = (raw: string) => {
|
||||
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
return "AMBIGUOUS" as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return candidates.find((t) => t.targetId === resolved.targetId) ?? null;
|
||||
};
|
||||
|
||||
const pickDefault = () => {
|
||||
const last = profileState.lastTargetId?.trim() || "";
|
||||
const lastResolved = last ? resolveById(last) : null;
|
||||
if (lastResolved && lastResolved !== "AMBIGUOUS") {
|
||||
return lastResolved;
|
||||
}
|
||||
// Prefer a real page tab first (avoid service workers/background targets).
|
||||
const page = candidates.find((t) => (t.type ?? "page") === "page");
|
||||
return page ?? candidates.at(0) ?? null;
|
||||
};
|
||||
|
||||
const chosen = targetId ? resolveById(targetId) : pickDefault();
|
||||
|
||||
if (chosen === "AMBIGUOUS") {
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
}
|
||||
if (!chosen) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
profileState.lastTargetId = chosen.targetId;
|
||||
return chosen;
|
||||
};
|
||||
|
||||
const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return resolved.targetId;
|
||||
};
|
||||
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.focusPageByTargetIdViaPlaywright;
|
||||
if (typeof focusPageByTargetIdViaPlaywright === "function") {
|
||||
await focusPageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
});
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
};
|
||||
|
||||
const closeTab = async (targetId: string): Promise<void> => {
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.closePageByTargetIdViaPlaywright;
|
||||
if (typeof closePageByTargetIdViaPlaywright === "function") {
|
||||
await closePageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
|
||||
};
|
||||
|
||||
return {
|
||||
ensureTabAvailable,
|
||||
focusTab,
|
||||
closeTab,
|
||||
};
|
||||
}
|
||||
243
extensions/browser/src/browser/server-context.tab-ops.ts
Normal file
243
extensions/browser/src/browser/server-context.tab-ops.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
requiresInspectableBrowserNavigationRedirects,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import {
|
||||
MANAGED_BROWSER_PAGE_TAB_LIMIT,
|
||||
OPEN_TAB_DISCOVERY_POLL_MS,
|
||||
OPEN_TAB_DISCOVERY_WINDOW_MS,
|
||||
} from "./server-context.constants.js";
|
||||
import type {
|
||||
BrowserServerState,
|
||||
BrowserTab,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
|
||||
type TabOpsDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
state: () => BrowserServerState;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
};
|
||||
|
||||
type ProfileTabOps = {
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a CDP WebSocket URL to use the correct base URL.
|
||||
*/
|
||||
function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeCdpWsUrl(raw, cdpBaseUrl);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
type CdpTarget = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function createProfileTabOps({
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
return await listChromeMcpTabs(profile.name, profile.userDataDir);
|
||||
}
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||
if (typeof listPagesViaPlaywright === "function") {
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl });
|
||||
return pages.map((p) => ({
|
||||
targetId: p.targetId,
|
||||
title: p.title,
|
||||
url: p.url,
|
||||
type: p.type,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await fetchJson<
|
||||
Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"));
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
title: t.title ?? "",
|
||||
url: t.url ?? "",
|
||||
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: t.type,
|
||||
}))
|
||||
.filter((t) => Boolean(t.targetId));
|
||||
};
|
||||
|
||||
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
if (
|
||||
!capabilities.supportsManagedTabLimit ||
|
||||
state().resolved.attachOnly ||
|
||||
!profileState.running
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageTabs = await listTabs()
|
||||
.then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page"))
|
||||
.catch(() => [] as BrowserTab[]);
|
||||
if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
|
||||
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
|
||||
for (const tab of candidates.slice(0, excessCount)) {
|
||||
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
|
||||
// best-effort cleanup only
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const triggerManagedTabLimit = (keepTargetId: string): void => {
|
||||
void enforceManagedTabLimit(keepTargetId).catch(() => {
|
||||
// best-effort cleanup only
|
||||
});
|
||||
};
|
||||
|
||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||
const page = await openChromeMcpTab(profile.name, url, profile.userDataDir);
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = page.targetId;
|
||||
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
|
||||
return page;
|
||||
}
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
||||
if (typeof createPageViaPlaywright === "function") {
|
||||
const page = await createPageViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
url,
|
||||
...ssrfPolicyOpts,
|
||||
});
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = page.targetId;
|
||||
triggerManagedTabLimit(page.targetId);
|
||||
return {
|
||||
targetId: page.targetId,
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
type: page.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {
|
||||
throw new InvalidBrowserNavigationUrlError(
|
||||
"Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",
|
||||
);
|
||||
}
|
||||
|
||||
const createdViaCdp = await createTargetViaCdp({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
url,
|
||||
...ssrfPolicyOpts,
|
||||
})
|
||||
.then((r) => r.targetId)
|
||||
.catch(() => null);
|
||||
|
||||
if (createdViaCdp) {
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = createdViaCdp;
|
||||
const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const tabs = await listTabs().catch(() => [] as BrowserTab[]);
|
||||
const found = tabs.find((t) => t.targetId === createdViaCdp);
|
||||
if (found) {
|
||||
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
|
||||
triggerManagedTabLimit(found.targetId);
|
||||
return found;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
|
||||
}
|
||||
triggerManagedTabLimit(createdViaCdp);
|
||||
return { targetId: createdViaCdp, title: "", url, type: "page" };
|
||||
}
|
||||
|
||||
const encoded = encodeURIComponent(url);
|
||||
const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));
|
||||
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
|
||||
const endpoint = endpointUrl.search
|
||||
? (() => {
|
||||
endpointUrl.searchParams.set("url", url);
|
||||
return endpointUrl.toString();
|
||||
})()
|
||||
: `${endpointUrl.toString()}?${encoded}`;
|
||||
const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, {
|
||||
method: "PUT",
|
||||
}).catch(async (err) => {
|
||||
if (String(err).includes("HTTP 405")) {
|
||||
return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!created.id) {
|
||||
throw new Error("Failed to open tab (missing id)");
|
||||
}
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = created.id;
|
||||
const resolvedUrl = created.url ?? url;
|
||||
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
|
||||
triggerManagedTabLimit(created.id);
|
||||
return {
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: resolvedUrl,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: created.type,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
listTabs,
|
||||
openTab,
|
||||
};
|
||||
}
|
||||
258
extensions/browser/src/browser/server-context.ts
Normal file
258
extensions/browser/src/browser/server-context.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import {
|
||||
refreshResolvedBrowserConfigFromDisk,
|
||||
resolveBrowserProfileWithHotReload,
|
||||
} from "./resolved-config-refresh.js";
|
||||
import { createProfileAvailability } from "./server-context.availability.js";
|
||||
import { createProfileResetOps } from "./server-context.reset.js";
|
||||
import { createProfileSelectionOps } from "./server-context.selection.js";
|
||||
import { createProfileTabOps } from "./server-context.tab-ops.js";
|
||||
import type {
|
||||
BrowserServerState,
|
||||
BrowserRouteContext,
|
||||
BrowserTab,
|
||||
ContextOptions,
|
||||
ProfileContext,
|
||||
ProfileRuntimeState,
|
||||
ProfileStatus,
|
||||
} from "./server-context.types.js";
|
||||
|
||||
export type {
|
||||
BrowserRouteContext,
|
||||
BrowserServerState,
|
||||
BrowserTab,
|
||||
ProfileContext,
|
||||
ProfileRuntimeState,
|
||||
ProfileStatus,
|
||||
} from "./server-context.types.js";
|
||||
|
||||
export function listKnownProfileNames(state: BrowserServerState): string[] {
|
||||
const names = new Set(Object.keys(state.resolved.profiles));
|
||||
for (const name of state.profiles.keys()) {
|
||||
names.add(name);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a profile-scoped context for browser operations.
|
||||
*/
|
||||
function createProfileContext(
|
||||
opts: ContextOptions,
|
||||
profile: ResolvedBrowserProfile,
|
||||
): ProfileContext {
|
||||
const state = () => {
|
||||
const current = opts.getState();
|
||||
if (!current) {
|
||||
throw new Error("Browser server not started");
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const getProfileState = (): ProfileRuntimeState => {
|
||||
const current = state();
|
||||
let profileState = current.profiles.get(profile.name);
|
||||
if (!profileState) {
|
||||
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
|
||||
current.profiles.set(profile.name, profileState);
|
||||
}
|
||||
return profileState;
|
||||
};
|
||||
|
||||
const setProfileRunning = (running: ProfileRuntimeState["running"]) => {
|
||||
const profileState = getProfileState();
|
||||
profileState.running = running;
|
||||
};
|
||||
|
||||
const { listTabs, openTab } = createProfileTabOps({
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
});
|
||||
|
||||
const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } =
|
||||
createProfileAvailability({
|
||||
opts,
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
});
|
||||
|
||||
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
});
|
||||
|
||||
const { resetProfile } = createProfileResetOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
stopRunningBrowser,
|
||||
isHttpReachable,
|
||||
resolveOpenClawUserDataDir,
|
||||
});
|
||||
|
||||
return {
|
||||
profile,
|
||||
ensureBrowserAvailable,
|
||||
ensureTabAvailable,
|
||||
isHttpReachable,
|
||||
isReachable,
|
||||
listTabs,
|
||||
openTab,
|
||||
focusTab,
|
||||
closeTab,
|
||||
stopRunningBrowser,
|
||||
resetProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
|
||||
const refreshConfigFromDisk = opts.refreshConfigFromDisk === true;
|
||||
|
||||
const state = () => {
|
||||
const current = opts.getState();
|
||||
if (!current) {
|
||||
throw new Error("Browser server not started");
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const forProfile = (profileName?: string): ProfileContext => {
|
||||
const current = state();
|
||||
const name = profileName ?? current.resolved.defaultProfile;
|
||||
const profile = resolveBrowserProfileWithHotReload({
|
||||
current,
|
||||
refreshConfigFromDisk,
|
||||
name,
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
const available = Object.keys(current.resolved.profiles).join(", ");
|
||||
throw new BrowserProfileNotFoundError(
|
||||
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
|
||||
);
|
||||
}
|
||||
return createProfileContext(opts, profile);
|
||||
};
|
||||
|
||||
const listProfiles = async (): Promise<ProfileStatus[]> => {
|
||||
const current = state();
|
||||
refreshResolvedBrowserConfigFromDisk({
|
||||
current,
|
||||
refreshConfigFromDisk,
|
||||
mode: "cached",
|
||||
});
|
||||
const result: ProfileStatus[] = [];
|
||||
|
||||
for (const name of listKnownProfileNames(current)) {
|
||||
const profileState = current.profiles.get(name);
|
||||
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
let tabCount = 0;
|
||||
let running = false;
|
||||
const profileCtx = createProfileContext(opts, profile);
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
try {
|
||||
running = await profileCtx.isReachable(300);
|
||||
if (running) {
|
||||
const tabs = await profileCtx.listTabs();
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
}
|
||||
} catch {
|
||||
// Chrome MCP not available
|
||||
}
|
||||
} else if (profileState?.running) {
|
||||
running = true;
|
||||
try {
|
||||
const tabs = await profileCtx.listTabs();
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
} catch {
|
||||
// Browser might not be responsive
|
||||
}
|
||||
} else {
|
||||
// Check if something is listening on the port
|
||||
try {
|
||||
const reachable = await isChromeReachable(
|
||||
profile.cdpUrl,
|
||||
200,
|
||||
current.resolved.ssrfPolicy,
|
||||
);
|
||||
if (reachable) {
|
||||
running = true;
|
||||
const tabs = await profileCtx.listTabs().catch(() => []);
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
}
|
||||
} catch {
|
||||
// Not reachable
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
name,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl,
|
||||
color: profile.color,
|
||||
driver: profile.driver,
|
||||
running,
|
||||
tabCount,
|
||||
isDefault: name === current.resolved.defaultProfile,
|
||||
isRemote: !profile.cdpIsLoopback,
|
||||
missingFromConfig: !(name in current.resolved.profiles) || undefined,
|
||||
reconcileReason: profileState?.reconcile?.reason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Create default profile context for backward compatibility
|
||||
const getDefaultContext = () => forProfile();
|
||||
|
||||
const mapTabError = (err: unknown) => {
|
||||
const browserMapped = toBrowserErrorResponse(err);
|
||||
if (browserMapped) {
|
||||
return browserMapped;
|
||||
}
|
||||
if (err instanceof SsrFBlockedError) {
|
||||
return { status: 400, message: err.message };
|
||||
}
|
||||
if (err instanceof InvalidBrowserNavigationUrlError) {
|
||||
return { status: 400, message: err.message };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
forProfile,
|
||||
listProfiles,
|
||||
// Legacy methods delegate to default profile
|
||||
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
|
||||
ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId),
|
||||
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
|
||||
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
|
||||
listTabs: () => getDefaultContext().listTabs(),
|
||||
openTab: (url) => getDefaultContext().openTab(url),
|
||||
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
|
||||
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
|
||||
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
|
||||
resetProfile: () => getDefaultContext().resetProfile(),
|
||||
mapTabError,
|
||||
};
|
||||
}
|
||||
74
extensions/browser/src/browser/server-context.types.ts
Normal file
74
extensions/browser/src/browser/server-context.types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Server } from "node:http";
|
||||
import type { RunningChrome } from "./chrome.js";
|
||||
import type { BrowserTransport } from "./client.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
|
||||
export type { BrowserTab };
|
||||
|
||||
/**
|
||||
* Runtime state for a single profile's Chrome instance.
|
||||
*/
|
||||
export type ProfileRuntimeState = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
running: RunningChrome | null;
|
||||
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
|
||||
lastTargetId?: string | null;
|
||||
reconcile?: {
|
||||
previousProfile: ResolvedBrowserProfile;
|
||||
reason: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server?: Server | null;
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
};
|
||||
|
||||
type BrowserProfileActions = {
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
export type BrowserRouteContext = {
|
||||
state: () => BrowserServerState;
|
||||
forProfile: (profileName?: string) => ProfileContext;
|
||||
listProfiles: () => Promise<ProfileStatus[]>;
|
||||
// Legacy methods delegate to default profile for backward compatibility
|
||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||
} & BrowserProfileActions;
|
||||
|
||||
export type ProfileContext = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
} & BrowserProfileActions;
|
||||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
transport: BrowserTransport;
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
color: string;
|
||||
driver: ResolvedBrowserProfile["driver"];
|
||||
running: boolean;
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
missingFromConfig?: boolean;
|
||||
reconcileReason?: string | null;
|
||||
};
|
||||
|
||||
export type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||
refreshConfigFromDisk?: boolean;
|
||||
};
|
||||
47
extensions/browser/src/browser/server-lifecycle.ts
Normal file
47
extensions/browser/src/browser/server-lifecycle.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { stopOpenClawChrome } from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
listKnownProfileNames,
|
||||
} from "./server-context.js";
|
||||
|
||||
export async function ensureExtensionRelayForProfiles(_params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
onWarn: (message: string) => void;
|
||||
}) {
|
||||
// Intentional no-op: the Chrome extension relay path has been removed.
|
||||
// runtime-lifecycle still calls this helper, so keep the stub until the next
|
||||
// breaking cleanup rather than changing the call graph in a patch release.
|
||||
}
|
||||
|
||||
export async function stopKnownBrowserProfiles(params: {
|
||||
getState: () => BrowserServerState | null;
|
||||
onWarn: (message: string) => void;
|
||||
}) {
|
||||
const current = params.getState();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: params.getState,
|
||||
refreshConfigFromDisk: true,
|
||||
});
|
||||
try {
|
||||
for (const name of listKnownProfileNames(current)) {
|
||||
try {
|
||||
const runtime = current.profiles.get(name);
|
||||
if (runtime?.running) {
|
||||
await stopOpenClawChrome(runtime.running);
|
||||
runtime.running = null;
|
||||
continue;
|
||||
}
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
params.onWarn(`openclaw browser stop failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
37
extensions/browser/src/browser/server-middleware.ts
Normal file
37
extensions/browser/src/browser/server-middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Express } from "express";
|
||||
import express from "express";
|
||||
import { browserMutationGuardMiddleware } from "./csrf.js";
|
||||
import { isAuthorizedBrowserRequest } from "./http-auth.js";
|
||||
|
||||
export function installBrowserCommonMiddleware(app: Express) {
|
||||
app.use((req, res, next) => {
|
||||
const ctrl = new AbortController();
|
||||
const abort = () => ctrl.abort(new Error("request aborted"));
|
||||
req.once("aborted", abort);
|
||||
res.once("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
abort();
|
||||
}
|
||||
});
|
||||
// Make the signal available to browser route handlers (best-effort).
|
||||
(req as unknown as { signal?: AbortSignal }).signal = ctrl.signal;
|
||||
next();
|
||||
});
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(browserMutationGuardMiddleware());
|
||||
}
|
||||
|
||||
export function installBrowserAuthMiddleware(
|
||||
app: Express,
|
||||
auth: { token?: string; password?: string },
|
||||
) {
|
||||
if (!auth.token && !auth.password) {
|
||||
return;
|
||||
}
|
||||
app.use((req, res, next) => {
|
||||
if (isAuthorizedBrowserRequest(req, auth)) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).send("Unauthorized");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
getBrowserControlServerBaseUrl,
|
||||
installBrowserControlServerHooks,
|
||||
startBrowserControlServerFromConfig,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
import { getBrowserTestFetch } from "./test-fetch.js";
|
||||
|
||||
export function installAgentContractHooks() {
|
||||
installBrowserControlServerHooks();
|
||||
}
|
||||
|
||||
export async function startServerAndBase(): Promise<string> {
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = getBrowserControlServerBaseUrl();
|
||||
const realFetch = getBrowserTestFetch();
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
const realFetch = getBrowserTestFetch();
|
||||
const res = await realFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js";
|
||||
import { getFreePort } from "./test-port.js";
|
||||
|
||||
export { getFreePort } from "./test-port.js";
|
||||
|
||||
type HarnessState = {
|
||||
testPort: number;
|
||||
cdpBaseUrl: string;
|
||||
reachable: boolean;
|
||||
cfgAttachOnly: boolean;
|
||||
cfgEvaluateEnabled: boolean;
|
||||
cfgDefaultProfile: string;
|
||||
cfgProfiles: Record<
|
||||
string,
|
||||
{
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
color: string;
|
||||
driver?: "openclaw" | "existing-session";
|
||||
attachOnly?: boolean;
|
||||
}
|
||||
>;
|
||||
createTargetId: string | null;
|
||||
prevGatewayPort: string | undefined;
|
||||
prevGatewayToken: string | undefined;
|
||||
prevGatewayPassword: string | undefined;
|
||||
};
|
||||
|
||||
const state: HarnessState = {
|
||||
testPort: 0,
|
||||
cdpBaseUrl: "",
|
||||
reachable: false,
|
||||
cfgAttachOnly: false,
|
||||
cfgEvaluateEnabled: true,
|
||||
cfgDefaultProfile: "openclaw",
|
||||
cfgProfiles: {},
|
||||
createTargetId: null,
|
||||
prevGatewayPort: undefined,
|
||||
prevGatewayToken: undefined,
|
||||
prevGatewayPassword: undefined,
|
||||
};
|
||||
|
||||
export function getBrowserControlServerTestState(): HarnessState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getBrowserControlServerBaseUrl(): string {
|
||||
return `http://127.0.0.1:${state.testPort}`;
|
||||
}
|
||||
|
||||
export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void {
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerCreateTargetId(targetId: string | null): void {
|
||||
state.createTargetId = targetId;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerAttachOnly(attachOnly: boolean): void {
|
||||
state.cfgAttachOnly = attachOnly;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void {
|
||||
state.cfgEvaluateEnabled = enabled;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerReachable(reachable: boolean): void {
|
||||
state.reachable = reachable;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerProfiles(
|
||||
profiles: HarnessState["cfgProfiles"],
|
||||
defaultProfile = Object.keys(profiles)[0] ?? "openclaw",
|
||||
): void {
|
||||
state.cfgProfiles = profiles;
|
||||
state.cfgDefaultProfile = defaultProfile;
|
||||
}
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockFn } {
|
||||
return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn };
|
||||
}
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
batchViaPlaywright: vi.fn(async () => ({ results: [] })),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
traceStopViaPlaywright: vi.fn(async () => {}),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
export function getPwMocks(): Record<string, MockFn> {
|
||||
return pwMocks as unknown as Record<string, MockFn>;
|
||||
}
|
||||
|
||||
const chromeMcpMocks = vi.hoisted(() => ({
|
||||
clickChromeMcpElement: vi.fn(async () => {}),
|
||||
closeChromeMcpSession: vi.fn(async () => true),
|
||||
closeChromeMcpTab: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: vi.fn(async () => {}),
|
||||
ensureChromeMcpAvailable: vi.fn(async () => {}),
|
||||
evaluateChromeMcpScript: vi.fn(async () => true),
|
||||
fillChromeMcpElement: vi.fn(async () => {}),
|
||||
fillChromeMcpForm: vi.fn(async () => {}),
|
||||
focusChromeMcpTab: vi.fn(async () => {}),
|
||||
getChromeMcpPid: vi.fn(() => 4321),
|
||||
hoverChromeMcpElement: vi.fn(async () => {}),
|
||||
listChromeMcpTabs: vi.fn(async () => [
|
||||
{ targetId: "7", title: "", url: "https://example.com", type: "page" },
|
||||
]),
|
||||
navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })),
|
||||
openChromeMcpTab: vi.fn(async (_profile: string, url: string) => ({
|
||||
targetId: "8",
|
||||
title: "",
|
||||
url,
|
||||
type: "page",
|
||||
})),
|
||||
pressChromeMcpKey: vi.fn(async () => {}),
|
||||
resizeChromeMcpPage: vi.fn(async () => {}),
|
||||
takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")),
|
||||
takeChromeMcpSnapshot: vi.fn(async () => ({
|
||||
id: "root",
|
||||
role: "document",
|
||||
name: "Example",
|
||||
children: [{ id: "btn-1", role: "button", name: "Continue" }],
|
||||
})),
|
||||
uploadChromeMcpFile: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
export function getChromeMcpMocks(): Record<string, MockFn> {
|
||||
return chromeMcpMocks as unknown as Record<string, MockFn>;
|
||||
}
|
||||
|
||||
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
|
||||
installChromeUserDataDirHooks(chromeUserDataDir);
|
||||
|
||||
type BrowserServerModule = typeof import("./server.js");
|
||||
let browserServerModule: BrowserServerModule | null = null;
|
||||
|
||||
async function loadBrowserServerModule(): Promise<BrowserServerModule> {
|
||||
if (browserServerModule) {
|
||||
return browserServerModule;
|
||||
}
|
||||
vi.resetModules();
|
||||
browserServerModule = await import("./server.js");
|
||||
return browserServerModule;
|
||||
}
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) {
|
||||
cb(0);
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"] {
|
||||
return {
|
||||
openclaw: { cdpPort: testPort + 9, color: "#FF4500" },
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
const loadConfig = () => {
|
||||
return {
|
||||
browser: {
|
||||
enabled: true,
|
||||
evaluateEnabled: state.cfgEvaluateEnabled,
|
||||
color: "#FF4500",
|
||||
attachOnly: state.cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: state.cfgDefaultProfile,
|
||||
profiles:
|
||||
Object.keys(state.cfgProfiles).length > 0
|
||||
? state.cfgProfiles
|
||||
: defaultProfilesForState(state.testPort),
|
||||
},
|
||||
};
|
||||
};
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
return {
|
||||
...actual,
|
||||
createConfigIO: vi.fn(() => ({
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
})),
|
||||
getRuntimeConfigSnapshot: vi.fn(() => null),
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
|
||||
export function getLaunchCalls() {
|
||||
return launchCalls;
|
||||
}
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => state.reachable),
|
||||
isChromeReachable: vi.fn(async () => state.reachable),
|
||||
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
state.reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: chromeUserDataDir.dir,
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
}),
|
||||
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
|
||||
stopOpenClawChrome: vi.fn(async () => {
|
||||
state.reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, cdpPath: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("./chrome-mcp.js", () => chromeMcpMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
MEDIA_MAX_BYTES: 5 * 1024 * 1024,
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
getMediaDir: vi.fn(() => "/tmp"),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
export async function startBrowserControlServerFromConfig() {
|
||||
const server = await loadBrowserServerModule();
|
||||
return await server.startBrowserControlServerFromConfig();
|
||||
}
|
||||
|
||||
export async function stopBrowserControlServer(): Promise<void> {
|
||||
const server = browserServerModule;
|
||||
browserServerModule = null;
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await server.stopBrowserControlServer();
|
||||
}
|
||||
|
||||
export function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function mockClearAll(obj: Record<string, { mockClear: () => unknown }>) {
|
||||
for (const fn of Object.values(obj)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetBrowserControlServerTestContext(): Promise<void> {
|
||||
state.reachable = false;
|
||||
state.cfgAttachOnly = false;
|
||||
state.cfgEvaluateEnabled = true;
|
||||
state.cfgDefaultProfile = "openclaw";
|
||||
state.cfgProfiles = defaultProfilesForState(state.testPort);
|
||||
state.createTargetId = null;
|
||||
|
||||
mockClearAll(pwMocks);
|
||||
mockClearAll(cdpMocks);
|
||||
mockClearAll(chromeMcpMocks);
|
||||
|
||||
state.testPort = await getFreePort();
|
||||
state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 9}`;
|
||||
state.cfgProfiles = defaultProfilesForState(state.testPort);
|
||||
state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
|
||||
process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2);
|
||||
// Avoid flaky auth coupling: some suites temporarily set gateway env auth
|
||||
// which would make the browser control server require auth.
|
||||
state.prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
state.prevGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
}
|
||||
|
||||
export function restoreGatewayAuthEnv(
|
||||
prevGatewayToken: string | undefined,
|
||||
prevGatewayPassword: string | undefined,
|
||||
): void {
|
||||
if (prevGatewayToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken;
|
||||
}
|
||||
if (prevGatewayPassword === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = prevGatewayPassword;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupBrowserControlServerTestContext(): Promise<void> {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
restoreGatewayPortEnv(state.prevGatewayPort);
|
||||
restoreGatewayAuthEnv(state.prevGatewayToken, state.prevGatewayPassword);
|
||||
await stopBrowserControlServer();
|
||||
}
|
||||
|
||||
export function installBrowserControlServerHooks() {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (state.createTargetId) {
|
||||
return { targetId: state.createTargetId };
|
||||
}
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
await resetBrowserControlServerTestContext();
|
||||
await loadBrowserServerModule();
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!state.reachable) {
|
||||
return makeResponse([]);
|
||||
}
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) {
|
||||
return makeResponse("ok");
|
||||
}
|
||||
if (u.includes("/json/close/")) {
|
||||
return makeResponse("ok");
|
||||
}
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupBrowserControlServerTestContext();
|
||||
});
|
||||
}
|
||||
1
extensions/browser/src/browser/server.ts
Normal file
1
extensions/browser/src/browser/server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../server.js";
|
||||
189
extensions/browser/src/browser/session-tab-registry.ts
Normal file
189
extensions/browser/src/browser/session-tab-registry.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { browserCloseTab } from "./client.js";
|
||||
|
||||
export type TrackedSessionBrowserTab = {
|
||||
sessionKey: string;
|
||||
targetId: string;
|
||||
baseUrl?: string;
|
||||
profile?: string;
|
||||
trackedAt: number;
|
||||
};
|
||||
|
||||
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
|
||||
|
||||
function normalizeSessionKey(raw: string): string {
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTargetId(raw: string): string {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function normalizeProfile(raw?: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw?: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string {
|
||||
return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`;
|
||||
}
|
||||
|
||||
function isIgnorableCloseError(err: unknown): boolean {
|
||||
const message = String(err).toLowerCase();
|
||||
return (
|
||||
message.includes("tab not found") ||
|
||||
message.includes("target closed") ||
|
||||
message.includes("target not found") ||
|
||||
message.includes("no such target")
|
||||
);
|
||||
}
|
||||
|
||||
export function trackSessionBrowserTab(params: {
|
||||
sessionKey?: string;
|
||||
targetId?: string;
|
||||
baseUrl?: string;
|
||||
profile?: string;
|
||||
}): void {
|
||||
const sessionKeyRaw = params.sessionKey?.trim();
|
||||
const targetIdRaw = params.targetId?.trim();
|
||||
if (!sessionKeyRaw || !targetIdRaw) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = normalizeSessionKey(sessionKeyRaw);
|
||||
const targetId = normalizeTargetId(targetIdRaw);
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl);
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const tracked: TrackedSessionBrowserTab = {
|
||||
sessionKey,
|
||||
targetId,
|
||||
baseUrl,
|
||||
profile,
|
||||
trackedAt: Date.now(),
|
||||
};
|
||||
const trackedId = toTrackedTabId(tracked);
|
||||
let trackedForSession = trackedTabsBySession.get(sessionKey);
|
||||
if (!trackedForSession) {
|
||||
trackedForSession = new Map();
|
||||
trackedTabsBySession.set(sessionKey, trackedForSession);
|
||||
}
|
||||
trackedForSession.set(trackedId, tracked);
|
||||
}
|
||||
|
||||
export function untrackSessionBrowserTab(params: {
|
||||
sessionKey?: string;
|
||||
targetId?: string;
|
||||
baseUrl?: string;
|
||||
profile?: string;
|
||||
}): void {
|
||||
const sessionKeyRaw = params.sessionKey?.trim();
|
||||
const targetIdRaw = params.targetId?.trim();
|
||||
if (!sessionKeyRaw || !targetIdRaw) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = normalizeSessionKey(sessionKeyRaw);
|
||||
const trackedForSession = trackedTabsBySession.get(sessionKey);
|
||||
if (!trackedForSession) {
|
||||
return;
|
||||
}
|
||||
const trackedId = toTrackedTabId({
|
||||
targetId: normalizeTargetId(targetIdRaw),
|
||||
baseUrl: normalizeBaseUrl(params.baseUrl),
|
||||
profile: normalizeProfile(params.profile),
|
||||
});
|
||||
trackedForSession.delete(trackedId);
|
||||
if (trackedForSession.size === 0) {
|
||||
trackedTabsBySession.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
function takeTrackedTabsForSessionKeys(
|
||||
sessionKeys: Array<string | undefined>,
|
||||
): TrackedSessionBrowserTab[] {
|
||||
const uniqueSessionKeys = new Set<string>();
|
||||
for (const key of sessionKeys) {
|
||||
if (!key?.trim()) {
|
||||
continue;
|
||||
}
|
||||
uniqueSessionKeys.add(normalizeSessionKey(key));
|
||||
}
|
||||
if (uniqueSessionKeys.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const seenTrackedIds = new Set<string>();
|
||||
const tabs: TrackedSessionBrowserTab[] = [];
|
||||
for (const sessionKey of uniqueSessionKeys) {
|
||||
const trackedForSession = trackedTabsBySession.get(sessionKey);
|
||||
if (!trackedForSession || trackedForSession.size === 0) {
|
||||
continue;
|
||||
}
|
||||
trackedTabsBySession.delete(sessionKey);
|
||||
for (const tracked of trackedForSession.values()) {
|
||||
const trackedId = toTrackedTabId(tracked);
|
||||
if (seenTrackedIds.has(trackedId)) {
|
||||
continue;
|
||||
}
|
||||
seenTrackedIds.add(trackedId);
|
||||
tabs.push(tracked);
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export async function closeTrackedBrowserTabsForSessions(params: {
|
||||
sessionKeys: Array<string | undefined>;
|
||||
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
|
||||
onWarn?: (message: string) => void;
|
||||
}): Promise<number> {
|
||||
const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys);
|
||||
if (tabs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const closeTab =
|
||||
params.closeTab ??
|
||||
(async (tab: { targetId: string; baseUrl?: string; profile?: string }) => {
|
||||
await browserCloseTab(tab.baseUrl, tab.targetId, {
|
||||
profile: tab.profile,
|
||||
});
|
||||
});
|
||||
let closed = 0;
|
||||
for (const tab of tabs) {
|
||||
try {
|
||||
await closeTab({
|
||||
targetId: tab.targetId,
|
||||
baseUrl: tab.baseUrl,
|
||||
profile: tab.profile,
|
||||
});
|
||||
closed += 1;
|
||||
} catch (err) {
|
||||
if (!isIgnorableCloseError(err)) {
|
||||
params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
export function __resetTrackedSessionBrowserTabsForTests(): void {
|
||||
trackedTabsBySession.clear();
|
||||
}
|
||||
|
||||
export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number {
|
||||
if (typeof sessionKey === "string" && sessionKey.trim()) {
|
||||
return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0;
|
||||
}
|
||||
let count = 0;
|
||||
for (const tracked of trackedTabsBySession.values()) {
|
||||
count += tracked.size;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
63
extensions/browser/src/browser/snapshot-roles.ts
Normal file
63
extensions/browser/src/browser/snapshot-roles.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared ARIA role classification sets used by both the Playwright and Chrome MCP
|
||||
* snapshot paths. Keep these in sync — divergence causes the two drivers to produce
|
||||
* different snapshot output for the same page.
|
||||
*/
|
||||
|
||||
/** Roles that represent user-interactive elements and always get a ref. */
|
||||
export const INTERACTIVE_ROLES = new Set([
|
||||
"button",
|
||||
"checkbox",
|
||||
"combobox",
|
||||
"link",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"radio",
|
||||
"searchbox",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"switch",
|
||||
"tab",
|
||||
"textbox",
|
||||
"treeitem",
|
||||
]);
|
||||
|
||||
/** Roles that carry meaningful content and get a ref when named. */
|
||||
export const CONTENT_ROLES = new Set([
|
||||
"article",
|
||||
"cell",
|
||||
"columnheader",
|
||||
"gridcell",
|
||||
"heading",
|
||||
"listitem",
|
||||
"main",
|
||||
"navigation",
|
||||
"region",
|
||||
"rowheader",
|
||||
]);
|
||||
|
||||
/** Structural/container roles — typically skipped in compact mode. */
|
||||
export const STRUCTURAL_ROLES = new Set([
|
||||
"application",
|
||||
"directory",
|
||||
"document",
|
||||
"generic",
|
||||
"grid",
|
||||
"group",
|
||||
"ignored",
|
||||
"list",
|
||||
"menu",
|
||||
"menubar",
|
||||
"none",
|
||||
"presentation",
|
||||
"row",
|
||||
"rowgroup",
|
||||
"table",
|
||||
"tablist",
|
||||
"toolbar",
|
||||
"tree",
|
||||
"treegrid",
|
||||
]);
|
||||
30
extensions/browser/src/browser/target-id.ts
Normal file
30
extensions/browser/src/browser/target-id.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type TargetIdResolution =
|
||||
| { ok: true; targetId: string }
|
||||
| { ok: false; reason: "not_found" | "ambiguous"; matches?: string[] };
|
||||
|
||||
export function resolveTargetIdFromTabs(
|
||||
input: string,
|
||||
tabs: Array<{ targetId: string }>,
|
||||
): TargetIdResolution {
|
||||
const needle = input.trim();
|
||||
if (!needle) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
const exact = tabs.find((t) => t.targetId === needle);
|
||||
if (exact) {
|
||||
return { ok: true, targetId: exact.targetId };
|
||||
}
|
||||
|
||||
const lower = needle.toLowerCase();
|
||||
const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower));
|
||||
|
||||
const only = matches.length === 1 ? matches[0] : undefined;
|
||||
if (only) {
|
||||
return { ok: true, targetId: only };
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
return { ok: false, reason: "ambiguous", matches };
|
||||
}
|
||||
30
extensions/browser/src/browser/test-fetch.ts
Normal file
30
extensions/browser/src/browser/test-fetch.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
type FetchLike = ((input: string | URL, init?: RequestInit) => Promise<Response>) & {
|
||||
mock?: unknown;
|
||||
};
|
||||
|
||||
export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function isUsableFetch(value: unknown): value is FetchLike {
|
||||
return typeof value === "function" && !("mock" in (value as FetchLike));
|
||||
}
|
||||
|
||||
export function getBrowserTestFetch(): BrowserTestFetch {
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitest = (globalThis as { vi?: { doUnmock?: (id: string) => void } }).vi;
|
||||
vitest?.doUnmock?.("undici");
|
||||
try {
|
||||
delete require.cache[require.resolve("undici")];
|
||||
} catch {
|
||||
// Best-effort cache bust for shared-thread test workers.
|
||||
}
|
||||
const { fetch } = require("undici") as typeof import("undici");
|
||||
if (isUsableFetch(fetch)) {
|
||||
return (input, init) => fetch(input, init);
|
||||
}
|
||||
if (isUsableFetch(globalThis.fetch)) {
|
||||
return (input, init) => globalThis.fetch(input, init);
|
||||
}
|
||||
throw new TypeError("fetch is not a function");
|
||||
}
|
||||
18
extensions/browser/src/browser/test-port.ts
Normal file
18
extensions/browser/src/browser/test-port.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user