Skip to content

Commit fb24be7

Browse files
author
linyuan.yang
committed
command
1 parent 73ad416 commit fb24be7

10 files changed

Lines changed: 467 additions & 389 deletions

File tree

packages/sbot/src/Tools/Command/shell.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ export function createShellTool(): StructuredToolInterface {
1111
schema: z.object({
1212
command: z.string().min(1).describe('Command or multi-line shell script to run, e.g. "git status", "npm install && npm run build", or a newline-separated script'),
1313
workingDir: z.string().describe('Absolute path of the working directory'),
14+
stdin: z.string().optional().describe('Data to pipe into the command via stdin (use when payload is large or contains special characters, e.g. JSON)'),
1415
timeout: z.number().optional().default(60000).describe('Timeout in milliseconds, default 60000 (60 s)'),
1516
}) as any,
16-
func: async ({ command, workingDir, timeout = 60000 }: any) => {
17+
func: async ({ command, workingDir, stdin, timeout = 60000 }: any) => {
1718
const { cwd, error } = await resolveWorkingDir(workingDir);
1819
if (error) return createErrorResult(error);
19-
return runShellCommand(command, cwd!, timeout, `command "${command}"`);
20+
return runShellCommand(command, cwd!, timeout, `command "${command}"`, stdin);
2021
},
2122
});
2223
}

packages/sbot/src/Tools/Command/utils.ts

Lines changed: 14 additions & 364 deletions
Large diffs are not rendered by default.

packages/scorpio.ai/src/Skills/SkillService.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@ import { DynamicStructuredTool, type StructuredToolInterface } from "@langchain/
88
import { z } from "zod";
99
import fs from "fs";
1010
import path from "path";
11-
import { exec } from "child_process";
12-
import { promisify } from "util";
1311
import {
1412
createTextContent,
1513
createErrorResult,
1614
createSuccessResult,
17-
MCPToolResult
15+
MCPToolResult,
16+
runProgram,
17+
runShellCommand,
18+
isCommandAvailable,
1819
} from "../Tools";
1920
import { UsageTracker, UsageState } from "../Utils/UsageTracker";
2021

