feat(agent): unified cross-platform shell execution contract (cmd/bash)

Windows runs 'cmd.exe /d /s /c <command>' as a string command line; Linux uses ['/bin/bash', '-lc', command]. Removes implicit shell=True behavior and aligns the agent system prompt per platform.
This commit is contained in:
2026-09-17 16:40:02 +08:00
parent ce56c77023
commit 60200260ab
5 changed files with 424 additions and 54 deletions
+253
View File
@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
"""
P1-02 跨平台 shell 契约单测(纯函数级 + 真实进程级,无 UI 依赖)
覆盖:
A. 平台参数:Windows `cmd.exe /d /c` argv / Linux `/bin/bash -lc` argv
Linux Popen 独立进程组(start_new_session),Windows 无该参数
B. 提示词平台段:通用正文完全相同、只插入对应平台段、互不串段;
load_system_prompt() 与文件内容一致(当前平台)
C. 进程树(真实进程,父+孙):
- 当前平台:超时 → 父与孙都不存在
- 当前平台:主动中止 → 父与孙都不存在
- Linux 专属:独立进程组整组终止(Windows 上跳过)
D. kill_process_tree 安全边界:已退出的进程 / 无独立组 → 不抛异常、不误杀
运行: PYTHONIOENCODING=utf-8 python tests/test_cross_platform_shell.py
"""
import os
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core import platform_shell # noqa: E402
from core.agent.tools import tool_bash # noqa: E402
from core.agent.types import AbortSignal # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
def text_of(res):
c = res.content
if isinstance(c, str):
return c
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
_TMP = tempfile.mkdtemp(prefix="haocode_crossshell_")
PY = sys.executable
IS_WIN = os.name == "nt"
# ======================================================================
# A. 平台参数(mock 平台,纯逻辑)
# ======================================================================
_orig_is_win = platform_shell.is_windows
def _mock_win(v):
platform_shell.is_windows = lambda: v
try:
_mock_win(True)
check("A1 Windows 命令 = cmd.exe /d /s /c \"<cmd>\"",
platform_shell.shell_command("echo hi")
== 'cmd.exe /d /s /c "echo hi"',
repr(platform_shell.shell_command("echo hi")))
check("A2 Windows popen_flags 无进程组参数",
platform_shell.popen_flags() == {}, repr(platform_shell.popen_flags()))
_mock_win(False)
check("A3 Linux argv = /bin/bash -lc <cmd>",
platform_shell.shell_command("echo hi") == ["/bin/bash", "-lc", "echo hi"],
repr(platform_shell.shell_command("echo hi")))
check("A4 Linux popen_flags 独立进程组",
platform_shell.popen_flags() == {"start_new_session": True},
repr(platform_shell.popen_flags()))
finally:
platform_shell.is_windows = _orig_is_win
# ======================================================================
# B. 提示词平台段
# ======================================================================
_PROMPT_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"SYSTEM_PROMPT.md")
with open(_PROMPT_FILE, "r", encoding="utf-8") as f:
_PROMPT_TEXT = f.read().strip()
check("B1 通用正文含占位符",
platform_shell.SHELL_SECTION_PLACEHOLDER in _PROMPT_TEXT)
_GENERIC_NO_LEAK = ("cmd.exe" not in _PROMPT_TEXT
and "/bin/bash" not in _PROMPT_TEXT)
check("B2 通用正文无平台泄漏(无 cmd.exe / 无 /bin/bash", _GENERIC_NO_LEAK)
try:
_mock_win(True)
full_win = platform_shell.apply_platform_section(_PROMPT_TEXT)
sec_win = platform_shell.shell_prompt_section()
_mock_win(False)
full_lin = platform_shell.apply_platform_section(_PROMPT_TEXT)
sec_lin = platform_shell.shell_prompt_section()
finally:
platform_shell.is_windows = _orig_is_win
check("B3 Windows 段含 cmd.exe、无 /bin/bash",
"cmd.exe" in sec_win and "/bin/bash" not in sec_win)
check("B4 Linux 段含 /bin/bash -lc、无 cmd.exe",
"/bin/bash -lc" in sec_lin and "cmd.exe" not in sec_lin)
check("B5 只有对应平台段被插入",
full_win == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_win)
and full_lin == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_lin))
check("B6 通用正文两平台完全相同",
full_win.replace(sec_win, "§SEC§") == full_lin.replace(sec_lin, "§SEC§"))
check("B7 无占位符文本原样返回",
platform_shell.apply_platform_section("无占位符的兜底文本") == "无占位符的兜底文本")
# load_system_prompt()(当前平台,真实文件)
from core import llm_engine # noqa: E402 (PyQt6/openai 已装,无需 QApplication)
check("B8 load_system_prompt = 文件内容 + 当前平台段",
llm_engine.load_system_prompt()
== platform_shell.apply_platform_section(_PROMPT_TEXT))
# ======================================================================
# C. 进程树(真实进程:父 + 孙,心跳文件证明生死)
# ======================================================================
_CHILD = os.path.join(_TMP, "child.py")
with open(_CHILD, "w", encoding="utf-8") as f:
f.write(
"import sys, time\n"
"hb = open(sys.argv[1], 'a', encoding='utf-8')\n"
"t0 = time.time()\n"
"while time.time() - t0 < 30:\n"
" hb.write(f'{time.time():.3f}\\n')\n"
" hb.flush()\n"
" time.sleep(0.2)\n"
"open(sys.argv[2], 'w').write('done')\n"
)
def _spawn_parent(hb_path, done_path, out_path):
"""写一个父进程脚本(启动孙进程后挂 30s),返回 bash 命令字符串。"""
parent = os.path.join(_TMP, f"parent_{os.path.basename(hb_path)}.py")
with open(parent, "w", encoding="utf-8") as f:
f.write(
"import subprocess, sys, time\n"
"p = subprocess.Popen([sys.executable, "
+ repr(_CHILD) + ", sys.argv[1], sys.argv[2]], "
"stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
"time.sleep(30)\n"
)
cmd = f'"{PY}" -u "{parent}" "{hb_path}" "{done_path}"'
return cmd
def _hb_last(hb_path):
try:
with open(hb_path, "r", encoding="utf-8") as f:
lines = [l.strip() for l in f if l.strip()]
return float(lines[-1]) if lines else None
except FileNotFoundError:
return None
def _tree_dead(hb_path, done_path, quiesce=1.2):
"""等待静默后判断:孙进程不再有心跳、且未跑完 → 进程树已死。"""
time.sleep(quiesce)
last = _hb_last(hb_path)
return last is not None and (time.time() - last) > 0.6, os.path.exists(done_path)
def run_tree_case(tag, timeout=None, abort_after=None):
hb = os.path.join(_TMP, f"hb_{tag}.txt")
done = os.path.join(_TMP, f"done_{tag}.txt")
out = os.path.join(_TMP, f"out_{tag}.log")
cmd = _spawn_parent(hb, done, out)
updates, timers = [], []
sig = AbortSignal()
if abort_after is not None:
import threading
def _aborter():
time.sleep(abort_after)
sig.abort("test")
threading.Thread(target=_aborter, daemon=True).start()
t0 = time.time()
res = tool_bash(f"call_{tag}", {"command": cmd, "timeout": timeout or 60}, sig,
lambda t: updates.append(t),
{"cwd": _TMP, "on_timer": lambda e, tt: timers.append((e, tt))})
dur = time.time() - t0
return res, dur, hb, done, timers
# ---- C1: 超时 → 父与孙都不存在 ----
res, dur, hb, done, timers = run_tree_case("timeout", timeout=3)
txt = text_of(res)
dead, finished = _tree_dead(hb, done)
check("C1.1 超时返回错误结果", res.is_error and "超时" in txt, f"{txt!r}")
check("C1.2 超时快速返回(<15s,含 taskkill 缓冲)", dur < 15, f"dur={dur:.1f}s")
check("C1.3 超时后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
f"hb_last={_hb_last(hb)} finished={finished}")
check("C1.4 读秒滴答仍工作", len(timers) >= 2, f"timers={timers}")
# ---- C2: 主动中止 → 父与孙都不存在 ----
res, dur, hb, done, _timers2 = run_tree_case("abort", timeout=60, abort_after=1.5)
txt = text_of(res)
dead, finished = _tree_dead(hb, done)
check("C2.1 中止返回错误结果", res.is_error and "中止" in txt, f"{txt!r}")
check("C2.2 中止后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
f"hb_last={_hb_last(hb)} finished={finished}")
# ---- C3: Linux 独立进程组整组终止(Windows 跳过) ----
if not IS_WIN:
proc = subprocess.Popen(
platform_shell.shell_command("sleep 30"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
**platform_shell.popen_flags())
time.sleep(0.3)
try:
pgid = os.getpgid(proc.pid)
check("C3.1 start_new_session 生效(pgid == 子 pid", pgid == proc.pid,
f"pgid={pgid} pid={proc.pid}")
platform_shell.kill_process_tree(proc, grace_s=1.0)
time.sleep(0.5)
check("C3.2 整组终止后子进程不存在",
proc.poll() is not None, f"poll={proc.poll()}")
except Exception as e:
try:
platform_shell.kill_process_tree(proc, grace_s=1.0)
except Exception:
pass
check("C3 Linux 进程组用例", False, repr(e))
else:
print("SKIP C3 Linux 进程组用例(当前为 WindowsWindows 用例见 C1/C2", flush=True)
# ======================================================================
# D. kill_process_tree 安全边界
# ======================================================================
_p = subprocess.Popen([PY, "-c", "pass"])
_p.wait(timeout=5)
try:
platform_shell.kill_process_tree(_p, grace_s=0.5)
check("D1 已退出进程:不抛异常", True)
except Exception as e:
check("D1 已退出进程:不抛异常", False, repr(e))
try:
platform_shell.kill_process_tree(None)
check("D2 None 输入:不抛异常", True)
except Exception as e:
check("D2 None 输入:不抛异常", False, repr(e))
# ======================================================================
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)