Files
blackbird/aibox/subsystems/executors.py
T
qing 67acae708c fix(aibox): Batch N 波次4+5 LLM 健壮性与权限隔离,Batch N 收束
波次 4 LLM 健壮性(T10/T19/T20/T21/T23 + T14 复现证伪):
- T10 调度器 await 改 wait_for 硬超时兜底(task_timeout+30s 宽限),
  软取消够不到的卡死路径不再永久停摆调度循环
- T19 FailoverClient 异常收窄为瞬时白名单(Connection/Timeout/
  ChunkedEncoding),MissingSchema 等永久配置错误不再污染冷却表
- T20 流式未收到 [DONE] 抛 TransientLLMError,回答无声截断可感知
- T21 env 覆盖黑名单 filter_env_overrides(LD_*/DYLD_*/PATH 等),
  executors 两处 + process_manager 接入
- T23 压缩后 tool 配对双向修复(失配 tool_calls 剥离防 API 400)
- T14 FTS5 复现证伪:JOIN 别名场景表名 MATCH 正常,无需修复

波次 5 权限隔离(T22/T-L17/T-L18;T1/T2 产品决策:仅告警/维持现状):
- T22a TOOL_PROFILE_TASK 定时任务精简工具面(无 update_identity/
  update_soul/set_proxy)
- T22b ProcessManager owner 隔离:bg_write/bg_kill 属主校验,
  观察面共享;DeepSeekChat 新增 owner_id 传导
- T1 绑定非回环地址时启动显著告警
- T-L17 model_config env 名按域名推荐(防跨厂商凭据外发)
- T-L18 list_profiles 过滤控制字符(防 ANSI 注入)

测试:test_n_fixes.py 增至 62 项;全量 598 passed / 1 failed
(Windows 向 ping 用例,环境项),零回归。审计报告补记 §24.7~24.11,
README 同步 owner 隔离语义。八轮审计 T 系列 28 项全部处置完毕。
2026-08-21 14:53:42 +00:00

