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:
@@ -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 进程组用例(当前为 Windows,Windows 用例见 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)
|
||||
Reference in New Issue
Block a user