feat(browser): add CDP remote connection support for server environments (#52)

* 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 <noreply@anthropic.com>

* 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 <yj976240184@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-03-18 15:11:14 +08:00
1 parent 2d52abde7c
commit abfd4b902c
9 files changed
+328 -9

No files matched your search

+103
View File
@@ -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.
+103
View File
@@ -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` 配置文件中。
+4
View File
@@ -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.
<details>
<summary>Manual setup (alternative)</summary>
+4
View File
@@ -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)。
<details>
<summary>手动配置(备选方案)</summary>
+79 -5
View File
@@ -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/<GUID>
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;
+17 -1
View File
@@ -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'))
+1
View File
@@ -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';
+8 -3
View File
@@ -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<Page>((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;
}
+9
View File
@@ -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(', ')})`