From abfd4b902cb1305bce7969fdd34429bd93a6ccb8 Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 18 Mar 2026 15:11:14 +0800 Subject: [PATCH] feat(browser): add CDP remote connection support for server environments (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(browser): add CDP remote connection support for server environments This feature enables OpenCLI to connect to a Chrome browser running on a different machine (e.g., your local computer) from a headless server environment via Chrome DevTools Protocol (CDP). Server environments (CI, cloud VMs, headless Linux) cannot run Chrome with a GUI or install the Playwright MCP Bridge extension. This makes it impossible to use OpenCLI commands that require browser authentication. Add support for the `OPENCLI_CDP_ENDPOINT` environment variable, which tells OpenCLI to connect to a remote Chrome instance via CDP instead of using the local extension mode. 1. Start Chrome with remote debugging on local machine: ``` chrome --remote-debugging-port=9222 --user-data-dir="$HOME/chrome-debug" ``` 2. Create SSH tunnel to forward port to server: ``` ssh -R 9222:localhost:9222 your-server ``` 3. Run OpenCLI on server: ``` export OPENCLI_CDP_ENDPOINT="http://localhost:9222" opencli bilibili hot --limit 5 ``` - src/browser.ts: Add CDP endpoint detection in buildMcpArgs() - src/doctor.ts: Show CDP mode status in doctor report - README.md: Add "Remote Chrome (Server/Headless)" section - README.zh-CN.md: Add corresponding Chinese documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * docs: extract CDP connection guide into separate files * docs: clarify CDP vs SSH/Proxy distinction in CDP guides * docs: restructure CDP guides into 3 distinct phases (preparation, tunnel, execution) --------- Co-authored-by: ByteYue Co-authored-by: Claude Opus 4.5 --- CDP.md | 103 ++++++++++++++++++++++++++++++++++++++++ CDP.zh-CN.md | 103 ++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ README.zh-CN.md | 4 ++ src/browser/discover.ts | 84 ++++++++++++++++++++++++++++++-- src/browser/errors.ts | 18 ++++++- src/browser/index.ts | 1 + src/browser/mcp.ts | 11 +++-- src/doctor.ts | 9 ++++ 9 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 CDP.md create mode 100644 CDP.zh-CN.md diff --git a/CDP.md b/CDP.md new file mode 100644 index 0000000..a833d88 --- /dev/null +++ b/CDP.md @@ -0,0 +1,103 @@ +# Connecting OpenCLI via CDP (Remote/Headless Servers) + +If you cannot use the Playwright MCP Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**. + +Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel. + +This guide is broken down into three phases: +1. **Preparation**: Start Chrome with CDP enabled locally. +2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**. +3. **Execution**: Run OpenCLI on your server. + +--- + +## Phase 1: Preparation (Local Machine) + +First, you need to start a Chrome browser on your local machine with remote debugging enabled. + +**macOS:** +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/chrome-debug-profile" \ + --remote-allow-origins="*" +``` + +**Linux:** +```bash +google-chrome \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/chrome-debug-profile" \ + --remote-allow-origins="*" +``` + +**Windows:** +```cmd +"C:\Program Files\Google\Chrome\Application\chrome.exe" ^ + --remote-debugging-port=9222 ^ + --user-data-dir="%USERPROFILE%\chrome-debug-profile" ^ + --remote-allow-origins="*" +``` + +> **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok). + +Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies. + +--- + +## Phase 2: Remote Access Methods + +Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions. + +### Method A: SSH Tunnel (Recommended) + +If your local machine has SSH access to the remote server, this is the most secure and straightforward method. + +Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`: + +```bash +ssh -R 9222:localhost:9222 your-server-user@your-server-ip +``` + +Leave this SSH session running in the background. + +### Method B: Reverse Proxy (ngrok / frp / socat) + +If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`. + +Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok: + +```bash +ngrok http 9222 +``` + +This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**. + +--- + +## Phase 3: Execution (Remote Server) + +Now switch to your **Remote Server** where OpenCLI is installed. + +Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands. + +### If you used Method A (SSH Tunnel): + +```bash +export OPENCLI_CDP_ENDPOINT="http://localhost:9222" +opencli doctor # Verify connection +opencli bilibili hot --limit 5 # Test a command +``` + +### If you used Method B (Reverse Proxy like ngrok): + +```bash +# Use the URL you copied from ngrok earlier +export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app" +opencli doctor # Verify connection +opencli bilibili hot --limit 5 # Test a command +``` + +> *Tip: OpenCLI automatically requests the `/json/version` HTTP endpoint to discover the underlying WebSocket URL if you provide a standard HTTP/HTTPS address.* + +If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server. diff --git a/CDP.zh-CN.md b/CDP.zh-CN.md new file mode 100644 index 0000000..5641ed6 --- /dev/null +++ b/CDP.zh-CN.md @@ -0,0 +1,103 @@ +# 通过 CDP 远程连接 OpenCLI (服务器/无头环境) + +如果你无法使用 Playwright MCP Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。 + +出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。 + +本指南将整个过程拆分为三个阶段: +1. **阶段一:准备工作**(在本地启动允许 CDP 调试的 Chrome)。 +2. **阶段二:建立网络隧道**(通过 **SSH反向隧道** 或 **反向代理工具**,将本地的 CDP 端口暴露给服务器)。 +3. **阶段三:执行命令**(在服务器端运行 OpenCLI)。 + +--- + +## 阶段一:准备工作 (本地电脑) + +首先,你需要在你的本地电脑上,通过命令行参数启动一个开启了远程调试端口的 Chrome 实例。 + +**macOS:** +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/chrome-debug-profile" \ + --remote-allow-origins="*" +``` + +**Linux:** +```bash +google-chrome \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/chrome-debug-profile" \ + --remote-allow-origins="*" +``` + +**Windows:** +```cmd +"C:\Program Files\Google\Chrome\Application\chrome.exe" ^ + --remote-debugging-port=9222 ^ + --user-data-dir="%USERPROFILE%\chrome-debug-profile" ^ + --remote-allow-origins="*" +``` + +> **注意**:此处增加的 `--remote-allow-origins="*"` 参数对于较新版本的 Chrome 来说通常是[必需的],以允许来自反向代理(如 ngrok)的跨域 WebSocket 连接请求。 + +待这个新的浏览器实例打开后,**手工登录那些你打算使用的网站**(如 bilibili.com、zhihu.com 等),这可以让该浏览器的运行资料(Profile)保留上这些网站登录用的 Cookie。 + +--- + +## 阶段二:建立网络隧道 + +现在你的本地已经有了一个监听在 `9222` 端口的 CDP 服务,接下来,选择以下任意一种方式将其实际暴露给你的远端服务器。 + +### 方法 A:SSH 反向端口转发 (推荐) + +如果你的本地电脑可以直连远程服务器的 SSH,那么这是最简单且最安全的做法。 + +在你的 **本地电脑** 终端上直接运行这条 ssh 命令,将远程服务器的 `9222` 端口反向映射回本地的 `9222` 端口: + +```bash +ssh -R 9222:localhost:9222 your-server-user@your-server-ip +``` + +保持此 SSH 会话在后台运行即可。 + +### 方法 B:反向代理 / 内网穿透 (ngrok / frp / socat) + +如果因为 NAT 或防火墙等因素导致无法直连 SSH 服务器,你可以使用 `ngrok` 等内网穿透工具。 + +在 **本地电脑** 运行 ngrok 将本地的 `9222` 端口暴露到公网: + +```bash +ngrok http 9222 +``` + +此时终端里会打印出一段专属的转发 URL 地址(如:`https://abcdef.ngrok.app`)。**复制这一段 URL 地址备用**。 + +--- + +## 阶段三:执行命令 (远程服务器) + +现在,所有的准备工作已结束。请切换到你已安装好 OpenCLI 的 **远程服务器** 终端上。 + +根据你在上方阶段二所选择的隧道方案,在终端中配置对应的 `OPENCLI_CDP_ENDPOINT` 环境变量: + +### 若使用 方法 A (SSH 反向隧道): + +```bash +export OPENCLI_CDP_ENDPOINT="http://localhost:9222" +opencli doctor # 查看并验证连接是否通畅 +opencli bilibili hot --limit 5 # 执行目标命令 +``` + +### 若使用 方法 B (Ngrok 等反向代理): + +```bash +# 将刚刚使用 ngrok 得到的地址填入这里 +export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app" +opencli doctor # 查看并验证连接是否通畅 +opencli bilibili hot --limit 5 # 执行目标命令 +``` + +> *Tip: 如果你填写的是一个普通 HTTP/HTTPS 的 URL 地址,OpenCLI 会自动尝试抓取该地址下的 `/json/version` 节点,来动态解析并连接真正底层依赖的 WebSocket 地址。* + +如果你想在此服务器上永久启用该配置,可以将对应的 `export` 语句追加进入你的 `~/.bashrc` 或 `~/.zshrc` 配置文件中。 diff --git a/README.md b/README.md index 6eda20a..10ff2c3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A CLI tool that turns **any website** into a command-line interface — Bilibili - [Built-in Commands](#built-in-commands) - [Output Formats](#output-formats) - [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide) +- [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless) - [Testing](#testing) - [Troubleshooting](#troubleshooting) - [Releasing New Versions](#releasing-new-versions) @@ -69,6 +70,9 @@ The interactive TUI will: > opencli doctor --fix -y # Fix all configs non-interactively > ``` +**Alternative: CDP Mode (For Servers/Headless)** +If you cannot install the browser extension (e.g. running OpenCLI on a remote headless server), you can connect OpenCLI to your local Chrome via CDP using SSH tunnels or reverse proxies. See the [CDP Connection Guide](./CDP.md) for detailed instructions. +
Manual setup (alternative) diff --git a/README.zh-CN.md b/README.zh-CN.md index 2e78999..68460b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,7 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi - [内置命令](#内置命令) - [输出格式](#输出格式) - [致 AI Agent(开发者指南)](#致-ai-agent开发者指南) +- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境) - [常见问题排查](#常见问题排查) - [版本发布](#版本发布) - [License](#license) @@ -68,6 +69,9 @@ opencli setup > opencli doctor --fix -y # 无交互直接修复所有配置 > ``` +**备选方案:CDP 模式 (适用于服务器/无头环境)** +如果你无法安装浏览器扩展(比如在远程无头服务器上运行 OpenCLI),你可以通过 SSH 隧道或反向代理,利用 CDP (Chrome DevTools Protocol) 连接到本地的 Chrome 浏览器。详细指南请参考 [CDP 连接教程](./CDP.zh-CN.md)。 +
手动配置(备选方案) diff --git a/src/browser/discover.ts b/src/browser/discover.ts index 992acb6..c9783be 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -111,13 +111,87 @@ export function findMcpServerPath(): string | null { return _cachedMcpServerPath; } -function buildRuntimeArgs(input?: { executablePath?: string | null }): string[] { +/** + * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint. + * + * 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. + */ +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')); + } + + // 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')); + } + + 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/ + if (port > 0 && browserPath.startsWith('/devtools/browser/')) { + return `ws://127.0.0.1:${port}${browserPath}`; + } + } + } catch {} + } + 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) { - // Local: always connect to user's running Chrome via MCP Bridge extension args.push('--extension'); } - // CI: standalone mode — @playwright/mcp launches its own browser (headed by default). + + // 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); @@ -125,11 +199,11 @@ function buildRuntimeArgs(input?: { executablePath?: string | null }): string[] return args; } -export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { +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 }): { +export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): { command: string; args: string[]; usedNpxFallback: boolean; diff --git a/src/browser/errors.ts b/src/browser/errors.ts index c03e743..db41fbb 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; -export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown'; +export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown'; export type ConnectFailureInput = { kind: ConnectFailureKind; @@ -26,6 +26,15 @@ export function formatBrowserConnectError(input: ConnectFailureInput): Error { 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' + @@ -74,9 +83,16 @@ export function inferConnectFailureKind(args: { 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')) diff --git a/src/browser/index.ts b/src/browser/index.ts index be17baf..1ad2f8b 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -9,6 +9,7 @@ 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'; // Test-only helpers — exposed for unit tests import { createJsonRpcRequest } from './mcp.js'; diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index 7003577..b0967fd 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -9,7 +9,7 @@ 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 } from './discover.js'; +import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js'; import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; const STDERR_BUFFER_LIMIT = 16 * 1024; @@ -114,7 +114,8 @@ export class PlaywrightMCP { return new Promise((resolve, reject) => { const isDebug = process.env.DEBUG?.includes('opencli:mcp'); const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); - const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; + const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint(); + const useExtension = !requestedCdp; const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; const tokenFingerprint = getTokenFingerprint(extensionToken); let stderrBuffer = ''; @@ -150,15 +151,17 @@ export class PlaywrightMCP { 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: ${useExtension ? 'extension' : 'standalone'}`); + 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'); @@ -218,6 +221,7 @@ export class PlaywrightMCP { hasExtensionToken: !!extensionToken, stderr: stderrBuffer, exited: true, + isCdpMode: requestedCdp, }), { exitCode: code }); } }); @@ -235,6 +239,7 @@ export class PlaywrightMCP { hasExtensionToken: !!extensionToken, stderr: stderrBuffer, rawMessage: `MCP init failed: ${resp.error.message}`, + isCdpMode: requestedCdp, }), { rawMessage: resp.error.message }); return; } diff --git a/src/doctor.ts b/src/doctor.ts index 98066a2..3e75210 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -591,6 +591,15 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { 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'); + } + const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING'; const installDetail = report.extensionInstalled ? `Extension installed (${report.extensionBrowsers.join(', ')})`