diff --git a/docs/advanced/cdp.md b/docs/advanced/cdp.md
index 09079f31..aca13d07 100644
--- a/docs/advanced/cdp.md
+++ b/docs/advanced/cdp.md
@@ -101,3 +101,22 @@ opencli bilibili hot --limit 5 # Test a command
> *Tip: If you provide a standard HTTP/HTTPS CDP endpoint, OpenCLI requests the `/json` target list and picks the most likely inspectable app/page target automatically. If multiple app targets exist, you can further narrow selection with `OPENCLI_CDP_TARGET` (for example `antigravity` or `codex`).*
If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
+
+## Local Shortcut: `--browser-cdp`
+
+For local browser-backed commands, you can bypass the daemon/extension path explicitly with `--browser-cdp`.
+
+```bash
+opencli cascade https://news.ycombinator.com --browser-cdp
+opencli explore https://linux.do --browser-cdp
+```
+
+When this flag is present, OpenCLI forces the browser command through Chrome CDP directly. If `OPENCLI_CDP_ENDPOINT` is not already set, OpenCLI will try to auto-discover a local Chrome/Edge debugging session from `DevToolsActivePort`.
+
+If you want this behavior globally, set:
+
+```bash
+export OPENCLI_BROWSER_CDP=1
+```
+
+With that environment variable enabled, browser-backed commands use the same direct-CDP path by default. You can still disable it for one command with `--no-browser-cdp`.
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index e5d310fe..181850c8 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -6,6 +6,8 @@
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- Run `opencli doctor` to diagnose connectivity.
+- If Chrome is already running with `--remote-debugging-port`, browser-backed commands can bypass the daemon path explicitly with `--browser-cdp`.
+- If you want browser-backed commands to use that path by default, set `OPENCLI_BROWSER_CDP=1`.
### Empty data or 'Unauthorized' error
diff --git a/docs/images/pr-408-browser-cdp-mode.svg b/docs/images/pr-408-browser-cdp-mode.svg
new file mode 100644
index 00000000..fab98267
--- /dev/null
+++ b/docs/images/pr-408-browser-cdp-mode.svg
@@ -0,0 +1,27 @@
+
diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts
index 480f32ae..48543e50 100644
--- a/src/browser/cdp.test.ts
+++ b/src/browser/cdp.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'node:fs';
const { MockWebSocket } = vi.hoisted(() => {
class MockWebSocket {
@@ -36,7 +37,15 @@ vi.mock('ws', () => ({
WebSocket: MockWebSocket,
}));
-import { CDPBridge } from './cdp.js';
+import { CDPBridge, __test__ } from './cdp.js';
+
+function clearPersistentTargetRegistry(): void {
+ try {
+ fs.unlinkSync(__test__.persistentTargetRegistryPath);
+ } catch {
+ // Ignore missing file.
+ }
+}
describe('CDPBridge cookies', () => {
beforeEach(() => {
@@ -64,3 +73,364 @@ describe('CDPBridge cookies', () => {
]);
});
});
+
+describe('CDP browser websocket helpers', () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ clearPersistentTargetRegistry();
+ });
+
+ it('accepts browser-level targets that only expose targetId', () => {
+ const target = __test__.selectCDPTarget([
+ {
+ targetId: 'page-1',
+ type: 'page',
+ title: 'Hacker News',
+ url: 'https://news.ycombinator.com/',
+ },
+ {
+ targetId: 'devtools-1',
+ type: 'page',
+ title: 'DevTools - localhost:9222',
+ url: 'devtools://devtools/bundled/inspector.html',
+ },
+ ]);
+
+ expect(target?.targetId).toBe('page-1');
+ });
+
+ it('keeps browser-level attach compatible with app and webview targets', () => {
+ const target = __test__.selectBrowserAttachTarget([
+ {
+ targetId: 'app-1',
+ type: 'app',
+ title: 'Codex Desktop',
+ url: 'file:///app/index.html',
+ },
+ {
+ targetId: 'worker-1',
+ type: 'service_worker',
+ title: 'Service Worker',
+ url: 'https://news.ycombinator.com/sw.js',
+ },
+ {
+ targetId: 'page-1',
+ type: 'page',
+ title: 'Hacker News',
+ url: 'https://news.ycombinator.com/',
+ },
+ ]);
+
+ expect(target?.targetId).toBe('app-1');
+ });
+
+ it('parses browser websocket URLs from DevToolsActivePort content', () => {
+ const wsUrl = __test__.parseBrowserWebSocketUrlFromActivePort(
+ '9222',
+ '127.0.0.1',
+ '9222\n/devtools/browser/abc-123\n',
+ );
+
+ expect(wsUrl).toBe('ws://127.0.0.1:9222/devtools/browser/abc-123');
+ });
+
+ it('parses auto-discovered browser websocket URLs from DevToolsActivePort content', () => {
+ const wsUrl = __test__.parseAnyBrowserWebSocketUrlFromActivePort(
+ '9333\n/devtools/browser/abc-123\n',
+ '127.0.0.1',
+ );
+
+ expect(wsUrl).toBe('ws://127.0.0.1:9333/devtools/browser/abc-123');
+ });
+
+ it('extracts browser websocket URLs from /json/version payloads', () => {
+ expect(__test__.extractBrowserWebSocketUrlFromVersionPayload({
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9222/devtools/browser/abc',
+ })).toBe('ws://127.0.0.1:9222/devtools/browser/abc');
+ });
+
+ it('rewrites loopback /json/version browser websocket URLs onto proxied endpoints', () => {
+ expect(__test__.rewriteBrowserWebSocketUrlForEndpoint(
+ 'https://demo.ngrok.app',
+ 'ws://127.0.0.1:9222/devtools/browser/abc',
+ )).toBe('wss://demo.ngrok.app/devtools/browser/abc');
+ });
+
+ it('rewrites loopback /json page websocket URLs onto proxied endpoints too', () => {
+ expect(__test__.rewriteBrowserWebSocketUrlForEndpoint(
+ 'https://demo.ngrok.app',
+ 'ws://127.0.0.1:9222/devtools/page/abc',
+ )).toBe('wss://demo.ngrok.app/devtools/page/abc');
+ });
+
+ it('detects browser-level websocket endpoints', () => {
+ expect(__test__.isBrowserLevelWebSocket('ws://127.0.0.1:9222/devtools/browser/abc')).toBe(true);
+ expect(__test__.isBrowserLevelWebSocket('ws://127.0.0.1:9222/devtools/page/abc')).toBe(false);
+ });
+
+ it('accepts IPv6 loopback hosts for local browser websocket fallback', () => {
+ expect(__test__.isLoopbackHost('[::1]')).toBe(true);
+ expect(__test__.isLoopbackHost('::1')).toBe(true);
+ expect(__test__.isLoopbackHost('192.168.0.10')).toBe(false);
+ });
+
+ it('prefers a fresh target only for auto-discovered browser sessions without an explicit target hint', () => {
+ expect(__test__.shouldPreferNewBrowserTarget('auto')).toBe(true);
+
+ vi.stubEnv('OPENCLI_CDP_TARGET', 'linux.do');
+ expect(__test__.shouldPreferNewBrowserTarget('auto')).toBe(false);
+ expect(__test__.shouldPreferNewBrowserTarget('http://127.0.0.1:9222')).toBe(false);
+ });
+
+ it('normalizes blank workspaces away before persistence decisions', () => {
+ expect(__test__.normalizeWorkspaceKey(' site:twitter ')).toBe('site:twitter');
+ expect(__test__.normalizeWorkspaceKey(' ')).toBeUndefined();
+ });
+
+ it('matches cached browser targets for app/webview tabs too', () => {
+ const target = __test__.selectTargetById([
+ {
+ targetId: 'webview-1',
+ type: 'webview',
+ title: 'Embedded App',
+ url: 'https://embedded.example/app',
+ },
+ ], 'webview-1');
+
+ expect(target?.targetId).toBe('webview-1');
+ });
+});
+
+describe('CDPBridge lifecycle', () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ clearPersistentTargetRegistry();
+ });
+
+ it('closes owned blank targets before disconnecting', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+ bridge._ownedTargetId = 'target-1';
+ bridge._closeOwnedTargetOnClose = true;
+
+ const sendSpy = vi.spyOn(bridge, 'send').mockResolvedValue({ success: true });
+
+ await bridge.close();
+
+ expect(sendSpy).toHaveBeenCalledWith(
+ 'Target.closeTarget',
+ { targetId: 'target-1' },
+ expect.any(Number),
+ { root: true },
+ );
+ });
+
+ it('keeps workspace-persistent targets open on disconnect', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+ bridge._ownedTargetId = 'target-1';
+ bridge._closeOwnedTargetOnClose = false;
+
+ const sendSpy = vi.spyOn(bridge, 'send').mockResolvedValue({ success: true });
+
+ await bridge.close();
+
+ expect(sendSpy).not.toHaveBeenCalledWith(
+ 'Target.closeTarget',
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('resets internal command ids on close', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+ bridge._ownedTargetId = 'target-1';
+ bridge._closeOwnedTargetOnClose = true;
+ bridge._idCounter = 42;
+
+ vi.spyOn(bridge, 'send').mockResolvedValue({ success: true });
+
+ await bridge.close();
+
+ expect(bridge._idCounter).toBe(0);
+ });
+
+ it('reuses a stored workspace target before creating a new browser target', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+
+ fs.writeFileSync(
+ __test__.persistentTargetRegistryPath,
+ JSON.stringify({
+ [__test__.makePersistentTargetRegistryKey('auto', 'site:twitter')]: 'target-keep',
+ }),
+ 'utf8',
+ );
+
+ const sendSpy = vi.spyOn(bridge, 'send').mockImplementation(async (...args: unknown[]) => {
+ const method = args[0] as string;
+ switch (method) {
+ case 'Target.getTargets':
+ return {
+ targetInfos: [
+ { targetId: 'target-keep', type: 'page', title: 'X', url: 'https://x.com/home' },
+ ],
+ };
+ case 'Target.activateTarget':
+ return {};
+ case 'Target.attachToTarget':
+ return { sessionId: 'session-1' };
+ default:
+ return {};
+ }
+ });
+
+ await bridge.attachToBrowserTarget({
+ preferNewTarget: true,
+ workspace: 'site:twitter',
+ endpointKey: 'auto',
+ });
+
+ expect(sendSpy).not.toHaveBeenCalledWith(
+ 'Target.createTarget',
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(bridge._sessionId).toBe('session-1');
+ });
+
+ it('lets explicit target hints override a stored workspace target', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+
+ vi.stubEnv('OPENCLI_CDP_TARGET', 'second');
+ fs.writeFileSync(
+ __test__.persistentTargetRegistryPath,
+ JSON.stringify({
+ [__test__.makePersistentTargetRegistryKey('auto', 'site:twitter')]: 'target-keep',
+ }),
+ 'utf8',
+ );
+
+ const activated: string[] = [];
+ vi.spyOn(bridge, 'send').mockImplementation(async (...args: unknown[]) => {
+ const method = args[0] as string;
+ const params = args[1] as Record | undefined;
+ switch (method) {
+ case 'Target.getTargets':
+ return {
+ targetInfos: [
+ { targetId: 'target-keep', type: 'page', title: 'First Tab', url: 'https://x.com/home' },
+ { targetId: 'target-second', type: 'page', title: 'Second Match', url: 'https://second.example' },
+ ],
+ };
+ case 'Target.activateTarget':
+ if (params && typeof params.targetId === 'string') activated.push(params.targetId);
+ return {};
+ case 'Target.attachToTarget':
+ return { sessionId: 'session-2' };
+ default:
+ return {};
+ }
+ });
+
+ await bridge.attachToBrowserTarget({
+ preferNewTarget: true,
+ workspace: 'site:twitter',
+ endpointKey: 'auto',
+ });
+
+ expect(activated).toEqual(['target-second']);
+ expect(fs.readFileSync(__test__.persistentTargetRegistryPath, 'utf8')).toContain('"auto::site:twitter": "target-second"');
+ });
+
+ it('persists newly created workspace targets for later reuse', async () => {
+ const bridge = new CDPBridge() as any;
+ bridge._ws = { readyState: MockWebSocket.OPEN, close: vi.fn() };
+
+ vi.spyOn(bridge, 'send').mockImplementation(async (...args: unknown[]) => {
+ const method = args[0] as string;
+ switch (method) {
+ case 'Target.getTargets':
+ return { targetInfos: [] };
+ case 'Target.createTarget':
+ return { targetId: 'target-new' };
+ case 'Target.activateTarget':
+ return {};
+ case 'Target.attachToTarget':
+ return { sessionId: 'session-1' };
+ default:
+ return {};
+ }
+ });
+
+ await bridge.attachToBrowserTarget({
+ preferNewTarget: true,
+ workspace: 'site:twitter',
+ endpointKey: 'auto',
+ });
+
+ expect(fs.existsSync(__test__.persistentTargetRegistryPath)).toBe(true);
+ expect(fs.readFileSync(__test__.persistentTargetRegistryPath, 'utf8')).toContain('"auto::site:twitter": "target-new"');
+ expect(bridge._closeOwnedTargetOnClose).toBe(false);
+ });
+});
+
+describe('CDPPage navigation', () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('surfaces Page.navigate errorText directly', async () => {
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
+
+ const bridge = new CDPBridge();
+ vi.spyOn(bridge, 'send')
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({ errorText: 'net::ERR_NAME_NOT_RESOLVED' });
+ vi.spyOn(bridge, 'waitForEvent').mockRejectedValue(new Error('should not be awaited after errorText'));
+
+ const page = await bridge.connect();
+
+ await expect(page.goto('http://oops-typo.com')).rejects.toThrow('net::ERR_NAME_NOT_RESOLVED');
+ });
+
+ it('does not leave an unhandled rejection behind when Page.navigate throws', async () => {
+ vi.useFakeTimers();
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
+
+ const bridge = new CDPBridge();
+ vi.spyOn(bridge, 'send')
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({})
+ .mockRejectedValueOnce(new Error('Target closed'));
+ vi.spyOn(bridge, 'waitForEvent').mockImplementation(() =>
+ new Promise((_, reject) => setTimeout(() => reject(new Error('load timeout')), 10))
+ );
+
+ const unhandled: unknown[] = [];
+ const onUnhandled = (reason: unknown) => {
+ unhandled.push(reason);
+ };
+ process.on('unhandledRejection', onUnhandled);
+
+ try {
+ const page = await bridge.connect();
+ await expect(page.goto('https://example.com')).rejects.toThrow('Target closed');
+ await vi.runAllTimersAsync();
+ await Promise.resolve();
+ expect(unhandled).toEqual([]);
+ } finally {
+ process.off('unhandledRejection', onUnhandled);
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts
index 14b1054f..31c30ad1 100644
--- a/src/browser/cdp.ts
+++ b/src/browser/cdp.ts
@@ -11,6 +11,9 @@
import { WebSocket, type RawData } from 'ws';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
@@ -25,9 +28,9 @@ import {
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
-import { isRecord, saveBase64ToFile } from '../utils.js';
export interface CDPTarget {
+ targetId?: string;
type?: string;
url?: string;
title?: string;
@@ -45,10 +48,15 @@ interface RuntimeEvaluateResult {
};
}
-const CDP_SEND_TIMEOUT = 30_000;
+const CDP_SEND_TIMEOUT = 30_000; // 30s per command
+const CDP_CLOSE_TIMEOUT = 1_500;
+const PERSISTENT_TARGET_REGISTRY_PATH = path.join(os.tmpdir(), 'opencli-cdp-targets.json');
export class CDPBridge {
private _ws: WebSocket | null = null;
+ private _sessionId: string | null = null;
+ private _ownedTargetId: string | null = null;
+ private _closeOwnedTargetOnClose = false;
private _idCounter = 0;
private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>();
private _eventListeners = new Map void>>();
@@ -59,28 +67,37 @@ export class CDPBridge {
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
- let wsUrl = endpoint;
- if (endpoint.startsWith('http')) {
- const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[];
- const target = selectCDPTarget(targets);
- if (!target || !target.webSocketDebuggerUrl) {
- throw new Error('No inspectable targets found at CDP endpoint');
- }
- wsUrl = target.webSocketDebuggerUrl;
- }
+ const connection = await resolveConnectionEndpoint(endpoint);
return new Promise((resolve, reject) => {
- const ws = new WebSocket(wsUrl);
- const timeoutMs = (opts?.timeout ?? 10) * 1000;
+ const ws = new WebSocket(connection.wsUrl);
+ const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
ws.on('open', async () => {
- clearTimeout(timeout);
- this._ws = ws;
try {
+ clearTimeout(timeout);
+ this._ws = ws;
+ if (connection.browserLevel) {
+ await this.attachToBrowserTarget({
+ preferNewTarget: connection.preferNewTarget,
+ workspace: normalizeWorkspaceKey(opts?.workspace),
+ endpointKey: endpoint,
+ });
+ }
+ } catch (err) {
+ await this.close().catch(() => {});
+ reject(err);
+ return;
+ }
+
+ try {
+ // Register stealth script to run before any page JS on every navigation.
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
- } catch {}
+ } catch {
+ // Non-fatal: stealth is best-effort
+ }
resolve(new CDPPage(this));
});
@@ -92,6 +109,7 @@ export class CDPBridge {
ws.on('message', (data: RawData) => {
try {
const msg = JSON.parse(data.toString());
+ // Handle command responses
if (msg.id && this._pending.has(msg.id)) {
const entry = this._pending.get(msg.id)!;
clearTimeout(entry.timer);
@@ -102,31 +120,50 @@ export class CDPBridge {
entry.resolve(msg.result);
}
}
+ // Handle CDP events
if (msg.method) {
+ if (this._sessionId && msg.sessionId && msg.sessionId !== this._sessionId) {
+ return;
+ }
const listeners = this._eventListeners.get(msg.method);
if (listeners) {
for (const fn of listeners) fn(msg.params);
}
}
- } catch {}
+ } catch {
+ // ignore parsing errors
+ }
});
});
}
async close(): Promise {
+ if (this._ownedTargetId && this._closeOwnedTargetOnClose && this._ws && this._ws.readyState === WebSocket.OPEN) {
+ await this.send('Target.closeTarget', { targetId: this._ownedTargetId }, CDP_CLOSE_TIMEOUT, { root: true }).catch(() => {});
+ }
+ this._ownedTargetId = null;
+ this._closeOwnedTargetOnClose = false;
if (this._ws) {
this._ws.close();
this._ws = null;
}
+ this._sessionId = null;
for (const p of this._pending.values()) {
clearTimeout(p.timer);
p.reject(new Error('CDP connection closed'));
}
this._pending.clear();
this._eventListeners.clear();
+ this._idCounter = 0;
}
- async send(method: string, params: Record = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise {
+ /** Send a CDP command with timeout guard (P0 fix #4) */
+ async send(
+ method: string,
+ params: Record = {},
+ timeoutMs: number = CDP_SEND_TIMEOUT,
+ opts: { root?: boolean } = {},
+ ): Promise {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('CDP connection is not open');
}
@@ -137,23 +174,27 @@ export class CDPBridge {
reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
}, timeoutMs);
this._pending.set(id, { resolve, reject, timer });
- this._ws!.send(JSON.stringify({ id, method, params }));
+ const payload: Record = { id, method, params };
+ if (this._sessionId && !opts.root) {
+ payload.sessionId = this._sessionId;
+ }
+ this._ws!.send(JSON.stringify(payload));
});
}
+ /** Listen for a CDP event */
on(event: string, handler: (params: unknown) => void): void {
let set = this._eventListeners.get(event);
- if (!set) {
- set = new Set();
- this._eventListeners.set(event, set);
- }
+ if (!set) { set = new Set(); this._eventListeners.set(event, set); }
set.add(handler);
}
+ /** Remove a CDP event listener */
off(event: string, handler: (params: unknown) => void): void {
this._eventListeners.get(event)?.delete(handler);
}
+ /** Wait for a CDP event to fire (one-shot) */
waitForEvent(event: string, timeoutMs: number = 15_000): Promise {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -168,20 +209,97 @@ export class CDPBridge {
this.on(event, handler);
});
}
+
+ private async attachToBrowserTarget(opts: { preferNewTarget: boolean; workspace?: string; endpointKey: string }): Promise {
+ let targetId: string | undefined;
+ const targetInfos = await this.listBrowserTargets();
+
+ if (hasExplicitTargetHint()) {
+ const hintedTarget = selectBrowserAttachTarget(targetInfos);
+ targetId = hintedTarget?.targetId;
+ if (targetId && opts.workspace) {
+ setPersistentTargetId(opts.endpointKey, opts.workspace, targetId);
+ }
+ } else if (opts.workspace) {
+ const storedTargetId = getPersistentTargetId(opts.endpointKey, opts.workspace);
+ const storedTarget = selectTargetById(targetInfos, storedTargetId);
+ if (storedTarget?.targetId) {
+ targetId = storedTarget.targetId;
+ } else if (storedTargetId) {
+ clearPersistentTargetId(opts.endpointKey, opts.workspace);
+ }
+ }
+
+ if (!targetId && opts.preferNewTarget) {
+ const created = await this.send('Target.createTarget', { url: 'about:blank' }, CDP_SEND_TIMEOUT, { root: true });
+ targetId = isRecord(created) && typeof created.targetId === 'string' ? created.targetId : undefined;
+ if (targetId) {
+ this._ownedTargetId = targetId;
+ this._closeOwnedTargetOnClose = !opts.workspace;
+ if (opts.workspace) {
+ setPersistentTargetId(opts.endpointKey, opts.workspace, targetId);
+ }
+ }
+ }
+
+ if (!targetId) {
+ const target = selectBrowserAttachTarget(targetInfos);
+ targetId = target?.targetId;
+ }
+
+ if (!targetId) {
+ throw new Error('No inspectable targets found at CDP endpoint');
+ }
+
+ await this.send('Target.activateTarget', { targetId }, CDP_SEND_TIMEOUT, { root: true }).catch(() => {});
+ const attachResult = await this.send('Target.attachToTarget', {
+ targetId,
+ flatten: true,
+ }, CDP_SEND_TIMEOUT, { root: true });
+ const sessionId = isRecord(attachResult) ? attachResult.sessionId : undefined;
+ if (typeof sessionId !== 'string' || !sessionId) {
+ throw new Error(`Failed to attach to CDP target '${targetId}'`);
+ }
+ this._sessionId = sessionId;
+ }
+
+ private async listBrowserTargets(): Promise {
+ const result = await this.send('Target.getTargets', {}, CDP_SEND_TIMEOUT, { root: true });
+ return isRecord(result) && Array.isArray(result.targetInfos)
+ ? result.targetInfos as CDPTarget[]
+ : [];
+ }
}
class CDPPage implements IPage {
private _pageEnabled = false;
constructor(private bridge: CDPBridge) {}
+ /** Navigate with proper load event waiting (P1 fix #3) */
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise {
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
this._pageEnabled = true;
}
- const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {});
- await this.bridge.send('Page.navigate', { url });
- await loadPromise;
+ const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000);
+ // Guard the event wait immediately so a navigation transport failure does not
+ // leave a late unhandled rejection behind.
+ void loadPromise.catch(() => {});
+ const navigateResult = await this.bridge.send('Page.navigate', { url });
+ const navigationError = isRecord(navigateResult) && typeof navigateResult.errorText === 'string'
+ ? navigateResult.errorText.trim()
+ : '';
+ if (navigationError) {
+ throw new Error(`Navigation failed for ${url}: ${navigationError}`);
+ }
+ try {
+ await loadPromise;
+ } catch (error) {
+ logVerbose(`[cdp] Timed out waiting for Page.loadEventFired after navigating to ${url}`, normalizeError(error));
+ // Do not fail on load timeout alone; SPAs and long-polling pages may never emit a clean load event.
+ }
+ // Smart settle: use DOM stability detection instead of fixed sleep.
+ // settleMs is now a timeout cap (default 1000ms), not a fixed wait.
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
@@ -193,7 +311,7 @@ class CDPPage implements IPage {
const result = await this.bridge.send('Runtime.evaluate', {
expression,
returnByValue: true,
- awaitPromise: true,
+ awaitPromise: true
}) as RuntimeEvaluateResult;
if (result.exceptionDetails) {
throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
@@ -222,6 +340,8 @@ class CDPPage implements IPage {
return this.evaluate(snapshotJs);
}
+ // ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
+
async click(ref: string): Promise {
await this.evaluate(clickJs(ref));
}
@@ -244,12 +364,12 @@ class CDPPage implements IPage {
async wait(options: number | WaitOptions): Promise {
if (typeof options === 'number') {
- await new Promise((resolve) => setTimeout(resolve, options * 1000));
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (typeof options.time === 'number') {
const waitTime = options.time;
- await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
+ await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
return;
}
if (options.text) {
@@ -258,6 +378,8 @@ class CDPPage implements IPage {
}
}
+ // ── Implemented methods (P1 fix #2) ──
+
async scroll(direction: string = 'down', amount: number = 500): Promise {
await this.evaluate(scrollJs(direction, amount));
}
@@ -295,7 +417,7 @@ class CDPPage implements IPage {
}
async newTab(): Promise {
- await this.bridge.send('Target.createTarget', { url: 'about:blank' });
+ await this.bridge.send('Target.createTarget', { url: 'about:blank' }, CDP_SEND_TIMEOUT, { root: true });
}
async selectTab(_index: number): Promise {
@@ -321,6 +443,8 @@ class CDPPage implements IPage {
}
}
+import { isRecord, saveBase64ToFile } from '../utils.js';
+
function isCookie(value: unknown): value is BrowserCookie {
return isRecord(value)
&& typeof value.name === 'string'
@@ -335,6 +459,268 @@ function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolea
|| normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`);
}
+async function resolveConnectionEndpoint(
+ endpoint: string,
+): Promise<{ wsUrl: string; browserLevel: boolean; preferNewTarget: boolean }> {
+ if (endpoint === 'auto') {
+ const wsUrl = resolveAnyBrowserWebSocketUrl();
+ if (!wsUrl) {
+ throw new Error('Failed to auto-discover a local browser CDP websocket. Start Chrome with --remote-debugging-port and retry, or set OPENCLI_CDP_ENDPOINT explicitly.');
+ }
+ return {
+ wsUrl,
+ browserLevel: true,
+ preferNewTarget: shouldPreferNewBrowserTarget(endpoint),
+ };
+ }
+
+ if (endpoint.startsWith('ws://') || endpoint.startsWith('wss://')) {
+ const browserLevel = isBrowserLevelWebSocket(endpoint);
+ return {
+ wsUrl: endpoint,
+ browserLevel,
+ preferNewTarget: browserLevel && shouldPreferNewBrowserTarget(endpoint),
+ };
+ }
+
+ if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://')) {
+ return { wsUrl: endpoint, browserLevel: false, preferNewTarget: false };
+ }
+
+ const normalized = endpoint.replace(/\/$/, '');
+ let discoveryError: Error | undefined;
+ let versionError: Error | undefined;
+
+ try {
+ const targets = await fetchJsonDirect(`${normalized}/json`) as CDPTarget[];
+ const target = selectCDPTarget(targets);
+ if (!target?.webSocketDebuggerUrl) {
+ throw new Error('No inspectable targets found at CDP endpoint');
+ }
+ return {
+ wsUrl: rewriteBrowserWebSocketUrlForEndpoint(normalized, target.webSocketDebuggerUrl) ?? target.webSocketDebuggerUrl,
+ browserLevel: false,
+ preferNewTarget: false,
+ };
+ } catch (error) {
+ discoveryError = normalizeError(error);
+ logVerbose(`[cdp] Failed to resolve page websocket from ${normalized}/json`, discoveryError);
+ }
+
+ try {
+ const payload = await fetchJsonDirect(`${normalized}/json/version`);
+ const browserWsUrl = rewriteBrowserWebSocketUrlForEndpoint(
+ normalized,
+ extractBrowserWebSocketUrlFromVersionPayload(payload),
+ );
+ if (browserWsUrl) {
+ return {
+ wsUrl: browserWsUrl,
+ browserLevel: true,
+ preferNewTarget: shouldPreferNewBrowserTarget(endpoint),
+ };
+ }
+ } catch (error) {
+ versionError = normalizeError(error);
+ logVerbose(`[cdp] Failed to resolve browser websocket from ${normalized}/json/version`, versionError);
+ }
+
+ const browserWsUrl = resolveBrowserWebSocketUrl(normalized);
+ if (browserWsUrl) {
+ return {
+ wsUrl: browserWsUrl,
+ browserLevel: true,
+ preferNewTarget: shouldPreferNewBrowserTarget(endpoint),
+ };
+ }
+
+ const detail = [discoveryError?.message, versionError?.message].filter(Boolean).join('; ');
+ throw new Error(detail
+ ? `Failed to resolve an inspectable target from CDP endpoint (${detail})`
+ : 'Failed to resolve an inspectable target from CDP endpoint');
+}
+
+function resolveBrowserWebSocketUrl(endpoint: string): string | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(endpoint);
+ } catch {
+ return null;
+ }
+
+ const host = parsed.hostname.toLowerCase();
+ if (!isLoopbackHost(host)) return null;
+ const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
+
+ for (const filePath of getDevToolsActivePortCandidates()) {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const wsUrl = parseBrowserWebSocketUrlFromActivePort(port, host, content);
+ if (wsUrl) return wsUrl;
+ } catch {
+ // Try the next candidate.
+ }
+ }
+
+ return null;
+}
+
+function resolveAnyBrowserWebSocketUrl(host: string = '127.0.0.1'): string | null {
+ for (const filePath of getDevToolsActivePortCandidates()) {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const wsUrl = parseAnyBrowserWebSocketUrlFromActivePort(content, host);
+ if (wsUrl) return wsUrl;
+ } catch {
+ // Try the next candidate.
+ }
+ }
+
+ return null;
+}
+
+function parseBrowserWebSocketUrlFromActivePort(port: string, host: string, content: string): string | null {
+ const lines = content.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean);
+ if (lines.length < 2) return null;
+ if (lines[0] !== port) return null;
+ if (!lines[1].startsWith('/devtools/browser/')) return null;
+ return `ws://${formatHostForUrl(host)}:${port}${lines[1]}`;
+}
+
+function parseAnyBrowserWebSocketUrlFromActivePort(content: string, host: string): string | null {
+ const lines = content.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean);
+ if (lines.length < 2) return null;
+ if (!/^\d+$/.test(lines[0])) return null;
+ if (!lines[1].startsWith('/devtools/browser/')) return null;
+ return `ws://${formatHostForUrl(host)}:${lines[0]}${lines[1]}`;
+}
+
+function getDevToolsActivePortCandidates(): string[] {
+ const candidates: string[] = [];
+ if (process.platform === 'win32') {
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
+ candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
+ candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
+ } else if (process.platform === 'darwin') {
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
+ } else {
+ candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
+ candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
+ candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
+ }
+ return candidates;
+}
+
+function isBrowserLevelWebSocket(endpoint: string): boolean {
+ return endpoint.includes('/devtools/browser/');
+}
+
+function shouldPreferNewBrowserTarget(endpoint: string): boolean {
+ return endpoint === 'auto' && !process.env.OPENCLI_CDP_TARGET?.trim();
+}
+
+function normalizeWorkspaceKey(workspace?: string): string | undefined {
+ const trimmed = workspace?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function makePersistentTargetRegistryKey(endpointKey: string, workspace: string): string {
+ return `${endpointKey}::${workspace}`;
+}
+
+function readPersistentTargetRegistry(): Record {
+ try {
+ const raw = fs.readFileSync(PERSISTENT_TARGET_REGISTRY_PATH, 'utf8');
+ const parsed = JSON.parse(raw);
+ if (!isRecord(parsed)) return {};
+ return Object.fromEntries(
+ Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string'),
+ );
+ } catch {
+ return {};
+ }
+}
+
+function writePersistentTargetRegistry(registry: Record): void {
+ try {
+ fs.writeFileSync(PERSISTENT_TARGET_REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf8');
+ } catch {
+ // Best-effort cache only.
+ }
+}
+
+function getPersistentTargetId(endpointKey: string, workspace: string): string | undefined {
+ const registry = readPersistentTargetRegistry();
+ return registry[makePersistentTargetRegistryKey(endpointKey, workspace)];
+}
+
+function setPersistentTargetId(endpointKey: string, workspace: string, targetId: string): void {
+ const registry = readPersistentTargetRegistry();
+ registry[makePersistentTargetRegistryKey(endpointKey, workspace)] = targetId;
+ writePersistentTargetRegistry(registry);
+}
+
+function clearPersistentTargetId(endpointKey: string, workspace: string): void {
+ const registry = readPersistentTargetRegistry();
+ delete registry[makePersistentTargetRegistryKey(endpointKey, workspace)];
+ writePersistentTargetRegistry(registry);
+}
+
+function selectTargetById(targets: CDPTarget[], targetId?: string): CDPTarget | undefined {
+ if (!targetId) return undefined;
+ return targets.find((target) => target.targetId === targetId && isBrowserAttachableTarget(target));
+}
+
+function isLoopbackHost(host: string): boolean {
+ return ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(host);
+}
+
+function formatHostForUrl(host: string): string {
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
+}
+
+function extractBrowserWebSocketUrlFromVersionPayload(payload: unknown): string | null {
+ if (!isRecord(payload)) return null;
+ const wsUrl = payload.webSocketDebuggerUrl;
+ return typeof wsUrl === 'string' && isBrowserLevelWebSocket(wsUrl) ? wsUrl : null;
+}
+
+function rewriteBrowserWebSocketUrlForEndpoint(endpoint: string, wsUrl: string | null): string | null {
+ if (!wsUrl) return null;
+
+ let endpointUrl: URL;
+ let browserWsUrl: URL;
+ try {
+ endpointUrl = new URL(endpoint);
+ browserWsUrl = new URL(wsUrl);
+ } catch {
+ return wsUrl;
+ }
+
+ if (isLoopbackHost(endpointUrl.hostname.toLowerCase())) return wsUrl;
+ if (!isLoopbackHost(browserWsUrl.hostname.toLowerCase())) return wsUrl;
+
+ browserWsUrl.protocol = endpointUrl.protocol === 'https:' ? 'wss:' : 'ws:';
+ browserWsUrl.hostname = endpointUrl.hostname;
+ browserWsUrl.port = endpointUrl.port;
+ browserWsUrl.username = endpointUrl.username;
+ browserWsUrl.password = endpointUrl.password;
+ return browserWsUrl.toString();
+}
+
+function isBrowserAttachableTarget(target: CDPTarget): boolean {
+ const type = (target.type ?? '').toLowerCase();
+ if (!type) return true;
+ return ['page', 'app', 'webview', 'iframe'].includes(type);
+}
+
+function selectBrowserAttachTarget(targets: CDPTarget[]): CDPTarget | undefined {
+ return selectCDPTarget(targets.filter(isBrowserAttachableTarget));
+}
+
+// ── CDP target selection (unchanged) ──
+
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
@@ -350,7 +736,7 @@ function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
}
function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
- if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY;
+ if (!target.webSocketDebuggerUrl && !target.targetId) return Number.NEGATIVE_INFINITY;
const type = (target.type ?? '').toLowerCase();
const url = (target.url ?? '').toLowerCase();
@@ -399,13 +785,31 @@ function compilePreferredPattern(raw: string | undefined): RegExp | undefined {
return new RegExp(escapeRegExp(value.toLowerCase()));
}
+function hasExplicitTargetHint(): boolean {
+ return !!process.env.OPENCLI_CDP_TARGET?.trim();
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const __test__ = {
selectCDPTarget,
+ selectBrowserAttachTarget,
+ selectTargetById,
+ isBrowserAttachableTarget,
scoreCDPTarget,
+ parseBrowserWebSocketUrlFromActivePort,
+ parseAnyBrowserWebSocketUrlFromActivePort,
+ extractBrowserWebSocketUrlFromVersionPayload,
+ rewriteBrowserWebSocketUrlForEndpoint,
+ isBrowserLevelWebSocket,
+ isLoopbackHost,
+ shouldPreferNewBrowserTarget,
+ hasExplicitTargetHint,
+ normalizeWorkspaceKey,
+ makePersistentTargetRegistryKey,
+ persistentTargetRegistryPath: PERSISTENT_TARGET_REGISTRY_PATH,
};
function fetchJsonDirect(url: string): Promise {
@@ -415,7 +819,7 @@ function fetchJsonDirect(url: string): Promise {
const statusCode = res.statusCode ?? 0;
if (statusCode < 200 || statusCode >= 300) {
res.resume();
- reject(new Error(`Failed to fetch CDP targets: HTTP ${statusCode}`));
+ reject(new Error(`HTTP ${statusCode}`));
return;
}
@@ -425,7 +829,7 @@ function fetchJsonDirect(url: string): Promise {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
- reject(error instanceof Error ? error : new Error(String(error)));
+ reject(normalizeError(error));
}
});
});
@@ -435,3 +839,12 @@ function fetchJsonDirect(url: string): Promise {
request.end();
});
}
+
+function logVerbose(message: string, error?: Error): void {
+ if (process.env.OPENCLI_VERBOSE !== '1') return;
+ console.error(error ? `${message}: ${error.message}` : message);
+}
+
+function normalizeError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
diff --git a/src/browserEnvOptions.ts b/src/browserEnvOptions.ts
new file mode 100644
index 00000000..ac0b0f04
--- /dev/null
+++ b/src/browserEnvOptions.ts
@@ -0,0 +1,31 @@
+import { Command } from 'commander';
+import { extractBrowserEnvOverrides, withBrowserEnvOverrides } from './runtime.js';
+
+export interface BrowserEnvOptionConfig {
+ allowBrowserCdp?: boolean;
+}
+
+export function addBrowserEnvOverrideOptions(
+ command: Command,
+ config: BrowserEnvOptionConfig = {},
+): Command {
+ command
+ .option('--cdp-endpoint ', 'Override the CDP endpoint for this command')
+ .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern');
+
+ if (config.allowBrowserCdp) {
+ command
+ .option('--browser-cdp', 'Connect directly to a local Chrome CDP session and bypass the daemon/extension')
+ .option('--no-browser-cdp', 'Disable direct Chrome CDP mode for this command, even if enabled globally');
+ }
+
+ return command;
+}
+
+export async function runWithBrowserEnvOptions(
+ options: Record | null | undefined,
+ fn: () => Promise,
+ config: BrowserEnvOptionConfig = {},
+): Promise {
+ return withBrowserEnvOverrides(extractBrowserEnvOverrides(options), fn, config);
+}
diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts
index 263f5922..9aa051e4 100644
--- a/src/build-manifest.test.ts
+++ b/src/build-manifest.test.ts
@@ -129,7 +129,6 @@ describe('manifest helper rules', () => {
expect(scanTs(file, 'demo')).toBeNull();
});
-
it('keeps literal domain and navigateBefore for TS adapters', () => {
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
const entry = scanTs(file, 'xueqiu');
@@ -166,4 +165,27 @@ describe('manifest helper rules', () => {
replacedBy: 'opencli demo new',
});
});
+
+ it('derives supportsBrowserCdp for desktop-style TS adapters', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
+ tempDirs.push(dir);
+ const file = path.join(dir, 'ask.ts');
+ fs.writeFileSync(file, `
+import { cli, Strategy } from '../../registry.js';
+cli({
+ site: 'doubao-app',
+ name: 'ask',
+ description: 'ask',
+ domain: 'doubao-app',
+ strategy: Strategy.UI,
+ browser: true,
+});
+`);
+
+ expect(scanTs(file, 'doubao-app')).toEqual(expect.objectContaining({
+ site: 'doubao-app',
+ name: 'ask',
+ supportsBrowserCdp: false,
+ }));
+ });
});
diff --git a/src/build-manifest.ts b/src/build-manifest.ts
index 0d428000..1db46185 100644
--- a/src/build-manifest.ts
+++ b/src/build-manifest.ts
@@ -14,6 +14,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { getErrorMessage } from './errors.js';
+import { Strategy, deriveSupportsBrowserCdp } from './registry.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLIS_DIR = path.resolve(__dirname, 'clis');
@@ -26,6 +27,7 @@ export interface ManifestEntry {
domain?: string;
strategy: string;
browser: boolean;
+ supportsBrowserCdp?: boolean;
args: Array<{
name: string;
type?: string;
@@ -52,6 +54,12 @@ import type { YamlCliDefinition } from './yaml-schema.js';
import { isRecord } from './utils.js';
+function toStrategyEnum(strategy: string | undefined, browser: boolean): Strategy {
+ if (!strategy) return browser ? Strategy.COOKIE : Strategy.PUBLIC;
+ const key = strategy.toUpperCase() as keyof typeof Strategy;
+ return Strategy[key] ?? (browser ? Strategy.COOKIE : Strategy.PUBLIC);
+}
+
function extractBalancedBlock(
source: string,
@@ -197,6 +205,11 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
domain: cliDef.domain,
strategy: strategy.toLowerCase(),
browser,
+ supportsBrowserCdp: deriveSupportsBrowserCdp({
+ browser,
+ strategy: Strategy[strategy as keyof typeof Strategy],
+ domain: cliDef.domain,
+ }),
args,
columns: cliDef.columns,
pipeline: cliDef.pipeline,
@@ -252,6 +265,14 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
if (browserMatch) entry.browser = browserMatch[1] === 'true';
else entry.browser = entry.strategy !== 'public';
+ const supportsBrowserCdpMatch = src.match(/supportsBrowserCdp\s*:\s*(true|false)/);
+ entry.supportsBrowserCdp = deriveSupportsBrowserCdp({
+ browser: entry.browser,
+ strategy: toStrategyEnum(entry.strategy, entry.browser),
+ domain: entry.domain,
+ supportsBrowserCdp: supportsBrowserCdpMatch ? supportsBrowserCdpMatch[1] === 'true' : undefined,
+ });
+
// Extract columns
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
if (colMatch) {
diff --git a/src/cli.ts b/src/cli.ts
index d9c26684..c3cc6220 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -11,6 +11,7 @@ import { type CliCommand, fullName, getRegistry, strategyLabel } from './registr
import { serializeCommand, formatArgSummary } from './serialization.js';
import { render as renderOutput } from './output.js';
import { getBrowserFactory, browserSession } from './runtime.js';
+import { addBrowserEnvOverrideOptions, runWithBrowserEnvOptions } from './browserEnvOptions.js';
import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
@@ -124,7 +125,8 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
// ── Built-in: explore / synthesize / generate / cascade ───────────────────
- program
+ addBrowserEnvOverrideOptions(
+ program
.command('explore')
.alias('probe')
.description('Explore a website: discover APIs, stores, and recommend strategies')
@@ -133,23 +135,27 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.option('--goal ')
.option('--wait ', '', '3')
.option('--auto', 'Enable interactive fuzzing')
- .option('--click ', 'Comma-separated labels to click before fuzzing')
+ .option('--click ', 'Comma-separated labels to click before fuzzing'),
+ { allowBrowserCdp: true },
+ )
.action(async (url, opts) => {
- const { exploreUrl, renderExploreSummary } = await import('./explore.js');
- const clickLabels = opts.click
- ? opts.click.split(',').map((s: string) => s.trim())
- : undefined;
- const workspace = `explore:${inferHost(url, opts.site)}`;
- const result = await exploreUrl(url, {
- BrowserFactory: getBrowserFactory(),
- site: opts.site,
- goal: opts.goal,
- waitSeconds: parseFloat(opts.wait),
- auto: opts.auto,
- clickLabels,
- workspace,
- });
- console.log(renderExploreSummary(result));
+ await runWithBrowserEnvOptions(opts, async () => {
+ const { exploreUrl, renderExploreSummary } = await import('./explore.js');
+ const clickLabels = opts.click
+ ? opts.click.split(',').map((s: string) => s.trim())
+ : undefined;
+ const workspace = `explore:${inferHost(url, opts.site)}`;
+ const result = await exploreUrl(url, {
+ BrowserFactory: getBrowserFactory(),
+ site: opts.site,
+ goal: opts.goal,
+ waitSeconds: parseFloat(opts.wait),
+ auto: opts.auto,
+ clickLabels,
+ workspace,
+ });
+ console.log(renderExploreSummary(result));
+ }, { allowBrowserCdp: true });
});
program
@@ -162,67 +168,82 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) })));
});
- program
+ addBrowserEnvOverrideOptions(
+ program
.command('generate')
.description('One-shot: explore → synthesize → register')
.argument('')
.option('--goal ')
- .option('--site ')
+ .option('--site '),
+ { allowBrowserCdp: true },
+ )
.action(async (url, opts) => {
- const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js');
- const workspace = `generate:${inferHost(url, opts.site)}`;
- const r = await generateCliFromUrl({
- url,
- BrowserFactory: getBrowserFactory(),
- goal: opts.goal,
- site: opts.site,
- workspace,
- });
- console.log(renderGenerateSummary(r));
- process.exitCode = r.ok ? 0 : 1;
+ await runWithBrowserEnvOptions(opts, async () => {
+ const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js');
+ const workspace = `generate:${inferHost(url, opts.site)}`;
+ const r = await generateCliFromUrl({
+ url,
+ BrowserFactory: getBrowserFactory(),
+ goal: opts.goal,
+ site: opts.site,
+ workspace,
+ });
+ console.log(renderGenerateSummary(r));
+ process.exitCode = r.ok ? 0 : 1;
+ }, { allowBrowserCdp: true });
});
// ── Built-in: record ─────────────────────────────────────────────────────
- program
+ addBrowserEnvOverrideOptions(
+ program
.command('record')
.description('Record API calls from a live browser session → generate YAML candidates')
.argument('', 'URL to open and record')
.option('--site ', 'Site name (inferred from URL if omitted)')
.option('--out ', 'Output directory for candidates')
.option('--poll ', 'Poll interval in milliseconds', '2000')
- .option('--timeout ', 'Auto-stop after N milliseconds (default: 60000)', '60000')
+ .option('--timeout ', 'Auto-stop after N milliseconds (default: 60000)', '60000'),
+ { allowBrowserCdp: true },
+ )
.action(async (url, opts) => {
- const { recordSession, renderRecordSummary } = await import('./record.js');
- const result = await recordSession({
- BrowserFactory: getBrowserFactory(),
- url,
- site: opts.site,
- outDir: opts.out,
- pollMs: parseInt(opts.poll, 10),
- timeoutMs: parseInt(opts.timeout, 10),
- });
- console.log(renderRecordSummary(result));
- process.exitCode = result.candidateCount > 0 ? 0 : 1;
+ await runWithBrowserEnvOptions(opts, async () => {
+ const { recordSession, renderRecordSummary } = await import('./record.js');
+ const result = await recordSession({
+ BrowserFactory: getBrowserFactory(),
+ url,
+ site: opts.site,
+ outDir: opts.out,
+ pollMs: parseInt(opts.poll, 10),
+ timeoutMs: parseInt(opts.timeout, 10),
+ });
+ console.log(renderRecordSummary(result));
+ process.exitCode = result.candidateCount > 0 ? 0 : 1;
+ }, { allowBrowserCdp: true });
});
- program
+ addBrowserEnvOverrideOptions(
+ program
.command('cascade')
.description('Strategy cascade: find simplest working strategy')
.argument('')
- .option('--site ')
+ .option('--site '),
+ { allowBrowserCdp: true },
+ )
.action(async (url, opts) => {
- const { cascadeProbe, renderCascadeResult } = await import('./cascade.js');
- const workspace = `cascade:${inferHost(url, opts.site)}`;
- const result = await browserSession(getBrowserFactory(), async (page) => {
- try {
- const siteUrl = new URL(url);
- await page.goto(`${siteUrl.protocol}//${siteUrl.host}`);
- await page.wait(2);
- } catch {}
- return cascadeProbe(page, url);
- }, { workspace });
- console.log(renderCascadeResult(result));
+ await runWithBrowserEnvOptions(opts, async () => {
+ const { cascadeProbe, renderCascadeResult } = await import('./cascade.js');
+ const workspace = `cascade:${inferHost(url, opts.site)}`;
+ const result = await browserSession(getBrowserFactory(), async (page) => {
+ try {
+ const siteUrl = new URL(url);
+ await page.goto(`${siteUrl.protocol}//${siteUrl.host}`);
+ await page.wait(2);
+ } catch {}
+ return cascadeProbe(page, url);
+ }, { workspace });
+ console.log(renderCascadeResult(result));
+ }, { allowBrowserCdp: true });
});
// ── Built-in: doctor / completion ──────────────────────────────────────────
@@ -436,13 +457,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
// ── Antigravity serve (long-running, special case) ────────────────────────
const antigravityCmd = program.command('antigravity').description('antigravity commands');
- antigravityCmd
+ addBrowserEnvOverrideOptions(
+ antigravityCmd
.command('serve')
.description('Start Anthropic-compatible API proxy for Antigravity')
- .option('--port ', 'Server port (default: 8082)', '8082')
+ .option('--port ', 'Server port (default: 8082)', '8082'),
+ )
.action(async (opts) => {
- const { startServe } = await import('./clis/antigravity/serve.js');
- await startServe({ port: parseInt(opts.port) });
+ await runWithBrowserEnvOptions(opts, async () => {
+ const { startServe } = await import('./clis/antigravity/serve.js');
+ await startServe({ port: parseInt(opts.port) });
+ });
});
// ── Dynamic adapter commands ──────────────────────────────────────────────
diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts
index 97c9b881..bb33c62c 100644
--- a/src/commanderAdapter.test.ts
+++ b/src/commanderAdapter.test.ts
@@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import type { CliCommand } from './registry.js';
-const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
+const { mockExecuteCommand, mockRender } = vi.hoisted(() => ({
mockExecuteCommand: vi.fn(),
- mockRenderOutput: vi.fn(),
+ mockRender: vi.fn(),
}));
vi.mock('./execution.js', () => ({
@@ -12,7 +12,7 @@ vi.mock('./execution.js', () => ({
}));
vi.mock('./output.js', () => ({
- render: mockRenderOutput,
+ render: mockRender,
}));
import { registerCommandToProgram } from './commanderAdapter.js';
@@ -32,10 +32,9 @@ describe('commanderAdapter bool normalization', () => {
};
beforeEach(() => {
- mockExecuteCommand.mockReset();
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
mockExecuteCommand.mockResolvedValue([]);
- mockRenderOutput.mockReset();
- delete process.env.OPENCLI_VERBOSE;
process.exitCode = undefined;
});
@@ -90,3 +89,115 @@ describe('commanderAdapter bool normalization', () => {
stderr.mockRestore();
});
});
+
+describe('registerCommandToProgram browser env overrides', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ process.exitCode = undefined;
+ });
+
+ it('applies command-level CDP overrides only while a browser command executes', async () => {
+ const seen: Array<{ endpoint?: string; target?: string }> = [];
+ mockExecuteCommand.mockImplementation(async () => {
+ seen.push({
+ endpoint: process.env.OPENCLI_CDP_ENDPOINT,
+ target: process.env.OPENCLI_CDP_TARGET,
+ });
+ return [];
+ });
+
+ const cmd: CliCommand = {
+ site: 'antigravity',
+ name: 'status',
+ description: 'status',
+ browser: true,
+ supportsBrowserCdp: false,
+ args: [],
+ };
+
+ const program = new Command();
+ const siteCmd = program.command('antigravity');
+ registerCommandToProgram(siteCmd, cmd);
+
+ await program.parseAsync([
+ 'node',
+ 'opencli',
+ 'antigravity',
+ 'status',
+ '--cdp-endpoint',
+ 'http://127.0.0.1:9333',
+ '--cdp-target',
+ 'launchpad',
+ ]);
+
+ expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
+ expect(seen).toEqual([
+ {
+ endpoint: 'http://127.0.0.1:9333',
+ target: 'launchpad',
+ },
+ ]);
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBeUndefined();
+ expect(process.env.OPENCLI_CDP_TARGET).toBeUndefined();
+ });
+
+ it('enables browser-cdp auto mode only for supported browser commands', async () => {
+ const seen: Array<{ endpoint?: string }> = [];
+ mockExecuteCommand.mockImplementation(async () => {
+ seen.push({ endpoint: process.env.OPENCLI_CDP_ENDPOINT });
+ return [];
+ });
+
+ const cmd: CliCommand = {
+ site: 'linux-do',
+ name: 'categories',
+ description: 'categories',
+ browser: true,
+ supportsBrowserCdp: true,
+ args: [],
+ };
+
+ const program = new Command();
+ const siteCmd = program.command('linux-do');
+ registerCommandToProgram(siteCmd, cmd);
+
+ await program.parseAsync([
+ 'node',
+ 'opencli',
+ 'linux-do',
+ 'categories',
+ '--browser-cdp',
+ ]);
+
+ expect(seen).toEqual([{ endpoint: 'auto' }]);
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBeUndefined();
+ });
+
+ it('does not register browser-cdp flags for desktop-style commands', async () => {
+ const cmd: CliCommand = {
+ site: 'cursor',
+ name: 'ask',
+ description: 'ask',
+ browser: true,
+ supportsBrowserCdp: false,
+ args: [],
+ };
+
+ const program = new Command();
+ program.exitOverride();
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const siteCmd = program.command('cursor');
+ registerCommandToProgram(siteCmd, cmd);
+
+ await expect(program.parseAsync([
+ 'node',
+ 'opencli',
+ 'cursor',
+ 'ask',
+ '--browser-cdp',
+ ])).rejects.toThrow();
+
+ errorSpy.mockRestore();
+ });
+});
diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts
index 293ce3bc..5d7fa13f 100644
--- a/src/commanderAdapter.ts
+++ b/src/commanderAdapter.ts
@@ -17,6 +17,7 @@ import { formatRegistryHelpText } from './serialization.js';
import { render as renderOutput } from './output.js';
import { executeCommand } from './execution.js';
import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js';
+import { addBrowserEnvOverrideOptions, runWithBrowserEnvOptions } from './browserEnvOptions.js';
export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown {
if (argType !== 'bool') return value;
@@ -56,6 +57,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
subCmd
.option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table')
.option('-v, --verbose', 'Debug output', false);
+ if (cmd.browser) {
+ addBrowserEnvOverrideOptions(subCmd, { allowBrowserCdp: cmd.supportsBrowserCdp !== false });
+ }
subCmd.addHelpText('after', formatRegistryHelpText(cmd));
@@ -87,8 +91,13 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
const replacement = cmd.replacedBy ? ` Use ${cmd.replacedBy} instead.` : '';
console.error(chalk.yellow(`Deprecated: ${message}${replacement}`));
}
-
- const result = await executeCommand(cmd, kwargs, verbose);
+ const result = cmd.browser
+ ? await runWithBrowserEnvOptions(
+ optionsRecord,
+ () => executeCommand(cmd, kwargs, verbose),
+ { allowBrowserCdp: cmd.supportsBrowserCdp !== false },
+ )
+ : await executeCommand(cmd, kwargs, verbose);
if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
diff --git a/src/discovery.ts b/src/discovery.ts
index 90f6a0ac..bdd98b9d 100644
--- a/src/discovery.ts
+++ b/src/discovery.ts
@@ -13,7 +13,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
-import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
+import { type CliCommand, type InternalCliCommand, type Arg, Strategy, deriveSupportsBrowserCdp, registerCommand } from './registry.js';
import { getErrorMessage } from './errors.js';
import { log } from './logger.js';
import type { ManifestEntry } from './build-manifest.js';
@@ -72,6 +72,12 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
domain: entry.domain,
strategy,
browser: entry.browser,
+ supportsBrowserCdp: deriveSupportsBrowserCdp({
+ browser: entry.browser,
+ strategy,
+ domain: entry.domain,
+ supportsBrowserCdp: entry.supportsBrowserCdp,
+ }),
args: entry.args ?? [],
columns: entry.columns,
pipeline: entry.pipeline,
@@ -94,6 +100,12 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
domain: entry.domain,
strategy,
browser: entry.browser ?? true,
+ supportsBrowserCdp: deriveSupportsBrowserCdp({
+ browser: entry.browser ?? true,
+ strategy,
+ domain: entry.domain,
+ supportsBrowserCdp: entry.supportsBrowserCdp,
+ }),
args: entry.args ?? [],
columns: entry.columns,
timeoutSeconds: entry.timeout,
@@ -184,6 +196,11 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise {
+ const tempBuildRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-manifest-flags-'));
+ const distDir = path.join(tempBuildRoot, 'dist');
+ const manifestPath = path.join(tempBuildRoot, 'cli-manifest.json');
+
+ try {
+ await fs.promises.mkdir(distDir, { recursive: true });
+ await fs.promises.writeFile(manifestPath, JSON.stringify([
+ {
+ site: 'doubao-app',
+ name: 'ask',
+ description: 'ask',
+ domain: 'doubao-app',
+ strategy: 'ui',
+ browser: true,
+ supportsBrowserCdp: false,
+ args: [],
+ type: 'ts',
+ modulePath: 'doubao-app/ask.js',
+ },
+ ]));
+
+ await discoverClis(distDir);
+
+ expect(getRegistry().get('doubao-app/ask')?.supportsBrowserCdp).toBe(false);
+ } finally {
+ await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
+ }
+ });
});
describe('discoverPlugins', () => {
diff --git a/src/registry.test.ts b/src/registry.test.ts
index dfe5d2e7..6d401626 100644
--- a/src/registry.test.ts
+++ b/src/registry.test.ts
@@ -53,6 +53,38 @@ describe('cli() registration', () => {
expect(cmd.strategy).toBe(Strategy.PUBLIC);
});
+ it('disables browser-cdp by default for local desktop UI targets', () => {
+ const localhostCmd = cli({
+ site: 'test-registry',
+ name: 'localhost-ui',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ });
+ const appCmd = cli({
+ site: 'test-registry',
+ name: 'app-ui',
+ domain: 'doubao-app',
+ strategy: Strategy.UI,
+ browser: true,
+ });
+
+ expect(localhostCmd.supportsBrowserCdp).toBe(false);
+ expect(appCmd.supportsBrowserCdp).toBe(false);
+ });
+
+ it('keeps browser-cdp enabled for real website UI targets', () => {
+ const cmd = cli({
+ site: 'test-registry',
+ name: 'remote-ui',
+ domain: 'x.com',
+ strategy: Strategy.UI,
+ browser: true,
+ });
+
+ expect(cmd.supportsBrowserCdp).toBe(true);
+ });
+
it('overwrites existing command on re-registration', () => {
cli({ site: 'test-registry', name: 'overwrite', description: 'v1' });
cli({ site: 'test-registry', name: 'overwrite', description: 'v2' });
diff --git a/src/registry.ts b/src/registry.ts
index c7363c52..ee24d0c0 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -30,6 +30,11 @@ export interface RequiredEnv {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- kwargs from CLI parsing are inherently untyped
export type CommandArgs = Record;
+export interface RequiredEnv {
+ name: string;
+ help?: string;
+}
+
export interface CliCommand {
site: string;
name: string;
@@ -37,6 +42,7 @@ export interface CliCommand {
domain?: string;
strategy?: Strategy;
browser?: boolean;
+ supportsBrowserCdp?: boolean;
args: Arg[];
columns?: string[];
func?: (page: IPage, kwargs: CommandArgs, debug?: boolean) => Promise;
@@ -75,6 +81,31 @@ export interface CliOptions extends Partial {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('extracts browser overrides from commander-style option names', () => {
+ expect(extractBrowserEnvOverrides({
+ 'browser-cdp': true,
+ 'cdp-endpoint': ' http://127.0.0.1:9333 ',
+ 'cdp-target': ' antigravity ',
+ })).toEqual({
+ browserCdp: true,
+ cdpEndpoint: 'http://127.0.0.1:9333',
+ cdpTarget: 'antigravity',
+ });
+ });
+
+ it('rejects invalid cdp endpoint values early', () => {
+ expect(() => extractBrowserEnvOverrides({
+ 'cdp-endpoint': 'foobar',
+ })).toThrow('Invalid --cdp-endpoint value');
+ });
+
+ it('temporarily applies overrides and restores previous values', async () => {
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');
+ vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
+
+ let seenEndpoint: string | undefined;
+ let seenTarget: string | undefined;
+
+ await withBrowserEnvOverrides({
+ cdpEndpoint: 'http://127.0.0.1:9333',
+ cdpTarget: 'antigravity',
+ }, async () => {
+ seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
+ seenTarget = process.env.OPENCLI_CDP_TARGET;
+ });
+
+ expect(seenEndpoint).toBe('http://127.0.0.1:9333');
+ expect(seenTarget).toBe('antigravity');
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222');
+ expect(process.env.OPENCLI_CDP_TARGET).toBe('codex');
+ });
+
+ it('leaves unrelated browser env unchanged when an override is omitted', async () => {
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');
+ vi.stubEnv('OPENCLI_CDP_TARGET', 'cursor');
+
+ let seenEndpoint: string | undefined;
+ let seenTarget: string | undefined;
+
+ await withBrowserEnvOverrides({
+ cdpEndpoint: 'http://127.0.0.1:9333',
+ }, async () => {
+ seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
+ seenTarget = process.env.OPENCLI_CDP_TARGET;
+ });
+
+ expect(seenEndpoint).toBe('http://127.0.0.1:9333');
+ expect(seenTarget).toBe('cursor');
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222');
+ expect(process.env.OPENCLI_CDP_TARGET).toBe('cursor');
+ });
+
+ it('prefers command-level browser-cdp auto mode over existing env defaults', async () => {
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');
+
+ let seenEndpoint: string | undefined;
+ await withBrowserEnvOverrides({
+ browserCdp: true,
+ }, async () => {
+ seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
+ }, { allowBrowserCdp: true });
+
+ expect(seenEndpoint).toBe('auto');
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222');
+ });
+
+ it('honors --no-browser-cdp by clearing an inherited endpoint during the command', async () => {
+ vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222');
+
+ let seenEndpoint: string | undefined;
+ await withBrowserEnvOverrides({
+ browserCdp: false,
+ }, async () => {
+ seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
+ }, { allowBrowserCdp: true });
+
+ expect(seenEndpoint).toBeUndefined();
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222');
+ });
+
+ it('restores outer overrides correctly across nested calls', async () => {
+ const seen: Array<{ label: string; endpoint?: string; target?: string }> = [];
+
+ await withBrowserEnvOverrides({
+ cdpEndpoint: 'http://127.0.0.1:9333',
+ cdpTarget: 'outer',
+ }, async () => {
+ seen.push({
+ label: 'outer-before',
+ endpoint: process.env.OPENCLI_CDP_ENDPOINT,
+ target: process.env.OPENCLI_CDP_TARGET,
+ });
+
+ await withBrowserEnvOverrides({
+ cdpEndpoint: 'http://127.0.0.1:9444',
+ cdpTarget: 'inner',
+ }, async () => {
+ seen.push({
+ label: 'inner',
+ endpoint: process.env.OPENCLI_CDP_ENDPOINT,
+ target: process.env.OPENCLI_CDP_TARGET,
+ });
+ });
+
+ seen.push({
+ label: 'outer-after',
+ endpoint: process.env.OPENCLI_CDP_ENDPOINT,
+ target: process.env.OPENCLI_CDP_TARGET,
+ });
+ });
+
+ expect(seen).toEqual([
+ { label: 'outer-before', endpoint: 'http://127.0.0.1:9333', target: 'outer' },
+ { label: 'inner', endpoint: 'http://127.0.0.1:9444', target: 'inner' },
+ { label: 'outer-after', endpoint: 'http://127.0.0.1:9333', target: 'outer' },
+ ]);
+ expect(process.env.OPENCLI_CDP_ENDPOINT).toBeUndefined();
+ expect(process.env.OPENCLI_CDP_TARGET).toBeUndefined();
+ });
+
+ it('honors global browser cdp default only for commands that allow it', async () => {
+ vi.stubEnv('OPENCLI_BROWSER_CDP', '1');
+
+ let seenAllowed: string | undefined;
+ await withBrowserEnvOverrides({}, async () => {
+ seenAllowed = process.env.OPENCLI_CDP_ENDPOINT;
+ }, { allowBrowserCdp: true });
+
+ let seenDisallowed: string | undefined;
+ await withBrowserEnvOverrides({}, async () => {
+ seenDisallowed = process.env.OPENCLI_CDP_ENDPOINT;
+ }, { allowBrowserCdp: false });
+
+ expect(seenAllowed).toBe('auto');
+ expect(seenDisallowed).toBeUndefined();
+ });
+});
diff --git a/src/runtime.ts b/src/runtime.ts
index f3b63961..b0298a93 100644
--- a/src/runtime.ts
+++ b/src/runtime.ts
@@ -2,6 +2,16 @@ import { BrowserBridge, CDPBridge } from './browser/index.js';
import type { IPage } from './types.js';
import { TimeoutError } from './errors.js';
+export type BrowserEnvOverrides = {
+ browserCdp?: boolean;
+ cdpEndpoint?: string;
+ cdpTarget?: string;
+};
+
+export interface BrowserEnvOverrideConfig {
+ allowBrowserCdp?: boolean;
+}
+
/**
* Returns the appropriate browser factory based on environment config.
* Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge.
@@ -10,6 +20,46 @@ export function getBrowserFactory(): new () => IBrowserFactory {
return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as unknown as new () => IBrowserFactory;
}
+export function extractBrowserEnvOverrides(options?: Record | null): BrowserEnvOverrides {
+ const input = options ?? {};
+ return {
+ browserCdp: readBooleanOption(input['browser-cdp'] ?? input.browserCdp),
+ cdpEndpoint: readCdpEndpointOption(input['cdp-endpoint'] ?? input.cdpEndpoint),
+ cdpTarget: readStringOption(input['cdp-target'] ?? input.cdpTarget),
+ };
+}
+
+export async function withBrowserEnvOverrides(
+ overrides: BrowserEnvOverrides,
+ fn: () => Promise,
+ config: BrowserEnvOverrideConfig = {},
+): Promise {
+ const effectiveEndpoint = resolveEffectiveCdpEndpoint(overrides, config);
+ const pairs: Array<[key: 'OPENCLI_CDP_ENDPOINT' | 'OPENCLI_CDP_TARGET', value: string | null | undefined]> = [
+ ['OPENCLI_CDP_ENDPOINT', effectiveEndpoint],
+ ['OPENCLI_CDP_TARGET', overrides.cdpTarget],
+ ];
+ const previous = new Map();
+
+ for (const [key, value] of pairs) {
+ if (value === undefined) continue;
+ previous.set(key, process.env[key]);
+ if (value === null) delete process.env[key];
+ else process.env[key] = value;
+ }
+
+ try {
+ return await fn();
+ } finally {
+ for (const [key, value] of pairs) {
+ if (value === undefined) continue;
+ const prior = previous.get(key);
+ if (prior === undefined) delete process.env[key];
+ else process.env[key] = prior;
+ }
+ }
+}
+
function parseEnvTimeout(envVar: string, fallback: number): number {
const raw = process.env[envVar];
if (raw === undefined) return fallback;
@@ -21,6 +71,53 @@ function parseEnvTimeout(envVar: string, fallback: number): number {
return parsed;
}
+function readStringOption(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function readBooleanOption(value: unknown): boolean | undefined {
+ if (typeof value === 'boolean') return value;
+ if (typeof value !== 'string') return undefined;
+ const normalized = value.trim().toLowerCase();
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
+ return undefined;
+}
+
+function readBooleanEnv(name: string): boolean | undefined {
+ return readBooleanOption(process.env[name]);
+}
+
+function readCdpEndpointOption(value: unknown): string | undefined {
+ const normalized = readStringOption(value);
+ if (!normalized) return undefined;
+ if (normalized === 'auto') return normalized;
+ if (/^(https?|wss?):\/\//.test(normalized)) return normalized;
+ throw new Error('Invalid --cdp-endpoint value. Expected http://, https://, ws://, or wss:// URL.');
+}
+
+function resolveEffectiveCdpEndpoint(
+ overrides: BrowserEnvOverrides,
+ config: BrowserEnvOverrideConfig,
+): string | null | undefined {
+ if (overrides.cdpEndpoint) {
+ if (overrides.cdpEndpoint === 'auto' && !config.allowBrowserCdp) {
+ throw new Error('The "auto" CDP endpoint is only supported for browser CDP commands.');
+ }
+ return overrides.cdpEndpoint;
+ }
+
+ if (!config.allowBrowserCdp) return undefined;
+
+ if (typeof overrides.browserCdp === 'boolean') {
+ return overrides.browserCdp ? 'auto' : null;
+ }
+
+ return readBooleanEnv('OPENCLI_BROWSER_CDP') ? 'auto' : undefined;
+}
+
export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_CONNECT_TIMEOUT', 30);
export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_COMMAND_TIMEOUT', 60);
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_EXPLORE_TIMEOUT', 120);