package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.document.WorkspaceArtifactSurfacer;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
/**
* 内置工具:本地命令执行(跨平台)
*
fileLinks = WorkspaceArtifactSurfacer.collect(generatedFileCache, workingDir, runStart, ctx);
if (!fileLinks.isEmpty()) {
result.set("generatedFiles", String.join("\n", fileLinks));
}
}
} catch (Exception e) {
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
result.set("exitCode", -1);
result.set("stdout", "");
result.set("stderr", i18n.msg("tool.shell.error.exception", e.getMessage()));
result.set("timedOut", false);
result.set("error", e.getMessage());
} finally {
deleteQuietly(stdoutFile);
deleteQuietly(stderrFile);
}
return JSONUtil.toJsonPrettyStr(result);
}
/**
* 根据当前操作系统构建 shell 进程。
* Windows: cmd.exe /D /S /C "command"
* /D 禁用 AutoRun 注册表项,避免副作用
* /S 保留引号原样传递给命令
* Unix: $SHELL -c command (honors the user's interactive shell)
* honors the user's interactive shell so alias resolution / PATH
* from the calling environment still apply; falls back to /bin/sh
* when $SHELL is unset or points at a non-executable path.
*/
private static ProcessBuilder buildShellProcess(String command, @Nullable ToolContext ctx) {
ProcessBuilder pb;
if (IS_WINDOWS) {
String winCommand = sanitizeWindowsCommand(command);
pb = new ProcessBuilder("cmd.exe", "/D", "/S", "/C", winCommand);
} else {
String shell = selectPosixShell(System.getenv("SHELL"));
pb = new ProcessBuilder(shell, "-c", command);
}
// Pin the process cwd to the same workspace basePath the validator
// checked against. Using getWorkingDirectory(ctx) (not the no-arg
// ThreadLocal-only overload) keeps validation and execution on a
// single source of truth — otherwise a caller that only sets
// ToolContext could validate against one basePath and run with the
// ThreadLocal fallback's basePath.
java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory(ctx);
if (workingDir != null && java.nio.file.Files.isDirectory(workingDir)) {
pb.directory(workingDir.toFile());
log.info("[ShellExecute] Working directory set to: {}", workingDir);
}
return pb;
}
/**
* Collapse embedded newlines for Windows cmd.exe (where they break parsing),
* but **leave them alone on Unix**.
*
* The original implementation collapsed on every platform under the worry
* that a stray newline could be misread as a command separator on POSIX
* shells. In practice that worry is wrong for two common idioms the LLM
* actually uses to write files: heredocs (`cat <<EOF\nbody\nEOF`) and
* `python <<EOF` invocations. Both depend on real line breaks to
* delimit the body from the closing tag — collapsing newlines turns
* `cat <<EOF\nbody\nEOF` into `cat <<EOF body EOF`, which the
* shell reads as "open heredoc, immediately close, write 0 bytes." The
* symptom: every chapter file produced by the agent ends up 0-byte.
*
* Unix shell already separates commands with `;` or `&&`, not
* unquoted newlines, so leaving newlines in is actually safer — and
* heredocs / multi-line commands now behave as the LLM expects. Windows
* cmd.exe still gets the collapse because there it really does break.
*/
private static String collapseEmbeddedNewlines(String command) {
if (command == null || !command.contains("\n")) {
return command;
}
if (!IS_WINDOWS) {
// POSIX shell handles newlines correctly within heredocs / scripts
return command;
}
return command.replace("\r\n", " ").replace("\n", " ");
}
/**
* Pick the POSIX shell binary to invoke for a non-Windows tool call.
*
*
Returns {@code userShellEnv} verbatim when:
*
* - the value is non-blank,
* - parses as a valid path,
* - and the resolved binary is executable.
*
*
* Otherwise falls back to {@code /bin/sh} — the legacy hardcoded
* default. Important: {@code /bin/sh} on Debian/Ubuntu is dash, which
* does NOT honor users' bash-isms; the whole point of this method is
* to prefer the user's actual interactive shell when one is configured.
*/
static String selectPosixShell(String userShellEnv) {
return selectPosixShell(userShellEnv, Files::isExecutable);
}
/**
* Test seam — same logic as {@link #selectPosixShell(String)} but with
* an injectable executable check so unit tests can drive every branch
* without depending on which shells actually exist on the test runner
* (Windows CI has no {@code /bin/sh}, POSIX dev hosts have varying
* shells installed). Production callers go through the single-arg
* overload above.
*/
static String selectPosixShell(String userShellEnv, Predicate executableCheck) {
if (userShellEnv == null || userShellEnv.isBlank()) {
return "/bin/sh";
}
try {
Path candidate = Path.of(userShellEnv);
if (executableCheck.test(candidate)) {
return userShellEnv;
}
} catch (InvalidPathException ignored) {
// Some exotic $SHELL value that isn't a path — fall through to default.
}
return "/bin/sh";
}
/**
* 修复 LLM 常见的 Windows 命令转义问题。
* LLM 有时会产生 bash 风格的反斜杠转义引号 (\"),
* 如果命令中所有双引号都被反斜杠转义,则认为是 JSON/bash 伪影并去除反斜杠。
*/
private static String sanitizeWindowsCommand(String command) {
if (command.contains("\\\"") && !command.replace("\\\"", "").contains("\"")) {
return command.replace("\\\"", "\"");
}
return command;
}
/**
* 尽力终止进程树。
* Windows: 使用 taskkill /F /T 终止整个进程树(包括子进程)。
* Unix: destroyForcibly() 发送 SIGKILL,对于 /bin/sh 启动的子进程基本够用。
* 注意:Windows 上如果 taskkill 失败,仍回退到 destroyForcibly(),
* 极端情况下可能有子进程残留(如后台 detached 进程)。
*/
private static void killProcessTree(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();
}
}
/**
* 从临时文件中读取输出,截断到 maxBytes 字节。
* 进程退出或被杀死后调用,读取子进程已写入文件的内容。
*/
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... [output truncated, exceeds " + maxBytes + " byte limit]";
}
return content;
}
} catch (IOException e) {
return "[read output failed: " + e.getMessage() + "]";
}
}
private static void deleteQuietly(Path file) {
if (file != null) {
try { Files.deleteIfExists(file); } catch (IOException ignored) {}
}
}
private String truncateForLog(String text) {
if (text == null) return "null";
return text.length() > 200 ? text.substring(0, 200) + "..." : text;
}
}