diff --git a/README.md b/README.md index faa4f52..5de718b 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,12 @@ npx vitest run tests/e2e/ # E2E tests - **"Failed to connect to Playwright MCP Bridge"** - Ensure the Playwright MCP extension is installed and **enabled** in your running Chrome. - Restart the Chrome browser if you just installed the extension. + - If you are running on a VPS or CI host, force standalone mode instead of the extension: + ```bash + export OPENCLI_BROWSER_MODE=standalone + export OPENCLI_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium + ``` + - If Chromium is running as root on Linux, also set `OPENCLI_MCP_NO_SANDBOX=1`. - **Empty data returns or 'Unauthorized' error** - Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page to prove you are human. - **Node API errors** diff --git a/README.zh-CN.md b/README.zh-CN.md index c994183..5a2265d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,16 @@ opencli setup > opencli doctor --fix # 修复不一致的配置(交互确认) > opencli doctor --fix -y # 无交互直接修复所有配置 > ``` +> +> **VPS / 无头环境提示**:如果你希望启动独立浏览器,而不是连接本地 Chrome 扩展模式,请设置: +> ```bash +> export OPENCLI_BROWSER_MODE=standalone +> export OPENCLI_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium +> ``` +> 如果是在 Linux root 环境跑 Chromium,还需要再加: +> ```bash +> export OPENCLI_MCP_NO_SANDBOX=1 +> ```
手动配置(备选方案) @@ -201,6 +211,12 @@ opencli cascade https://api.example.com/data - **"Failed to connect to Playwright MCP Bridge"** 报错 - 确保你当前的 Chrome 已安装且**开启了** Playwright MCP Bridge 浏览器插件。 - 如果是刚装完插件,需要重启 Chrome 浏览器。 + - 如果你跑在 VPS 或 CI 上,改成强制 standalone 模式,不要走扩展连接: + ```bash + export OPENCLI_BROWSER_MODE=standalone + export OPENCLI_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium + ``` + - 如果是在 Linux root 环境跑 Chromium,再设置 `OPENCLI_MCP_NO_SANDBOX=1`。 - **返回空数据,或者报错 "Unauthorized"** - Chrome 里的登录态可能已经过期(甚至被要求过滑动验证码)。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。 - **Node API 错误 (如 parseArgs, fs 等)** diff --git a/TESTING.md b/TESTING.md index 59fd647..f043a54 100644 --- a/TESTING.md +++ b/TESTING.md @@ -213,9 +213,17 @@ CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径: ```yaml env: + OPENCLI_BROWSER_MODE: standalone OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} ``` +如果在 Linux root 环境运行 Chromium,可额外设置: + +```yaml +env: + OPENCLI_MCP_NO_SANDBOX: '1' +``` + --- ## 站点兼容性 diff --git a/src/browser.test.ts b/src/browser.test.ts index e3ad022..04ddb25 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -2,6 +2,24 @@ import { describe, it, expect } from 'vitest'; import { PlaywrightMCP, __test__ } from './browser/index.js'; describe('browser helpers', () => { + const setUidForTest = (uid: number) => { + const saved = process.getuid; + Object.defineProperty(process, 'getuid', { + value: () => uid, + configurable: true, + }); + return () => { + if (saved) { + Object.defineProperty(process, 'getuid', { + value: saved, + configurable: true, + }); + } else { + delete (process as NodeJS.Process & { getuid?: () => number }).getuid; + } + }; + }; + it('creates JSON-RPC requests with unique ids', () => { const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' }); const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' }); @@ -51,7 +69,12 @@ describe('browser helpers', () => { it('builds extension MCP args in local mode (no CI)', () => { const savedCI = process.env.CI; + const savedMode = process.env.OPENCLI_BROWSER_MODE; + const savedNoSandbox = process.env.OPENCLI_MCP_NO_SANDBOX; + const restoreUid = setUidForTest(1000); delete process.env.CI; + delete process.env.OPENCLI_BROWSER_MODE; + delete process.env.OPENCLI_MCP_NO_SANDBOX; try { expect(__test__.buildMcpArgs({ mcpPath: '/tmp/cli.js', @@ -70,19 +93,22 @@ describe('browser helpers', () => { '--extension', ]); } finally { - if (savedCI !== undefined) { - process.env.CI = savedCI; - } else { - delete process.env.CI; - } + restoreUid(); + if (savedCI !== undefined) process.env.CI = savedCI; else delete process.env.CI; + if (savedMode !== undefined) process.env.OPENCLI_BROWSER_MODE = savedMode; else delete process.env.OPENCLI_BROWSER_MODE; + if (savedNoSandbox !== undefined) process.env.OPENCLI_MCP_NO_SANDBOX = savedNoSandbox; else delete process.env.OPENCLI_MCP_NO_SANDBOX; } }); it('builds standalone MCP args in CI mode', () => { const savedCI = process.env.CI; + const savedMode = process.env.OPENCLI_BROWSER_MODE; + const savedNoSandbox = process.env.OPENCLI_MCP_NO_SANDBOX; + const restoreUid = setUidForTest(1000); process.env.CI = 'true'; + delete process.env.OPENCLI_BROWSER_MODE; + delete process.env.OPENCLI_MCP_NO_SANDBOX; try { - // CI mode: no --extension — browser launches in standalone headed mode expect(__test__.buildMcpArgs({ mcpPath: '/tmp/cli.js', })).toEqual([ @@ -98,11 +124,75 @@ describe('browser helpers', () => { '/usr/bin/chromium', ]); } finally { - if (savedCI !== undefined) { - process.env.CI = savedCI; - } else { - delete process.env.CI; - } + restoreUid(); + if (savedCI !== undefined) process.env.CI = savedCI; else delete process.env.CI; + if (savedMode !== undefined) process.env.OPENCLI_BROWSER_MODE = savedMode; else delete process.env.OPENCLI_BROWSER_MODE; + if (savedNoSandbox !== undefined) process.env.OPENCLI_MCP_NO_SANDBOX = savedNoSandbox; else delete process.env.OPENCLI_MCP_NO_SANDBOX; + } + }); + + it('allows forcing standalone mode outside CI', () => { + const savedCI = process.env.CI; + const savedMode = process.env.OPENCLI_BROWSER_MODE; + const restoreUid = setUidForTest(1000); + delete process.env.CI; + process.env.OPENCLI_BROWSER_MODE = 'standalone'; + try { + expect(__test__.buildMcpArgs({ + mcpPath: '/tmp/cli.js', + })).toEqual([ + '/tmp/cli.js', + ]); + } finally { + restoreUid(); + if (savedCI !== undefined) process.env.CI = savedCI; else delete process.env.CI; + if (savedMode !== undefined) process.env.OPENCLI_BROWSER_MODE = savedMode; else delete process.env.OPENCLI_BROWSER_MODE; + } + }); + + it('allows forcing extension mode in CI', () => { + const savedCI = process.env.CI; + const savedMode = process.env.OPENCLI_BROWSER_MODE; + const restoreUid = setUidForTest(1000); + process.env.CI = 'true'; + process.env.OPENCLI_BROWSER_MODE = 'extension'; + try { + expect(__test__.buildMcpArgs({ + mcpPath: '/tmp/cli.js', + })).toEqual([ + '/tmp/cli.js', + '--extension', + ]); + } finally { + restoreUid(); + if (savedCI !== undefined) process.env.CI = savedCI; else delete process.env.CI; + if (savedMode !== undefined) process.env.OPENCLI_BROWSER_MODE = savedMode; else delete process.env.OPENCLI_BROWSER_MODE; + } + }); + + it('adds no-sandbox when explicitly requested', () => { + const savedCI = process.env.CI; + const savedMode = process.env.OPENCLI_BROWSER_MODE; + const savedNoSandbox = process.env.OPENCLI_MCP_NO_SANDBOX; + const restoreUid = setUidForTest(1000); + process.env.CI = 'true'; + delete process.env.OPENCLI_BROWSER_MODE; + process.env.OPENCLI_MCP_NO_SANDBOX = '1'; + try { + expect(__test__.buildMcpArgs({ + mcpPath: '/tmp/cli.js', + executablePath: '/usr/bin/chromium', + })).toEqual([ + '/tmp/cli.js', + '--executable-path', + '/usr/bin/chromium', + '--no-sandbox', + ]); + } finally { + restoreUid(); + if (savedCI !== undefined) process.env.CI = savedCI; else delete process.env.CI; + if (savedMode !== undefined) process.env.OPENCLI_BROWSER_MODE = savedMode; else delete process.env.OPENCLI_BROWSER_MODE; + if (savedNoSandbox !== undefined) process.env.OPENCLI_MCP_NO_SANDBOX = savedNoSandbox; else delete process.env.OPENCLI_MCP_NO_SANDBOX; } }); @@ -142,6 +232,4 @@ describe('PlaywrightMCP state', () => { await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing'); }); - - }); diff --git a/src/browser/discover.ts b/src/browser/discover.ts index 56f8538..c76ad68 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -75,16 +75,31 @@ export function findMcpServerPath(): string | null { return _cachedMcpServerPath; } +type BrowserMode = 'extension' | 'standalone'; + +function getBrowserMode(): BrowserMode { + const override = process.env.OPENCLI_BROWSER_MODE?.trim().toLowerCase(); + if (override === 'extension' || override === 'standalone') return override; + return process.env.CI ? 'standalone' : 'extension'; +} + +function shouldDisableSandbox(): boolean { + if (process.env.OPENCLI_MCP_NO_SANDBOX === '1') return true; + return process.platform === 'linux' && process.getuid?.() === 0; +} + export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { const args = [input.mcpPath]; - if (!process.env.CI) { - // Local: always connect to user's running Chrome via MCP Bridge extension + if (getBrowserMode() === 'extension') { + // Extension mode connects to the user's running Chrome via MCP Bridge. 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. + // Standalone mode launches its own browser. CI uses this by default. if (input.executablePath) { args.push('--executable-path', input.executablePath); } + if (shouldDisableSandbox()) { + args.push('--no-sandbox'); + } return args; }