- 零第三方依赖,Node >=20 原生 ESM - 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM - 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报 - H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json - H5 零依赖三栏控制台
264 lines
11 KiB
JavaScript
264 lines
11 KiB
JavaScript
/**
|
||
* 节点执行器(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}`);
|
||
};
|
||
}
|