package vip.mate.skill.runtime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 技能脚本执行服务
* 安全执行 scripts/ 目录下的脚本
*
* 输出重定向到临时文件,确保 timeout 不被管道阻塞失效。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillScriptExecutionService {
private static final long DEFAULT_TIMEOUT_SECONDS = 30;
private static final int MAX_OUTPUT_BYTES = 50_000;
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
/**
* 执行脚本(兼容签名 — 不注入额外 env vars)
*
* @param scriptPath 脚本绝对路径(已验证安全)
* @param args 脚本参数
* @return 执行结果
*/
public ScriptResult execute(Path scriptPath, List args) {
return execute(scriptPath, args, Collections.emptyMap());
}
/**
* 执行脚本,附加 env vars 到子进程环境(RFC-091 settings bridge)。
* 用于把 skill 在 mate_skill_secret 里存的解密 secret 注入到脚本运行
* 时环境,让 SKILL.md 里 {@code $AIRTABLE_API_KEY} 等引用自然解析。
*
* {@code envVars} 中的键值会 OVERRIDE 父进程同名环境变量;空值跳过。
*
* @param scriptPath 脚本绝对路径(已验证安全)
* @param args 脚本参数
* @param envVars 要注入子进程环境的额外键值对,可为空
* @return 执行结果
*/
public ScriptResult execute(Path scriptPath, List args, Map envVars) {
if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) {
return ScriptResult.error(-1, "Script not found: " + scriptPath);
}
Path stdoutFile = null;
Path stderrFile = null;
try {
// 构建命令(结构化参数,避免 shell 注入)
List command = new ArrayList<>();
// 根据文件扩展名选择解释器(跨平台适配)
String fileName = scriptPath.getFileName().toString();
if (fileName.endsWith(".py")) {
// Windows 通常只有 python,没有 python3
command.add(IS_WINDOWS ? "python" : "python3");
} else if (fileName.endsWith(".sh")) {
if (IS_WINDOWS) {
return ScriptResult.error(-1,
"Shell scripts (.sh) are not supported on Windows. " +
"Consider providing a .bat or .ps1 alternative.");
}
command.add("bash");
} else if (fileName.endsWith(".bat") || fileName.endsWith(".cmd")) {
if (!IS_WINDOWS) {
return ScriptResult.error(-1,
"Batch scripts (.bat/.cmd) are only supported on Windows.");
}
command.add("cmd.exe");
command.add("/D");
command.add("/C");
} else if (fileName.endsWith(".ps1")) {
command.add("powershell");
command.add("-ExecutionPolicy");
command.add("Bypass");
command.add("-File");
} else if (fileName.endsWith(".js")) {
command.add("node");
} else {
if (!IS_WINDOWS && !Files.isExecutable(scriptPath)) {
return ScriptResult.error(-1, "Script not executable: " + fileName);
}
}
command.add(scriptPath.toString());
if (args != null) {
command.addAll(args);
}
// 重定向到临时文件,使 waitFor(timeout) 不被管道阻塞
stdoutFile = Files.createTempFile("mc_script_out_", ".tmp");
stderrFile = Files.createTempFile("mc_script_err_", ".tmp");
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(scriptPath.getParent().toFile());
pb.redirectOutput(stdoutFile.toFile());
pb.redirectError(stderrFile.toFile());
// RFC-091: inject per-skill secrets / settings as env vars.
// pb.environment() inherits the parent process env; putAll
// OVERRIDES same-named entries with the supplied values.
// Null / blank values are skipped to avoid clearing
// legitimate parent env vars.
if (envVars != null && !envVars.isEmpty()) {
Map processEnv = pb.environment();
for (Map.Entry e : envVars.entrySet()) {
if (e.getKey() == null || e.getKey().isBlank()) continue;
if (e.getValue() == null) continue;
processEnv.put(e.getKey(), e.getValue());
}
}
Process process = pb.start();
boolean finished = process.waitFor(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
killProcess(process);
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
String timeoutMsg = "[timeout after " + DEFAULT_TIMEOUT_SECONDS + "s]";
stderr = stderr.isEmpty() ? timeoutMsg : stderr + "\n" + timeoutMsg;
return new ScriptResult(-1, stdout, stderr);
}
int exitCode = process.exitValue();
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
return new ScriptResult(exitCode, stdout, stderr);
} catch (Exception e) {
log.error("Failed to execute script {}: {}", scriptPath, e.getMessage());
return ScriptResult.error(-1, "Execution error: " + e.getMessage());
} finally {
deleteQuietly(stdoutFile);
deleteQuietly(stderrFile);
}
}
private static void killProcess(Process process) {
if (IS_WINDOWS) {
try {
new ProcessBuilder("taskkill", "/F", "/T", "/PID", String.valueOf(process.pid()))
.redirectErrorStream(true)
.start()
.waitFor(10, TimeUnit.SECONDS);
} catch (Exception e) {
process.destroyForcibly();
}
} else {
process.destroyForcibly();
}
try {
process.waitFor(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static String readFileTruncated(Path file, int maxBytes) {
try {
if (file == null || !Files.exists(file)) return "";
long size = Files.size(file);
if (size == 0) return "";
boolean truncated = size > maxBytes;
try (InputStream is = Files.newInputStream(file)) {
byte[] data = is.readNBytes(maxBytes);
String content = new String(data, StandardCharsets.UTF_8);
if (truncated) {
content += "\n... [输出已截断,超过 " + maxBytes + " 字节限制]";
}
return content;
}
} catch (IOException e) {
return "[读取输出失败: " + e.getMessage() + "]";
}
}
private static void deleteQuietly(Path file) {
if (file != null) {
try { Files.deleteIfExists(file); } catch (IOException ignored) {}
}
}
@lombok.Data
@lombok.AllArgsConstructor
public static class ScriptResult {
private int exitCode;
private String stdout;
private String stderr;
public static ScriptResult error(int code, String message) {
return new ScriptResult(code, "", message);
}
public boolean isSuccess() {
return exitCode == 0;
}
}
}