fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes

Remaining-item sweep (audit report §12):
- N9: cap prompt file writes (identity/soul/status, 10k chars) to bound
  persistent-injection surface
- F5: run_light_subagent switches to stream_chat + parent cancel_event
  (mid-stream interrupt); ExpressEmotionTool injects parent event
- Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention)
- Q5: malformed reminder_time tasks returned as due (were silently dropped
  by SQL-level string filter, which is now Python-side authoritative check)
- Q7b: load_conversation realpath prefix check (../ traversal defense)
- Q8: atomic JSON writes (tmp+os.replace) for index/current/archive
- F6: dedicated ws command executor for /new, /reload_prompts
- F9: --once hard-exit backstop after soft timeout
- PromptManager singleton lock; limit=0 semantics (None = no LIMIT)
This commit is contained in:
qing committed 2026-08-18 03:09:05 +00:00
1 parent 9fee9a1478
commit db54921066
6 files changed
+379 -23

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二)
覆盖:
- N9:prompt 写入内容长度上限(持久化注入面约束)
- Q7b:load_conversation 越界路径拒绝(realpath 前缀校验)
- F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退)
- Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃)
- Q8:对话文件原子写(无 .tmp 残留、JSON 完整)
- F6:WebSocketHandler 命令专用线程池存在且可释放
- limit=0 语义修正(None 才不加 LIMIT)
Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争),
不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。
"""
import os
import queue
import sqlite3
import sys
import threading
import time
import types
from pathlib import Path
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import run_light_subagent # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.prompt_tools import ( # noqa: E402
UpdateIdentityTool,
UpdateSoulTool,
SaveMemoryAndEndConversationTool,
)
# ==================== N9:prompt 写入长度上限 ====================
class TestN9PromptContentCap:
def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch):
"""manager 层兜底:超长身份内容拒绝写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False
assert not (tmp_path / pm.IDENTITY_FILE).exists()
def test_update_identity_tool_friendly_error(self, tmp_path):
"""工具层:超长内容返回明确提示而非写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateIdentityTool(pm, None)
r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_update_soul_tool_friendly_error(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateSoulTool(pm, None)
r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_save_memory_status_oversized_rejected(self, tmp_path):
"""save_memory_and_end_conversation:状态内容超长拒绝"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = SaveMemoryAndEndConversationTool(
exit_callback=lambda *a: None,
memory_db=None, prompt_manager=pm, logger=None)
r = tool.execute(summary="这是一段足够长的对话总结" * 20,
current_status="x" * PromptManager.MAX_CONTENT_LENGTH)
assert "状态内容过长" in r
def test_normal_content_still_written(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("正常身份内容") is True
assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8")
# ==================== Q7b:load_conversation 路径校验 ====================
class TestQ7bLoadConversationBoundary:
def test_traversal_rejected(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
secret = tmp_path / "secret.json"
secret.write_text('{"leak": true}', encoding="utf-8")
assert h.load_conversation("../secret.json") is None
def test_normal_file_still_loads(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [{"role": "user", "content": "hi"}],
"tool_calls": [], "stats": {},
}
h.save_current_conversation()
h.archive_current_conversation()
archives = list(h.archive_dir.glob("conversation_*.json"))
assert archives
loaded = h.load_conversation(archives[0].name)
assert loaded is not None and loaded["id"] == "1"
# ==================== F5:run_light_subagent 取消传播 ====================
class _BlockingLightClient:
"""流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端"""
model = "fake-model"
default_temperature = None
def __init__(self):
self.cancelled = threading.Event()
def cancel(self):
self.cancelled.set()
def stream_chat(self, messages, **kwargs):
yield {"type": "content", "text": "开头"}
while not self.cancelled.wait(timeout=0.1):
pass
raise RuntimeError("连接已中断(cancel)")
class TestF5LightSubagentCancel:
def test_cancelled_before_start_returns_early(self):
"""父事件已置位 → 直接返回取消提示,不创建客户端"""
parent = threading.Event()
parent.set()
with mock.patch("aibox.create_llm_client") as mk:
r = run_light_subagent("r", "t", None, cancel_event=parent)
assert "取消" in r
mk.assert_not_called()
def test_cancel_during_stream_aborts(self):
"""流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回"""
parent = threading.Event()
fake = _BlockingLightClient()
with mock.patch("aibox.create_llm_client", return_value=fake):
result_holder = {}
def _run():
result_holder["r"] = run_light_subagent(
"r", "t", None, cancel_event=parent)
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(0.5) # 等待首段产出、观察者就绪
parent.set()
th.join(timeout=5)
assert not th.is_alive() # 未挂死
assert "失败" in result_holder.get("r", "")
def test_express_emotion_registered_with_parent_event(self):
"""DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "express_emotion"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== Q5:格式异常任务不丢弃 ====================
class TestQ5MalformedReminderTime:
def test_malformed_time_task_returned_as_due(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert any(m["id"] == mid for m in pending)
def test_normal_pending_still_filtered(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert pending == []
# ==================== Q8:对话文件原子写 ====================
class TestQ8AtomicWrites:
def test_no_tmp_leftover_and_valid_json(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [], "tool_calls": [], "stats": {},
}
h.save_current_conversation()
assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性
import json
json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON
h.archive_current_conversation() # 归档后 current 文件按设计移除
assert list(Path(tmp_path).rglob("*.tmp")) == []
# ==================== F6:WS 命令专用线程池 ====================
class TestF6CmdExecutor:
def test_handler_has_dedicated_executor(self):
from aibox import WebSocketHandler
h = WebSocketHandler(ai_manager=None)
assert h._cmd_executor is not None
h.shutdown()
assert h._cmd_executor is None
# ==================== limit=0 语义 ====================
class TestLimitZeroSemantics:
def test_pending_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00")
assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == []
def test_immediate_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", is_immediate=True)
assert db.get_immediate_memos(limit=0) == []