511 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
subsystems.executors - 命令执行器
抽离自 aibox.py,提供命令执行的抽象基类与 subprocess 实现。
- CommandExecutor : 抽象基类,定义 execute / execute_streaming / cancel
- SubprocessCommandExecutor : 基于 subprocess.Popen 的实现,支持超时/工作目录/
1MB 输出上限/线程安全取消
依赖:common (ConfigManager, constants, Logger) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import queue
import signal
import subprocess
import threading
import time
from abc import ABC, abstractmethod
from typing import Dict, Optional, Tuple
from common import ConfigManager, constants, Logger
# T21(审计 2026-08-21):env 覆盖黑名单——工具 schema 中 env 是开放
# object,LLM(可被网页内容提示注入诱导)可传 LD_PRELOAD/PYTHONPATH/
# PATH 等在 shell=True 之上进一步劫持进程加载(POSIX LD_PRELOAD 可加载
# 任意共享库)。命中黑名单的覆盖键拒绝并告警
ENV_DENY_EXACT = frozenset({
"PATH", "PYTHONPATH", "PYTHONHOME", "SHELL", "IFS",
"LD_LIBRARY_PATH", "LD_PRELOAD", "LD_AUDIT",
"DYLD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES",
"PSMODULEPATH", "COMSPEC",
})
ENV_DENY_PREFIXES = ("LD_", "DYLD_")
def filter_env_overrides(env) -> Tuple[Dict, list]:
"""T21:过滤 env 覆盖项,返回 (安全覆盖, 被拒绝键列表)。
键大小写不敏感匹配(Windows 环境变量不区分大小写);
LD_*/DYLD_* 前缀整体拒绝(加载器劫持面)。
"""
if not env:
return {}, []
safe, rejected = {}, []
for key, value in dict(env).items():
ku = str(key).upper()
if ku in ENV_DENY_EXACT or ku.startswith(ENV_DENY_PREFIXES):
rejected.append(key)
else:
safe[key] = value
return safe, rejected
class CommandExecutor(ABC):
"""命令执行器抽象基类 - 负责执行系统命令"""
def __init__(self, logger: Optional[Logger] = None):
self.logger = logger or Logger("CommandExecutor")
self.config = ConfigManager()
self.process = None
@abstractmethod
def execute(self, command: str, timeout: int = 30, **kwargs) -> str:
"""执行命令并返回结果(非流式)"""
pass
@abstractmethod
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, **kwargs):
"""流式执行命令,通过队列输出结果"""
pass
def is_enabled(self) -> bool:
"""检查命令执行是否启用"""
return constants.COMMANDS_ENABLED
def format_result(self, success: bool, message: str, data: Dict = None) -> str:
"""统一结果格式化"""
result = []
emoji = "✅" if success else "❌"
result.append(f"{emoji} {message}")
if data:
for key, value in data.items():
if isinstance(value, str) and "\n" in value:
result.append(f" {key}:")
for line in value.split("\n"):
result.append(f" {line}")
else:
result.append(f" {key}: {value}")
return "\n".join(result)
class SubprocessCommandExecutor(CommandExecutor):
"""基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消"""
# 命令输出最大字节数(1MB),超出截断
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
def __init__(self, logger=None):
super().__init__(logger)
self._current_process = None # 兼容保留:最近一次注册的进程(诊断用)
self._current_pgid = None # L9:Popen 后立即捕获,shell 死亡后 getpgid 会失败
self._process_lock = threading.Lock()
# T5:单槽 _current_process 在并发执行时互相覆盖——先启动的执行
# 超时/取消会误杀后来者的进程组(killpg 连坐孙进程),先结束的
# finally 清引用导致后来者永远无法取消。改为令牌多槽注册表,
# 终止只作用于自己槽位内的 (proc, pgid)。
self._proc_slots: Dict[int, Tuple] = {}
self._token_seq = 0
def _register_proc(self, process):
"""T5:注册本次执行的进程槽,返回 (token, pgid)"""
with self._process_lock:
self._token_seq += 1
token = self._token_seq
pgid = None
if os.name == "posix":
try:
pgid = os.getpgid(process.pid)
except Exception:
pgid = None
self._proc_slots[token] = (process, pgid)
# 兼容属性指向最新注册(诊断/旧测试观察用,不参与终止逻辑)
self._current_process = process
self._current_pgid = pgid
return token, pgid
def _unregister_proc(self, token):
"""T5:仅移除自己的槽位;全部槽位清空时同步清兼容属性"""
with self._process_lock:
self._proc_slots.pop(token, None)
if not self._proc_slots:
self._current_process = None
self._current_pgid = None
def _terminate_tree(self, proc, force: bool = False, pgid=None):
"""终止整个进程组(L9:POSIX 下 shell=True 只杀 shell 时,孙进程
(`(x &)` 等)仍持管道写端——reader 阻塞无 EOF、fd/线程悬挂至孙
进程退出,写满 64KB 管道缓冲后孙进程也卡死。start_new_session 使
shell 成为会话首进程,按组信号可一次覆盖整棵进程树)。
组 ID 在 Popen 后立即保存:shell 死亡后 os.getpgid(pid) 会抛
ProcessLookupError,届时无法再从进程反查组。
T5:pgid 由调用方显式传入(注册槽位时捕获),不再读共享
_current_pgid——并发执行时那是别人的进程组。
T11(审计 2026-08-21):Windows 无进程组概念,单进程 terminate/
kill 收割不到 shell=True 生成的 cmd.exe /c 之下 spawn 的孙进程
(超时/取消后孙进程成孤儿,持续占用 CPU/端口/文件锁)。用
taskkill /T 递归终止整棵进程树;Windows 无 SIGTERM 等价物,
taskkill 不带 /F 仅对有消息窗口的 GUI 进程有效,控制台进程
杀不掉,故统一 /F 强制,force 参数在 Windows 上不区分。"""
try:
if os.name == "posix":
sig = signal.SIGKILL if force else signal.SIGTERM
if pgid is None:
try:
pgid = os.getpgid(proc.pid)
except Exception:
pgid = None
if pgid is not None:
os.killpg(pgid, sig)
else:
proc.kill() if force else proc.terminate()
else:
# T11:Windows 树收割——taskkill /T /F 递归终止子孙进程,
# 失败(进程已退出/无权限)时兜底单进程 kill
try:
subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True, timeout=10)
except Exception:
pass
try:
if proc.poll() is None:
proc.kill()
except Exception:
pass
except Exception:
try:
proc.kill()
except Exception:
pass
def cancel(self):
"""取消本执行器当前注册的全部运行中命令(kill 子进程组)。线程安全。
T5:多槽令牌注册表——并发执行时定向终止各自槽位,互不误杀;
无参调用语义为"终止本执行器全部在途命令"(中断/关闭路径)。"""
with self._process_lock:
slots = list(self._proc_slots.values())
for proc, pgid in slots:
try:
if proc.poll() is None:
self._terminate_tree(proc, force=True, pgid=pgid)
self.logger.info("已终止正在运行的子进程组")
except Exception:
pass
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None,
pgid=None) -> Tuple[str, bool, bool]:
"""从进程读取输出,限制总大小和超时。返回 (output_str, timeout_occurred, truncated)。
H2 修复:阻塞 readline 在独立 reader 线程执行,主循环带超时轮询队列。
此前超时检查只在两次 readline 之间执行,无输出命令会让 readline
阻塞到进程自然退出,timeout 形同虚设(审计 PoC 实证)。
"""
output_chunks = []
total_bytes = 0
timeout_occurred = False
truncated = False
line_queue: queue.Queue = queue.Queue()
reader_done = threading.Event()
def _reader():
"""后台 reader 线程:阻塞 readline,行入队;EOF/异常结束"""
try:
for line in iter(process.stdout.readline, ""):
line_queue.put(line)
except Exception:
pass
finally:
reader_done.set()
reader = threading.Thread(target=_reader, daemon=True)
reader.start()
shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻
while True:
# 检查超时(readline 阻塞不再影响本判断)
# J16(审计 2026-08-19):timeout<=0 语义为"不设超时"(有意为之,
# 调用方显式传 0 禁用);registry 层 3600s 流式兜底仍生效
if timeout > 0 and time.time() - start_time > timeout:
self._terminate_tree(process, pgid=pgid)
timeout_occurred = True
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
self._terminate_tree(process, pgid=pgid)
break
try:
line = line_queue.get(timeout=0.1)
shell_dead_since = None
except queue.Empty:
if reader_done.is_set():
break
# shell 已被 kill/退出但 reader 仍阻塞(如 Windows shell=True
# 下 cancel 只杀死包装进程,孙进程仍持有管道写端导致无 EOF):
# 宽限 1 秒排空残留输出后放弃等待
if process.poll() is not None:
if shell_dead_since is None:
shell_dead_since = time.time()
elif time.time() - shell_dead_since > 1.0:
# L9:shell 已死但孙进程仍持管道写端(`(sleep 300 &) ;
# echo done` 形态)。只靠 daemon reader 兜底会悬挂线程
# 与管道 fd 至孙进程退出,写满 64KB 缓冲后孙进程自身
# 也卡死。此处强杀进程组收尾(nohup 重定向输出的
# 真正脱离场景不持管道,不会走到这里)。
self._terminate_tree(process, force=True, pgid=pgid)
break
continue
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
# L5:decode 用 errors='ignore'——按字节截断可能落在多字节
# 字符中间,errors='replace' 会在行尾留下半个字符(�)
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='ignore'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
self._terminate_tree(process, pgid=pgid)
break
output_chunks.append(line)
total_bytes += line_bytes
if output_queue:
output_queue.put(("line", line.rstrip('\n') + "\n"))
# N4 修复(审计 2026-08-18,二次修正):reader 线程收尾。
# 超时/截断/宽限到期 break 后,reader 可能仍阻塞在 readline
# (Windows shell=True 下孙进程持管道写端,永远等不到 EOF)。
# 注意:此时不能 close stdout——CPython 流对象内部有锁,
# readline 持锁阻塞时 close() 会一同挂起直到 readline 返回
# (实测命令超时被拖满整个 Start-Sleep 时长)。reader 为 daemon
# 线程,孙进程退出收到 EOF 后自然结束,不阻塞主流程;
# reader 已正常退出的场景才 close 显式释放管道 fd。
if reader_done.is_set():
try:
process.stdout.close()
except Exception:
pass
else:
reader.join(timeout=2)
if reader.is_alive():
self.logger.warning(
"reader 线程仍阻塞在管道读取(孙进程持写端),已放弃等待,"
"线程由 daemon 兜底、随管道 EOF 自然退出")
return ''.join(output_chunks), timeout_occurred, truncated
def execute(self, command: str, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs) -> str:
"""执行命令并返回完整结果(带输出大小限制)"""
if not self.is_enabled():
return "❌ 命令执行功能已禁用"
# T24:非流式路径没有 registry 3600s 流式兜底,timeout<=0("不设
# 超时")会让挂起命令永久阻塞调用线程——下限夹取到 1s 快速失败。
# 流式路径保留 0=不限语义(registry 兜底在位)
try:
timeout = max(1, int(timeout))
except (TypeError, ValueError):
timeout = 30
# 默认工作目录为workspace,避免在启动目录散落文件
if work_dir is None or work_dir == ".":
work_dir = constants.WORKSPACE_DIR
self.logger.info(f"执行命令: {command} (work_dir={work_dir})")
# 准备环境变量(T21:黑名单键拒绝,防加载器/解释器劫持)
exec_env = os.environ.copy()
if env:
safe_env, rejected_keys = filter_env_overrides(env)
if rejected_keys:
self.logger.warning(f"env 覆盖被黑名单拒绝(T21): {rejected_keys}")
exec_env.update(safe_env)
process = None
token = None
pgid = None
try:
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True,
bufsize=1,
universal_newlines=True,
encoding="utf-8", # T16:显式 UTF-8,避免 Windows cp936 解码 UTF-8 输出
errors="replace", # T16:非法字节不抛异常中断 reader 线程
env=exec_env,
cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
)
# T5:注册本执行专属槽位,终止/取消只作用于自己的 (proc, pgid)
token, pgid = self._register_proc(process)
start_time = time.time()
output_str, timeout_occurred, truncated = self._read_with_limit(
process, timeout, start_time, pgid=pgid)
# 确保进程终止
if process.poll() is None:
self._terminate_tree(process, force=timeout_occurred or truncated, pgid=pgid)
try:
process.wait(timeout=2)
except:
self._terminate_tree(process, force=True, pgid=pgid)
return_code = process.returncode if process.returncode is not None else -1
data = {
"输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
return self.format_result(False, f"命令执行超时({timeout}秒)", data)
if truncated:
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
return self.format_result(False, "命令执行完成(输出已截断)", data)
return self.format_result(return_code == 0, "命令执行完成", data)
except subprocess.TimeoutExpired:
return f"❌ 命令执行超时({timeout}秒)"
except FileNotFoundError as e:
return f"❌ 命令执行错误: 命令未找到 - {str(e)}"
except PermissionError as e:
return f"❌ 命令执行错误: 权限不足 - {str(e)}"
except Exception as e:
return f"❌ 执行错误: {str(e)}"
finally:
if process and process.poll() is None:
self._terminate_tree(process, force=True, pgid=pgid)
# T5:仅注销自己的槽位,不再无差别清空共享引用
if token is not None:
self._unregister_proc(token)
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
work_dir: str = None, env: Dict = None, **kwargs):
"""流式执行命令,支持超时控制、工作目录和输出大小限制"""
if not self.is_enabled():
output_queue.put(("line", "❌ 命令执行功能已禁用\n"))
output_queue.put(("complete", "❌ 命令执行功能已禁用"))
return
# 默认工作目录为workspace,避免在启动目录散落文件
if work_dir is None or work_dir == ".":
work_dir = constants.WORKSPACE_DIR
self.logger.info(f"流式执行命令: {command} (work_dir={work_dir})")
output_queue.put(("line", f"\n🔧 执行命令: {command}\n"))
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n"))
output_queue.put(("line", f"📦 输出限制: {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB\n"))
output_queue.put(("line", f"📁 工作目录: {work_dir}\n"))
output_queue.put(("line", "-" * 50 + "\n"))
# 准备环境变量(T21:黑名单键拒绝,防加载器/解释器劫持)
exec_env = os.environ.copy()
if env:
safe_env, rejected_keys = filter_env_overrides(env)
if rejected_keys:
self.logger.warning(f"env 覆盖被黑名单拒绝(T21): {rejected_keys}")
exec_env.update(safe_env)
process = None
token = None
pgid = None
try:
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True,
bufsize=1,
universal_newlines=True,
encoding="utf-8", # T16:显式 UTF-8,避免 Windows cp936 解码 UTF-8 输出
errors="replace", # T16:非法字节不抛异常中断 reader 线程
env=exec_env,
cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
)
self.process = process
# T5:注册本执行专属槽位,终止/取消只作用于自己的 (proc, pgid)
token, pgid = self._register_proc(process)
start_time = time.time()
# H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
output_str, timeout_occurred, truncated = self._read_with_limit(
process, timeout, start_time, output_queue=output_queue, pgid=pgid)
if timeout_occurred:
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
elif truncated:
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
# 如果超时或截断,确保进程被终止
if (timeout_occurred or truncated) and process.poll() is None:
self._terminate_tree(process, force=True, pgid=pgid)
try:
process.wait(timeout=2)
except:
pass
return_code = process.returncode if process.returncode is not None else -1
output_queue.put(("line", "-" * 50 + "\n"))
if timeout_occurred:
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
elif truncated:
output_queue.put(("line", f"📦 输出已截断,返回码: {return_code}\n"))
else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
data = {
"输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
elif truncated:
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
result = self.format_result(False, "命令执行完成(输出已截断)", data)
else:
result = self.format_result(return_code == 0, "命令执行完成", data)
output_queue.put(("complete", result))
except Exception as e:
output_queue.put(("line", f"❌ 执行错误: {str(e)}\n"))
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
finally:
if process and process.poll() is None:
self._terminate_tree(process, pgid=pgid)
try:
process.wait(timeout=2)
except:
self._terminate_tree(process, force=True, pgid=pgid)
self.process = None
# T5:仅注销自己的槽位,不再无差别清空共享引用
if token is not None:
self._unregister_proc(token)