202 lines
7.2 KiB
JavaScript
202 lines
7.2 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
// browserbase-mcp-wrapper.js v2.2
|
|||
|
|
// 本地安装 + 代理健康检查 + 启动重试
|
|||
|
|
// 替代旧版 npx 模式,消除下载超时和缓存碎片问题
|
|||
|
|
// v2.1: 修复环境变量泄露 -- 只传递业务必需的变量 (P1-11)
|
|||
|
|
// v2.2: 添加 STAGEHAND_AGENT_PROVIDER + Gemini CUA env 支持
|
|||
|
|
|
|||
|
|
const { spawn } = require('child_process');
|
|||
|
|
const path = require('path');
|
|||
|
|
const http = require('http');
|
|||
|
|
|
|||
|
|
// 路径常量
|
|||
|
|
const BOOTSTRAP_PATH = path.join(__dirname, 'undici-proxy-bootstrap.js');
|
|||
|
|
const LOCAL_MCP_BIN = path.join(__dirname, '..', 'mcp-servers', 'browserbase',
|
|||
|
|
'node_modules', '@browserbasehq', 'mcp-server-browserbase', 'cli.js');
|
|||
|
|
const LOCAL_MCP_DIR = path.join(__dirname, '..', 'mcp-servers', 'browserbase');
|
|||
|
|
|
|||
|
|
// 配置
|
|||
|
|
const PROXY_URL = process.env.https_proxy || process.env.HTTPS_PROXY
|
|||
|
|
|| process.env.http_proxy || process.env.HTTP_PROXY || '';
|
|||
|
|
const MAX_RETRIES = 2;
|
|||
|
|
const PROXY_CHECK_TIMEOUT = 3000; // ms
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查代理是否可达 (仅在配置了代理时检查)
|
|||
|
|
*/
|
|||
|
|
function checkProxy() {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
if (!PROXY_URL) {
|
|||
|
|
process.stderr.write('[bb-wrapper] 未配置代理,跳过检查\n');
|
|||
|
|
return resolve(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const url = new URL(PROXY_URL);
|
|||
|
|
const req = http.request({
|
|||
|
|
hostname: url.hostname,
|
|||
|
|
port: url.port || 7893,
|
|||
|
|
method: 'CONNECT',
|
|||
|
|
path: 'api.browserbase.com:443',
|
|||
|
|
timeout: PROXY_CHECK_TIMEOUT
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('connect', (res) => {
|
|||
|
|
res.socket.destroy();
|
|||
|
|
resolve(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', () => resolve(false));
|
|||
|
|
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|||
|
|
req.end();
|
|||
|
|
} catch {
|
|||
|
|
resolve(false);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 启动 MCP 服务器
|
|||
|
|
* 安全: 只传递 Browserbase MCP 实际需要的环境变量 (最小权限原则)
|
|||
|
|
*/
|
|||
|
|
function startServer() {
|
|||
|
|
const modelApiKey = process.env.ANTHROPIC_API_KEY
|
|||
|
|
|| process.env.GEMINI_API_KEY || process.env.MODEL_API_KEY || '';
|
|||
|
|
const modelName = process.env.STAGEHAND_MODEL_NAME || 'claude-sonnet-4-6';
|
|||
|
|
// --experimental: 禁用 Stagehand 云端 API,改为本地 Playwright + 本地 LLM 调用
|
|||
|
|
// 原因: 中转站 ANTHROPIC_API_KEY 在云端 API 不可用,本地模式可通过 ANTHROPIC_BASE_URL 正确路由
|
|||
|
|
const args = ['--modelApiKey', modelApiKey, '--modelName', modelName, '--experimental'];
|
|||
|
|
|
|||
|
|
const child = spawn('node', [LOCAL_MCP_BIN, ...args], {
|
|||
|
|
stdio: 'inherit',
|
|||
|
|
cwd: LOCAL_MCP_DIR,
|
|||
|
|
env: {
|
|||
|
|
// ---- 系统运行必需 ----
|
|||
|
|
PATH: process.env.PATH || process.env.Path || '',
|
|||
|
|
HOME: process.env.HOME || process.env.USERPROFILE || '',
|
|||
|
|
USERPROFILE: process.env.USERPROFILE || '',
|
|||
|
|
TEMP: process.env.TEMP || process.env.TMP || '',
|
|||
|
|
TMP: process.env.TMP || process.env.TEMP || '',
|
|||
|
|
APPDATA: process.env.APPDATA || '',
|
|||
|
|
SystemRoot: process.env.SystemRoot || '',
|
|||
|
|
// ---- Node.js 运行必需 ----
|
|||
|
|
NODE_OPTIONS: `--require ${BOOTSTRAP_PATH}`,
|
|||
|
|
NODE_PATH: path.join(LOCAL_MCP_DIR, 'node_modules'),
|
|||
|
|
// ---- Browserbase 业务必需 ----
|
|||
|
|
BROWSERBASE_API_KEY: process.env.BROWSERBASE_API_KEY || '',
|
|||
|
|
BROWSERBASE_PROJECT_ID: process.env.BROWSERBASE_PROJECT_ID || '',
|
|||
|
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY || process.env.MODEL_API_KEY || '',
|
|||
|
|
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || '',
|
|||
|
|
GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || '',
|
|||
|
|
// ---- Stagehand Agent 配置 ----
|
|||
|
|
STAGEHAND_AGENT_PROVIDER: process.env.STAGEHAND_AGENT_PROVIDER || 'google',
|
|||
|
|
STAGEHAND_MODEL_NAME: process.env.STAGEHAND_MODEL_NAME || 'claude-sonnet-4-6',
|
|||
|
|
// ---- Anthropic 中转站 (Stagehand AI 功能) ----
|
|||
|
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
|
|||
|
|
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || '',
|
|||
|
|
// ---- 代理配置 ----
|
|||
|
|
http_proxy: process.env.http_proxy || process.env.HTTP_PROXY || '',
|
|||
|
|
https_proxy: process.env.https_proxy || process.env.HTTPS_PROXY || '',
|
|||
|
|
no_proxy: process.env.no_proxy || process.env.NO_PROXY || '',
|
|||
|
|
// ---- TLS 配置 (如需自签证书) ----
|
|||
|
|
NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || ''
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return child;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 启动前环境变量校验 (P0: 快速失败,避免无密钥静默启动)
|
|||
|
|
*/
|
|||
|
|
function validateEnv() {
|
|||
|
|
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|||
|
|
const projectId = process.env.BROWSERBASE_PROJECT_ID;
|
|||
|
|
const missing = [];
|
|||
|
|
if (!apiKey) missing.push('BROWSERBASE_API_KEY');
|
|||
|
|
if (!projectId) missing.push('BROWSERBASE_PROJECT_ID');
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
process.stderr.write(`[bb-wrapper] FATAL: 缺少必需环境变量: ${missing.join(', ')}\n`);
|
|||
|
|
process.stderr.write('[bb-wrapper] 请在 .claude.json 的 browserbase env 段中配置密钥\n');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 带重试的主流程
|
|||
|
|
*/
|
|||
|
|
async function main() {
|
|||
|
|
// 0. 环境变量校验 (快速失败)
|
|||
|
|
validateEnv();
|
|||
|
|
|
|||
|
|
// 1. 代理健康检查
|
|||
|
|
const proxyOk = await checkProxy();
|
|||
|
|
if (!proxyOk) {
|
|||
|
|
process.stderr.write(`[bb-wrapper] WARNING: 代理 ${PROXY_URL} 不可达,MCP 可能无法连接 Browserbase API\n`);
|
|||
|
|
process.stderr.write('[bb-wrapper] 请确认代理软件 (Clash/V2Ray) 已启动\n');
|
|||
|
|
// 仍然尝试启动,万一有直连能力
|
|||
|
|
} else if (PROXY_URL) {
|
|||
|
|
process.stderr.write(`[bb-wrapper] 代理 ${PROXY_URL} 连通性 OK\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 启动服务器 (带重试)
|
|||
|
|
let retries = 0;
|
|||
|
|
|
|||
|
|
function launch() {
|
|||
|
|
const child = startServer();
|
|||
|
|
|
|||
|
|
child.on('error', (err) => {
|
|||
|
|
process.stderr.write(`[bb-wrapper] 启动错误: ${err.message}\n`);
|
|||
|
|
if (retries < MAX_RETRIES) {
|
|||
|
|
retries++;
|
|||
|
|
process.stderr.write(`[bb-wrapper] 第 ${retries}/${MAX_RETRIES} 次重试...\n`);
|
|||
|
|
setTimeout(launch, 1000 * retries);
|
|||
|
|
} else {
|
|||
|
|
process.stderr.write('[bb-wrapper] 已达最大重试次数,退出\n');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
child.on('exit', (code) => {
|
|||
|
|
if (code !== 0 && code !== null && retries < MAX_RETRIES) {
|
|||
|
|
retries++;
|
|||
|
|
process.stderr.write(`[bb-wrapper] 进程退出 (code=${code}),第 ${retries}/${MAX_RETRIES} 次重试...\n`);
|
|||
|
|
setTimeout(launch, 1000 * retries);
|
|||
|
|
} else {
|
|||
|
|
process.exit(code || 0);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
launch();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 退出时尝试清理泄漏 session (best-effort, 不阻塞退出)
|
|||
|
|
function cleanupOnExit() {
|
|||
|
|
try {
|
|||
|
|
const cleanup = require(path.join(__dirname, 'browserbase-session-cleanup.js'));
|
|||
|
|
const config = cleanup.loadConfig();
|
|||
|
|
const sessions = cleanup.listRunningSessions(config);
|
|||
|
|
const stale = sessions.filter(s => {
|
|||
|
|
const created = new Date(s.createdAt || s.created_at || 0).getTime();
|
|||
|
|
return (Date.now() - created) > 30 * 60 * 1000;
|
|||
|
|
});
|
|||
|
|
for (const s of stale) {
|
|||
|
|
cleanup.closeSession(s.id || s.sessionId, config);
|
|||
|
|
}
|
|||
|
|
if (stale.length > 0) {
|
|||
|
|
process.stderr.write(`[bb-wrapper] 退出清理: 关闭 ${stale.length} 个泄漏 session\n`);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// best-effort, 不阻塞退出
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
process.on('SIGINT', () => { cleanupOnExit(); process.exit(0); });
|
|||
|
|
process.on('SIGTERM', () => { cleanupOnExit(); process.exit(0); });
|
|||
|
|
|
|||
|
|
main().catch((err) => {
|
|||
|
|
process.stderr.write(`[bb-wrapper] 致命错误: ${err.message}\n`);
|
|||
|
|
cleanupOnExit();
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|