feat: replace @playwright/mcp with lightweight daemon + Chrome Extension

Architecture:
- Micro-daemon (HTTP + WebSocket bridge, ~190 lines, auto-start/idle-exit)
- Chrome MV3 Extension using chrome.debugger CDP (10KB build)
- 5 protocol actions: exec, navigate, tabs, cookies, screenshot
- All DOM ops via JS evaluate — no extension update needed for new features

Key features:
- CDP Runtime.evaluate for JS execution in page context
- Tab management, cookie access via Chrome APIs
- Auto-start daemon on cold boot, idle auto-exit (5min)
- Minimal permissions: debugger, tabs, cookies, activeTab, alarms

Tested: zhihu hot (14.3s), twitter timeline (9.3s)
This commit is contained in:
jackwener
2026-03-19 06:08:55 +08:00
parent 0374b77d16
commit b2fa7daf57
29 changed files with 1245 additions and 2070 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+2
View File
@@ -0,0 +1,2 @@
// Minimal content script — required by manifest to auto-grant host permissions.
// No actual logic needed; chrome.scripting.executeScript handles all injection.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+42
View File
@@ -0,0 +1,42 @@
{
"manifest_version": 3,
"name": "opencli Browser Bridge",
"version": "0.1.0",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"permissions": [
"debugger",
"scripting",
"tabs",
"cookies",
"activeTab",
"alarms"
],
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content/noop.js"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_title": "opencli Browser Bridge",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"homepage_url": "https://github.com/jackwener/opencli"
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "opencli-extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+246
View File
@@ -0,0 +1,246 @@
/**
* opencli Browser Bridge — Service Worker (background script).
*
* Connects to the opencli daemon via WebSocket, receives commands,
* dispatches them to Chrome APIs (scripting/tabs/cookies), returns results.
*
* IMPORTANT: In IIFE mode (non-module), all chrome.* API registrations
* must happen inside lifecycle events (onInstalled/onStartup), NOT at
* the top level, or the service worker registration will fail.
*/
import type { Command, Result } from './protocol';
import { DAEMON_WS_URL, WS_RECONNECT_DELAY } from './protocol';
import * as cdp from './cdp';
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// ─── WebSocket connection ────────────────────────────────────────────
function connect(): void {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
try {
ws = new WebSocket(DAEMON_WS_URL);
} catch {
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log('[opencli] Connected to daemon');
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = async (event) => {
try {
const command = JSON.parse(event.data as string) as Command;
const result = await handleCommand(command);
ws?.send(JSON.stringify(result));
} catch (err) {
console.error('[opencli] Message handling error:', err);
}
};
ws.onclose = () => {
console.log('[opencli] Disconnected from daemon');
ws = null;
scheduleReconnect();
};
ws.onerror = () => {
ws?.close();
};
}
function scheduleReconnect(): void {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, WS_RECONNECT_DELAY);
}
// ─── Lifecycle events ────────────────────────────────────────────────
// All chrome.* API registrations must be in these listeners, not top-level.
function initialize(): void {
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
cdp.registerListeners();
connect();
console.log('[opencli] Browser Bridge extension initialized');
}
chrome.runtime.onInstalled.addListener(() => {
initialize();
});
chrome.runtime.onStartup.addListener(() => {
initialize();
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepalive') connect();
});
// ─── Command dispatcher ─────────────────────────────────────────────
async function handleCommand(cmd: Command): Promise<Result> {
try {
switch (cmd.action) {
case 'exec':
return await handleExec(cmd);
case 'navigate':
return await handleNavigate(cmd);
case 'tabs':
return await handleTabs(cmd);
case 'cookies':
return await handleCookies(cmd);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
} catch (err) {
return {
id: cmd.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── Action handlers ─────────────────────────────────────────────────
/** Resolve target tab: use specified tabId or fall back to active web page tab */
async function resolveTabId(tabId?: number): Promise<number> {
if (tabId !== undefined) return tabId;
// Try the active tab first
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (activeTab?.id && activeTab.url && !activeTab.url.startsWith('chrome://') && !activeTab.url.startsWith('chrome-extension://')) {
return activeTab.id;
}
// Active tab is not debuggable (chrome:// or chrome-extension://),
// try to find any open web page tab
const allTabs = await chrome.tabs.query({ currentWindow: true });
const webTab = allTabs.find(t => t.id && t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://'));
if (webTab?.id) {
await chrome.tabs.update(webTab.id, { active: true });
return webTab.id;
}
// No web tabs at all — create one
const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
if (!newTab.id) throw new Error('Failed to create new tab');
return newTab.id;
}
async function handleExec(cmd: Command): Promise<Result> {
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
const tabId = await resolveTabId(cmd.tabId);
try {
const data = await cdp.evaluateAsync(tabId, cmd.code);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNavigate(cmd: Command): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
const tabId = await resolveTabId(cmd.tabId);
await chrome.tabs.update(tabId, { url: cmd.url });
// Wait for page to finish loading
await new Promise<void>((resolve) => {
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Timeout fallback
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 30000);
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
}
async function handleTabs(cmd: Command): Promise<Result> {
switch (cmd.op) {
case 'list': {
const tabs = await chrome.tabs.query({});
const data = tabs
.filter((t) => t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://'))
.map((t, i) => ({
index: i,
tabId: t.id,
url: t.url,
title: t.title,
active: t.active,
}));
return { id: cmd.id, ok: true, data };
}
case 'new': {
const tab = await chrome.tabs.create({ url: cmd.url, active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case 'close': {
if (cmd.index !== undefined) {
// Close by index
const tabs = await chrome.tabs.query({});
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.remove(target.id);
cdp.detach(target.id);
return { id: cmd.id, ok: true, data: { closed: target.id } };
}
// Close by tabId or active tab
const tabId = await resolveTabId(cmd.tabId);
await chrome.tabs.remove(tabId);
cdp.detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case 'select': {
if (cmd.index === undefined && cmd.tabId === undefined)
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
if (cmd.tabId !== undefined) {
await chrome.tabs.update(cmd.tabId, { active: true });
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
}
const tabs = await chrome.tabs.query({});
const target = tabs[cmd.index!];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.update(target.id, { active: true });
return { id: cmd.id, ok: true, data: { selected: target.id } };
}
default:
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
}
}
async function handleCookies(cmd: Command): Promise<Result> {
const details: chrome.cookies.GetAllDetails = {};
if (cmd.domain) details.domain = cmd.domain;
if (cmd.url) details.url = cmd.url;
const cookies = await chrome.cookies.getAll(details);
const data = cookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate,
}));
return { id: cmd.id, ok: true, data };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* CDP execution via chrome.debugger API.
*
* chrome.debugger only needs the "debugger" permission — no host_permissions.
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
* tabs (resolveTabId in background.ts filters them).
*/
const attached = new Set<number>();
async function ensureAttached(tabId: number): Promise<void> {
if (attached.has(tabId)) return;
try {
await chrome.debugger.attach({ tabId }, '1.3');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Another debugger is already attached')) {
throw new Error(`attach failed: ${msg}`);
}
}
attached.add(tabId);
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
} catch {
// Some pages may not need explicit enable
}
}
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
await ensureAttached(tabId);
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
}
return result.result?.value;
}
export const evaluateAsync = evaluate;
export function detach(tabId: number): void {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* opencli browser protocol — shared types between daemon, extension, and CLI.
*
* Only 4 actions. Everything else is just JS code sent via 'exec'.
*/
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies';
export interface Command {
/** Unique request ID */
id: string;
/** Action type */
action: Action;
/** Target tab ID (omit for active tab) */
tabId?: number;
/** JS code to evaluate in page context (exec action) */
code?: string;
/** URL to navigate to (navigate action) */
url?: string;
/** Sub-operation for tabs: list, new, close, select */
op?: 'list' | 'new' | 'close' | 'select';
/** Tab index for tabs select/close */
index?: number;
/** Cookie domain filter */
domain?: string;
}
export interface Result {
/** Matching request ID */
id: string;
/** Whether the command succeeded */
ok: boolean;
/** Result data on success */
data?: unknown;
/** Error message on failure */
error?: string;
}
/** Default daemon port */
export const DAEMON_PORT = 19825;
export const DAEMON_HOST = 'localhost';
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
/** Reconnect delay for extension WebSocket (ms) */
export const WS_RECONNECT_DELAY = 3000;
/** Idle timeout before daemon auto-exits (ms) */
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"types": ["chrome"]
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/background.ts'),
output: {
entryFileNames: 'background.js',
format: 'es',
},
},
target: 'esnext',
minify: false,
},
});
+37 -66
View File
@@ -13,15 +13,16 @@
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0"
"js-yaml": "^4.1.0",
"ws": "^8.18.0"
},
"bin": {
"opencli": "dist/main.js"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
@@ -560,23 +561,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/mcp": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
"integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.0-alpha-1771104257000",
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright-mcp": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@@ -899,6 +883,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
@@ -1563,6 +1557,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1570,53 +1565,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1805,6 +1753,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -1846,6 +1795,7 @@
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
@@ -2017,6 +1967,27 @@
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+4 -3
View File
@@ -33,7 +33,7 @@
"browser",
"web",
"ai",
"playwright"
"browser"
],
"author": "jackwener",
"license": "Apache-2.0",
@@ -45,11 +45,12 @@
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0"
"js-yaml": "^4.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/ws": "^8.5.13",
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
+3 -224
View File
@@ -1,14 +1,6 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { PlaywrightMCP, __test__ } from './browser/index.js';
afterEach(() => {
__test__.resetMcpServerPathCache();
__test__.setMcpDiscoveryTestHooks();
delete process.env.OPENCLI_MCP_SERVER_PATH;
});
describe('browser helpers', () => {
it('creates JSON-RPC requests with unique ids', () => {
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
@@ -57,220 +49,9 @@ describe('browser helpers', () => {
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
});
it('builds extension MCP args in local mode (no CI)', () => {
const savedCI = process.env.CI;
delete process.env.CI;
try {
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
})).toEqual([
'/tmp/cli.js',
'--extension',
'--executable-path',
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
]);
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
})).toEqual([
'/tmp/cli.js',
'--extension',
]);
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
} else {
delete process.env.CI;
}
}
});
it('builds standalone MCP args in CI mode', () => {
const savedCI = process.env.CI;
process.env.CI = 'true';
try {
// CI mode: no --extension — browser launches in standalone headed mode
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
})).toEqual([
'/tmp/cli.js',
]);
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
executablePath: '/usr/bin/chromium',
})).toEqual([
'/tmp/cli.js',
'--executable-path',
'/usr/bin/chromium',
]);
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
} else {
delete process.env.CI;
}
}
});
it('builds a direct node launch spec when a local MCP path is available', () => {
const savedCI = process.env.CI;
delete process.env.CI;
try {
expect(__test__.buildMcpLaunchSpec({
mcpPath: '/tmp/cli.js',
executablePath: '/usr/bin/google-chrome',
})).toEqual({
command: 'node',
args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
usedNpxFallback: false,
});
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
} else {
delete process.env.CI;
}
}
});
it('falls back to npx bootstrap when no MCP path is available', () => {
const savedCI = process.env.CI;
delete process.env.CI;
try {
expect(__test__.buildMcpLaunchSpec({
mcpPath: null,
})).toEqual({
command: 'npx',
args: ['-y', '@playwright/mcp@latest', '--extension'],
usedNpxFallback: true,
});
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
} else {
delete process.env.CI;
}
}
});
it('times out slow promises', async () => {
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
});
it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
const execSync = vi.fn();
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
expect(execSync).not.toHaveBeenCalled();
expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
});
it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
const originalExecPath = process.execPath;
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
Object.defineProperty(process, 'execPath', {
value: runtimeExecPath,
configurable: true,
});
const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
const execSync = vi.fn();
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
try {
expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
expect(execSync).not.toHaveBeenCalled();
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
} finally {
Object.defineProperty(process, 'execPath', {
value: originalExecPath,
configurable: true,
});
}
});
it('falls back to npm root -g when runtime prefix lookup misses', () => {
const originalExecPath = process.execPath;
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
Object.defineProperty(process, 'execPath', {
value: runtimeExecPath,
configurable: true,
});
const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
const execSync = vi.fn((command: string) => {
if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
throw new Error(`unexpected command: ${String(command)}`);
});
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
try {
expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
expect(execSync).toHaveBeenCalledOnce();
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
} finally {
Object.defineProperty(process, 'execPath', {
value: originalExecPath,
configurable: true,
});
}
});
it('returns null when new global discovery paths are unavailable', () => {
const originalExecPath = process.execPath;
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
Object.defineProperty(process, 'execPath', {
value: runtimeExecPath,
configurable: true,
});
const existsSync = vi.fn(() => false);
const execSync = vi.fn((command: string) => {
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
throw new Error(`missing command: ${String(command)}`);
});
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
try {
expect(__test__.findMcpServerPath()).toBeNull();
} finally {
Object.defineProperty(process, 'execPath', {
value: originalExecPath,
configurable: true,
});
}
});
it('ignores non-server playwright cli paths discovered from fallback scans', () => {
const wrongCli = '/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/lib/mcp/terminal/cli.js';
const npxCacheBase = path.join(os.homedir(), '.npm', '_npx');
const existsSync = vi.fn((candidate: any) => {
const value = String(candidate);
return value === npxCacheBase || value === wrongCli;
});
const execSync = vi.fn((command: string) => {
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
if (String(command).includes('--package=@playwright/mcp which mcp-server-playwright')) return `${wrongCli}\n` as any;
if (String(command).includes('which mcp-server-playwright')) return '' as any;
if (String(command).includes(`find "${npxCacheBase}"`)) return `${wrongCli}\n` as any;
throw new Error(`unexpected command: ${String(command)}`);
});
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
expect(__test__.findMcpServerPath()).toBeNull();
});
});
describe('PlaywrightMCP state', () => {
@@ -288,22 +69,20 @@ describe('PlaywrightMCP state', () => {
const mcp = new PlaywrightMCP();
await mcp.close();
await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
await expect(mcp.connect()).rejects.toThrow('Session is closed');
});
it('rejects connect() while already connecting', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'connecting';
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
await expect(mcp.connect()).rejects.toThrow('Already connecting');
});
it('rejects connect() while closing', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'closing';
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
await expect(mcp.connect()).rejects.toThrow('Session is closing');
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* HTTP client for communicating with the opencli daemon.
*
* Provides a typed send() function that posts a Command and returns a Result.
*/
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
let _idCounter = 0;
function generateId(): string {
return `cmd_${Date.now()}_${++_idCounter}`;
}
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies';
tabId?: number;
code?: string;
url?: string;
op?: string;
index?: number;
domain?: string;
}
export interface DaemonResult {
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
/**
* Check if daemon is running.
*/
export async function isDaemonRunning(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
/**
* Check if daemon is running AND the extension is connected.
*/
export async function isExtensionConnected(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json() as { extensionConnected?: boolean };
return !!data.extensionConnected;
} catch {
return false;
}
}
/**
* Send a command to the daemon and wait for a result.
*/
export async function sendCommand(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
): Promise<unknown> {
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
const res = await fetch(`${DAEMON_URL}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
});
const result = (await res.json()) as DaemonResult;
if (!result.ok) {
throw new Error(result.error ?? 'Daemon command failed');
}
return result.data;
}
+19 -233
View File
@@ -1,241 +1,27 @@
/**
* MCP server path discovery and argument building.
*/
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
let _cachedMcpServerPath: string | null | undefined;
let _existsSync = fs.existsSync;
let _execSync = execSync;
function isSupportedMcpEntrypoint(candidate: string): boolean {
const normalized = candidate.replace(/\\/g, '/').toLowerCase();
return normalized.endsWith('/@playwright/mcp/cli.js') ||
normalized.endsWith('/mcp-server-playwright') ||
normalized.endsWith('/mcp-server-playwright.js');
}
function resolveSupportedMcpPath(candidate: string | null | undefined): string | null {
const trimmed = candidate?.trim();
if (!trimmed || !_existsSync(trimmed)) return null;
return isSupportedMcpEntrypoint(trimmed) ? trimmed : null;
}
export function resetMcpServerPathCache(): void {
_cachedMcpServerPath = undefined;
}
export function setMcpDiscoveryTestHooks(input?: {
existsSync?: typeof fs.existsSync;
execSync?: typeof execSync;
}): void {
_existsSync = input?.existsSync ?? fs.existsSync;
_execSync = input?.execSync ?? execSync;
}
export function findMcpServerPath(): string | null {
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
if (envMcp && _existsSync(envMcp)) {
_cachedMcpServerPath = envMcp;
return _cachedMcpServerPath;
}
// Check local node_modules first (@playwright/mcp is the modern package)
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
if (_existsSync(localMcp)) {
_cachedMcpServerPath = localMcp;
return _cachedMcpServerPath;
}
// Check project-relative path
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
if (_existsSync(projectMcp)) {
_cachedMcpServerPath = projectMcp;
return _cachedMcpServerPath;
}
// Check global npm/yarn locations derived from current Node runtime.
const nodePrefix = path.resolve(path.dirname(process.execPath), '..');
const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules');
const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js');
if (_existsSync(globalMcp)) {
_cachedMcpServerPath = globalMcp;
return _cachedMcpServerPath;
}
// Check npm global root directly.
try {
const npmRootGlobal = _execSync('npm root -g 2>/dev/null', {
encoding: 'utf-8',
timeout: 5000,
}).trim();
const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js');
if (npmRootGlobal && _existsSync(npmGlobalMcp)) {
_cachedMcpServerPath = npmGlobalMcp;
return _cachedMcpServerPath;
}
} catch {}
// Check common locations
const candidates = [
path.join(os.homedir(), '.npm', '_npx'),
path.join(os.homedir(), 'node_modules', '.bin'),
'/usr/local/lib/node_modules',
];
// Try npx resolution (legacy package name)
try {
const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
const resolved = resolveSupportedMcpPath(result);
if (resolved) {
_cachedMcpServerPath = resolved;
return _cachedMcpServerPath;
}
} catch {}
// Try which
try {
const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
const resolved = resolveSupportedMcpPath(result);
if (resolved) {
_cachedMcpServerPath = resolved;
return _cachedMcpServerPath;
}
} catch {}
// Search in common npx cache
for (const base of candidates) {
if (!_existsSync(base)) continue;
try {
const found = _execSync(`find "${base}" -type f -path "*/@playwright/mcp/cli.js" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
const resolved = resolveSupportedMcpPath(found);
if (resolved) {
_cachedMcpServerPath = resolved;
return _cachedMcpServerPath;
}
} catch {}
}
_cachedMcpServerPath = null;
return _cachedMcpServerPath;
}
/**
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
* Daemon discovery — simplified from MCP server path discovery.
*
* Starting with Chrome 144, users can enable remote debugging from
* chrome://inspect#remote-debugging without any command-line flags.
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
* in the user data directory, which we read to construct the WebSocket endpoint.
* Only needs to check if the daemon is running. No more file system
* scanning for @playwright/mcp locations.
*/
export function discoverChromeEndpoint(): string | null {
const candidates: string[] = [];
// User-specified Chrome data dir takes highest priority
if (process.env.CHROME_USER_DATA_DIR) {
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
}
import { isDaemonRunning } from './daemon-client.js';
// Standard Chrome/Edge user data dirs per platform
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'));
}
export { isDaemonRunning };
for (const filePath of candidates) {
try {
const content = fs.readFileSync(filePath, 'utf-8').trim();
const lines = content.split('\n');
if (lines.length >= 2) {
const port = parseInt(lines[0], 10);
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
return `ws://127.0.0.1:${port}${browserPath}`;
}
}
} catch {}
/**
* Check daemon status and return connection info.
*/
export async function checkDaemonStatus(): Promise<{
running: boolean;
extensionConnected: boolean;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const res = await fetch(`http://127.0.0.1:${port}/status`);
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
return { running: true, extensionConnected: data.extensionConnected };
} catch {
return { running: false, extensionConnected: false };
}
return null;
}
export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
const envVal = process.env.OPENCLI_CDP_ENDPOINT;
if (envVal === '1' || envVal?.toLowerCase() === 'true') {
const autoDiscovered = discoverChromeEndpoint();
return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
}
if (envVal) {
return { endpoint: envVal, requestedCdp: true };
}
// Fallback to auto-discovery if not explicitly set
const autoDiscovered = discoverChromeEndpoint();
if (autoDiscovered) {
return { endpoint: autoDiscovered, requestedCdp: true };
}
return { requestedCdp: false };
}
function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
const args: string[] = [];
// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
if (input?.cdpEndpoint) {
args.push('--cdp-endpoint', input.cdpEndpoint);
return args;
}
// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
if (!process.env.CI) {
args.push('--extension');
}
// CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
// xvfb provides a virtual display for headed mode in GitHub Actions.
if (input?.executablePath) {
args.push('--executable-path', input.executablePath);
}
return args;
}
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
return [input.mcpPath, ...buildRuntimeArgs(input)];
}
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
command: string;
args: string[];
usedNpxFallback: boolean;
} {
const runtimeArgs = buildRuntimeArgs(input);
if (input.mcpPath) {
return {
command: 'node',
args: [input.mcpPath, ...runtimeArgs],
usedNpxFallback: false,
};
}
return {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
usedNpxFallback: true,
};
}
+30 -100
View File
@@ -1,105 +1,35 @@
/**
* Browser connection error classification and formatting.
* Browser connection error helpers.
*
* Simplified — no more token/extension/CDP classification.
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
import { createHash } from 'node:crypto';
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
export type ConnectFailureInput = {
kind: ConnectFailureKind;
timeout: number;
hasExtensionToken: boolean;
tokenFingerprint?: string | null;
stderr?: string;
exitCode?: number | null;
rawMessage?: string;
};
export function getTokenFingerprint(token: string | undefined): string | null {
if (!token) return null;
return createHash('sha256').update(token).digest('hex').slice(0, 8);
}
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
const stderr = input.stderr?.trim();
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
if (input.kind === 'cdp-connection-failed') {
return new Error(
`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
`Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
`If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
suffix,
);
}
if (input.kind === 'missing-token') {
return new Error(
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
suffix,
);
}
if (input.kind === 'extension-not-installed') {
return new Error(
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
'If Chrome shows an approval dialog, click Allow.' +
suffix,
);
}
if (input.kind === 'extension-timeout') {
const likelyCause = input.hasExtensionToken
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
return new Error(
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
`${likelyCause} If a browser prompt is visible, click Allow.` +
suffix,
);
}
if (input.kind === 'mcp-init') {
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
}
if (input.kind === 'process-exit') {
return new Error(
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
suffix,
);
}
return new Error(input.rawMessage ?? 'Failed to connect to browser');
}
export function inferConnectFailureKind(args: {
hasExtensionToken: boolean;
stderr: string;
rawMessage?: string;
exited?: boolean;
isCdpMode?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
if (args.isCdpMode) {
if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
if (args.exited) return 'cdp-connection-failed';
return 'cdp-connection-failed';
}
if (!args.hasExtensionToken)
return 'missing-token';
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
return 'extension-not-installed';
if (args.rawMessage?.startsWith('MCP init failed:'))
return 'mcp-init';
if (args.exited)
return 'process-exit';
return 'extension-timeout';
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
switch (kind) {
case 'daemon-not-running':
return new Error(
'Cannot connect to opencli daemon.\n\n' +
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
'Make sure port 19825 is available.' +
(detail ? `\n\n${detail}` : ''),
);
case 'extension-not-connected':
return new Error(
'opencli Browser Bridge extension is not connected.\n\n' +
'Please install the extension:\n' +
' 1. Download from GitHub Releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder\n' +
' 4. Make sure Chrome is running' +
(detail ? `\n\n${detail}` : ''),
);
case 'command-failed':
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
default:
return new Error(detail ?? 'Failed to connect to browser');
}
}
+7 -10
View File
@@ -7,14 +7,16 @@
export { Page } from './page.js';
export { PlaywrightMCP } from './mcp.js';
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
export { resolveCdpEndpoint } from './discover.js';
export { isDaemonRunning } from './daemon-client.js';
// Test-only helpers — exposed for unit tests
// Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
export function getTokenFingerprint(_token: string | undefined): string | null {
return null;
}
// Test-only helpers
import { createJsonRpcRequest } from './mcp.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
import { withTimeoutMs } from '../runtime.js';
export const __test__ = {
@@ -22,10 +24,5 @@ export const __test__ = {
extractTabEntries,
diffTabIndexes,
appendLimited,
buildMcpArgs,
buildMcpLaunchSpec,
findMcpServerPath,
resetMcpServerPathCache,
setMcpDiscoveryTestHooks,
withTimeoutMs,
};
+85 -279
View File
@@ -1,312 +1,118 @@
/**
* Playwright MCP process manager.
* Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
* Browser session manager — auto-spawns daemon and provides IPage.
*
* Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
* for backward compatibility with main.ts and other consumers.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
import { PKG_VERSION } from '../version.js';
import { Page } from './page.js';
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
// JSON-RPC helpers
let _nextId = 1;
// Re-export for __test__ compatibility
let _jsonRpcId = 0;
export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
const id = _nextId++;
return {
id,
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
};
const id = ++_jsonRpcId;
return { id, message: JSON.stringify({ id, method, params }) };
}
/**
* Playwright MCP process manager.
* Browser factory: manages daemon lifecycle and provides IPage instances.
*
* Kept as `PlaywrightMCP` class name for backward compatibility.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = new Set();
private static _cleanupRegistered = false;
private static _registerGlobalCleanup() {
if (this._cleanupRegistered) return;
this._cleanupRegistered = true;
const cleanup = () => {
for (const inst of this._activeInsts) {
if (inst._proc && !inst._proc.killed) {
try { inst._proc.kill('SIGKILL'); } catch {}
}
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
}
private _proc: ChildProcess | null = null;
private _buffer = '';
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
private _daemonProc: ChildProcess | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
return new Promise<any>((resolve, reject) => {
if (!this._proc?.stdin?.writable) {
reject(new Error('Playwright MCP process is not writable'));
return;
}
const { id, message } = createJsonRpcRequest(method, params);
this._pending.set(id, { resolve, reject });
this._proc.stdin.write(message, (err) => {
if (!err) return;
this._pending.delete(id);
reject(err);
});
});
}
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Already connecting');
if (this._state === 'closing') throw new Error('Session is closing');
if (this._state === 'closed') throw new Error('Session is closed');
private _rejectPendingRequests(error: Error): void {
const pending = [...this._pending.values()];
this._pending.clear();
for (const waiter of pending) waiter.reject(error);
}
this._state = 'connecting';
private _resetAfterFailedConnect(): void {
const proc = this._proc;
this._page = null;
this._proc = null;
this._buffer = '';
this._initialTabIdentities = [];
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
PlaywrightMCP._activeInsts.delete(this);
if (proc && !proc.killed) {
try { proc.kill('SIGKILL'); } catch {}
try {
await this._ensureDaemon();
this._page = new Page();
this._state = 'connected';
return this._page;
} catch (err) {
this._state = 'idle';
throw err;
}
}
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
const mcpPath = findMcpServerPath();
PlaywrightMCP._registerGlobalCleanup();
PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
return new Promise<Page>((resolve, reject) => {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
const useExtension = !requestedCdp;
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
let stderrBuffer = '';
let settled = false;
const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
if (settled) return;
settled = true;
this._state = 'idle';
clearTimeout(timer);
this._resetAfterFailedConnect();
reject(formatBrowserConnectError({
kind,
timeout,
hasExtensionToken: !!extensionToken,
tokenFingerprint,
stderr: stderrBuffer,
exitCode: extra.exitCode,
rawMessage: extra.rawMessage,
}));
};
const settleSuccess = (pageToResolve: Page) => {
if (settled) return;
settled = true;
this._state = 'connected';
clearTimeout(timer);
resolve(pageToResolve);
};
const timer = setTimeout(() => {
debugLog('Connection timed out');
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
isCdpMode: requestedCdp,
}));
}, timeout * 1000);
const launchSpec = buildMcpLaunchSpec({
mcpPath,
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
cdpEndpoint,
});
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
if (launchSpec.usedNpxFallback) {
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
}
}
debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
this._proc = spawn(launchSpec.command, launchSpec.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Increase max listeners to avoid warnings
this._proc.setMaxListeners(20);
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
const page = new Page((method, params = {}) => this._sendRequest(method, params));
this._page = page;
this._proc.stdout?.on('data', (chunk: Buffer) => {
this._buffer += chunk.toString();
const lines = this._buffer.split('\n');
this._buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
debugLog(`RECV: ${line}`);
try {
const parsed = JSON.parse(line);
if (typeof parsed?.id === 'number') {
const waiter = this._pending.get(parsed.id);
if (waiter) {
this._pending.delete(parsed.id);
waiter.resolve(parsed);
}
}
} catch (e) {
debugLog(`Parse error: ${e}`);
}
}
});
this._proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
debugLog(`STDERR: ${text}`);
});
this._proc.on('error', (err) => {
debugLog(`Subprocess error: ${err.message}`);
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
settleError('process-exit', { rawMessage: err.message });
});
this._proc.on('close', (code) => {
debugLog(`Subprocess closed with code ${code}`);
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
if (!settled) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
exited: true,
isCdpMode: requestedCdp,
}), { exitCode: code });
}
});
// Initialize: send initialize request
debugLog('Waiting for initialize response...');
this._sendRequest('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'opencli', version: PKG_VERSION },
}).then((resp: any) => {
debugLog('Got initialize response');
if (resp.error) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
isCdpMode: requestedCdp,
}), { rawMessage: resp.error.message });
return;
}
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
debugLog(`SEND: ${initializedMsg.trim()}`);
this._proc?.stdin?.write(initializedMsg);
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
debugLog('Fetching initial tabs count...');
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
this._initialTabIdentities = extractTabIdentities(tabs);
settleSuccess(page);
}).catch((err: Error) => {
debugLog(`Tabs fetch error: ${err.message}`);
settleSuccess(page);
});
}).catch((err: Error) => {
debugLog(`Init promise rejected: ${err.message}`);
settleError('mcp-init', { rawMessage: err.message });
});
});
}
async close(): Promise<void> {
if (this._closingPromise) return this._closingPromise;
if (this._state === 'closed') return;
this._state = 'closing';
this._closingPromise = (async () => {
try {
// Extension mode opens bridge/session tabs that we can clean up best-effort.
if (this._page && this._proc && !this._proc.killed) {
try {
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
const tabEntries = extractTabEntries(tabs);
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
for (const index of tabsToClose) {
try { await this._page.closeTab(index); } catch {}
}
} catch {}
}
if (this._proc && !this._proc.killed) {
this._proc.kill('SIGTERM');
const exited = await new Promise<boolean>((res) => {
let done = false;
const finish = (value: boolean) => {
if (done) return;
done = true;
res(value);
};
this._proc?.once('exit', () => finish(true));
setTimeout(() => finish(false), 3000);
});
if (!exited && this._proc && !this._proc.killed) {
try { this._proc.kill('SIGKILL'); } catch {}
}
}
} finally {
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
this._page = null;
this._proc = null;
this._state = 'closed';
PlaywrightMCP._activeInsts.delete(this);
}
})();
return this._closingPromise;
// We don't kill the daemon — it auto-exits on idle.
// Just clean up our reference.
this._page = null;
this._state = 'closed';
}
private async _ensureDaemon(): Promise<void> {
if (await isDaemonRunning()) return;
// Find daemon relative to this file — works for both:
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
// node dist/main.js → dist/browser/mcp.js → dist/daemon.js
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const parentDir = path.resolve(__dirname, '..');
const daemonTs = path.join(parentDir, 'daemon.ts');
const daemonJs = path.join(parentDir, 'daemon.js');
const isTs = fs.existsSync(daemonTs);
const daemonPath = isTs ? daemonTs : daemonJs;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
}
// Use tsx for .ts files, node for .js files
const runtime = isTs ? 'npx' : process.execPath;
const args = isTs ? ['tsx', daemonPath] : [daemonPath];
this._daemonProc = spawn(runtime, args, {
detached: true,
stdio: 'ignore',
env: { ...process.env },
});
this._daemonProc.unref();
// Wait for daemon to be ready AND extension to connect
const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 300));
if (await isExtensionConnected()) return;
}
// Daemon might be up but extension not connected — give a useful error
if (await isDaemonRunning()) {
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
);
}
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
'Make sure port 19825 is available.',
);
}
}
+190 -87
View File
@@ -1,139 +1,236 @@
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
* Page abstraction — implements IPage by sending commands to the daemon.
*
* All browser operations are ultimately 'exec' (JS evaluation via CDP)
* plus a few native Chrome Extension APIs (tabs, cookies, navigate).
*
* IMPORTANT: After goto(), we remember the tabId returned by the navigate
* action and pass it to all subsequent commands. This avoids the issue
* where resolveTabId() in the extension picks a chrome:// or
* chrome-extension:// tab that can't be debugged.
*/
import { formatSnapshot } from '../snapshotFormatter.js';
import { normalizeEvaluateSource } from '../pipeline/template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
import type { IPage } from '../types.js';
import { BrowserConnectError } from '../errors.js';
import { sendCommand } from './daemon-client.js';
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
* Page — implements IPage by talking to the daemon via HTTP.
*/
export class Page implements IPage {
constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
const resp = await this._request(method, params);
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
// Extract text content from MCP result
const result = resp.result as any;
if (result?.isError) {
const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
throw new BrowserConnectError(
errorText,
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
);
}
if (result?.content) {
const textParts = result.content.filter((c: any) => c.type === 'text');
if (textParts.length >= 1) {
let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
// Some versions of the MCP return error text without the `isError` boolean flag
if (typeof text === 'string' && text.trim().startsWith('### Error')) {
throw new BrowserConnectError(
text.trim(),
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
);
}
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
// Strip the "### Ran Playwright code" suffix to get clean JSON
const codeMarker = text.indexOf('### Ran Playwright code');
if (codeMarker !== -1) {
text = text.slice(0, codeMarker).trim();
}
// Also handle "### Result\n[JSON]" format (some MCP versions)
const resultMarker = text.indexOf('### Result\n');
if (resultMarker !== -1) {
text = text.slice(resultMarker + '### Result\n'.length).trim();
}
try { return JSON.parse(text); } catch { return text; }
}
}
return result;
}
// --- High-level methods ---
/** Active tab ID, set after navigate and used in all subsequent commands */
private _tabId: number | undefined;
async goto(url: string): Promise<void> {
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
const result = await sendCommand('navigate', {
url,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
if (result?.tabId) {
this._tabId = result.tabId;
}
}
async evaluate(js: string): Promise<any> {
// Normalize IIFE format to function format expected by MCP browser_evaluate
const normalized = normalizeEvaluateSource(js);
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
// Wrap function-style code: `() => { ... }` or `async () => { ... }` → IIFE
const trimmed = normalized.trim();
const code = trimmed.startsWith('async')
? `(${trimmed})()`
: trimmed.startsWith('function') || trimmed.startsWith('(')
? `(${trimmed})()`
: trimmed;
return sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
// Use CDP Accessibility.getFullAXTree via exec
const code = `
(async () => {
// Build a simplified accessibility tree from the DOM
function buildTree(node, depth = 0) {
if (depth > ${opts.maxDepth ?? 50}) return '';
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
let indent = ' '.repeat(depth);
let line = indent + role;
if (name) line += ' "' + name.replace(/"/g, '\\"') + '"';
if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
let result = line + '\\n';
if (node.children) {
for (const child of node.children) {
result += buildTree(child, depth + 1);
}
}
return result;
}
return buildTree(document.body);
})()
`;
const raw = await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
const safeRef = JSON.stringify(ref);
const code = `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
if (!el) throw new Error('Element not found: ' + ref);
el.scrollIntoView({ behavior: 'instant', block: 'center' });
el.click();
return 'clicked';
})()
`;
await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async typeText(ref: string, text: string): Promise<void> {
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
const safeRef = JSON.stringify(ref);
const safeText = JSON.stringify(text);
const code = `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
if (!el) throw new Error('Element not found: ' + ref);
el.focus();
el.value = ${safeText};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return 'typed';
})()
`;
await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async pressKey(key: string): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
const code = `
(() => {
const el = document.activeElement || document.body;
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
return 'pressed';
})()
`;
await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
if (typeof options === 'number') {
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
} else {
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (options.time) {
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
const code = `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${timeout};
const check = () => {
if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
setTimeout(check, 200);
};
check();
})
`;
await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
}
async tabs(): Promise<any> {
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
return sendCommand('tabs', { op: 'list' });
}
async closeTab(index?: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
}
async newTab(): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
await sendCommand('tabs', { op: 'new' });
}
async selectTab(index: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
await sendCommand('tabs', { op: 'select', index });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
// Use performance API to get network entries
const code = `
(() => {
const entries = performance.getEntriesByType('resource');
return entries
${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
.map(e => ({
url: e.name,
type: e.initiatorType,
duration: Math.round(e.duration),
size: e.transferSize || 0,
}));
})()
`;
return sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async consoleMessages(level: string = 'info'): Promise<any> {
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
// Console messages can't be retrospectively read via exec.
// Return empty for now — users should use networkRequests or evaluate.
return [];
}
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
await sendCommand('exec', {
code: `window.scrollBy(${dx}, ${dy})`,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const js = `
async () => {
const maxTimes = ${times};
const maxWaitMs = ${delayMs};
for (let i = 0; i < maxTimes; i++) {
const code = `
(async () => {
for (let i = 0; i < ${times}; i++) {
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
@@ -142,30 +239,36 @@ export class Page implements IPage {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100); // Small debounce for rendering
setTimeout(resolve, 100);
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(null);
}, maxWaitMs);
timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
});
}
}
})()
`;
await this.evaluate(js);
await sendCommand('exec', {
code,
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async installInterceptor(pattern: string): Promise<void> {
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
await sendCommand('exec', {
code: generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}),
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
}
async getInterceptedRequests(): Promise<any[]> {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return result || [];
const result = await sendCommand('exec', {
code: generateReadInterceptedJs('__opencli_xhr'),
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
});
return (result as any[]) || [];
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
*
* Architecture:
* CLI → HTTP POST /command → daemon → WebSocket → Extension
* Extension → WebSocket result → daemon → HTTP response → CLI
*
* Lifecycle:
* - Auto-spawned by opencli on first browser command
* - Auto-exits after 5 minutes of idle
* - Listens on localhost:19825
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// ─── State ───────────────────────────────────────────────────────────
let extensionWs: WebSocket | null = null;
const pending = new Map<string, {
resolve: (data: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
let idleTimer: ReturnType<typeof setTimeout> | null = null;
// ─── Idle auto-exit ──────────────────────────────────────────────────
function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
console.error('[daemon] Idle timeout, shutting down');
process.exit(0);
}, IDLE_TIMEOUT);
}
// ─── HTTP Server ─────────────────────────────────────────────────────
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(data));
}
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = req.url ?? '/';
if (req.method === 'GET' && url === '/status') {
jsonResponse(res, 200, {
ok: true,
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
pending: pending.size,
});
return;
}
if (req.method === 'POST' && url === '/command') {
resetIdleTimer();
try {
const body = JSON.parse(await readBody(req));
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
return;
}
const result = await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(body.id);
reject(new Error('Command timeout (30s)'));
}, 30000);
pending.set(body.id, { resolve, reject, timer });
extensionWs!.send(JSON.stringify(body));
});
jsonResponse(res, 200, result);
} catch (err) {
jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
ok: false,
error: err instanceof Error ? err.message : 'Invalid request',
});
}
return;
}
jsonResponse(res, 404, { error: 'Not found' });
}
// ─── WebSocket for Extension ─────────────────────────────────────────
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
wss.on('connection', (ws) => {
console.error('[daemon] Extension connected');
extensionWs = ws;
ws.on('message', (data) => {
try {
const result = JSON.parse(data.toString());
const p = pending.get(result.id);
if (p) {
clearTimeout(p.timer);
pending.delete(result.id);
p.resolve(result);
}
} catch {
// Ignore malformed messages
}
});
ws.on('close', () => {
console.error('[daemon] Extension disconnected');
if (extensionWs === ws) {
extensionWs = null;
// Reject all pending requests since the extension is gone
for (const [id, p] of pending) {
clearTimeout(p.timer);
p.reject(new Error('Extension disconnected'));
}
pending.clear();
}
});
ws.on('error', () => {
if (extensionWs === ws) extensionWs = null;
});
});
// ─── Start ───────────────────────────────────────────────────────────
httpServer.listen(PORT, '127.0.0.1', () => {
console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`);
resetIdleTimer();
});
httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
process.exit(0);
}
console.error('[daemon] Server error:', err.message);
process.exit(1);
});
// Graceful shutdown
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
+32 -193
View File
@@ -1,223 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
readTokenFromShellContent,
renderBrowserDoctorReport,
upsertShellToken,
readTomlConfigToken,
upsertTomlConfigToken,
upsertJsonConfigToken,
} from './doctor.js';
describe('shell token helpers', () => {
it('reads token from shell export', () => {
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
});
it('appends token export when missing', () => {
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
});
it('replaces token export when present', () => {
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
expect(next).not.toContain('"old"');
});
});
describe('toml token helpers', () => {
it('reads token from playwright env section', () => {
const content = `
[mcp_servers.playwright.env]
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
`;
expect(readTomlConfigToken(content)).toBe('abc123');
});
it('updates token inside existing env section', () => {
const content = `
[mcp_servers.playwright.env]
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
`;
const next = upsertTomlConfigToken(content, 'new');
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
expect(next).not.toContain('"old"');
});
it('creates env section when missing', () => {
const content = `
[mcp_servers.playwright]
type = "stdio"
`;
const next = upsertTomlConfigToken(content, 'abc123');
expect(next).toContain('[mcp_servers.playwright.env]');
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
});
});
describe('json token helpers', () => {
it('writes token into standard mcpServers config', () => {
const next = upsertJsonConfigToken(JSON.stringify({
mcpServers: {
playwright: {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', '--extension'],
},
},
}), 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
it('writes token into opencode mcp config', () => {
const next = upsertJsonConfigToken(JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: {
playwright: {
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
enabled: true,
type: 'local',
},
},
}), 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
const next = upsertJsonConfigToken('', 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
expect(parsed.mcp).toBeUndefined();
});
it('creates OpenCode format when filePath contains opencode', () => {
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
const parsed = JSON.parse(next);
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
expect(parsed.mcpServers).toBeUndefined();
});
it('creates standard format when filePath is claude.json', () => {
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
});
describe('fish shell support', () => {
it('generates fish set -gx syntax for fish config path', () => {
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
expect(next).not.toContain('export');
});
it('replaces existing fish set line', () => {
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
expect(next).not.toContain('"old"');
});
it('appends fish syntax to existing fish config', () => {
const content = 'set -gx PATH /usr/bin\n';
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
expect(next).toContain('set -gx PATH /usr/bin');
});
it('uses export syntax for zshrc even with filePath', () => {
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
expect(next).not.toContain('set -gx');
});
});
import { renderBrowserDoctorReport } from './doctor.js';
describe('doctor report rendering', () => {
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
it('renders OK-style report when tokens match', () => {
it('renders OK-style report when daemon and extension connected', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
daemonRunning: true,
extensionConnected: true,
issues: [],
}));
expect(text).toContain('[OK] Extension installed (Chrome)');
expect(text).toContain('[OK] Environment token: configured (fp1)');
expect(text).toContain('[OK] /tmp/mcp.json');
expect(text).toContain('configured (fp1)');
expect(text).toContain('[OK] Daemon: running on port 19825');
expect(text).toContain('[OK] Extension: connected');
expect(text).toContain('Everything looks good!');
});
it('renders MISMATCH-style report when fingerprints differ', () => {
it('renders MISSING when daemon not running', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: null,
extensionFingerprint: null,
extensionInstalled: false,
extensionBrowsers: [],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
daemonRunning: false,
extensionConnected: false,
issues: ['Daemon is not running.'],
}));
expect(text).toContain('[MISSING] Extension not installed in any browser');
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
expect(text).toContain('configured (fp2)');
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
expect(text).toContain('[MISSING] Daemon: not running');
expect(text).toContain('[MISSING] Extension: not connected');
expect(text).toContain('Daemon is not running.');
});
it('renders extension not connected when daemon is running', () => {
const text = strip(renderBrowserDoctorReport({
daemonRunning: true,
extensionConnected: false,
issues: ['Daemon is running but the Chrome extension is not connected.'],
}));
expect(text).toContain('[OK] Daemon: running on port 19825');
expect(text).toContain('[MISSING] Extension: not connected');
});
it('renders connectivity OK when live test succeeds', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [],
configs: [],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
daemonRunning: true,
extensionConnected: true,
connectivity: { ok: true, durationMs: 1234 },
warnings: [],
issues: [],
}));
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
expect(text).toContain('[OK] Connectivity: connected in 1.2s');
});
it('renders connectivity WARN when not tested', () => {
it('renders connectivity SKIP when not tested', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [],
configs: [],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
daemonRunning: true,
extensionConnected: true,
issues: [],
}));
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
});
});
+74 -668
View File
@@ -1,47 +1,22 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
/**
* opencli doctor — diagnose and fix browser connectivity.
*
* Simplified for the daemon-based architecture. No more token management,
* MCP path discovery, or config file scanning.
*/
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import chalk from 'chalk';
import type { IPage } from './types.js';
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
import { checkDaemonStatus } from './browser/discover.js';
import { PlaywrightMCP } from './browser/index.js';
import { browserSession } from './runtime.js';
const PLAYWRIGHT_SERVER_NAME = 'playwright';
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
export type DoctorOptions = {
fix?: boolean;
yes?: boolean;
live?: boolean;
shellRc?: string;
configPaths?: string[];
token?: string;
cliVersion?: string;
};
export type ShellFileStatus = {
path: string;
exists: boolean;
token: string | null;
fingerprint: string | null;
};
export type McpConfigFormat = 'json' | 'toml';
export type McpConfigStatus = {
path: string;
exists: boolean;
format: McpConfigFormat;
token: string | null;
fingerprint: string | null;
writable: boolean;
parseError?: string;
};
export type ConnectivityResult = {
ok: boolean;
error?: string;
@@ -50,463 +25,22 @@ export type ConnectivityResult = {
export type DoctorReport = {
cliVersion?: string;
envToken: string | null;
envFingerprint: string | null;
extensionToken: string | null;
extensionFingerprint: string | null;
extensionInstalled: boolean;
extensionBrowsers: string[];
shellFiles: ShellFileStatus[];
configs: McpConfigStatus[];
recommendedToken: string | null;
recommendedFingerprint: string | null;
daemonRunning: boolean;
extensionConnected: boolean;
connectivity?: ConnectivityResult;
warnings: string[];
issues: string[];
};
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
function colorLabel(status: ReportStatus): string {
switch (status) {
case 'OK': return chalk.green('[OK]');
case 'MISSING': return chalk.red('[MISSING]');
case 'MISMATCH': return chalk.yellow('[MISMATCH]');
case 'WARN': return chalk.yellow('[WARN]');
}
}
function statusLine(status: ReportStatus, text: string): string {
return `${colorLabel(status)} ${text}`;
}
function tokenSummary(token: string | null, fingerprint: string | null): string {
if (!token) return chalk.dim('missing');
return `configured ${chalk.dim(`(${fingerprint})`)}`;
}
export function shortenPath(p: string): string {
const home = os.homedir();
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
}
export function toolName(p: string): string {
if (p.includes('.codex/')) return 'Codex';
if (p.includes('.cursor/')) return 'Cursor';
if (p.includes('.claude.json')) return 'Claude Code';
if (p.includes('antigravity')) return 'Antigravity';
if (p.includes('.gemini/settings')) return 'Gemini CLI';
if (p.includes('opencode')) return 'OpenCode';
if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
if (p.includes('.vscode/')) return 'VS Code';
if (p.includes('.mcp.json')) return 'Project MCP';
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
return '';
}
export function getDefaultShellRcPath(): string {
const shell = process.env.SHELL ?? '';
if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
return path.join(os.homedir(), '.zshrc');
}
function isFishConfig(filePath: string): boolean {
return filePath.endsWith('config.fish') || filePath.includes('/fish/');
}
/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
function isOpenCodeConfig(filePath: string): boolean {
return filePath.includes('opencode');
}
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
const home = os.homedir();
const candidates = [
path.join(home, '.codex', 'config.toml'),
path.join(home, '.codex', 'mcp.json'),
path.join(home, '.cursor', 'mcp.json'),
path.join(home, '.claude.json'),
path.join(home, '.gemini', 'settings.json'),
path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
path.join(home, '.config', 'opencode', 'opencode.json'),
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
path.join(cwd, '.cursor', 'mcp.json'),
path.join(cwd, '.vscode', 'mcp.json'),
path.join(cwd, '.opencode', 'opencode.json'),
path.join(cwd, '.mcp.json'),
];
return [...new Set(candidates)];
}
export function readTokenFromShellContent(content: string): string | null {
const m = content.match(TOKEN_LINE_RE);
return m?.[3] ?? null;
}
export function upsertShellToken(content: string, token: string, filePath?: string): string {
if (filePath && isFishConfig(filePath)) {
// Fish shell uses `set -gx` instead of `export`
const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
if (!content.trim()) return `${fishLine}\n`;
if (fishRe.test(content)) return content.replace(fishRe, fishLine);
return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
}
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
if (!content.trim()) return `${nextLine}\n`;
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
token
}"`);
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
}
function readJsonConfigToken(content: string): string | null {
try {
const parsed = JSON.parse(content);
return readTokenFromJsonObject(parsed);
} catch {
return null;
}
}
function readTokenFromJsonObject(parsed: any): string | null {
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
if (typeof direct === 'string' && direct) return direct;
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
if (typeof opencode === 'string' && opencode) return opencode;
return null;
}
export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
const parsed = content.trim() ? JSON.parse(content) : {};
// Determine format: use OpenCode format only if explicitly an opencode config,
// or if the existing content already uses `mcp` key (not `mcpServers`)
const useOpenCodeFormat = filePath
? isOpenCodeConfig(filePath)
: (!parsed.mcpServers && parsed.mcp);
if (useOpenCodeFormat) {
parsed.mcp = parsed.mcp ?? {};
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
enabled: true,
type: 'local',
};
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
} else {
parsed.mcpServers = parsed.mcpServers ?? {};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', '--extension'],
};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
}
return `${JSON.stringify(parsed, null, 2)}\n`;
}
export function readTomlConfigToken(content: string): string | null {
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
if (!sectionMatch) return null;
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
return tokenMatch?.[1] ?? null;
}
export function upsertTomlConfigToken(content: string, token: string): string {
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
if (envSectionRe.test(content)) {
return content.replace(envSectionRe, (section) => {
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
}
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
});
}
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
if (baseSectionRe.test(content)) {
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
}
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
}
export function fileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function canWrite(filePath: string): boolean {
try {
if (fileExists(filePath)) {
fs.accessSync(filePath, fs.constants.W_OK);
return true;
}
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function readConfigStatus(filePath: string): McpConfigStatus {
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
if (!fileExists(filePath)) {
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
return {
path: filePath,
exists: true,
format,
token,
fingerprint: getTokenFingerprint(token ?? undefined),
writable: canWrite(filePath),
};
} catch (error: any) {
return {
path: filePath,
exists: true,
format,
token: null,
fingerprint: null,
writable: canWrite(filePath),
parseError: error?.message ?? String(error),
};
}
}
/**
* Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
* directories across all browser base paths. Falls back to ['Default'] if none found.
* Test connectivity by attempting a real browser command.
*/
function enumerateProfiles(baseDirs: string[]): string[] {
const profiles = new Set<string>();
for (const base of baseDirs) {
if (!fileExists(base)) continue;
try {
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
profiles.add(entry.name);
}
}
} catch { /* permission denied, etc. */ }
}
return profiles.size > 0 ? [...profiles].sort() : ['Default'];
}
/**
* Discover the auth token stored by the Playwright MCP Bridge extension
* by scanning Chrome's LevelDB localStorage files directly.
*
* Reads LevelDB .ldb/.log files as raw binary and searches for the
* extension ID near base64url token values. This works reliably across
* platforms because LevelDB's internal encoding can split ASCII strings
* like "auth-token" and the extension ID across byte boundaries, making
* text-based tools like `strings` + `grep` unreliable.
*/
export function discoverExtensionToken(): string | null {
const home = os.homedir();
const platform = os.platform();
const bases: string[] = [];
if (platform === 'darwin') {
bases.push(
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
path.join(home, 'Library', 'Application Support', 'Chromium'),
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
);
} else if (platform === 'linux') {
bases.push(
path.join(home, '.config', 'google-chrome'),
path.join(home, '.config', 'google-chrome-unstable'),
path.join(home, '.config', 'google-chrome-beta'),
path.join(home, '.config', 'chromium'),
path.join(home, '.config', 'microsoft-edge'),
);
} else if (platform === 'win32') {
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
bases.push(
path.join(appData, 'Google', 'Chrome', 'User Data'),
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
);
}
const profiles = enumerateProfiles(bases);
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
for (const base of bases) {
for (const profile of profiles) {
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
if (!fileExists(dir)) continue;
const token = extractTokenViaBinaryRead(dir, tokenRe);
if (token) return token;
}
}
return null;
}
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
// LevelDB fragments strings across byte boundaries, so we can't search
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
// search for a short prefix of the extension ID that reliably appears as
// contiguous bytes, then scan a window around each match for a base64url
// token value.
//
// Observed LevelDB layout near the auth-token entry:
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
//
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
let files: string[];
try {
files = fs.readdirSync(dir)
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
.map(f => path.join(dir, f));
} catch { return null; }
// Sort by mtime descending so we find the freshest token first
files.sort((a, b) => {
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
});
for (const file of files) {
let data: Buffer;
try { data = fs.readFileSync(file); } catch { continue; }
// Quick check: file must contain at least the prefix
if (data.indexOf(extIdPrefix) === -1) continue;
// Strategy 1: scan after each occurrence of the extension ID prefix
// for base64url tokens within a 500-byte window
let idx = 0;
while (true) {
const pos = data.indexOf(extIdPrefix, idx);
if (pos === -1) break;
const scanStart = pos;
const scanEnd = Math.min(data.length, pos + 500);
const window = data.subarray(scanStart, scanEnd).toString('latin1');
const m = window.match(tokenRe);
if (m && validateBase64urlToken(m[1])) {
// Make sure this isn't another extension ID that happens to match
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
}
idx = pos + 1;
}
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
const keyBuf = Buffer.from('auth-token');
idx = 0;
while (true) {
const kp = data.indexOf(keyBuf, idx);
if (kp === -1) break;
const contextStart = Math.max(0, kp - 500);
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
const m = after.match(tokenRe);
if (m && validateBase64urlToken(m[1])) return m[1];
}
idx = kp + 1;
}
}
return null;
}
function validateBase64urlToken(token: string): boolean {
try {
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
const decoded = Buffer.from(b64, 'base64');
return decoded.length >= 28 && decoded.length <= 36;
} catch { return false; }
}
/**
* Check whether the Playwright MCP Bridge extension is installed in any browser.
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
*/
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
const home = os.homedir();
const platform = os.platform();
const browserDirs: Array<{ name: string; base: string }> = [];
if (platform === 'darwin') {
browserDirs.push(
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
);
} else if (platform === 'linux') {
browserDirs.push(
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
);
} else if (platform === 'win32') {
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
browserDirs.push(
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
);
}
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
const foundBrowsers: string[] = [];
for (const { name, base } of browserDirs) {
for (const profile of profiles) {
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
if (fileExists(extDir)) {
foundBrowsers.push(name);
break; // one match per browser is enough
}
}
}
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
}
/**
* Test token connectivity by attempting a real MCP connection.
* Connects, does the JSON-RPC handshake, and immediately closes.
*/
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
const timeout = opts?.timeout ?? 8;
export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
const start = Date.now();
try {
const mcp = new PlaywrightMCP();
await mcp.connect({ timeout });
const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
// Try a simple eval to verify end-to-end connectivity
await page.evaluate('1 + 1');
await mcp.close();
return { ok: true, durationMs: Date.now() - start };
} catch (err: any) {
@@ -515,215 +49,87 @@ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promi
}
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
const content = fs.readFileSync(filePath, 'utf-8');
const token = readTokenFromShellContent(content);
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
});
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
const configs = configPaths.map(readConfigStatus);
const status = await checkDaemonStatus();
// Try to discover the token directly from the Chrome extension's localStorage
const extensionToken = discoverExtensionToken();
const allTokens = [
opts.token ?? null,
extensionToken,
envToken,
...shellFiles.map(s => s.token),
...configs.map(c => c.token),
].filter((v): v is string => !!v);
const uniqueTokens = [...new Set(allTokens)];
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
// Check extension installation
const extInstall = checkExtensionInstalled();
// Connectivity test (only when --live)
let connectivity: ConnectivityResult | undefined;
if (opts.live) {
connectivity = await checkTokenConnectivity();
connectivity = await checkConnectivity();
}
const report: DoctorReport = {
const issues: string[] = [];
if (!status.running) {
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
}
if (status.running && !status.extensionConnected) {
issues.push(
'Daemon is running but the Chrome extension is not connected.\n' +
'Please install the opencli Browser Bridge extension:\n' +
' 1. Download from GitHub Releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder',
);
}
if (connectivity && !connectivity.ok) {
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
}
return {
cliVersion: opts.cliVersion,
envToken,
envFingerprint: getTokenFingerprint(envToken ?? undefined),
extensionToken,
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
extensionInstalled: extInstall.installed,
extensionBrowsers: extInstall.browsers,
shellFiles,
configs,
recommendedToken,
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
daemonRunning: status.running,
extensionConnected: status.extensionConnected,
connectivity,
warnings: [],
issues: [],
issues,
};
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
for (const config of configs) {
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
}
if (!recommendedToken) {
report.warnings.push('No token source found.');
}
return report;
}
export function renderBrowserDoctorReport(report: DoctorReport): string {
const tokenFingerprints = [
report.extensionFingerprint,
report.envFingerprint,
...report.shellFiles.map(shell => shell.fingerprint),
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
].filter((value): value is string => !!value);
const uniqueFingerprints = [...new Set(tokenFingerprints)];
const hasMismatch = uniqueFingerprints.length > 1;
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
// CDP endpoint mode (for remote/server environments)
const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (cdpEndpoint) {
lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
lines.push('');
return lines.join('\n');
}
// Daemon status
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
const installDetail = report.extensionInstalled
? `Extension installed (${report.extensionBrowsers.join(', ')})`
: 'Extension not installed in any browser';
lines.push(statusLine(installStatus, installDetail));
// Extension status
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
for (const shell of report.shellFiles) {
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
const tool = toolName(shell.path);
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
}
const existingConfigs = report.configs.filter(config => config.exists);
const missingConfigCount = report.configs.length - existingConfigs.length;
if (existingConfigs.length > 0) {
for (const config of existingConfigs) {
const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
const configStatus: ReportStatus = config.parseError
? 'WARN'
: !config.token
? 'MISSING'
: hasMismatch
? 'MISMATCH'
: 'OK';
const tool = toolName(config.path);
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
}
} else {
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
}
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
lines.push('');
// Connectivity result
// Connectivity
if (report.connectivity) {
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
const connDetail = report.connectivity.ok
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
lines.push(statusLine(connStatus, connDetail));
const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
const detail = report.connectivity.ok
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
: `failed (${report.connectivity.error ?? 'unknown'})`;
lines.push(`${connIcon} Connectivity: ${detail}`);
} else {
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
}
lines.push(statusLine(
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
));
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
for (const issue of report.issues) lines.push(chalk.dim(`${issue}`));
}
if (report.warnings.length) {
lines.push('', chalk.yellow('Warnings:'));
for (const warning of report.warnings) lines.push(chalk.dim(`${warning}`));
for (const issue of report.issues) {
lines.push(chalk.dim(`${issue}`));
}
} else if (report.daemonRunning && report.extensionConnected) {
lines.push('', chalk.green('Everything looks good!'));
}
return lines.join('\n');
}
async function confirmPrompt(question: string): Promise<boolean> {
const rl = createInterface({ input, output });
try {
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
return answer === 'y' || answer === 'yes';
} finally {
rl.close();
}
}
export function writeFileWithMkdir(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
const token = opts.token ?? report.recommendedToken;
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
const fp = getTokenFingerprint(token);
const plannedWrites: string[] = [];
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
for (const config of report.configs) {
if (!config.writable) continue;
if (config.fingerprint === fp) continue; // already correct
plannedWrites.push(config.path);
}
if (plannedWrites.length === 0) {
console.log(chalk.green('All config files are already up to date.'));
return [];
}
if (!opts.yes) {
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
if (!ok) return [];
}
const written: string[] = [];
if (plannedWrites.includes(shellPath)) {
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
written.push(shellPath);
}
for (const config of report.configs) {
if (!plannedWrites.includes(config.path)) continue;
if (config.parseError) continue;
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
const next = config.format === 'toml'
? upsertTomlConfigToken(before, token)
: upsertJsonConfigToken(before, token, config.path);
writeFileWithMkdir(config.path, next);
written.push(config.path);
}
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
return written;
}
// Backward compatibility exports (no-ops for things that no longer exist)
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
export function discoverExtensionToken(): string | null { return null; }
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } { return { installed: false, browsers: [] }; }
export function applyBrowserDoctorFix(): Promise<string[]> { return Promise.resolve([]); }
export function getDefaultShellRcPath(): string { return ''; }
export function getDefaultMcpConfigPaths(): string[] { return []; }
export function readTokenFromShellContent(_content: string): string | null { return null; }
export function upsertShellToken(content: string): string { return content; }
export function upsertJsonConfigToken(content: string): string { return content; }
export function readTomlConfigToken(_content: string): string | null { return null; }
export function upsertTomlConfigToken(content: string): string { return content; }
export function shortenPath(p: string): string { return p; }
export function toolName(_p: string): string { return ''; }
export function fileExists(filePath: string): boolean { try { return require('node:fs').existsSync(filePath); } catch { return false; } }
export function writeFileWithMkdir(_p: string, _c: string): void {}
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> { return checkConnectivity(opts); }
+6 -23
View File
@@ -119,36 +119,19 @@ program.command('cascade').description('Strategy cascade: find simplest working
});
program.command('doctor')
.description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
.option('--token <token>', 'Override token to write instead of auto-detecting')
.description('Diagnose opencli browser bridge connectivity')
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
.option('--shell-rc <path>', 'Shell startup file to update')
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
.action(async (opts) => {
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
console.log(renderBrowserDoctorReport(report));
if (opts.fix) {
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
console.log();
if (written.length > 0) {
console.log(chalk.green('Updated files:'));
for (const filePath of written) console.log(`- ${filePath}`);
} else {
console.log(chalk.yellow('No files were changed.'));
}
}
});
program.command('setup')
.description('Interactive setup: configure Playwright MCP token across all detected tools')
.option('--token <token>', 'Provide token directly instead of auto-detecting')
.action(async (opts) => {
.description('Interactive setup: verify browser bridge connectivity')
.action(async () => {
const { runSetup } = await import('./setup.js');
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
await runSetup({ cliVersion: PKG_VERSION });
});
program.command('completion')
+1 -1
View File
@@ -52,7 +52,7 @@ export async function stepSnapshot(page: IPage | null, params: any, _data: any,
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const js = String(render(params, { args, data }));
let result = await page!.evaluate(normalizeEvaluateSource(js));
let result = await page!.evaluate(js);
// MCP may return JSON as a string — auto-parse it
if (typeof result === 'string') {
const trimmed = result.trim();
+47 -183
View File
@@ -1,205 +1,69 @@
/**
* setup.ts — Interactive Playwright MCP token setup
* setup.ts — Interactive browser setup for opencli
*
* Discovers the extension token, shows an interactive checkbox
* for selecting which config files to update, and applies changes.
* Simplified for daemon-based architecture. No more token management.
* Just verifies daemon + extension connectivity.
*/
import * as fs from 'node:fs';
import chalk from 'chalk';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import {
type DoctorReport,
PLAYWRIGHT_TOKEN_ENV,
checkExtensionInstalled,
checkTokenConnectivity,
discoverExtensionToken,
fileExists,
getDefaultShellRcPath,
runBrowserDoctor,
shortenPath,
toolName,
upsertJsonConfigToken,
upsertShellToken,
upsertTomlConfigToken,
writeFileWithMkdir,
} from './doctor.js';
import { getTokenFingerprint } from './browser/index.js';
import { type CheckboxItem, checkboxPrompt } from './tui.js';
import { checkDaemonStatus } from './browser/discover.js';
import { checkConnectivity } from './doctor.js';
import { PlaywrightMCP } from './browser/index.js';
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
console.log();
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
console.log();
// Step 1: Discover token
let token = opts.token ?? null;
// Step 1: Check daemon
console.log(chalk.dim(' Checking daemon status...'));
const status = await checkDaemonStatus();
if (!token) {
const extensionToken = discoverExtensionToken();
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
if (extensionToken && envToken && extensionToken === envToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
} else if (extensionToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
if (envToken && envToken !== extensionToken) {
console.log(` ${chalk.yellow('!')} Environment has different token ` +
chalk.dim(`(${getTokenFingerprint(envToken)})`));
}
} else if (envToken) {
token = envToken;
console.log(` ${chalk.green('✓')} Token from environment variable ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
if (status.running) {
console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
} else {
console.log(` ${chalk.green('')} Using provided token ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
console.log(` ${chalk.yellow('!')} Daemon is not running`);
console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
console.log(chalk.dim(' Starting daemon now...'));
if (!token) {
// Give precise diagnosis of why token scan failed
const extInstall = checkExtensionInstalled();
console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
if (!extInstall.installed) {
console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
} else {
console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
}
console.log();
console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
console.log();
const rl = createInterface({ input, output });
const answer = await rl.question(' Token (press Enter to abort): ');
rl.close();
token = answer.trim();
if (!token) {
console.log(chalk.red('\n No token provided. Aborting.\n'));
return;
// Try to spawn daemon
const mcp = new PlaywrightMCP();
try {
await mcp.connect({ timeout: 5 });
await mcp.close();
console.log(` ${chalk.green('✓')} Daemon started successfully`);
} catch {
console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
}
}
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
console.log();
// Step 2: Scan all config locations
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
// Step 3: Build checkbox items
const items: CheckboxItem[] = [];
// Shell file
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
const shellStatus = report.shellFiles[0];
const shellFp = shellStatus?.fingerprint;
const shellOk = shellFp === fingerprint;
const shellTool = toolName(shellPath) || 'Shell';
items.push({
label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
value: `shell:${shellPath}`,
checked: !shellOk,
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
});
// Config files
for (const config of report.configs) {
const fp = config.fingerprint;
const ok = fp === fingerprint;
const tool = toolName(config.path);
items.push({
label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
value: `config:${config.path}`,
checked: false, // let user explicitly select which tools to configure
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
statusColor: ok ? 'green' : 'yellow',
});
}
// Step 4: Show interactive checkbox
console.clear();
const selected = await checkboxPrompt(items, {
title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
});
if (selected.length === 0) {
console.log(chalk.dim(' No changes made.\n'));
// Step 2: Check extension
const statusAfter = await checkDaemonStatus();
if (statusAfter.extensionConnected) {
console.log(` ${chalk.green('✓')} Chrome extension connected`);
} else {
console.log(` ${chalk.red('✗')} Chrome extension not connected`);
console.log();
console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
console.log(chalk.dim(' 1. Download from GitHub Releases'));
console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
console.log(chalk.dim(' 4. Make sure Chrome is running'));
console.log();
return;
}
// Step 5: Apply changes
const written: string[] = [];
let wroteShell = false;
for (const sel of selected) {
if (sel.startsWith('shell:')) {
const p = sel.slice('shell:'.length);
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
writeFileWithMkdir(p, upsertShellToken(before, token, p));
written.push(p);
wroteShell = true;
} else if (sel.startsWith('config:')) {
const p = sel.slice('config:'.length);
const config = report.configs.find(c => c.path === p);
if (config && config.parseError) continue;
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
writeFileWithMkdir(p, next);
written.push(p);
}
}
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
// Step 6: Summary
if (written.length > 0) {
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
for (const p of written) {
const tool = toolName(p);
console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
}
if (wroteShell) {
console.log();
console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
}
// Step 3: Test connectivity
console.log();
console.log(chalk.dim(' Testing browser connectivity...'));
const conn = await checkConnectivity({ timeout: 5 });
if (conn.ok) {
console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
console.log();
console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
} else {
console.log(chalk.yellow(' No files were changed.'));
}
console.log();
// Step 7: Auto-verify browser connectivity
console.log(chalk.dim(' Verifying browser connectivity...'));
try {
const result = await checkTokenConnectivity({ timeout: 5 });
if (result.ok) {
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
} else {
console.log(` ${chalk.green('✓')} Token saved successfully.`);
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
}
} catch {
console.log(` ${chalk.green('✓')} Token saved successfully.`);
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
}
console.log();
}
function padRight(s: string, n: number): string {
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
}