// 短视频工作台 - CodeBuddy CLI 后端(09-29 新增) // // 用途:把「工作台 → AI 模型」的通路从 Dify 换成 **本地 CodeBuddy CLI 进程**。 // 形态:spawn 一个 CLI 子进程,用 stdio 通信(**不经任何端口**), // 解析 `--output-format stream-json` 的 NDJSON,把文本增量回调给上层。 // // ⛔ 三个必须处理的坑(均为实测踩出,勿删注释): // // 1) SERVER__PORT / SERVER__HOST 必须删掉。 // CLI(含 -p 打印模式)内部会起一个 HTTP server,端口取自 // `parseInt(process.env.SERVER__PORT) || config.get("cell.server").port`。 // WorkBuddy 桌面进程给自己设了 SERVER__PORT,任何在其进程树内启动的 CLI // 都会继承该值 → 去 listen 宿主已占用的端口 → `EADDRINUSE` → 进程卡死。 // (官方在 CLI 派生子进程时也是这么做的:`delete el.SERVER__PORT, delete el.SERVER__HOST`) // // 2) 必须显式指定 bundle,否则 launcher 可能命中 WorkBuddy 特供包或找不到产物。 // `CODEBUDDY_FORCE_LITE_WB_BUNDLE=1`(WorkBuddy 宿主注入)→ codebuddy-lite-wb.mjs // `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` → codebuddy-headless.js(我们要这个) // 两者都无 → dist/codebuddy(本安装里**不存在**,会 MODULE_NOT_FOUND) // // 3) 凭据无法从宿主继承(这是 WorkBuddy 的凭据保护设计,不是 bug)。 // WorkBuddy 用 4 个环境变量把凭据交给 CLI 子进程,CLI 读完**立即 delete**; // 且凭据不落明文盘。→ 独立进程必须自备凭据: // CODEBUDDY_API_KEY (个人开发,推荐)或 // CODEBUDDY_AUTH_TOKEN (已有 OAuth token,优先级最高) // 另需配套 CODEBUDDY_INTERNET_ENVIRONMENT(中国版 = internal)。 // // 认证优先级(官方):CODEBUDDY_AUTH_TOKEN > apiKeyHelper > CODEBUDDY_API_KEY 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); // ---------------------------------------------------------------- CLI 定位 // WorkBuddy 桌面版自带的 CLI 位置(用环境变量推导,不写死盘符) function candidateCliPaths() { const out = []; const localAppData = process.env.LOCALAPPDATA || ''; const appData = process.env.APPDATA || ''; const bases = [localAppData, appData].filter(Boolean); for (const b of bases) { out.push(path.join(b, 'Programs', 'WorkBuddy', 'resources', 'app.asar.unpacked', 'cli', 'bin', 'codebuddy')); } // 独立安装(npm -g / 原生安装器)→ PATH 里找 out.push('codebuddy'); return out; } // 返回 { cmd, args, kind } —— cmd 为可执行文件,args 为其前置参数 function resolveCli(explicitPath) { const tries = []; if (explicitPath) tries.push(explicitPath); if (process.env.CODEBUDDY_CLI_PATH) tries.push(process.env.CODEBUDDY_CLI_PATH); tries.push(...candidateCliPaths()); for (const p of tries) { if (!p) continue; if (p === 'codebuddy') return { cmd: 'codebuddy', args: [], kind: 'path' }; try { if (fs.existsSync(p) && fs.statSync(p).isFile()) { // 该 launcher 是 JS 文件(带 shebang)→ 用当前 node 执行,Windows 下最稳 return { cmd: process.execPath, args: [p], kind: 'bundled', script: p }; } } catch (e) { /* 忽略,继续探测 */ } } return null; } // ---------------------------------------------------------------- 凭据解析 // 凭据默认落点:**刻意放在仓库之外**。 // config.json 是 git 跟踪文件,绝不能让 key 落进去;本目录也不会被技能同步脚本复制。 function defaultCredentialFile() { return path.join(os.homedir(), '.workbuddy', 'mcn-workshop', 'ai-cli.json'); } // 读凭据文件:{"apiKey":"...","authToken":"..."} function readCredentialFile(p) { if (!p) return {}; try { if (!fs.existsSync(p)) return {}; const j = JSON.parse(fs.readFileSync(p, 'utf8')); return { apiKey: typeof j.apiKey === 'string' ? j.apiKey.trim() : '', authToken: typeof j.authToken === 'string' ? j.authToken.trim() : '', file: p, }; } catch (e) { return {}; } } /** * 凭据优先级(高 → 低): * 1) 调用方显式传入(config.json 的 ai.cli.apiKey / authToken) * 2) 凭据文件(默认 ~/.workbuddy/mcn-workshop/ai-cli.json) * 3) 环境变量 CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN * 返回 { apiKey, authToken, source } */ function resolveCredentials(o) { o = o || {}; const f = readCredentialFile(o.credentialFile || defaultCredentialFile()); const apiKey = o.apiKey || f.apiKey || process.env.CODEBUDDY_API_KEY || ''; const authToken = o.authToken || f.authToken || process.env.CODEBUDDY_AUTH_TOKEN || ''; let source = '(未配置)'; if (o.authToken) source = 'config:authToken'; else if (o.apiKey) source = 'config:apiKey'; else if (f.authToken) source = 'file:authToken ' + f.file; else if (f.apiKey) source = 'file:apiKey ' + f.file; else if (process.env.CODEBUDDY_AUTH_TOKEN) source = 'env:CODEBUDDY_AUTH_TOKEN'; else if (process.env.CODEBUDDY_API_KEY) source = 'env:CODEBUDDY_API_KEY'; return { apiKey, authToken, source, credentialFile: f.file || defaultCredentialFile() }; } // ---------------------------------------------------------------- 环境构造 // 把「宿主 WorkBuddy 注入的、会干扰子 CLI 的」环境变量剔除,并注入凭据 function buildChildEnv(opts) { const env = { ...process.env }; // ⛔ 坑 1:端口继承 → EADDRINUSE(见文件头注释) delete env.SERVER__PORT; delete env.SERVER__HOST; // ⛔ 必须清掉:WorkBuddy 注入的 node shim / 会话上下文 / bundle 分流 delete env.NODE_OPTIONS; env.CODEBUDDY_FORCE_LITE_WB_BUNDLE = ''; // 关掉 wb 特供包分流 env.CODEBUDDY_FORCE_HEADLESS_BUNDLE = '1'; // 【坑 2】强制官方 headless bundle for (const k of Object.keys(env)) { if (k.startsWith('CODEBUDDY_SESSION_') || k.startsWith('CODEBUDDY_CONVERSATION_') || k === 'CODEBUDDY_TOOL_CALL_ID' || k === 'CODEBUDDY_SERVICE_PROXY_URL' || k === 'CODEBUDDY_MCP_CONFIG' || // 宿主 MCP(weixinpay/sheetagent…)我们不要 k === 'CODEBUDDY_GATEWAY_PASSWORD' || k === 'CODEBUDDY_GATEWAY_AUTH' || k === 'WORKBUDDY_PAC_RPC_TOKEN' || k === 'WORKBUDDY_PAC_RPC_SOCKET' || k === 'SANDBOX_CENTER_IPC_ADDRESS' || k === 'BAGGAGE' || k === 'BASH_ENV') { delete env[k]; } } delete env.CLAUDE_SESSION_ID; // 隔离配置/数据目录:不污染 WorkBuddy 自身的配置与会话历史 env.CODEBUDDY_CONFIG_DIR = opts.homeDir; env.CODEBUDDY_SAFE_DELETE_ENABLED = '0'; // 工作台场景不需要工具删除保护 env.CODEBUDDY_GATEWAY_DISABLE_API_DOCS = '1'; env.CODEBUDDY_DISABLE_TELEMETRY = '1'; // 官方建议:纯 -p 单发不支持后台任务,开着会返回明确错误 env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS = '1'; // 【坑 3】凭据:独立进程必须自备。 // 注意:宿主也会用同名变量注入凭据且读后即删——必须先把继承来的残留清掉,再写我们自己的, // 否则可能带着一个「空壳/过期」的值进入子进程。 delete env.CODEBUDDY_API_KEY; delete env.CODEBUDDY_AUTH_TOKEN; const cred = resolveCredentials(opts); if (cred.authToken) env.CODEBUDDY_AUTH_TOKEN = cred.authToken; // 优先级最高 if (cred.apiKey) env.CODEBUDDY_API_KEY = cred.apiKey; if (opts.internetEnvironment) env.CODEBUDDY_INTERNET_ENVIRONMENT = opts.internetEnvironment; if (opts.baseUrl) env.CODEBUDY_BASE_URL = opts.baseUrl; // 本地回环不走代理(代理会把 127.0.0.1 也吃掉) env.no_proxy = '127.0.0.1,localhost'; env.NO_PROXY = '127.0.0.1,localhost'; return env; } // ---------------------------------------------------------------- 流式执行 /** * 跑一次 CLI,流式回调文本增量。 * * @param {object} o * @param {string} o.prompt 用户提示词 * @param {string} o.cwd 工作目录(决定 CLI 能看到哪些项目文件) * @param {string} [o.homeDir] CLI 的配置/数据目录(默认 /mcn-cli-home) * @param {string} [o.model] 模型 ID * @param {string} [o.apiKey] CODEBUDDY_API_KEY(优先级最高,一般留空走凭据文件) * @param {string} [o.authToken] CODEBUDDY_AUTH_TOKEN * @param {string} [o.credentialFile] 凭据文件路径,默认 ~/.workbuddy/mcn-workshop/ai-cli.json * @param {string} [o.internetEnvironment] internal / ioa / 留空 * @param {string} [o.cliPath] 显式指定 CLI 路径 * @param {number} [o.timeoutMs] * @param {boolean} [o.search] 是否允许联网搜索(默认 true)。true → 工具面收敛为 WebSearch+WebFetch * @param {string} [o.tools] 显式工具白名单:'default' | 'none' | 'WebSearch,WebFetch'(留空=按 search 推导) * @param {boolean} [o.strictMcp] 是否屏蔽宿主 MCP(默认 true) * @param {string} [o.appendSystemPrompt] 追加系统提示(如搜索使用策略) * @param {number} [o.maxTurns] 最大 agent 轮数(0=不限) * @param {function} o.onDelta (text) => void 文本增量 * @param {function} [o.onThink] (text) => void 思考增量(可选) * @param {function} [o.onEvent] (obj) => void 全部原始事件(可选,调试用) * @param {AbortSignal} [o.signal] * @returns {Promise<{ok:boolean, text:string, error?:string, sessionId?:string, usage?:object}>} */ function runCli(o) { return new Promise((resolve) => { const cli = resolveCli(o.cliPath); if (!cli) { return resolve({ ok: false, text: '', error: '未找到 CodeBuddy CLI(可在 config.json 的 ai.cli.path 指定,或设环境变量 CODEBUDDY_CLI_PATH)' }); } const homeDir = o.homeDir || path.join(os.tmpdir(), 'mcn-cli-home'); try { fs.mkdirSync(homeDir, { recursive: true }); } catch (e) {} const args = [ ...cli.args, '-p', o.prompt, '--output-format', 'stream-json', '--include-partial-messages', '-y', // 非交互必需(文档明确要求) ...(o.model ? ['--model', o.model] : []), ...(o.permissionMode ? ['--permission-mode', o.permissionMode] : []), ]; // ---- 工具面收敛(既提速,也是安全边界)---- // 工作台的 AI 只负责「产出文本」,prompt 里已自带全部上下文(账号设定/需求/历史), // 不需要读写文件、跑命令。默认只给联网搜索两个工具: // · 启动快(不必加载 Bash/Edit/Write/... 的工具 schema) // · 模型物理上碰不到本机文件系统 // o.search === false → 放开默认工具面,但明确禁用联网搜索 let tools = o.tools; if (tools === undefined || tools === null || tools === '') { tools = (o.search === false) ? 'default' : 'WebSearch,WebFetch'; } if (tools === 'none') args.push('--tools', ''); else if (tools && tools !== 'default') args.push('--tools', tools); if (o.search === false) args.push('--disallowedTools', 'WebSearch,WebFetch'); // ---- 屏蔽宿主 MCP ---- // 宿主会把 CODEBUDDY_MCP_CONFIG 注入进程树,里面挂着 weixinpay / sheetagent 等。 // 工作台场景一个都用不上,加载它们纯属浪费启动时间,还可能被模型误调。 if (o.strictMcp !== false) { args.push('--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'); } // 推理开销:把首字延迟从几十秒压到十几秒的关键开关(minimal|low|medium|high|xhigh|max) if (o.effort) args.push('--effort', o.effort); if (o.appendSystemPrompt) args.push('--append-system-prompt', o.appendSystemPrompt); if (parseInt(o.maxTurns, 10) > 0) args.push('--max-turns', String(parseInt(o.maxTurns, 10))); const env = buildChildEnv({ homeDir, apiKey: o.apiKey, authToken: o.authToken, internetEnvironment: o.internetEnvironment, baseUrl: o.baseUrl, }); let child; try { child = spawn(cli.cmd, args, { cwd: o.cwd || process.cwd(), env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); } catch (e) { return resolve({ ok: false, text: '', error: 'CLI 启动失败: ' + e.message }); } const timeoutMs = o.timeoutMs || 300000; let timer = setTimeout(() => { aborted = true; try { child.kill(); } catch (e) {} }, timeoutMs); let aborted = false; if (o.signal) { if (o.signal.aborted) { aborted = true; try { child.kill(); } catch (e) {} } else o.signal.addEventListener('abort', () => { aborted = true; try { child.kill(); } catch (e) {} }, { once: true }); } let full = ''; let streamed = ''; // 已经通过 onDelta 推给前端的内容(用于终态比对) let stderr = ''; let sessionId = ''; let usage = null; let resultText = ''; let errText = ''; let sawDelta = false; let buf = ''; // 每次工具调用的发生位置(= 当时的 full 长度)。 // 根因:模型在发起 WebSearch 前常先吐一句过程话术(如 // "I'll search for ... before responding."),它不是答案。 // 只保留「最后一次工具调用之后」的文本作为正文。 const toolMarks = []; const handleLine = (line) => { line = line.trim(); if (!line) return; let ev; try { ev = JSON.parse(line); } catch (e) { return; } if (o.onEvent) { try { o.onEvent(ev); } catch (e) {} } if (ev.type === 'system' && ev.subtype === 'init') { sessionId = ev.session_id || sessionId; return; } // 文本增量:stream_event → content_block_delta → text_delta if (ev.type === 'stream_event' && ev.event) { const e2 = ev.event; // 工具调用开始的边界(用于切掉调用前的过程话术) if (e2.type === 'content_block_start' && e2.content_block && (e2.content_block.type === 'tool_use' || e2.content_block.name)) { toolMarks.push(full.length); return; } if (e2.type === 'content_block_delta' && e2.delta) { if (e2.delta.type === 'text_delta' && e2.delta.text) { const prev = full; full += e2.delta.text; // CLI 自身的报错也会走文本通道(如未认证时整段就是一句英文报错)。 // 一旦发现开头是已知报错样板 → 不再当作回答下发给前端。 if (isCliBoilerplate(full)) { errText = errText || full; return; } // 已误判过一次的话,本段的累积都作废 if (isCliBoilerplate(prev)) { full = ''; errText = errText || prev; return; } sawDelta = true; streamed += e2.delta.text; if (o.onDelta) { try { o.onDelta(e2.delta.text); } catch (e) {} } } else if (e2.delta.type === 'thinking_delta' && e2.delta.thinking && o.onThink) { try { o.onThink(e2.delta.thinking); } catch (e) {} } } return; } // 兜底:未开启/未产生增量流时,整段文本在 assistant 消息里 if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { // 工具调用边界(未走增量流时,tool_use 只会出现在 assistant 消息里) for (const c of ev.message.content) { if (c && c.type === 'tool_use') toolMarks.push(full.length); } if (!sawDelta) { const t = ev.message.content .filter((c) => c && c.type === 'text' && typeof c.text === 'string') .map((c) => c.text).join(''); if (t && !full) { if (isCliBoilerplate(t)) { errText = errText || t; return; } full = t; streamed += t; if (o.onDelta) { try { o.onDelta(t); } catch (e) {} } } } return; } // 终态 if (ev.type === 'result') { sessionId = ev.session_id || sessionId; usage = ev.usage || null; if (ev.is_error) { // 错误态:正文是报错文本,不能当作回答。 // CLI 把真实原因放在 `errors[]`(也有 `result` / `_meta.errorMessage` 兜底) const e = Array.isArray(ev.errors) && ev.errors.length ? ev.errors.join(' | ') : (typeof ev.result === 'string' && ev.result ? ev.result : (ev._meta && ev._meta['codebuddy.ai/errorMessage']) || ''); errText = errText || String(e || ''); // 错误态下已累积的文本是报错样板(如 "Authentication required..."), // 不是回答 → 丢弃,避免被当成正文展示 full = ''; resultText = ''; } else if (typeof ev.result === 'string') { resultText = ev.result; } return; } }; child.stdout.on('data', (chunk) => { buf += chunk.toString('utf8'); let i; while ((i = buf.indexOf('\n')) >= 0) { handleLine(buf.slice(0, i)); buf = buf.slice(i + 1); } }); child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, text: full, error: 'CLI 进程错误: ' + e.message }); }); child.on('close', (code) => { clearTimeout(timer); if (buf.trim()) handleLine(buf); // 切掉「最后一次工具调用之前」的过程话术:那部分不是答案。 // (例:模型先输出 "I'll search for ... before responding." 再发起 WebSearch) const cut = toolMarks.length ? toolMarks[toolMarks.length - 1] : 0; const answer = (cut > 0 && cut < full.length) ? full.slice(cut).trim() : full; const text = answer || resultText; if (aborted) return resolve({ ok: false, text, streamed, error: 'CLI 执行超时或被取消', sessionId, usage }); // 事件里明确报了错(如未认证)→ 优先按错误返回 if (errText) { const raw = errText; return resolve({ ok: false, text, streamed, error: messageForCliError(raw) || raw, sessionId, usage, rawStderr: raw }); } if (code !== 0 && !text) { const msg = (stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | '); return resolve({ ok: false, text, streamed, error: messageForCliError(msg) || ('CLI 退出码 ' + code), sessionId, usage, rawStderr: msg }); } resolve({ ok: true, text, streamed, sessionId, usage }); }); }); } // 判断一段文本是否是 CLI 自身给的报错样板(而不是模型回答) // 实测:未认证时,assistant 文本整段就是这句英文 function isCliBoilerplate(text) { const t = String(text || '').trim(); if (!t) return false; return /^Authentication (required|failed)\. Please use \/login/i.test(t) || /^Failed to authenticate/i.test(t) || /^Invalid API key/i.test(t) || /^Unauthorized\b/i.test(t); } // 把 CLI 的原始英文报错翻成可操作的中文提示 function messageForCliError(msg) { if (!msg) return ''; if (/Authentication required/i.test(msg)) { return 'CodeBuddy 未认证:请在 config.json 的 ai.cli.apiKey 填入 API Key,' + '或设置环境变量 CODEBUDDY_API_KEY(中国版还需 CODEBUDDY_INTERNET_ENVIRONMENT=internal)。' + '原始信息: ' + msg; } if (/EADDRINUSE/i.test(msg)) { return 'CLI 端口冲突(SERVER__PORT 未被清除): ' + msg; } return msg; } module.exports = { runCli, resolveCli, buildChildEnv, resolveCredentials, defaultCredentialFile };