|
| 1 | +import { spawn, type ChildProcess } from 'child_process'; |
| 2 | +import { setTimeout as sleep } from 'timers/promises'; |
| 3 | +import { StringDecoder } from 'string_decoder'; |
| 4 | +import { GlobalLoggerService } from '../../Logger'; |
| 5 | +import { createTextContent, MCPToolResult } from '../types'; |
| 6 | +import { resolveShell } from './shell'; |
| 7 | + |
| 8 | +const logger = GlobalLoggerService.getLogger('Tools/Process'); |
| 9 | + |
| 10 | +export const MAX_OUTPUT_BYTES = 256 * 1024; |
| 11 | +const SIGKILL_TIMEOUT_MS = 800; |
| 12 | +const IO_DRAIN_TIMEOUT_MS = 2_000; |
| 13 | + |
| 14 | +async function killTree(proc: ChildProcess, isExited: () => boolean): Promise<void> { |
| 15 | + const pid = proc.pid; |
| 16 | + if (!pid || isExited()) return; |
| 17 | + |
| 18 | + if (process.platform === 'win32') { |
| 19 | + await new Promise<void>((resolve) => { |
| 20 | + const killer = spawn('taskkill', ['/pid', String(pid), '/f', '/t'], { stdio: 'ignore' }); |
| 21 | + killer.once('exit', () => resolve()); |
| 22 | + killer.once('error', () => resolve()); |
| 23 | + }); |
| 24 | + return; |
| 25 | + } |
| 26 | + |
| 27 | + try { |
| 28 | + process.kill(-pid, 'SIGTERM'); |
| 29 | + await sleep(SIGKILL_TIMEOUT_MS); |
| 30 | + if (!isExited()) process.kill(-pid, 'SIGKILL'); |
| 31 | + } catch { |
| 32 | + try { proc.kill('SIGTERM'); } catch { /* ignore */ } |
| 33 | + await sleep(SIGKILL_TIMEOUT_MS); |
| 34 | + if (!isExited()) { try { proc.kill('SIGKILL'); } catch { /* ignore */ } } |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +const QUIET_ENV = { |
| 39 | + CI: '1', |
| 40 | + NO_COLOR: '1', |
| 41 | + FORCE_COLOR: '0', |
| 42 | + NPM_CONFIG_PROGRESS: 'false', |
| 43 | + PIP_PROGRESS_BAR: 'off', |
| 44 | + PYTHONUNBUFFERED: '1', |
| 45 | + // 阻止子进程弹出交互式凭据提示(git push HTTPS、ssh、apt 等)。 |
| 46 | + GIT_TERMINAL_PROMPT: '0', |
| 47 | + GIT_ASKPASS: 'echo', |
| 48 | + SSH_ASKPASS: 'echo', |
| 49 | + GCM_INTERACTIVE: 'Never', |
| 50 | + DEBIAN_FRONTEND: 'noninteractive', |
| 51 | +}; |
| 52 | + |
| 53 | +const SECRET_ENV_RE = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION/i; |
| 54 | + |
| 55 | +// 进程启动时一次性快照 process.env,过滤敏感变量再合并 QUIET_ENV。 |
| 56 | +// 设计取舍:bot 运行期不会动态注入凭据,缓存一次避免每条命令都做 O(n) 过滤。 |
| 57 | +// 若将来需要热更新 env,请改成基于版本号失效的缓存。 |
| 58 | +let _safeEnv: NodeJS.ProcessEnv | undefined; |
| 59 | +function buildChildEnv(): NodeJS.ProcessEnv { |
| 60 | + if (_safeEnv) return _safeEnv; |
| 61 | + const filtered: NodeJS.ProcessEnv = {}; |
| 62 | + for (const [k, v] of Object.entries(process.env)) { |
| 63 | + if (v === undefined) continue; |
| 64 | + if (SECRET_ENV_RE.test(k)) continue; |
| 65 | + filtered[k] = v; |
| 66 | + } |
| 67 | + _safeEnv = { ...filtered, ...QUIET_ENV }; |
| 68 | + return _safeEnv; |
| 69 | +} |
| 70 | + |
| 71 | +interface RunOptions { |
| 72 | + cwd: string; |
| 73 | + timeout: number; |
| 74 | + label: string; |
| 75 | + /** 传字符串走 shell(解析 &&、管道、重定向等);传 false 直接 exec,args 不经 shell。 */ |
| 76 | + shell: string | false; |
| 77 | + /** 写入子进程 stdin 的字符串;写完即 end,子进程读到 EOF。未传则 stdin 关闭。 */ |
| 78 | + stdin?: string; |
| 79 | +} |
| 80 | + |
| 81 | +interface OutputBuffer { |
| 82 | + chunks: Buffer[]; |
| 83 | + bytes: number; |
| 84 | + overflow: boolean; |
| 85 | +} |
| 86 | + |
| 87 | +function appendChunk(buf: OutputBuffer, chunk: Buffer): void { |
| 88 | + if (buf.overflow) return; |
| 89 | + const remain = MAX_OUTPUT_BYTES - buf.bytes; |
| 90 | + if (chunk.length >= remain) { |
| 91 | + if (remain > 0) buf.chunks.push(chunk.subarray(0, remain)); |
| 92 | + buf.bytes = MAX_OUTPUT_BYTES; |
| 93 | + buf.overflow = true; |
| 94 | + return; |
| 95 | + } |
| 96 | + buf.chunks.push(chunk); |
| 97 | + buf.bytes += chunk.length; |
| 98 | +} |
| 99 | + |
| 100 | +// 累积阶段保留 Buffer 引用,避免反复 ConsString 拼接 / flatten 带来的 GC 压力; |
| 101 | +// finish 时一次性 concat 后用 StringDecoder 解码全 buffer,多字节边界由 decoder 内部处理, |
| 102 | +// 字节截断尾部不完整字符通过 decoder.end() 输出 replacement。 |
| 103 | +function decodeBuffer(buf: OutputBuffer): string { |
| 104 | + const merged = Buffer.concat(buf.chunks, buf.bytes); |
| 105 | + const decoder = new StringDecoder('utf8'); |
| 106 | + return decoder.write(merged) + decoder.end(); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * 子进程通用执行器。stdout/stderr 用 Buffer 累积,finish 时一次性解码以正确处理多字节边界; |
| 111 | + * 超时、输出溢出、非 0 退出码、spawn error 都会以 isError=true 返回。 |
| 112 | + */ |
| 113 | +async function runProcess(file: string, args: string[], opts: RunOptions): Promise<MCPToolResult> { |
| 114 | + const { cwd, timeout, label, shell, stdin } = opts; |
| 115 | + |
| 116 | + return new Promise<MCPToolResult>((resolve) => { |
| 117 | + const outBuf: OutputBuffer = { chunks: [], bytes: 0, overflow: false }; |
| 118 | + const errBuf: OutputBuffer = { chunks: [], bytes: 0, overflow: false }; |
| 119 | + let timedOut = false; |
| 120 | + let exited = false; |
| 121 | + let settled = false; |
| 122 | + |
| 123 | + // detached: !win32 —— 让子进程成为新进程组组长,killTree 才能 kill -pgid 杀到孙子进程。 |
| 124 | + const proc = spawn(file, args, { |
| 125 | + shell, |
| 126 | + cwd, |
| 127 | + env: buildChildEnv(), |
| 128 | + stdio: [stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'], |
| 129 | + detached: process.platform !== 'win32', |
| 130 | + }); |
| 131 | + |
| 132 | + if (stdin !== undefined && proc.stdin) { |
| 133 | + // 子进程不读 stdin 时 write 会触发 EPIPE,吞掉避免 unhandledRejection。 |
| 134 | + proc.stdin.on('error', () => { /* ignore EPIPE */ }); |
| 135 | + // end() 一次写完并关闭 fd,子进程读到 EOF;否则脚本可能一直等待更多输入。 |
| 136 | + proc.stdin.end(stdin, 'utf8'); |
| 137 | + } |
| 138 | + |
| 139 | + const kill = () => killTree(proc, () => exited); |
| 140 | + |
| 141 | + proc.stdout?.on('data', (chunk: Buffer) => appendChunk(outBuf, chunk)); |
| 142 | + proc.stderr?.on('data', (chunk: Buffer) => appendChunk(errBuf, chunk)); |
| 143 | + |
| 144 | + let exitCode: number | null = null; |
| 145 | + let exitSignal: NodeJS.Signals | null = null; |
| 146 | + |
| 147 | + let forcedDrainTimeout = false; |
| 148 | + const finish = () => { |
| 149 | + if (settled) return; |
| 150 | + settled = true; |
| 151 | + clearTimeout(timeoutTimer); |
| 152 | + if (drainTimer) clearTimeout(drainTimer); |
| 153 | + try { proc.stdout?.destroy(); } catch { /* ignore */ } |
| 154 | + try { proc.stderr?.destroy(); } catch { /* ignore */ } |
| 155 | + |
| 156 | + const parts = []; |
| 157 | + const out = decodeBuffer(outBuf).trim(); |
| 158 | + const err = decodeBuffer(errBuf).trim(); |
| 159 | + if (out) parts.push(createTextContent(out)); |
| 160 | + if (err) parts.push(createTextContent(`stderr:\n${err}`)); |
| 161 | + |
| 162 | + const outOverflow = outBuf.overflow; |
| 163 | + const errOverflow = errBuf.overflow; |
| 164 | + |
| 165 | + const isError = timedOut || outOverflow || errOverflow || forcedDrainTimeout || |
| 166 | + (exitCode !== null && exitCode !== 0) || |
| 167 | + exitSignal !== null; |
| 168 | + if (timedOut) parts.push(createTextContent(`Command timed out after ${timeout} ms`)); |
| 169 | + else if (outOverflow || errOverflow) parts.push(createTextContent(`Output exceeded ${MAX_OUTPUT_BYTES} bytes; remaining output discarded`)); |
| 170 | + else if (forcedDrainTimeout) parts.push(createTextContent(`Output streams did not close within ${IO_DRAIN_TIMEOUT_MS} ms; force-finished`)); |
| 171 | + else if (exitCode !== null && exitCode !== 0) parts.push(createTextContent(`Process exited with code ${exitCode}`)); |
| 172 | + else if (exitSignal !== null) parts.push(createTextContent(`Process terminated by signal ${exitSignal}`)); |
| 173 | + |
| 174 | + resolve({ content: parts, isError: isError || undefined }); |
| 175 | + }; |
| 176 | + |
| 177 | + let drainTimer: NodeJS.Timeout | undefined; |
| 178 | + const startDrainTimer = () => { |
| 179 | + if (drainTimer) return; |
| 180 | + drainTimer = setTimeout(() => { forcedDrainTimeout = true; finish(); }, IO_DRAIN_TIMEOUT_MS); |
| 181 | + }; |
| 182 | + |
| 183 | + const timeoutTimer = setTimeout(() => { |
| 184 | + timedOut = true; |
| 185 | + void kill(); |
| 186 | + startDrainTimer(); |
| 187 | + }, timeout); |
| 188 | + |
| 189 | + // 'exit' 在子进程终止时立即触发,但 stdio 中可能还有未消费的 tail bytes; |
| 190 | + // 'close' 在 stdout/stderr 全部 drain 后才触发,是聚合输出的正确时机。 |
| 191 | + // 若孙子进程持有 fd 导致 'close' 永远不触发,drainTimer 兜底强制收尾。 |
| 192 | + proc.once('exit', (code, signal) => { |
| 193 | + exited = true; |
| 194 | + exitCode = code; |
| 195 | + exitSignal = signal; |
| 196 | + startDrainTimer(); |
| 197 | + }); |
| 198 | + proc.once('close', () => { |
| 199 | + exited = true; |
| 200 | + finish(); |
| 201 | + }); |
| 202 | + |
| 203 | + proc.once('error', (error: Error) => { |
| 204 | + exited = true; |
| 205 | + if (settled) return; |
| 206 | + settled = true; |
| 207 | + clearTimeout(timeoutTimer); |
| 208 | + if (drainTimer) clearTimeout(drainTimer); |
| 209 | + logger?.error(`Error executing ${label}: ${error.message}`); |
| 210 | + resolve({ content: [createTextContent(`Error: ${error.message}`)], isError: true }); |
| 211 | + }); |
| 212 | + }); |
| 213 | +} |
| 214 | + |
| 215 | +/** 执行一段 shell 脚本字符串(支持 &&、管道、重定向)。整段交给 shell 解析。 */ |
| 216 | +export function runShellCommand(command: string, cwd: string, timeout: number, label: string, stdin?: string): Promise<MCPToolResult> { |
| 217 | + return runProcess(command, [], { cwd, timeout, label, shell: resolveShell(), stdin }); |
| 218 | +} |
| 219 | + |
| 220 | +/** 执行解释器 + 参数数组。args 直接作为 OS 层参数传递,不经 shell 解析,无引号/转义风险。 */ |
| 221 | +export function runProgram(file: string, args: string[], cwd: string, timeout: number, label: string, stdin?: string): Promise<MCPToolResult> { |
| 222 | + return runProcess(file, args, { cwd, timeout, label, shell: false, stdin }); |
| 223 | +} |
0 commit comments