417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* Bookworm Smart Assistant - Browserbase MCP 代理 Patch 脚本
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* WSL 环境下 Clash 代理劫持 DNS,Stagehand 内部 fetch/ws 不走代理环境变量。
|
|||
|
|
* 本脚本对 @browserbasehq/stagehand/dist/index.js 应用 3 个 patch:
|
|||
|
|
* 1. Proxy Bootstrap 头部注入 — undici ProxyAgent + http/https globalAgent
|
|||
|
|
* 2. WebSocket 代理注入 — CdpConnection.connect 中 ws 走 https-proxy-agent
|
|||
|
|
* 3. API 降级容错 — apiClient.init() 失败时降级继续
|
|||
|
|
*
|
|||
|
|
* 使用方式:
|
|||
|
|
* node patch-browserbase-proxy.js # 等同 --check
|
|||
|
|
* node patch-browserbase-proxy.js --check # 检查 patch 状态
|
|||
|
|
* node patch-browserbase-proxy.js --apply # 应用 patch (幂等)
|
|||
|
|
* node patch-browserbase-proxy.js --restore # 从备份恢复原始文件
|
|||
|
|
*
|
|||
|
|
* 集成点:
|
|||
|
|
* - 每次 npx -y @browserbasehq/mcp-server-browserbase@latest 更新后运行
|
|||
|
|
* - 可配合 post-tool hook 自动触发
|
|||
|
|
*
|
|||
|
|
* 创建: 2026-02-20
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const { execSync } = require('child_process');
|
|||
|
|
const { glob } = require('path');
|
|||
|
|
|
|||
|
|
// ─── 配置 ───────────────────────────────────────────────
|
|||
|
|
const IS_WSL = process.platform === 'linux' && fs.existsSync('/mnt/c');
|
|||
|
|
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|||
|
|
|
|||
|
|
// npx 缓存中的目标文件 glob 模式
|
|||
|
|
const NPX_CACHE_GLOB = path.join(HOME, '.npm/_npx/*/node_modules/@browserbasehq/stagehand/dist/index.js');
|
|||
|
|
|
|||
|
|
// patch 检测标记
|
|||
|
|
const MARKERS = {
|
|||
|
|
proxyBootstrap: '/* === Bookworm Proxy Bootstrap (injected) === */',
|
|||
|
|
wsProxy: 'const _proxyUrl = process.env.https_proxy',
|
|||
|
|
apiFallback: '[Stagehand] WARN - API client init failed',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── Patch 内容定义 ─────────────────────────────────────
|
|||
|
|
|
|||
|
|
// Patch 1: Proxy Bootstrap IIFE — 在文件最前面 var __create 之前注入
|
|||
|
|
const PATCH1_INJECT = `/* === Bookworm Proxy Bootstrap (injected) === */
|
|||
|
|
(function _setupGlobalProxy() {
|
|||
|
|
var _pUrl = process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY;
|
|||
|
|
if (!_pUrl) return;
|
|||
|
|
// 1) Patch undici global fetch to use proxy (native fetch)
|
|||
|
|
try {
|
|||
|
|
var _undiciPaths = [
|
|||
|
|
require("path").join(process.execPath, "../../lib/node_modules/undici"),
|
|||
|
|
require("path").join(process.env.HOME || "", ".nvm/versions/node", "v" + process.versions.node, "lib/node_modules/undici")
|
|||
|
|
];
|
|||
|
|
var _undici;
|
|||
|
|
for (var _up of _undiciPaths) {
|
|||
|
|
try { _undici = require(_up); break; } catch(e) {}
|
|||
|
|
}
|
|||
|
|
if (_undici && _undici.ProxyAgent && _undici.setGlobalDispatcher) {
|
|||
|
|
_undici.setGlobalDispatcher(new _undici.ProxyAgent(_pUrl));
|
|||
|
|
}
|
|||
|
|
} catch(e) {}
|
|||
|
|
// 2) Patch Node.js http/https globalAgent for node-fetch and other libs
|
|||
|
|
try {
|
|||
|
|
var _hpa = require("https-proxy-agent");
|
|||
|
|
var _httpsAgent = new _hpa.HttpsProxyAgent(_pUrl);
|
|||
|
|
require("https").globalAgent = _httpsAgent;
|
|||
|
|
var _httpPa = require("http-proxy-agent");
|
|||
|
|
var _httpAgent = new _httpPa.HttpProxyAgent(_pUrl);
|
|||
|
|
require("http").globalAgent = _httpAgent;
|
|||
|
|
} catch(e) {}
|
|||
|
|
})();
|
|||
|
|
/* === End Proxy Bootstrap === */
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Patch 1 匹配锚点 (原始文件开头)
|
|||
|
|
const PATCH1_ANCHOR = 'var __create = Object.create;';
|
|||
|
|
|
|||
|
|
// Patch 2: WebSocket 代理注入 — 替换 CdpConnection.connect 内的 ws 创建
|
|||
|
|
const PATCH2_ORIGINAL = ` static connect(wsUrl) {
|
|||
|
|
return __async(this, null, function* () {
|
|||
|
|
const ws = new import_ws.default(wsUrl);`;
|
|||
|
|
|
|||
|
|
const PATCH2_REPLACE = ` static connect(wsUrl) {
|
|||
|
|
return __async(this, null, function* () {
|
|||
|
|
let wsOpts = void 0;
|
|||
|
|
const _proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY;
|
|||
|
|
if (_proxyUrl && wsUrl.startsWith("wss://")) {
|
|||
|
|
try {
|
|||
|
|
const { HttpsProxyAgent } = require("https-proxy-agent");
|
|||
|
|
wsOpts = { agent: new HttpsProxyAgent(_proxyUrl) };
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
const ws = wsOpts ? new import_ws.default(wsUrl, wsOpts) : new import_ws.default(wsUrl);`;
|
|||
|
|
|
|||
|
|
// Patch 3: API 降级容错 — 在 apiClient 初始化块外包裹 try-catch
|
|||
|
|
const PATCH3_ORIGINAL = `if (!this.disableAPI && !this.experimental) {
|
|||
|
|
this.apiClient = new StagehandAPIClient({`;
|
|||
|
|
|
|||
|
|
const PATCH3_REPLACE = `if (!this.disableAPI && !this.experimental) {
|
|||
|
|
try {
|
|||
|
|
this.apiClient = new StagehandAPIClient({`;
|
|||
|
|
|
|||
|
|
// Patch 3 闭合部分: 在 sessionID 赋值后加 catch
|
|||
|
|
const PATCH3_CLOSE_ORIGINAL = `this.opts.browserbaseSessionID = sessionId2;
|
|||
|
|
}`;
|
|||
|
|
|
|||
|
|
const PATCH3_CLOSE_REPLACE = `this.opts.browserbaseSessionID = sessionId2;
|
|||
|
|
} catch (_apiErr) {
|
|||
|
|
process.stderr.write("[Stagehand] WARN - API client init failed (" + (_apiErr && _apiErr.message || _apiErr) + "), falling back to direct Browserbase session\\n");
|
|||
|
|
this.apiClient = null;
|
|||
|
|
}
|
|||
|
|
}`;
|
|||
|
|
|
|||
|
|
// ─── 工具函数 ───────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 查找所有 npx 缓存中的 stagehand index.js
|
|||
|
|
*/
|
|||
|
|
function findTargetFiles() {
|
|||
|
|
const npxDir = path.join(HOME, '.npm/_npx');
|
|||
|
|
const results = [];
|
|||
|
|
|
|||
|
|
if (!fs.existsSync(npxDir)) return results;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const entries = fs.readdirSync(npxDir, { withFileTypes: true });
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!entry.isDirectory()) continue;
|
|||
|
|
const candidate = path.join(npxDir, entry.name, 'node_modules/@browserbasehq/stagehand/dist/index.js');
|
|||
|
|
if (fs.existsSync(candidate)) {
|
|||
|
|
results.push(candidate);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch { /* 忽略 */ }
|
|||
|
|
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查单个文件的 patch 状态
|
|||
|
|
*/
|
|||
|
|
function checkPatchStatus(filePath) {
|
|||
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|||
|
|
return {
|
|||
|
|
filePath,
|
|||
|
|
fileSize: (fs.statSync(filePath).size / 1024).toFixed(0) + ' KB',
|
|||
|
|
proxyBootstrap: content.includes(MARKERS.proxyBootstrap),
|
|||
|
|
wsProxy: content.includes(MARKERS.wsProxy),
|
|||
|
|
apiFallback: content.includes(MARKERS.apiFallback),
|
|||
|
|
get allPatched() {
|
|||
|
|
return this.proxyBootstrap && this.wsProxy && this.apiFallback;
|
|||
|
|
},
|
|||
|
|
get nonePatched() {
|
|||
|
|
return !this.proxyBootstrap && !this.wsProxy && !this.apiFallback;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 创建备份
|
|||
|
|
*/
|
|||
|
|
function createBackup(filePath) {
|
|||
|
|
const backupPath = filePath + '.bak';
|
|||
|
|
fs.copyFileSync(filePath, backupPath);
|
|||
|
|
return backupPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 语法验证
|
|||
|
|
*/
|
|||
|
|
function validateSyntax(filePath) {
|
|||
|
|
try {
|
|||
|
|
execSync(`node -c "${filePath}"`, { stdio: 'pipe' });
|
|||
|
|
return { valid: true, error: null };
|
|||
|
|
} catch (err) {
|
|||
|
|
return { valid: false, error: err.stderr ? err.stderr.toString().trim() : '未知语法错误' };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 应用所有 patch (幂等)
|
|||
|
|
*/
|
|||
|
|
function applyPatches(filePath) {
|
|||
|
|
let content = fs.readFileSync(filePath, 'utf-8');
|
|||
|
|
const applied = [];
|
|||
|
|
const skipped = [];
|
|||
|
|
const errors = [];
|
|||
|
|
|
|||
|
|
// Patch 1: Proxy Bootstrap
|
|||
|
|
if (content.includes(MARKERS.proxyBootstrap)) {
|
|||
|
|
skipped.push('Patch 1 (Proxy Bootstrap) — 已存在');
|
|||
|
|
} else if (content.includes(PATCH1_ANCHOR)) {
|
|||
|
|
content = content.replace(PATCH1_ANCHOR, PATCH1_INJECT + PATCH1_ANCHOR);
|
|||
|
|
applied.push('Patch 1 (Proxy Bootstrap)');
|
|||
|
|
} else {
|
|||
|
|
errors.push('Patch 1 (Proxy Bootstrap) — 找不到锚点: "var __create = Object.create;"');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Patch 2: WebSocket 代理
|
|||
|
|
if (content.includes(MARKERS.wsProxy)) {
|
|||
|
|
skipped.push('Patch 2 (WebSocket Proxy) — 已存在');
|
|||
|
|
} else if (content.includes(PATCH2_ORIGINAL)) {
|
|||
|
|
content = content.replace(PATCH2_ORIGINAL, PATCH2_REPLACE);
|
|||
|
|
applied.push('Patch 2 (WebSocket Proxy)');
|
|||
|
|
} else {
|
|||
|
|
errors.push('Patch 2 (WebSocket Proxy) — 找不到锚点: "static connect(wsUrl)" 代码块');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Patch 3: API 降级容错
|
|||
|
|
if (content.includes(MARKERS.apiFallback)) {
|
|||
|
|
skipped.push('Patch 3 (API Fallback) — 已存在');
|
|||
|
|
} else if (content.includes(PATCH3_ORIGINAL)) {
|
|||
|
|
// 先替换开头
|
|||
|
|
content = content.replace(PATCH3_ORIGINAL, PATCH3_REPLACE);
|
|||
|
|
// 再替换闭合 — 找到 disableAPI 块结尾
|
|||
|
|
if (content.includes(PATCH3_CLOSE_ORIGINAL)) {
|
|||
|
|
content = content.replace(PATCH3_CLOSE_ORIGINAL, PATCH3_CLOSE_REPLACE);
|
|||
|
|
applied.push('Patch 3 (API Fallback)');
|
|||
|
|
} else {
|
|||
|
|
errors.push('Patch 3 (API Fallback) — 找不到闭合锚点: "this.opts.browserbaseSessionID = sessionId2;"');
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
errors.push('Patch 3 (API Fallback) — 找不到锚点: "if (!this.disableAPI && !this.experimental)" 代码块');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 写回文件
|
|||
|
|
if (applied.length > 0) {
|
|||
|
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { applied, skipped, errors };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 命令: --check ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
function cmdCheck() {
|
|||
|
|
const files = findTargetFiles();
|
|||
|
|
|
|||
|
|
if (files.length === 0) {
|
|||
|
|
console.log(' 未找到 stagehand dist/index.js');
|
|||
|
|
console.log(` 查找路径: ${path.join(HOME, '.npm/_npx/*/...')}`);
|
|||
|
|
console.log(' 提示: 先运行 npx -y @browserbasehq/mcp-server-browserbase@latest 安装');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const filePath of files) {
|
|||
|
|
const status = checkPatchStatus(filePath);
|
|||
|
|
const hash = filePath.split('/_npx/')[1].split('/')[0];
|
|||
|
|
|
|||
|
|
console.log(` 文件: .../_npx/${hash}/.../dist/index.js (${status.fileSize})`);
|
|||
|
|
console.log(` Patch 1 (Proxy Bootstrap): ${status.proxyBootstrap ? '✓ 已应用' : '✗ 未应用'}`);
|
|||
|
|
console.log(` Patch 2 (WebSocket Proxy): ${status.wsProxy ? '✓ 已应用' : '✗ 未应用'}`);
|
|||
|
|
console.log(` Patch 3 (API Fallback): ${status.apiFallback ? '✓ 已应用' : '✗ 未应用'}`);
|
|||
|
|
|
|||
|
|
const backupExists = fs.existsSync(filePath + '.bak');
|
|||
|
|
console.log(` 备份文件: ${backupExists ? '✓ 存在' : '- 无'}`);
|
|||
|
|
|
|||
|
|
if (status.allPatched) {
|
|||
|
|
console.log(` 状态: 全部 patch 已就绪`);
|
|||
|
|
} else if (status.nonePatched) {
|
|||
|
|
console.log(` 状态: 未 patch (运行 --apply 应用)`);
|
|||
|
|
} else {
|
|||
|
|
console.log(` 状态: 部分 patch (运行 --apply 补全)`);
|
|||
|
|
}
|
|||
|
|
console.log('');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 命令: --apply ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
function cmdApply() {
|
|||
|
|
const files = findTargetFiles();
|
|||
|
|
|
|||
|
|
if (files.length === 0) {
|
|||
|
|
console.log(' 未找到 stagehand dist/index.js');
|
|||
|
|
console.log(' 提示: 先运行 npx -y @browserbasehq/mcp-server-browserbase@latest 安装');
|
|||
|
|
process.exit(1);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let allSuccess = true;
|
|||
|
|
|
|||
|
|
for (const filePath of files) {
|
|||
|
|
const hash = filePath.split('/_npx/')[1].split('/')[0];
|
|||
|
|
console.log(` 目标: .../_npx/${hash}/.../dist/index.js`);
|
|||
|
|
|
|||
|
|
// 创建备份 (如果还没有)
|
|||
|
|
const backupPath = filePath + '.bak';
|
|||
|
|
if (!fs.existsSync(backupPath)) {
|
|||
|
|
createBackup(filePath);
|
|||
|
|
console.log(` 备份: 已创建 .bak`);
|
|||
|
|
} else {
|
|||
|
|
console.log(` 备份: 已存在 .bak (跳过)`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 应用 patch
|
|||
|
|
const result = applyPatches(filePath);
|
|||
|
|
|
|||
|
|
for (const msg of result.applied) console.log(` [应用] ${msg}`);
|
|||
|
|
for (const msg of result.skipped) console.log(` [跳过] ${msg}`);
|
|||
|
|
for (const msg of result.errors) {
|
|||
|
|
console.log(` [错误] ${msg}`);
|
|||
|
|
allSuccess = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证
|
|||
|
|
console.log('');
|
|||
|
|
console.log(' 验证:');
|
|||
|
|
|
|||
|
|
// 标记检查
|
|||
|
|
const status = checkPatchStatus(filePath);
|
|||
|
|
console.log(` 标记检查: ${status.allPatched ? '✓ 3/3 标记均存在' : '✗ 部分标记缺失'}`);
|
|||
|
|
|
|||
|
|
// 语法检查
|
|||
|
|
const syntax = validateSyntax(filePath);
|
|||
|
|
console.log(` 语法检查: ${syntax.valid ? '✓ node -c 通过' : '✗ ' + syntax.error}`);
|
|||
|
|
|
|||
|
|
if (!syntax.valid) {
|
|||
|
|
allSuccess = false;
|
|||
|
|
console.log(' ⚠ 语法错误! 建议运行 --restore 恢复备份');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (status.allPatched && syntax.valid) {
|
|||
|
|
console.log(' 结果: 全部 patch 成功');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.exit(allSuccess ? 0 : 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 命令: --restore ────────────────────────────────────
|
|||
|
|
|
|||
|
|
function cmdRestore() {
|
|||
|
|
const files = findTargetFiles();
|
|||
|
|
let restored = 0;
|
|||
|
|
|
|||
|
|
if (files.length === 0) {
|
|||
|
|
console.log(' 未找到 stagehand dist/index.js');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const filePath of files) {
|
|||
|
|
const backupPath = filePath + '.bak';
|
|||
|
|
const hash = filePath.split('/_npx/')[1].split('/')[0];
|
|||
|
|
|
|||
|
|
if (!fs.existsSync(backupPath)) {
|
|||
|
|
console.log(` .../_npx/${hash}/...: 无备份文件,跳过`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fs.copyFileSync(backupPath, filePath);
|
|||
|
|
restored++;
|
|||
|
|
console.log(` .../_npx/${hash}/...: 已从 .bak 恢复`);
|
|||
|
|
|
|||
|
|
// 验证恢复后状态
|
|||
|
|
const status = checkPatchStatus(filePath);
|
|||
|
|
console.log(` 恢复后状态: ${status.nonePatched ? '✓ 已清除所有 patch' : '仍有 patch 残留'}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (restored === 0) {
|
|||
|
|
console.log(' 没有找到任何备份文件');
|
|||
|
|
} else {
|
|||
|
|
console.log(`\n 已恢复 ${restored} 个文件`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 主程序 ─────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
const args = process.argv.slice(2);
|
|||
|
|
const command = args.find(a => a.startsWith('--')) || '--check';
|
|||
|
|
|
|||
|
|
console.log(`\n${'═'.repeat(50)}`);
|
|||
|
|
console.log(' Bookworm Browserbase Proxy Patcher');
|
|||
|
|
console.log(` ${new Date().toISOString().slice(0, 19)}`);
|
|||
|
|
console.log(`${'═'.repeat(50)}\n`);
|
|||
|
|
|
|||
|
|
switch (command) {
|
|||
|
|
case '--check':
|
|||
|
|
cmdCheck();
|
|||
|
|
break;
|
|||
|
|
case '--apply':
|
|||
|
|
cmdApply();
|
|||
|
|
break;
|
|||
|
|
case '--restore':
|
|||
|
|
cmdRestore();
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
console.log(' 用法:');
|
|||
|
|
console.log(' node patch-browserbase-proxy.js --check 检查 patch 状态');
|
|||
|
|
console.log(' node patch-browserbase-proxy.js --apply 应用 patch (幂等)');
|
|||
|
|
console.log(' node patch-browserbase-proxy.js --restore 从备份恢复');
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`${'═'.repeat(50)}\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 模块导出 (供测试使用)
|
|||
|
|
if (typeof module !== 'undefined') {
|
|||
|
|
module.exports = {
|
|||
|
|
MARKERS, PATCH1_INJECT, PATCH1_ANCHOR, PATCH2_ORIGINAL, PATCH2_REPLACE,
|
|||
|
|
PATCH3_ORIGINAL, PATCH3_REPLACE, PATCH3_CLOSE_ORIGINAL, PATCH3_CLOSE_REPLACE,
|
|||
|
|
findTargetFiles, checkPatchStatus, createBackup, validateSyntax, applyPatches,
|
|||
|
|
cmdCheck, cmdApply, cmdRestore, main,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (require.main === module) {
|
|||
|
|
main();
|
|||
|
|
}
|