21-
const execAsync = promisify(exec);
22-
2322
export const READ_SKILL_FILE_TOOL_NAME = 'read_skill_file';
2423
export const EXECUTE_SKILL_SCRIPT_TOOL_NAME = 'execute_skill_script';
2524
export const LIST_SKILL_FILES_TOOL_NAME = 'list_skill_files';
@@ -142,11 +141,13 @@ export class SkillService implements ISkillService {
142141
name: EXECUTE_SKILL_SCRIPT_TOOL_NAME,
143142
description: this.toolExecDesc!,
144143
schema: z.object({
145-
skillName: z.string().describe("Skill name (kebab-case)"),
144+
skillName: z.string().describe("Skill name (kebab-case)"),
146145
scriptPath: z.string().describe('Relative path to the script, e.g. "scripts/process.py". Confirm via list_skill_files first.'),
147-
args: z.array(z.string()).optional().describe("Arguments to pass to the script")
146+
args: z.array(z.string()).optional().describe("Arguments to pass to the script"),
147+
stdin: z.string().optional().describe('Data to pipe into the script via stdin (use when payload is large or contains special characters, e.g. JSON)'),
148+
timeout: z.number().optional().default(60000).describe('Timeout in milliseconds, default 60000 (60 s)'),
148149
}) as any,
149-
func: async ({ skillName, scriptPath, args = [] }: any): Promise<MCPToolResult> => {
150+
func: async ({ skillName, scriptPath, args = [], stdin, timeout = 60000 }: any): Promise<MCPToolResult> => {
150151
try {
151152
const skill = this.getAllSkills().find(s => s.name === skillName);
152153
if (!skill) return createErrorResult(`Skill "${skillName}" not found`);
@@ -156,30 +157,38 @@ export class SkillService implements ISkillService {
156157
if (!fs.existsSync(fullPath)) return createErrorResult(`Script not found: ${scriptPath}`);
157158

158159
const ext = path.extname(scriptPath).toLowerCase();
159-
let command = "";
160+
// .sh 走 shell(脚本本身可能依赖 shell 语法);其他走 runProgram 数组传参,免转义。
161+
// python 解释器名按平台双探测:现代 Linux 通常只有 python3,Windows 多为 python。
162+
let interpreter: string;
163+
let useShell = false;
160164
switch (ext) {
161-
case ".py": command = `python "${fullPath}" ${args.join(" ")}`; break;
162-
case ".sh": command = `bash "${fullPath}" ${args.join(" ")}`; break;
163-
case ".js": command = `node "${fullPath}" ${args.join(" ")}`; break;
164-
case ".ts": command = `ts-node "${fullPath}" ${args.join(" ")}`; break;
165-
default: return createErrorResult(`Unsupported script type: ${ext}. Supported: .py, .sh, .js, .ts`);
165+
case ".py":
166+
interpreter = isCommandAvailable("python") ? "python" : "python3";
167+
break;
168+
case ".js": interpreter = "node"; break;
169+
case ".ts": interpreter = "ts-node"; break;
170+
case ".sh": interpreter = ""; useShell = true; break;
171+
default:
172+
return createErrorResult(`Unsupported script type: ${ext}. Supported: .py, .sh, .js, .ts`);
173+
}
174+
if (!useShell && !isCommandAvailable(interpreter)) {
175+
return createErrorResult(`Interpreter "${interpreter}" not found in PATH`);
166176
}
167177

168178
new UsageTracker(skill.path).recordUse();
169179
const cwd = path.dirname(fullPath);
180+
const label = `skill ${skillName}/${scriptPath}`;
170181
this.logger?.info(`执行 skill 脚本 ${skillName}/${scriptPath} cwd=${cwd}`);
171-
const { stdout, stderr } = await execAsync(command, { cwd, env: process.env, timeout: 60000, maxBuffer: 10 * 1024 * 1024 });
172182

173-
const result = [];
174-
if (stdout.trim()) result.push(createTextContent(stdout.trim()));
175-
if (stderr.trim()) result.push(createTextContent(`stderr:\n${stderr.trim()}`));
176-
return createSuccessResult(...result);
183+
if (useShell) {
184+
// .sh:拼一段 shell 命令,args 数组用 single-quote 包裹避免 injection。
185+
const quoted = [fullPath, ...args].map((s: string) => `'${String(s).replace(/'/g, `'\\''`)}'`).join(" ");
186+
return await runShellCommand(quoted, cwd, timeout, label, stdin);
187+
}
188+
return await runProgram(interpreter, [fullPath, ...args], cwd, timeout, label, stdin);
177189
} catch (error: any) {
178190
this.logger?.error(`Error executing skill script ${skillName}/${scriptPath}: ${error.message}`);
179-
const errorDetails = [createTextContent(`Error: ${error.message}`)];
180-
if (error.stdout?.trim()) errorDetails.push(createTextContent(`stdout:\n${error.stdout.trim()}`));
181-
if (error.stderr?.trim()) errorDetails.push(createTextContent(`stderr:\n${error.stderr.trim()}`));
182-
return { content: errorDetails, isError: true };
191+
return createErrorResult(`Error: ${error.message}`);
183192
}
184193
}
185194
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { runProgram, runShellCommand, MAX_OUTPUT_BYTES } from './runner';
2+
export { resolveShell, isCommandAvailable } from './shell';
3+
export { validatePath, resolveWorkingDir } from './paths';
4+
export { createScriptCodeTool, scriptCodeSchema, type ScriptCodeToolOptions } from './scriptTool';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
const PROBE_TIMEOUT_MS = 2_000;
5+
6+
export function validatePath(filePath: string): { valid: boolean; error?: string; absolutePath?: string } {
7+
if (!filePath || typeof filePath !== 'string') {
8+
return { valid: false, error: 'Path is empty' };
9+
}
10+
if (!path.isAbsolute(filePath)) {
11+
return { valid: false, error: `Path must be absolute: ${filePath}` };
12+
}
13+
return { valid: true, absolutePath: path.normalize(filePath) };
14+
}
15+
16+
/**
17+
* 给 Promise 套超时。一旦 race 输给 timeout,原 promise 之后仍可能 reject ——
18+
* 通过提前挂一个 noop catch 把它标记为已处理,避免 unhandledRejection。
19+
*/
20+
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
21+
let timer: NodeJS.Timeout | undefined;
22+
p.catch(() => { /* 防止 race 失败方触发 unhandledRejection */ });
23+
return Promise.race([
24+
p,
25+
new Promise<T>((_, reject) => {
26+
timer = setTimeout(() => reject(Object.assign(new Error(`${label} timed out (${ms} ms)`), { code: 'ETIMEDOUT' })), ms);
27+
}),
28+
]).finally(() => { if (timer) clearTimeout(timer); });
29+
}
30+
31+
export async function resolveWorkingDir(workingDir: string | undefined): Promise<{ cwd?: string; error?: string }> {
32+
if (!workingDir) return { error: 'workingDir is required' };
33+
34+
const v = validatePath(workingDir);
35+
if (!v.valid) return { error: v.error };
36+
37+
const cwd = v.absolutePath!;
38+
try {
39+
const stat = await withTimeout(fs.promises.stat(cwd), PROBE_TIMEOUT_MS, `stat ${cwd}`);
40+
if (!stat.isDirectory()) return { error: `Path is not a directory: ${cwd}` };
41+
return { cwd };
42+
} catch (e: any) {
43+
if (e?.code === 'ENOENT') return { error: `Working directory not found: ${cwd}` };
44+
if (e?.code === 'ETIMEDOUT') return { error: e.message };
45+
return { error: `Failed to check working directory: ${e?.message ?? e}` };
46+
}
47+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)