mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-09-06 17:47:39 +02:00
635 lines
24 KiB
Python
635 lines
24 KiB
Python
from . import register_tool
|
|
from .base import _xml_response
|
|
import shlex
|
|
import threading
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
import os
|
|
import re
|
|
from typing import Dict, Tuple, List
|
|
|
|
# 危险命令黑名单模式
|
|
DANGEROUS_COMMAND_PATTERNS = [
|
|
r'rm\s+-rf\s+/$', # rm -rf /
|
|
r'rm\s+-rf\s+/\*', # rm -rf /*
|
|
r'rm\s+-rf\s+~', # rm -rf ~
|
|
r'rm\s+-rf\s+/www/server', # 保护面板
|
|
r'rm\s+-rf\s+/etc', # 删除系统配置
|
|
r'rm\s+-rf\s+/boot', # 删除启动文件
|
|
r'dd\s+if=', # dd 磁盘操作
|
|
r'mkfs', # 格式化
|
|
r'>\s*/dev/sd[a-z]', # 直接写入磁盘
|
|
r'>\s*/dev/nvme', # 直接写入NVMe
|
|
r'>\s*/etc/passwd', # 覆盖用户数据库
|
|
r'>\s*/etc/shadow', # 覆盖密码文件
|
|
r'chmod\s+777\s+/', # 修改根目录权限
|
|
r'chown\s+.*\s+/\s*$', # 修改根目录所有者
|
|
r'shutdown', # 关机
|
|
r'reboot', # 重启
|
|
r'halt', # 停机
|
|
r'poweroff', # 关电
|
|
r'init\s+0', # 关机
|
|
r'init\s+6', # 重启
|
|
r':\(\)\s*\{', # Fork bomb
|
|
r'curl.*\|\s*(bash|sh|python|perl)', # 远程执行
|
|
r'wget.*\|\s*(bash|sh|python|perl)', # 远程执行
|
|
r'base64.*\|\s*(bash|sh)', # base64 解码执行
|
|
r'iptables\s+-F', # 清空防火墙规则
|
|
r'\bnc\s+(-e|--exec)', # 反弹 shell
|
|
r'eval\s+', # eval 执行
|
|
r'exec\s+', # exec 执行
|
|
]
|
|
|
|
|
|
def _protect_substitutions(command: str) -> tuple:
|
|
"""将 $(...) 和 `...` 替换为占位符, 防止 shlex 拆分命令替换内部内容。
|
|
支持嵌套 $(): 循环替换直到稳定, 由内向外逐层处理。
|
|
"""
|
|
replacements: List[str] = []
|
|
|
|
def _replace(m):
|
|
idx = len(replacements)
|
|
replacements.append(m.group(0))
|
|
return f'__SUBST_{idx}__'
|
|
|
|
# 先替换反引号
|
|
result = re.sub(r'`[^`]*`', _replace, command)
|
|
# 循环替换 $() 直到稳定, 处理嵌套 $(echo $(cmd))
|
|
prev = None
|
|
while prev != result:
|
|
prev = result
|
|
result = re.sub(r'\$\([^()]*\)', _replace, result)
|
|
return result, replacements
|
|
|
|
|
|
def _restore_substitutions(text: str, replacements: List[str]) -> str:
|
|
"""将占位符还原为原始的 $(...) 和 `...` 内容。"""
|
|
for i, orig in enumerate(replacements):
|
|
text = text.replace(f'__SUBST_{i}__', orig)
|
|
return text
|
|
|
|
|
|
def _tokenize_command(command: str) -> list:
|
|
"""
|
|
使用 shlex 分词命令, 正确处理所有 shell 引号规则。
|
|
仅将 ; & | 视为标点 (用于识别 && || ; 分隔符),
|
|
不拆分 > < ( ) 等重定向和子 shell 符号, 保持 2>/dev/null 等原样。
|
|
"""
|
|
try:
|
|
lex = shlex.shlex(command, punctuation_chars=';&|')
|
|
lex.whitespace_split = True
|
|
return list(lex)
|
|
except ValueError:
|
|
# 引号不匹配等异常, 降级为不拆分
|
|
return [command.strip()] if command.strip() else []
|
|
|
|
|
|
_SPLIT_DELIMITERS = {';', '&&', '||'}
|
|
|
|
|
|
def _split_merged_command(command: str) -> list:
|
|
"""
|
|
按逻辑控制符 (&&, ;, ||) 拆分命令, 跳过引号内和命令替换内的分隔符。
|
|
基于 shlex 标准库, 覆盖单/双引号、反斜杠转义、ANSI-C 引用等场景。
|
|
"""
|
|
protected, subs = _protect_substitutions(command)
|
|
tokens = _tokenize_command(protected)
|
|
|
|
# 找到顶层分隔符位置
|
|
delim_indices = [i for i, t in enumerate(tokens) if t.strip() in _SPLIT_DELIMITERS]
|
|
if not delim_indices:
|
|
return [command.strip()] if command.strip() else []
|
|
|
|
# 按分隔符位置切分 token 组, 每组合并为子命令字符串
|
|
parts = []
|
|
prev = 0
|
|
for di in delim_indices:
|
|
group = tokens[prev:di]
|
|
if group:
|
|
cmd = ' '.join(group)
|
|
cmd = _restore_substitutions(cmd, subs)
|
|
parts.append(cmd.strip())
|
|
prev = di + 1
|
|
# 末尾段
|
|
tail = tokens[prev:]
|
|
if tail:
|
|
cmd = ' '.join(tail)
|
|
cmd = _restore_substitutions(cmd, subs)
|
|
parts.append(cmd.strip())
|
|
|
|
return [p for p in parts if p]
|
|
|
|
|
|
def _split_merged_command_with_delims(command: str) -> list:
|
|
"""
|
|
拆分合并命令并保留原连接符。
|
|
返回 [(cmd, delim), ...] 列表。例如: "cmd1 && cmd2 ; cmd3" -> [('cmd1', '&&'), ('cmd2', ';'), ('cmd3', '')]
|
|
基于 shlex 标准库, 跳过引号内和命令替换内的分隔符。
|
|
"""
|
|
protected, subs = _protect_substitutions(command)
|
|
tokens = _tokenize_command(protected)
|
|
|
|
delim_indices = [(i, tokens[i].strip()) for i, t in enumerate(tokens) if t.strip() in _SPLIT_DELIMITERS]
|
|
if not delim_indices:
|
|
return [(command.strip(), "")] if command.strip() else []
|
|
|
|
result = []
|
|
prev = 0
|
|
for di, delim in delim_indices:
|
|
group = tokens[prev:di]
|
|
if group:
|
|
cmd = ' '.join(group)
|
|
cmd = _restore_substitutions(cmd, subs)
|
|
result.append((cmd.strip(), delim))
|
|
prev = di + 1
|
|
tail = tokens[prev:]
|
|
if tail:
|
|
cmd = ' '.join(tail)
|
|
cmd = _restore_substitutions(cmd, subs)
|
|
result.append((cmd.strip(), ""))
|
|
|
|
return [(cmd, delim) for cmd, delim in result if cmd]
|
|
|
|
|
|
def _wrap_merged_command_with_markers(command: str) -> tuple:
|
|
"""
|
|
包装合并命令,动态保留并应用原始连接符(&&, ||, ;),维持原 Shell 逻辑。
|
|
返回 (wrapped_cmd, markers), markers 供 _parse_merged_output 使用。
|
|
"""
|
|
parts = _split_merged_command_with_delims(command)
|
|
wrapped_parts = []
|
|
markers = []
|
|
|
|
for i, (cmd, delim) in enumerate(parts):
|
|
escaped_cmd = cmd.replace("'", "'\\''")
|
|
start_m = f"===CMD_S_{i}==="
|
|
end_m = f"===CMD_E_{i}==="
|
|
markers.append((start_m, end_m))
|
|
# 包装子命令; _rc 捕获退出码, exit $_rc 传播给外层 && / ||
|
|
wrapped = (
|
|
f'bash -c \'printf "{start_m}\\n"; {escaped_cmd}; _rc=$?; '
|
|
f'printf "\\n{end_m}\\nRC=%s\\n" "$_rc"; exit $_rc\''
|
|
)
|
|
|
|
# 将原始连接符附带在包装好的命令之后
|
|
if delim:
|
|
wrapped_parts.append(f"{wrapped} {delim}")
|
|
else:
|
|
wrapped_parts.append(wrapped)
|
|
|
|
return " ".join(wrapped_parts), markers
|
|
|
|
|
|
def _parse_merged_output(raw_output: str, command: str, markers: list = None) -> str:
|
|
"""
|
|
解析合并命令的输出, 按 marker 分段展示每步结果。
|
|
markers: _wrap_merged_command_with_markers 返回的 marker 列表,
|
|
为 None 时回退到顺序编号匹配 (兼容旧调用)。
|
|
"""
|
|
parts = _split_merged_command_with_delims(command)
|
|
results = []
|
|
|
|
def _get_markers(idx: int):
|
|
if markers and idx < len(markers):
|
|
return markers[idx]
|
|
return f"===CMD_S_{idx}===", f"===CMD_E_{idx}==="
|
|
|
|
for i in range(len(parts)):
|
|
cmd_text = parts[i][0] if i < len(parts) else f"command_{i}"
|
|
start_marker, end_marker = _get_markers(i)
|
|
|
|
# 如果前一步被判定为 skipped,或者在 raw_output 中找不到当前步骤的 Marker
|
|
start_idx = raw_output.find(start_marker)
|
|
if start_idx == -1:
|
|
is_previous_skipped = True
|
|
results.append(f"[{i + 1}] {cmd_text}\nExit code: Skipped (Not Executed)\nOutput: -")
|
|
continue
|
|
|
|
output_start = start_idx + len(start_marker)
|
|
end_idx = raw_output.find(end_marker, output_start)
|
|
|
|
if end_idx == -1:
|
|
cmd_output = raw_output[output_start:].strip()
|
|
rc = -1
|
|
else:
|
|
cmd_output = raw_output[output_start:end_idx].strip()
|
|
# 严格提取退出码: 跳过 printf 格式中的前导换行, 取第一个非空行匹配 ^RC=\d+$
|
|
rc_part = raw_output[end_idx + len(end_marker):].lstrip('\n')
|
|
first_nl = rc_part.find("\n")
|
|
rc_line = rc_part[:first_nl].strip() if first_nl != -1 else rc_part.strip()
|
|
m = re.match(r'^RC=(\d+)$', rc_line)
|
|
rc = int(m.group(1)) if m else -1
|
|
|
|
status = f"Exit code: {rc}"
|
|
results.append(f"[{i + 1}] {cmd_text}\n{status}\n{cmd_output}")
|
|
|
|
return "\n---\n".join(results)
|
|
|
|
|
|
def _is_dangerous_command(command: str) -> tuple:
|
|
"""检查命令是否危险, 返回 (is_dangerous, reason)。拆分合并命令后逐项检查。"""
|
|
# 仅检查首词的模式 (eval, exec) - 避免误杀 grep eval 等合法命令
|
|
_FIRST_TOKEN_PATTERNS = {r'eval\s+', r'exec\s+'}
|
|
|
|
for part in _split_merged_command(command):
|
|
# 提取命令首词
|
|
tokens = part.split()
|
|
first_token = tokens[0] if tokens else ""
|
|
|
|
for pattern in DANGEROUS_COMMAND_PATTERNS:
|
|
if pattern in _FIRST_TOKEN_PATTERNS:
|
|
# eval/exec 只检查首词
|
|
if re.search(pattern, first_token, re.IGNORECASE):
|
|
return True, f"Blocked by pattern: {pattern} (in: {part})"
|
|
else:
|
|
if re.search(pattern, part, re.IGNORECASE):
|
|
return True, f"Blocked by pattern: {pattern} (in: {part})"
|
|
return False, None
|
|
|
|
|
|
# --- Command Manager for Non-blocking Commands ---
|
|
class CommandManager:
|
|
# 已完成命令保留上限, 超出时自动清理最早的
|
|
_MAX_DONE_COMMANDS = 50
|
|
# 已完成命令最小存活时间 (秒)
|
|
_DONE_MIN_AGE = 30
|
|
|
|
def __init__(self):
|
|
self.commands = {}
|
|
self.lock = threading.Lock()
|
|
|
|
def start_command(self, command: str, cwd: str) -> tuple:
|
|
cmd_id = str(uuid.uuid4())
|
|
|
|
shell_cmd = command
|
|
if os.name == 'nt':
|
|
shell_cmd = ["powershell", "-Command", command]
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
shell_cmd,
|
|
cwd=cwd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
encoding='utf-8',
|
|
errors='replace',
|
|
shell=False if os.name == 'nt' else True
|
|
)
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
cmd_info = {
|
|
"id": cmd_id,
|
|
"process": process,
|
|
"output": [], # List of lines
|
|
"status": "running",
|
|
"start_time": time.time(),
|
|
"cwd": cwd,
|
|
"command": command
|
|
}
|
|
|
|
with self.lock:
|
|
self.commands[cmd_id] = cmd_info
|
|
|
|
# Start thread to read output
|
|
t = threading.Thread(target=self._read_output, args=(cmd_id, process))
|
|
t.daemon = True
|
|
t.start()
|
|
|
|
return cmd_id, None
|
|
|
|
def _read_output(self, cmd_id, process):
|
|
try:
|
|
for line in iter(process.stdout.readline, ''):
|
|
with self.lock:
|
|
if cmd_id in self.commands:
|
|
self.commands[cmd_id]["output"].append(line)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
process.stdout.close()
|
|
except:
|
|
pass
|
|
|
|
return_code = process.wait()
|
|
|
|
with self.lock:
|
|
if cmd_id in self.commands:
|
|
# 不覆盖 stop_command 设置的 "stopped" 状态
|
|
if self.commands[cmd_id]["status"] != "stopped":
|
|
self.commands[cmd_id]["status"] = "done"
|
|
self.commands[cmd_id]["returncode"] = return_code
|
|
self._cleanup_done_commands()
|
|
|
|
def _cleanup_done_commands(self):
|
|
"""清理超限的已完成命令, 释放内存。"""
|
|
now = time.time()
|
|
done_ids = [
|
|
cid for cid, info in self.commands.items()
|
|
if info["status"] == "done"
|
|
and now - info.get("start_time", now) > self._DONE_MIN_AGE
|
|
]
|
|
if len(done_ids) > self._MAX_DONE_COMMANDS:
|
|
# 按时间排序, 清理最旧的
|
|
done_ids.sort(key=lambda cid: self.commands[cid].get("start_time", 0))
|
|
for cid in done_ids[:len(done_ids) - self._MAX_DONE_COMMANDS]:
|
|
self.commands.pop(cid, None)
|
|
|
|
def get_status(self, cmd_id: str, priority: str = "bottom", limit: int = 1000):
|
|
with self.lock:
|
|
if cmd_id not in self.commands:
|
|
return None
|
|
|
|
cmd = self.commands[cmd_id]
|
|
output_lines = cmd["output"]
|
|
|
|
if priority == "bottom":
|
|
lines = output_lines[-limit:]
|
|
else:
|
|
lines = output_lines[:limit]
|
|
|
|
return {
|
|
"status": cmd["status"],
|
|
"returncode": cmd.get("returncode"),
|
|
"output": "".join(lines),
|
|
"cwd": cmd["cwd"],
|
|
"command": cmd["command"]
|
|
}
|
|
|
|
def stop_command(self, cmd_id: str):
|
|
with self.lock:
|
|
if cmd_id not in self.commands:
|
|
return False
|
|
|
|
cmd = self.commands[cmd_id]
|
|
if cmd["status"] == "running":
|
|
try:
|
|
cmd["process"].terminate()
|
|
cmd["status"] = "stopped"
|
|
except:
|
|
pass
|
|
return True
|
|
return False
|
|
|
|
|
|
_CMD_MANAGER = CommandManager()
|
|
|
|
|
|
class BashSessionManager:
|
|
"""管理 Bash 会话的 subprocess 实例,支持跨调用保持 cwd 和环境变量。"""
|
|
|
|
def __init__(self, session_timeout: int = 600):
|
|
self._sessions: Dict[str, subprocess.Popen] = {}
|
|
self._session_cwd: Dict[str, str] = {}
|
|
self._last_used: Dict[str, float] = {}
|
|
self._lock = threading.Lock()
|
|
self._exec_locks: Dict[str, threading.Lock] = {} # per-session 执行锁
|
|
self._session_timeout = session_timeout
|
|
|
|
def create_session(self, session_id: str, cwd: str) -> bool:
|
|
"""创建新的 shell 会话"""
|
|
with self._lock:
|
|
if session_id in self._sessions:
|
|
return False
|
|
if os.name == 'nt':
|
|
shell = ["powershell", "-NoExit", "-Command", "-"]
|
|
else:
|
|
shell = ["/bin/bash", "--norc", "--noprofile"]
|
|
try:
|
|
proc = subprocess.Popen(
|
|
shell, cwd=cwd,
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, encoding='utf-8', errors='replace', bufsize=0,
|
|
)
|
|
self._sessions[session_id] = proc
|
|
self._session_cwd[session_id] = cwd
|
|
self._last_used[session_id] = time.time()
|
|
self._exec_locks[session_id] = threading.Lock()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def execute_in_session(self, session_id: str, command: str, timeout: int = 120) -> Tuple[str, int]:
|
|
"""在已有会话中执行命令,返回 (output, returncode)。per-session 锁防止并发写入。"""
|
|
with self._lock:
|
|
proc = self._sessions.get(session_id)
|
|
exec_lock = self._exec_locks.get(session_id)
|
|
if not proc or proc.poll() is not None or not exec_lock:
|
|
return "Session not found or expired.", -1
|
|
self._last_used[session_id] = time.time()
|
|
with exec_lock: # per-session 锁: 防止多线程同时写入同一 stdin
|
|
try:
|
|
# 动态 delimiter: 含纳秒时间戳, 防止命令输出碰巧包含固定字符串
|
|
delimiter = f"===BASH_EOF_{time.time_ns()}==="
|
|
full_command = f"{command}; echo {delimiter}; echo $?"
|
|
proc.stdin.write(full_command + "\n")
|
|
proc.stdin.flush()
|
|
output_lines = []
|
|
deadline = time.time() + timeout
|
|
found_delimiter = False
|
|
while time.time() < deadline:
|
|
line = proc.stdout.readline()
|
|
if not line:
|
|
break
|
|
if delimiter in line:
|
|
found_delimiter = True
|
|
break
|
|
output_lines.append(line)
|
|
returncode = -1
|
|
if found_delimiter:
|
|
rc_line = proc.stdout.readline()
|
|
try:
|
|
returncode = int(rc_line.strip())
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return "".join(output_lines), returncode
|
|
except Exception as e:
|
|
return f"Error executing in session: {e}", -1
|
|
|
|
def close_session(self, session_id: str) -> bool:
|
|
"""关闭指定会话"""
|
|
with self._lock:
|
|
proc = self._sessions.pop(session_id, None)
|
|
self._session_cwd.pop(session_id, None)
|
|
self._last_used.pop(session_id, None)
|
|
self._exec_locks.pop(session_id, None)
|
|
if proc:
|
|
try:
|
|
proc.stdin.write("exit\n")
|
|
proc.stdin.flush()
|
|
proc.stdin.close()
|
|
proc.wait(timeout=5)
|
|
except:
|
|
proc.kill()
|
|
return True
|
|
return False
|
|
|
|
def cleanup_expired(self):
|
|
"""清理超时会话"""
|
|
now = time.time()
|
|
with self._lock:
|
|
expired = [sid for sid, t in self._last_used.items() if now - t > self._session_timeout]
|
|
for sid in expired:
|
|
self.close_session(sid)
|
|
|
|
|
|
_BASH_SESSION_MANAGER = BashSessionManager()
|
|
|
|
|
|
@register_tool(category="Agent", name_cn="Run Command", risk_level="high")
|
|
class RunCommand:
|
|
"""
|
|
Execute shell commands. Supports merged command chains (e.g., `cd /tmp && ls && cat file.txt`).
|
|
|
|
When to use:
|
|
- Run terminal commands: git, npm, pip, systemctl, docker, etc.
|
|
- Chain multiple dependent commands in one call using && or ; (output shows each step separately)
|
|
- Maintain state across calls with session_id (keeps cwd and env variables)
|
|
|
|
When NOT to use:
|
|
- For file operations (reading/writing/editing/searching) — use the dedicated file tools instead
|
|
- For interactive commands (top, nano, vi) — they will hang
|
|
- For commands requiring sudo user input — use non-interactive flags (e.g., apt-get -y)
|
|
|
|
Safety:
|
|
- Dangerous commands are auto-blocked (rm -rf /, dd, mkfs, curl|bash, etc.)
|
|
- Write-class commands (service restart/stop, file writes, custom script execution) are NOT auto-blocked but require user confirmation per Hard Red Lines — present the plan and wait for approval first.
|
|
|
|
Command chaining examples:
|
|
- `cd /www/wwwlogs && ls -la && tail -50 access.log` — 3 steps, single call
|
|
- `echo "line1" && echo "line2" && echo "line3"` — all outputs individually labeled
|
|
|
|
Args:
|
|
command: Shell command to execute (supports && ; || chains)
|
|
blocking: Wait for completion (default True). False returns immediately with a command_id for later polling.
|
|
cwd: Working directory for the command
|
|
timeout: Timeout in milliseconds (default 120000 = 2 min)
|
|
session_id: Optional session ID to reuse the same subprocess (preserves cwd, env vars)
|
|
description: Brief description of what this command does (for logging)
|
|
"""
|
|
|
|
# 工具返回块中 <tool_name> 的来源; 子类 (如只读 ReadOnlyCommand) 覆盖即可复用执行逻辑
|
|
_tool_name = "RunCommand"
|
|
|
|
def execute(self, command: str, blocking: bool = True, cwd: str = None, timeout: int = 120000,
|
|
session_id: str = None, description: str = None) -> str:
|
|
BASH_MAX_RETURN_CHARS = 30000
|
|
|
|
if not cwd:
|
|
cwd = os.getcwd()
|
|
|
|
# 危险命令检查
|
|
is_dangerous, reason = _is_dangerous_command(command)
|
|
if is_dangerous:
|
|
return _xml_response(self._tool_name, "error", f"Dangerous command blocked. {reason}.")
|
|
|
|
# 清理超时会话
|
|
_BASH_SESSION_MANAGER.cleanup_expired()
|
|
|
|
# 会话模式
|
|
if session_id:
|
|
return self._execute_session(command, session_id, cwd, timeout, description, BASH_MAX_RETURN_CHARS)
|
|
|
|
# 无状态模式
|
|
return self._execute_stateless(command, cwd, timeout, blocking, description, BASH_MAX_RETURN_CHARS)
|
|
|
|
def _execute_stateless(self, command: str, cwd: str, timeout: int, blocking: bool,
|
|
description: str, max_chars: int) -> str:
|
|
"""无状态模式:每次执行独立 subprocess,合并命令注入动态分界符"""
|
|
if not blocking:
|
|
cmd_id, err = _CMD_MANAGER.start_command(command, cwd)
|
|
if err:
|
|
return _xml_response(self._tool_name, "error", err, max_chars=max_chars)
|
|
|
|
result = (
|
|
f"<terminal_id>new</terminal_id>\n"
|
|
f"<terminal_cwd>{cwd}</terminal_cwd>\n"
|
|
f"Note: Command ID is provided for you to check command status later.\n"
|
|
f"<command_id>{cmd_id}</command_id>\n"
|
|
f"The command is running, you need to call check_command_status tool to get more logs.\n"
|
|
)
|
|
return _xml_response(self._tool_name, "running", result, max_chars=max_chars)
|
|
|
|
try:
|
|
parts = _split_merged_command(command)
|
|
is_merged = len(parts) > 1
|
|
|
|
if is_merged:
|
|
# 合并命令:注入动态分界符
|
|
shell_cmd, markers = _wrap_merged_command_with_markers(command)
|
|
else:
|
|
shell_cmd = command
|
|
markers = None
|
|
|
|
if os.name == 'nt':
|
|
shell_cmd = ["powershell", "-Command", shell_cmd]
|
|
|
|
timeout_sec = timeout / 1000.0
|
|
start_time = time.time()
|
|
|
|
result = subprocess.run(
|
|
shell_cmd, cwd=cwd, capture_output=True, text=True,
|
|
encoding='utf-8', errors='replace',
|
|
shell=False if os.name == 'nt' else True, timeout=timeout_sec
|
|
)
|
|
|
|
raw_output = result.stdout + result.stderr
|
|
duration = time.time() - start_time
|
|
|
|
if is_merged:
|
|
output = _parse_merged_output(raw_output, command, markers)
|
|
else:
|
|
output = raw_output
|
|
|
|
metadata = f"Exit Code: {result.returncode}\nDuration: {duration:.2f}s"
|
|
final_output = f"{metadata}\n\n{output}"
|
|
|
|
if description:
|
|
final_output = f"Description: {description}\n\n{final_output}"
|
|
|
|
return _xml_response(self._tool_name, "done", final_output, max_chars=max_chars)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return _xml_response(self._tool_name, "error", f"Command timed out after {timeout} ms", max_chars=max_chars)
|
|
except Exception as e:
|
|
return _xml_response(self._tool_name, "error", str(e), max_chars=max_chars)
|
|
|
|
def _execute_session(self, command: str, session_id: str, cwd: str, timeout: int,
|
|
description: str, max_chars: int) -> str:
|
|
"""会话模式:复用已有 subprocess 实例,合并命令注入动态分界符"""
|
|
if session_id not in _BASH_SESSION_MANAGER._sessions:
|
|
if not _BASH_SESSION_MANAGER.create_session(session_id, cwd):
|
|
return _xml_response(self._tool_name, "error", f"Failed to create session: {session_id}")
|
|
|
|
parts = _split_merged_command(command)
|
|
is_merged = len(parts) > 1
|
|
|
|
if is_merged:
|
|
exec_cmd, markers = _wrap_merged_command_with_markers(command)
|
|
else:
|
|
exec_cmd = command
|
|
markers = None
|
|
|
|
timeout_sec = timeout / 1000.0
|
|
start_time = time.time()
|
|
|
|
raw_output, returncode = _BASH_SESSION_MANAGER.execute_in_session(session_id, exec_cmd, int(timeout_sec))
|
|
duration = time.time() - start_time
|
|
|
|
if is_merged:
|
|
output = _parse_merged_output(raw_output, command, markers)
|
|
else:
|
|
output = raw_output
|
|
|
|
metadata = f"Exit Code: {returncode}\nDuration: {duration:.2f}s\nSession: {session_id}"
|
|
final_output = f"{metadata}\n\n{output}"
|
|
|
|
if description:
|
|
final_output = f"Description: {description}\n\n{final_output}"
|
|
|
|
return _xml_response(self._tool_name, "done", final_output, max_chars=max_chars)
|