/** * 节点执行器(H4/H5)。 * - nativeExecute:原生内置 worker(确定性,不冒充 LLM),独立工作目录 * - makeAdapterExecute:兼容转化层,把外部执行器包成统一协议 * kind=cmd 本地白名单命令 * kind=http 任意 OpenAI 兼容端点(真实大模型 worker) * kind=codex codex CLI 子进程(真实,需本机装 codex;Windows 已兼容 .cmd shim) * kind=cli 任意本地 agent CLI(--cli 指定,{prompt} 占位符) * 安全:危险关键词拦截、独立 workspace、spawn 参数数组(无 shell 注入)。 */ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs"; import { resolve, join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { isDangerousTask } from "../security.js"; import { chatComplete, llmConfig } from "../llm.js"; import { WorkspaceManager } from "../worktree.js"; // 产物回传上限:过小会把长文(如白皮书章节)截断导致内容永久丢失(历史教训: // task-mt9focod-zj852h 第二部分在 slice(0,2000) 处被切掉尾部)。可用 GW_ARTIFACT_MAX 覆盖。 const ARTIFACT_MAX = Number(process.env.GW_ARTIFACT_MAX) || 20_000; // 可选:设置 GW_WORKTREE=1 即每任务独立 git worktree(失败自动回退普通目录并记录原因) const wtEnabled = process.env.GW_WORKTREE === "1"; const wm = wtEnabled ? new WorkspaceManager({ repoRoot: process.env.GW_REPO_ROOT || resolve(process.cwd(), "workspace-repo"), workspaceRoot: process.env.GW_WORKSPACE_ROOT || resolve(process.cwd(), "workspace"), }) : null; let seq = 0; function taskWorkspace(tag, taskId) { const dir = resolve(tmpdir(), "gw-live-nodes", `${tag}-${process.pid}`, `${taskId}-${++seq}`); mkdirSync(dir, { recursive: true }); return dir; } function assertSafe(task) { const d = isDangerousTask(task); if (d.dangerous) throw new Error(`blocked: dangerous pattern "${d.keyword}"`); // 压测故障注入标记 if (String(task.prompt || "").includes("__FAIL__")) throw new Error("injected-failure"); } /** native 直连节点执行器。 */ export async function nativeExecute(task) { assertSafe(task); let ws, wtInfo = null; if (wm) { wtInfo = wm.acquire(task.id); // {path,mode:'worktree'|'dir',fallbackReason?} ws = wtInfo.path; } else { ws = taskWorkspace("native", task.id); } try { const text = String(task.prompt || task.title || ""); const words = (text.match(/[\w一-龿]+/g) || []).length; const artifact = `# ${task.title}\n\n${text}\n\n---\nnative worker @ ${new Date().toISOString()}\n词数 ${words}\n`; writeFileSync(join(ws, "result.md"), artifact); await new Promise((r) => setTimeout(r, 3 + Math.random() * 10)); return { output: artifact.slice(0, ARTIFACT_MAX), evidence: { workspace: ws, artifact: "result.md", words, isolation: wtInfo ? wtInfo : "temp-dir", }, executor: "native-builtin", }; } finally { // git worktree 保留供审计/diff 取证;临时目录直接清理 if (!wtInfo) { try { rmSync(ws, { recursive: true, force: true }); } catch {} } } } /** Windows:npm 的 .cmd 是跳板,解析出真实 exe/js 直接调用(绕开 cmd.exe 兼容坑)。 */ async function _resolveShimTarget(cmdPath) { try { const txt = readFileSync(cmdPath, "utf8"); // shim 里可能先出现 "%dp0%\node.exe"(探测分支),优先取 node_modules 下的真实入口 const re = /"%dp0%\\([^"]+?)"/g; let m, hit = null, last = null; while ((m = re.exec(txt))) { last = m[1]; if (/node_modules/i.test(m[1])) { hit = m[1]; break; } } const rel = hit || last; if (!rel) return null; const target = join(dirname(cmdPath), rel); return existsSync(target) ? target : null; } catch { return null; } } /** 解析可执行文件的真实路径(Windows npm 全局是 .cmd shim,裸 spawn 会失败)。 */ const _binCache = new Map(); export async function resolveBin(bin) { if (_binCache.has(bin)) return _binCache.get(bin); const isWin = process.platform === "win32"; let abs = bin; try { const { out } = await runCmd(isWin ? "where" : "which", [bin], null, 5_000); const hits = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); // where 会把无扩展名的 bash shim 排在前面,cmd.exe 无法执行它——优先可执行扩展名 let hit = isWin ? hits.find((h) => /\.(cmd|exe|bat)$/i.test(h)) || hits[0] : hits[0]; if (hit && isWin && /\.cmd$/i.test(hit)) { const real = await _resolveShimTarget(hit); if (real) hit = real; // claude→claude.exe;codex/gemini→node 脚本 } if (hit) abs = hit; } catch {} _binCache.set(bin, abs); return abs; } /** * 运行外部 agent CLI(binAbs 来自 resolveBin:真实 exe 或 node 脚本)。 * js 脚本用当前 node 直启;exe 直接 spawn——全程不经 cmd.exe(其控制台句柄会挂起部分 CLI)。 */ export function runCli(binAbs, argv, input, timeoutMs = 180_000, extraEnv = null) { const isScript = /\.(js|cjs|mjs)$/i.test(binAbs); const cmd = isScript ? process.execPath : binAbs; const finalArgs = isScript ? [binAbs, ...argv] : argv; return new Promise((resolveP, rejectP) => { const child = spawn(cmd, finalArgs, { shell: false, // 自定义模型接入点:extraEnv 覆盖同名变量(如 ANTHROPIC_BASE_URL/ANTHROPIC_MODEL/OPENAI_API_KEY) env: extraEnv && Object.keys(extraEnv).length ? { ...process.env, ...extraEnv } : undefined, }); let out = "", err = ""; const t = setTimeout(() => { child.kill(9); rejectP(new Error("cli-timeout")); }, timeoutMs); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("error", (e) => { clearTimeout(t); rejectP(e); }); child.on("close", (code) => { clearTimeout(t); code === 0 ? resolveP({ out, err }) : rejectP(new Error("exit " + code + ": " + err.slice(0, 300))); }); // 所有平台都要写完立即关闭 stdin:部分 CLI(如 claude -p)会等待 EOF 才开始处理 if (input != null) { try { child.stdin.write(input); } catch {} } try { child.stdin.end(); } catch {} }); } function runCmd(command, args, input, timeoutMs = 30_000) { return new Promise((resolveP, rejectP) => { const child = spawn(command, args, { shell: false }); let out = "", err = ""; const t = setTimeout(() => { child.kill(9); rejectP(new Error("cmd-timeout")); }, timeoutMs); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("error", (e) => { clearTimeout(t); rejectP(e); }); child.on("close", (code) => { clearTimeout(t); code === 0 ? resolveP({ out, err }) : rejectP(new Error(`exit ${code}: ${err.slice(0, 200)}`)); }); if (input != null) { child.stdin.write(input); child.stdin.end(); } }); } /** 兼容转化层执行器工厂。 */ export function makeAdapterExecute(kind, opts = {}) { return async function adapterExecute(task) { assertSafe(task); if (kind === "fast") { // 转化层包装的"外部快速执行单元"(压测用;证明协议转换而非吞吐瓶颈) await new Promise((r) => setTimeout(r, 2 + Math.random() * 8)); return { output: `[adapter-fast] processed: ${String(task.prompt || "").slice(0, 60)}`, evidence: { adapter: "fast" }, executor: "adapter-fast", }; } if (kind === "cmd") { const ws = taskWorkspace("adapter-cmd", task.id); writeFileSync(join(ws, "input.txt"), String(task.prompt || "")); const isWin = process.platform === "win32"; const { out } = await runCmd( isWin ? "cmd" : "sh", isWin ? ["/c", "echo", `[adapter-cmd] ${task.id}`] : ["-c", `echo '[adapter-cmd] ${task.id}'`], null, 10_000, ); return { output: out.trim(), evidence: { adapter: "cmd", workspace: ws }, executor: "adapter-cmd" }; } if (kind === "http") { const cfg = llmConfig(); const r = await chatComplete([ { role: "system", content: "你是子任务执行者,用 2-3 句中文给出结果。" }, { role: "user", content: String(task.prompt || task.title) }, ]); if (!r.ok) throw new Error(`upstream ${r.status}/${r.httpStatus}`); return { output: r.text.slice(0, ARTIFACT_MAX), evidence: { adapter: "http", model: cfg.model, latencyMs: r.latencyMs, usage: r.usage }, executor: "adapter-http-llm", }; } if (kind === "codex") { const bin = await resolveBin(opts.codexBin || "codex"); const t0 = Date.now(); // prompt 作为参数传入(跨平台免 stdin;Windows 的 .cmd 经 cmd.exe 包装也能收到) const { out } = await runCli( bin, ["exec", "--skip-git-repo-check", String(task.prompt || task.title)], null, Number(process.env.GW_CLI_TIMEOUT) || 120_000, opts.cliEnv, ); return { output: out.slice(0, ARTIFACT_MAX), evidence: { adapter: "codex-cli", bin, latencyMs: Date.now() - t0 }, executor: "adapter-codex" }; } if (kind === "cli") { // 通用 CLI 适配器:任意「提示词进、文本出」的本地 CLI 都能包成节点 // opts.cli 可执行名;opts.cliArgs 支持 {prompt} 占位符(如 "-p {prompt}"),缺省把 prompt 作为最后一个参数 const rawBin = opts.cli || process.env.GW_CLI_BIN; if (!rawBin) throw new Error("adapter-cli: 缺少 --cli <可执行名>"); const bin = await resolveBin(rawBin); const prompt = String(task.prompt || task.title || ""); const tmpl = String(opts.cliArgs ?? "").trim(); let argv; if (tmpl.includes("{prompt}")) { argv = tmpl.split("{prompt}").length === 2 ? [tmpl.split("{prompt}")[0].trim(), prompt, tmpl.split("{prompt}")[1].trim()].filter(Boolean) : tmpl.split(/\s+/); } else { argv = [...(tmpl ? tmpl.split(/\s+/) : []), prompt]; } const t0 = Date.now(); const { out, err } = await runCli(bin, argv, prompt, Number(process.env.GW_CLI_TIMEOUT) || 180_000, opts.cliEnv); return { output: (out || err).slice(0, ARTIFACT_MAX), evidence: { adapter: "cli", bin, args: argv.map((a) => (String(a).length > 60 ? "[prompt]" : a)), latencyMs: Date.now() - t0 }, executor: "cli:" + bin.split(/[\\/]/).pop(), }; } throw new Error(`unknown adapter kind: ${kind}`); }; }