fix(aibox): Batch L - round-6 audit low-risk fixes (R1~R3)

- R1: /memos WS command tolerates malformed reminder_time (J1 gap)

- R2: handle_interrupt no longer cancels global CLI executor

- R3: unregister closes websocket + cancels in-flight processing tasks

- tests: 6 new cases in test_l_fixes.py, 493 passed on Windows

- docs: round-6 audit report section 16, README sync (all 72 findings fixed)
This commit is contained in:
qing committed 2026-08-20 11:46:47 +08:00
1 parent 7dc6796f3a
commit e43aeeaf44
5 files changed
+273 -13

No files matched your search

+7
View File
@@ -162,6 +162,7 @@ common ← subsystems ← tools ← aibox.py
# 从仓库根目录运行
py -m pytest aibox/tests -q
# 当前:490 项全过(Linux;test_execute_timeout / test_flat_api_still_reads_key_file 为 Windows 向用例,Linux 预存失败)
# Windows 开发机:493 passed / 4 failed(平台环境项:symlink 权限、python3 缺失、POSIX 可执行位语义 ×2)/ 1 skipped
```
运行期数据存放于 `~/.aibox/`:
@@ -913,6 +914,12 @@ elif command == "/mycommand":
## 📄 更新日志
### v3.7
- ✅ **第六轮安全审计 + Batch L 修复(2026-08-20,交叉验证轮:Batch K 回归验证 / 并发与生命周期 / 降级路径 / 修复间相互作用,4 路并行审读 + 主审逐条复核)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md) §16):
- **Batch K 回归验证**:J1~J16 逐项在位、零回归,无修复引入的缺陷;历届 Batch E~I 修复抽查(executors reader 收尾、1MB 输出上限、registry 流式预算、broadcast 快照等)均在位
- **Batch L(R1~R3 低危全落地)**:R1 `/memos` WS 命令 strptime 容错(畸形 reminder_time 按"待执行"展示,不再整体"处理失败");R2 移除 `handle_interrupt` 对全局 `ai_manager.command_executor` 的取消(WS 中断不再误杀 CLI 主 AI 子进程);R3 `unregister` 补齐回收(关闭 websocket + 取消在途处理任务,broadcast 超时路径与连接关闭路径行为对齐,半开连接不再泄漏)
- **测试**:新增 `tests/test_l_fixes.py` 6 例(R1 容错与语义不回归 ×2 / R2 范围收窄与中断能力不回归 ×2 / R3 回收与幂等 ×2);Linux 维持 490 passed,Windows 开发机 493 passed / 4 failed(均为平台环境项,与代码无关)
### v3.6
- ✅ **交互式大模型接入点管理([model_config.py](model_config.py))**:菜单式 列表 / 添加 / 删除 / 连通性验证,不用手写 `config.json`;Key 仅记录环境变量名不落盘(空 = 免密本地端点);删除自动清理其他档案的 fallback 引用;默认档案经 `api.default_model` 生效(详见 [模型接入点管理](#管理大模型接入点不用手写配置))
- ✅ **第五轮安全审计(2026-08-19,4 路并行审读 + Linux PoC 2/2 实证,发现 J1~J16:高危 2 / 中危 5 / 低危 6 / 信息 3)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md) §13~§14):
+28 -8
View File
@@ -2172,6 +2172,14 @@ class WebSocketHandler:
# N13:discard 替代 remove——并发注销/重复注销时 remove 抛 KeyError
# 会逃逸到调用方(如 broadcast 的 except 分支),discard 幂等
self.connected_clients.discard(websocket)
# R3(审计 2026-08-20):取消该连接仍在进行的处理任务并主动关闭
# websocket。broadcast 超时路径(J7)也会走到这里——ping 禁用下
# 半开 TCP 的接收循环对断开无感知,不 close 的话连接对象/协程/
# 任务登记泄漏直至进程重启;与 handle_connection finally 对齐
pending = self._processing_tasks.pop(websocket, set())
for t in pending:
if not t.done():
t.cancel()
if websocket in self.client_sessions:
session = self.client_sessions.pop(websocket)
# 释放该连接独立的AI实例资源
@@ -2181,6 +2189,11 @@ class WebSocketHandler:
ai.cancel()
except Exception:
pass
# close 幂等(已关闭连接重复调用无副作用);mock/异常对象兜底
try:
await websocket.close()
except Exception:
pass
self.logger.info(i18n.get('client_disconnected', websocket.remote_address))
async def send_welcome_info(self, websocket):
@@ -2360,17 +2373,15 @@ class WebSocketHandler:
"type": "interrupt_ack",
"content": "已收到中断信号,正在停止..."
})
# 取消当前连接独立AI实例的对话(线程安全,只set Event + close连接 + kill进程)
# 取消当前连接独立AI实例的对话(线程安全,只set Event + close连接 + kill进程);
# ai.cancel() 已同时终止该会话自身 executor 中的子进程。
# R2(审计 2026-08-20):移除对 ai_manager.command_executor(CLI 主 AI
# 执行器)的全局取消——会话隔离架构下单实例时代的遗留,WS 用户中断
# 会误杀终端用户正在执行的命令;对会话自身亦冗余
session = self._get_session(websocket)
ai = session.get("ai") if session else None
if ai:
ai.cancel()
# 同时确保命令执行器中的进程也被终止
if self.ai_manager.command_executor and hasattr(self.ai_manager.command_executor, 'cancel'):
try:
self.ai_manager.command_executor.cancel()
except Exception:
pass
async def handle_task_message(self, websocket, data):
"""处理任务相关消息 - 简化版,所有任务都是对话任务"""
@@ -2572,7 +2583,16 @@ class WebSocketHandler:
memos = self.ai_manager.memo_db.get_all_memos(include_completed=False)
result = f"\n{i18n.get('pending_memos_title')}\n"
for memo in memos:
status = i18n.get('memo_overdue') if memo['reminder_time'] and datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < datetime.now() else i18n.get('memo_pending')
# R1(审计 2026-08-20):畸形 reminder_time 此前裸 strptime 抛
# ValueError → /memos 整体落入顶层兜底"处理失败"(J1 加固漏点)。
# 解析失败按"待执行"展示,对齐 CLI --memo-list 的既有模式
status = i18n.get('memo_pending')
if memo['reminder_time']:
try:
if datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < datetime.now():
status = i18n.get('memo_overdue')
except (ValueError, TypeError):
pass
immediate_tag = "⚡" if memo.get('is_immediate') else ""
repeat_info = ""
if memo['repeat_type'] != 'none':
+157
View File
@@ -0,0 +1,157 @@
"""aibox 第六轮审计(Batch L)修复回归测试(2026-08-20)
R1:/memos WS 命令对畸形 reminder_time 不再整体报错(J1 加固漏点)
R2:handle_interrupt 只取消本会话 AI,不再触碰 CLI 主 AI 的全局 executor
R3:unregister 补齐连接回收(关闭 websocket + 取消在途处理任务)
"""
import asyncio
import sys
import types
from unittest import mock
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
def _make_handler():
handler = aibox.WebSocketHandler(ai_manager=mock.MagicMock())
return handler
# ==================== R1:/memos 畸形时间容错 ====================
class TestR1MemosCommand:
def test_malformed_reminder_time_shows_pending(self):
"""存量脏数据(畸形 reminder_time):/memos 按"待执行"展示,不抛 ValueError"""
handler = _make_handler()
try:
handler.ai_manager.memo_db.get_all_memos.return_value = [
{"id": 1, "title": "脏数据任务", "reminder_time": "not-a-time",
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
{"id": 2, "title": "正常过期任务", "reminder_time": "2020-01-01 00:00:00",
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
]
sent = []
async def fake_send(ws, msg):
sent.append(msg)
handler.send_message = fake_send
ws = mock.AsyncMock()
asyncio.run(handler.handle_command(ws, {"command": "/memos"}))
results = [m for m in sent if m.get("type") == "command_result"]
errors = [m for m in sent if m.get("type") == "error"]
assert len(results) == 1
assert not errors # 不再落入顶层兜底"处理失败"
content = results[0]["content"]
assert "脏数据任务" in content and "正常过期任务" in content
finally:
handler.shutdown()
def test_valid_reminder_time_still_marks_overdue(self):
"""合法过期时间仍标 overdue(语义不回归)"""
handler = _make_handler()
try:
handler.ai_manager.memo_db.get_all_memos.return_value = [
{"id": 1, "title": "过期任务", "reminder_time": "2020-01-01 00:00:00",
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
{"id": 2, "title": "未来任务", "reminder_time": "2099-01-01 00:00:00",
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
]
sent = []
async def fake_send(ws, msg):
sent.append(msg)
handler.send_message = fake_send
ws = mock.AsyncMock()
asyncio.run(handler.handle_command(ws, {"command": "/memos"}))
content = next(m["content"] for m in sent
if m.get("type") == "command_result")
overdue = aibox.i18n.get('memo_overdue')
pending = aibox.i18n.get('memo_pending')
overdue_line = next(l for l in content.splitlines() if "过期任务" in l)
pending_line = next(l for l in content.splitlines() if "未来任务" in l)
assert overdue in overdue_line and pending not in overdue_line
assert pending in pending_line and overdue not in pending_line
finally:
handler.shutdown()
# ==================== R2:interrupt 不再误杀 CLI 主 AI 子进程 ====================
class TestR2InterruptScope:
def test_interrupt_does_not_touch_global_executor(self):
"""WS 中断不再取消 ai_manager.command_executor(CLI 主 AI 的执行器)"""
handler = _make_handler()
try:
ws = mock.AsyncMock()
asyncio.run(handler.handle_interrupt(ws, {}))
handler.ai_manager.command_executor.cancel.assert_not_called()
finally:
handler.shutdown()
def test_interrupt_still_cancels_session_ai(self):
"""本会话 AI 仍被正常取消(中断能力不回归)"""
handler = _make_handler()
try:
ws = mock.AsyncMock()
ai = mock.MagicMock()
handler.client_sessions[ws] = {"ai": ai, "is_processing": True}
asyncio.run(handler.handle_interrupt(ws, {}))
ai.cancel.assert_called_once()
handler.ai_manager.command_executor.cancel.assert_not_called()
finally:
handler.shutdown()
# ==================== R3:unregister 补齐连接回收 ====================
class TestR3UnregisterCleanup:
def test_unregister_closes_websocket_and_cancels_tasks(self):
"""broadcast 超时路径走 unregister 后:websocket 被关闭、在途任务被取消、登记清空"""
handler = _make_handler()
try:
ws = mock.AsyncMock()
async def scenario():
task = asyncio.create_task(asyncio.sleep(30))
handler.connected_clients.add(ws)
handler.client_sessions[ws] = {"ai": None, "is_processing": True}
handler._processing_tasks[ws] = {task}
await handler.unregister(ws)
await asyncio.sleep(0) # 让 cancel 传播
assert task.done() # 取消即视为完成(cancelled 或已收尾)
asyncio.run(scenario())
assert ws.close.called
assert ws not in handler.connected_clients
assert ws not in handler.client_sessions
assert ws not in handler._processing_tasks
finally:
handler.shutdown()
def test_unregister_idempotent_and_mock_safe(self):
"""重复注销幂等;close 抛异常的对象不炸(AttributeError 被兜底)"""
class _BadCloseWS:
remote_address = ("127.0.0.1", 1)
async def close(self):
raise AttributeError("no close")
handler = _make_handler()
try:
ws = _BadCloseWS()
handler.connected_clients.add(ws)
handler.client_sessions[ws] = {"ai": None}
asyncio.run(handler.unregister(ws))
asyncio.run(handler.unregister(ws)) # 二次注销不抛
assert ws not in handler.connected_clients
assert ws not in handler.client_sessions
finally:
handler.shutdown()