Compare commits

..
Author SHA1 Message Date
qing 2c6d1e8ce6 docs: record round-5 audit and batch K fixes in aibox report and README
审计报告追加第五轮(§13 发现 J1~J16 + §14 波次 1~5 落地记录),
README 同步测试清单与计数(490 项全过)。
2026-08-19 07:01:22 +08:00
qing 457bb900fb fix(aibox): round-5 audit batch K - max_turns clamp, task input validation, XSS hardening, atomic writes, broadcast timeout
第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制:
- 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试
- J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务
  防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传
- J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务
  分隔条改 textContent、CDN 脚本补 SRI
- J3 FTS 删除换序(先 fts 后内容表,索引不再残留)
- J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/
  history 全部接入;损坏配置备份后回退
- J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载)
- J7 broadcast 并发发送 + wait_for 超时回收僵死连接
- J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、
  memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、
  skill_manager 同名键/help 截断
2026-08-19 07:01:13 +08:00
qing f4fcf175ec fix(memdb): align _run.sh with CLI-passed args convention
_run.sh hardcoded --instance/--log-level while also forwarding "$@",
so core's services.yaml args produced a duplicate --instance and
boost program_options aborted startup. Now pure "$@" passthrough,
matching db2/ca/captcha2; binary defaults keep manual runs unchanged.
2026-08-19 05:00:41 +08:00
26 changed files with 1297 additions and 105 deletions

No files matched your search

+4 -2
View File
@@ -107,7 +107,7 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool │ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(441 项全过) ├── tests/ # 单元测试套件(490 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项 │ ├── test_b_tools.py # B 批工具 58 项
@@ -124,6 +124,8 @@ aibox/
│ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项) │ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项)
│ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项) │ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项)
│ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项) │ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项)
│ ├── test_subagent_max_turns.py # 子代理最大轮次行为回归(14 项)
│ ├── test_k_fixes.py # 第五轮审计 Batch K(35 项)
│ └── test_model_config.py # 模型接入点管理工具(17 项) │ └── test_model_config.py # 模型接入点管理工具(17 项)
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能) ├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
│ ├── hello-world.py │ ├── hello-world.py
@@ -159,7 +161,7 @@ common ← subsystems ← tools ← aibox.py
```bash ```bash
# 从仓库根目录运行 # 从仓库根目录运行
py -m pytest aibox/tests -q py -m pytest aibox/tests -q
# 当前:264 项全过 # 当前:490 项全过(Linux;test_execute_timeout / test_flat_api_still_reads_key_file 为 Windows 向用例,Linux 预存失败)
``` ```
运行期数据存放于 `~/.aibox/`: 运行期数据存放于 `~/.aibox/`:
+11
View File
@@ -227,3 +227,14 @@ except ImportError:
``` ```
**`--help` 要完备**。包含参数说明和使用示例两部分。AI 通过阅读 `--help` 输出学会如何调用技能,信息越完整,AI 调用越准确。 **`--help` 要完备**。包含参数说明和使用示例两部分。AI 通过阅读 `--help` 输出学会如何调用技能,信息越完整,AI 调用越准确。
---
## 安全边界(J12,审计 2026-08-19)
**技能即可信代码**。可执行技能由 AI(可被网页内容/用户消息诱导)以子进程方式运行,参数为不可信输入:
- **参数必须经 shell 引号转义**:凡技能内部把参数拼进 shell 命令/脚本串(subprocess shell、PowerShell、python-nmap 等内部拼串库),一律先 `shlex.quote`(POSIX)或对应语言转义(PowerShell 单引号翻倍)
- **禁止把凭据嵌入命令行参数**:命令行参数对同机进程可见(`ps`);密码/令牌应经环境变量或配置文件传递
- **ssh/sftp 类技能**:`AutoAddPolicy` 信任任意主机密钥存在 MITM 风险,仅限可信网络使用;生产环境改用 `known_hosts` 校验
- 内置技能示例(`skills/`)已按此规范处理;新增技能请遵守同一条例
+75 -25
View File
@@ -178,13 +178,20 @@ class TaskScheduler:
for memo in pending: for memo in pending:
try: try:
# 广播任务开始执行 # 广播任务开始执行
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('task_executing', memo['title'], memo['id'])) await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('task_executing', memo['title'], memo['id']))
# 【关键修复】执行前最后一次验证 # 【关键修复】执行前最后一次验证
# J1(审计 2026-08-19):格式异常按到期执行(Q5 语义),
# 不再抛 ValueError 致任务既不触发也不完成、每秒重试热循环
if memo.get('reminder_time'): if memo.get('reminder_time'):
try:
reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT)
if reminder_time > now: except (ValueError, TypeError) as e:
self.logger.warning(
f"任务 #{memo['id']} 提醒时间格式异常,按到期执行: {e}")
reminder_time = None
if reminder_time is not None and reminder_time > now:
self.logger.warning(f"任务 #{memo['id']} 的提醒时间 ({memo['reminder_time']}) 大于当前时间 ({now_str}),跳过执行") self.logger.warning(f"任务 #{memo['id']} 的提醒时间 ({memo['reminder_time']}) 大于当前时间 ({now_str}),跳过执行")
continue continue
@@ -196,7 +203,7 @@ class TaskScheduler:
result = await self.execute_task_as_conversation(memo, memo_db) result = await self.execute_task_as_conversation(memo, memo_db)
# 广播任务完成 # 广播任务完成
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "complete", result) await self.ws_handler.broadcast_task_status(memo, "complete", result)
executed_tasks.append({ executed_tasks.append({
@@ -211,7 +218,7 @@ class TaskScheduler:
self.logger.error(i18n.get('task_failed', memo['title'], str(e))) self.logger.error(i18n.get('task_failed', memo['title'], str(e)))
# 广播任务失败 # 广播任务失败
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "failed", str(e)) await self.ws_handler.broadcast_task_status(memo, "failed", str(e))
executed_tasks.append({ executed_tasks.append({
@@ -285,17 +292,11 @@ class TaskScheduler:
# 获取当前事件循环 # 获取当前事件循环
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# 创建一个队列来收集完整的响应 # 定义输出回调函数 - 直接广播
full_response_queue = queue.Queue()
# 定义输出回调函数 - 直接广播,同时收集
def output_callback(msg_type, content): def output_callback(msg_type, content):
"""AI输出的回调函数 - 直接广播,同时收集""" """AI输出的回调函数 - 直接广播"""
# 收集完整响应 # J15(审计 2026-08-19):full_response_queue 收集后从未被消费,
if msg_type == "complete": # 属死代码,已移除;广播路径保留
full_response_queue.put(content)
# 直接广播给所有客户端 - 必须带上 task_id 和 execution_id
if self.ws_handler and self.broadcast_enabled: if self.ws_handler and self.broadcast_enabled:
# 使用 run_coroutine_threadsafe 在正确的循环中运行异步函数 # 使用 run_coroutine_threadsafe 在正确的循环中运行异步函数
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
@@ -420,7 +421,7 @@ class TaskScheduler:
executed_tasks = [] executed_tasks = []
for memo in immediate: for memo in immediate:
try: try:
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('immediate_memo_executing', memo['title'])) await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('immediate_memo_executing', memo['title']))
self.logger.info(f"标记即时任务 #{memo['id']} 为已触发") self.logger.info(f"标记即时任务 #{memo['id']} 为已触发")
@@ -428,7 +429,7 @@ class TaskScheduler:
result = await self.execute_task_as_conversation(memo, memo_db) result = await self.execute_task_as_conversation(memo, memo_db)
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "complete", result) await self.ws_handler.broadcast_task_status(memo, "complete", result)
executed_tasks.append({ executed_tasks.append({
@@ -442,7 +443,7 @@ class TaskScheduler:
except Exception as e: except Exception as e:
self.logger.error(i18n.get('task_failed', memo['title'], str(e))) self.logger.error(i18n.get('task_failed', memo['title'], str(e)))
if self.ws_handler: if self.ws_handler and self.broadcast_enabled:
await self.ws_handler.broadcast_task_status(memo, "failed", str(e)) await self.ws_handler.broadcast_task_status(memo, "failed", str(e))
executed_tasks.append({ executed_tasks.append({
@@ -703,6 +704,9 @@ def run_full_subagent(role, task, config, logger=None, model=None,
_watch_done = threading.Event() # N5:取消观察者线程退出信号(finally 置位) _watch_done = threading.Event() # N5:取消观察者线程退出信号(finally 置位)
try: try:
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS)) max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
# max_turns 钳制:0/负数此前致首轮即拦截(只剩收尾报告、无任何执行);
# 0/空回退默认值的惯例对齐 max_tokens,下限 1 保证至少执行一个任务轮
max_turns = max(1, int(max_turns or 10))
temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE
# 创建子代理:完整 DeepSeekChat 实例,role 作为 system_prompt # 创建子代理:完整 DeepSeekChat 实例,role 作为 system_prompt
@@ -2099,6 +2103,8 @@ class WebSocketHandler:
# 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理) # 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理)
self._cmd_executor = ThreadPoolExecutor(max_workers=2, self._cmd_executor = ThreadPoolExecutor(max_workers=2,
thread_name_prefix="ws-cmd") thread_name_prefix="ws-cmd")
# J7(审计 2026-08-19):broadcast 单客户端发送超时(半开 TCP 防卡死)
self.broadcast_send_timeout = 5.0
def shutdown(self): def shutdown(self):
"""I 批:释放命令专用线程池(服务退出时调用)""" """I 批:释放命令专用线程池(服务退出时调用)"""
@@ -2269,14 +2275,31 @@ class WebSocketHandler:
self.logger.error(f"发送消息失败: {e}") self.logger.error(f"发送消息失败: {e}")
async def broadcast(self, message): async def broadcast(self, message):
"""广播消息到所有客户端""" """广播消息到所有客户端。
for websocket in self.connected_clients.copy():
J7(审计 2026-08-19):此前串行 await 且 send_message 吞掉全部异常,
broadcast 的 except→unregister 为死代码——ping 禁用(ping_interval=None)
下半开 TCP 无法探测,backpressure 下 websocket.send 可无限期阻塞,
卡死广播与调度器循环。现改为每客户端并发发送 + wait_for 超时,
超时即 unregister 回收(discard 幂等)。
"""
clients = list(self.connected_clients)
if not clients:
return
async def _send_one(websocket):
try: try:
await self.send_message(websocket, message) await asyncio.wait_for(
self.send_message(websocket, message),
timeout=self.broadcast_send_timeout)
except Exception: except Exception:
# N13:不再裸 except——吞掉 CancelledError 会破坏任务取消语义 # 超时/发送异常:回收该连接(取消事件被 wait_for 传播,
# CancelledError 为 BaseException 不在此列,不影响任务取消语义)
await self.unregister(websocket) await self.unregister(websocket)
await asyncio.gather(*(_send_one(ws) for ws in clients),
return_exceptions=True)
async def broadcast_task_status(self, task: Dict, status: str, message: str = None): async def broadcast_task_status(self, task: Dict, status: str, message: str = None):
"""广播任务状态""" """广播任务状态"""
await self.broadcast({ await self.broadcast({
@@ -2379,6 +2402,10 @@ class WebSocketHandler:
else: else:
reminder_time = (datetime.now() + timedelta(seconds=delay)).strftime(constants.DATETIME_FORMAT) reminder_time = (datetime.now() + timedelta(seconds=delay)).strftime(constants.DATETIME_FORMAT)
# J1(审计 2026-08-19):add_memo 入库校验抛 ValueError(畸形时间/
# 重复间隔等),此处转为友好错误消息,不让 handle_message 顶层
# 兜底以笼统的"处理失败"收场
try:
memo_id = self.ai_manager.memo_db.add_user_task( memo_id = self.ai_manager.memo_db.add_user_task(
title=title, title=title,
content=content, content=content,
@@ -2389,6 +2416,12 @@ class WebSocketHandler:
repeat_end_time=repeat_end_time, repeat_end_time=repeat_end_time,
is_immediate=is_immediate is_immediate=is_immediate
) )
except ValueError as e:
await self.send_message(websocket, {
"type": "error",
"content": f"任务添加失败: {e}"
})
return
immediate_msg = i18n.get('immediate_memo_added') if is_immediate else "" immediate_msg = i18n.get('immediate_memo_added') if is_immediate else ""
repeat_msg = "" repeat_msg = ""
@@ -2437,6 +2470,12 @@ class WebSocketHandler:
"type": "error", "type": "error",
"content": i18n.get('memo_complete_failed', memo_id) "content": i18n.get('memo_complete_failed', memo_id)
}) })
else:
# J15(审计 2026-08-19):未知 action 此前静默吞掉,客户端无任何反馈
await self.send_message(websocket, {
"type": "error",
"content": f"未知的任务操作: {action}"
})
async def handle_command(self, websocket, data): async def handle_command(self, websocket, data):
"""处理命令消息""" """处理命令消息"""
@@ -2467,6 +2506,14 @@ class WebSocketHandler:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not ai.is_busy(): if not ai.is_busy():
break break
# J11(审计 2026-08-19):取消后旧回合可能仍在收尾(回发内容),
# 且新消息的 think 线程可能与 reset 无序抢 _msg_lock(RLock 无
# 公平性)——等待该连接处理标志清零后再排队 reset,避免新回合
# 在旧上下文上执行后被 reset 抹掉(消息"消失")
for _ in range(20):
if not session or not session.get("is_processing"):
break
await asyncio.sleep(0.1)
# 重置对话状态,重新加载系统提示词 # 重置对话状态,重新加载系统提示词
# N3:reset_conversation 内部同步获取 _msg_lock(繁忙对话持锁 # N3:reset_conversation 内部同步获取 _msg_lock(繁忙对话持锁
# 可达数十秒),直接调用会冻结整个事件循环(所有连接的心跳/ # 可达数十秒),直接调用会冻结整个事件循环(所有连接的心跳/
@@ -2716,7 +2763,7 @@ class WebSocketHandler:
# 定义输出回调函数 - 实时发送 # 定义输出回调函数 - 实时发送
def output_callback(msg_type, content): def output_callback(msg_type, content):
"""AI输出的回调函数 - 实时发送每个chunk""" """AI输出的回调函数 - 实时发送每个chunk"""
self.logger.info(f"输出回调被调用: {msg_type}, 内容长度: {len(content)}") self.logger.debug(f"输出回调被调用: {msg_type}, 内容长度: {len(content)}")
# 创建发送消息的协程 # 创建发送消息的协程
async def send_msg(): async def send_msg():
@@ -2938,8 +2985,11 @@ class EnhancedAIManager:
async def handle_connection(self, websocket, path): async def handle_connection(self, websocket, path):
"""处理WebSocket连接 - 消息接收与处理解耦,确保中断信号能被即时处理""" """处理WebSocket连接 - 消息接收与处理解耦,确保中断信号能被即时处理"""
self.logger.info(f"新连接: {websocket.remote_address}, path: {path}") self.logger.info(f"新连接: {websocket.remote_address}, path: {path}")
await self.ws_handler.register(websocket)
try: try:
# J1(审计 2026-08-19):register 移入 try——此前在 try 外,握手期
# 异常(如任务展示路径解析失败)逃逸时 finally 不执行,会话登记
# 永久泄漏;unregister 为 discard 幂等,提前失败也会被正常清理
await self.ws_handler.register(websocket)
async for message in websocket: async for message in websocket:
# 【关键修复】用create_task异步处理消息,不await阻塞接收循环 # 【关键修复】用create_task异步处理消息,不await阻塞接收循环
# 这样当AI在后台生成响应时,interrupt消息仍能被立即接收和处理 # 这样当AI在后台生成响应时,interrupt消息仍能被立即接收和处理
@@ -3307,9 +3357,9 @@ def handle_once_mode(manager, args) -> bool:
if state["exit_code"] == 0 and not state["interrupted"] and not state["timed_out"]: if state["exit_code"] == 0 and not state["interrupted"] and not state["timed_out"]:
if response is None or (isinstance(response, str) and not response.strip()): if response is None or (isinstance(response, str) and not response.strip()):
state["exit_code"] = 2 state["exit_code"] = 2
except KeyboardInterrupt:
state["interrupted"] = True
except Exception as e: except Exception as e:
# J15(审计 2026-08-19):except KeyboardInterrupt 为死代码——SIGINT
# 已由自定义 handler 接管(_sigint_handler),KeyboardInterrupt 永不抛出
print(f"{i18n.get('error_prefix')}{e}", file=sys.stderr) print(f"{i18n.get('error_prefix')}{e}", file=sys.stderr)
state["exit_code"] = 2 state["exit_code"] = 2
finally: finally:
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
common.atomic - 原子写文件工具(J5,审计 2026-08-19)
同目录 mkstemp 临时文件 + fsync + os.replace:进程中断/断电不留半截文件;
mkstemp 随机文件名避免多进程并发写同路径互踩(conversation_history 旧实现
的固定 .tmp 名在并发场景会互相覆盖)。仅依赖标准库,供 common/subsystems
各写路径复用。
"""
import os
import tempfile
def atomic_write_text(path: str, content: str) -> None:
"""原子写文本文件。失败时清理临时文件并抛出原异常。"""
path = os.path.expanduser(path)
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".aibox_tmp_", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
+29 -6
View File
@@ -9,8 +9,12 @@ common.config - aibox 配置管理器
import os import os
import json import json
import copy import copy
import shutil
import threading
from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
from .atomic import atomic_write_text
from .i18n import i18n from .i18n import i18n
class ConfigManager: class ConfigManager:
"""配置管理器 - 单例模式,负责加载和保存配置""" """配置管理器 - 单例模式,负责加载和保存配置"""
@@ -18,13 +22,18 @@ class ConfigManager:
_instance = None _instance = None
_config = None _config = None
_config_path = None _config_path = None
_instance_lock = threading.Lock()
def __new__(cls): def __new__(cls):
# J6(审计 2026-08-19):此前单例创建与初始化均无锁——多线程首例
# 并发可双实例、双重加载配置
with cls._instance_lock:
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
with self._instance_lock:
if self._config is None: if self._config is None:
self._load_config() self._load_config()
@@ -183,6 +192,10 @@ class ConfigManager:
print(i18n.get('config_loaded', config_path)) print(i18n.get('config_loaded', config_path))
except Exception as e: except Exception as e:
print(f"⚠️ {i18n.get('error_prefix')}{e},使用默认配置") print(f"⚠️ {i18n.get('error_prefix')}{e},使用默认配置")
# J5(审计 2026-08-19):损坏文件先备份再继续——此前直接改用
# 默认配置,任一后续保存会用"默认+本次改动"整体覆盖原文件,
# proxy/api/context 等全部手工配置永久丢失
self._backup_corrupted_config(config_path)
self._config = self._get_default_config() self._config = self._get_default_config()
else: else:
print(i18n.get('config_not_found', config_path)) print(i18n.get('config_not_found', config_path))
@@ -190,6 +203,15 @@ class ConfigManager:
self._save_default_config(config_path) self._save_default_config(config_path)
self._migrate_legacy_proxy_password() self._migrate_legacy_proxy_password()
def _backup_corrupted_config(self, config_path: str):
"""J5:损坏配置先复制备份(带时间戳),防止后续保存整体覆盖丢失"""
try:
backup = f"{config_path}.corrupt.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
shutil.copy2(config_path, backup)
print(f"⚠️ 已备份损坏的配置文件到 {backup}")
except Exception as e:
print(f"⚠️ 备份损坏配置文件失败: {e}")
def _merge_configs(self, default: Dict, user: Dict) -> Dict: def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy() result = default.copy()
for key, value in user.items(): for key, value in user.items():
@@ -201,9 +223,9 @@ class ConfigManager:
def _save_default_config(self, config_path: str): def _save_default_config(self, config_path: str):
try: try:
os.makedirs(os.path.dirname(config_path), exist_ok=True) atomic_write_text(
with open(config_path, 'w', encoding='utf-8') as f: config_path,
json.dump(self._get_default_config(), f, ensure_ascii=False, indent=2) json.dumps(self._get_default_config(), ensure_ascii=False, indent=2))
print(i18n.get('config_created', config_path)) print(i18n.get('config_created', config_path))
except: except:
pass pass
@@ -296,9 +318,10 @@ class ConfigManager:
socks = safe_config.get("proxy", {}).get("socks5") socks = safe_config.get("proxy", {}).get("socks5")
if socks and socks.get("password"): if socks and socks.get("password"):
socks["password"] = None socks["password"] = None
os.makedirs(os.path.dirname(config_path), exist_ok=True) # J5:原子写(mkstemp + fsync + os.replace),崩溃不留半截 JSON
with open(config_path, 'w', encoding='utf-8') as f: atomic_write_text(
json.dump(safe_config, f, ensure_ascii=False, indent=2) config_path,
json.dumps(safe_config, ensure_ascii=False, indent=2))
print(f"✅ 配置已保存到 {config_path}") print(f"✅ 配置已保存到 {config_path}")
return True return True
except Exception as e: except Exception as e:
+16 -5
View File
@@ -6,10 +6,12 @@
<title>AI智能助手 - Socket.IO客户端</title> <title>AI智能助手 - Socket.IO客户端</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> <!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js" integrity="sha384-odPBjvtXVM/5hOYIr3A1dB+flh0c3wAT3bSesIOqEGmyUA4JoKf/YTWy0XKOYAY7" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js" integrity="sha384-g4mRvs7AO0/Ol5LxcGyz4Doe21pVhGNnC3EQw5shw+z+aXDN86HqUdwXWO+Gz2zI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" integrity="sha384-eEu5CTj3qGvu9PdJuS+YlkNi7d2XxQROAFYOr59zgObtlcux1ae1Il3u7jvdCSWu" crossorigin="anonymous"></script>
<!-- Socket.IO 客户端库 --> <!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.5.0/socket.io.min.js" integrity="sha384-7EyYLQZgWBi67fBtVxw60/OWl1kjsfrPFcaU0pp0nAh+i8FD068QogUvg85Ewy1k" crossorigin="anonymous"></script>
<style> <style>
/* 样式保持不变,完全使用原有的样式 */ /* 样式保持不变,完全使用原有的样式 */
* { * {
@@ -982,6 +984,15 @@
</template> </template>
<script> <script>
// J2(审计 2026-08-19):marked 输出经 DOMPurify 净化——AI 输出/命令
// 执行结果是不可信输入,含原始 HTML 即可注入脚本;DOMPurify 缺失时
// 退化为转义文本(只显示不执行,防 XSS 兜底)
function renderMarkdown(content) {
const html = marked.parse(content);
if (window.DOMPurify) return DOMPurify.sanitize(html);
return html.replace(/</g, '&lt;');
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// 配置marked // 配置marked
marked.setOptions({ marked.setOptions({
@@ -1124,7 +1135,7 @@
textDiv.setAttribute('data-raw-content', streamData.content); textDiv.setAttribute('data-raw-content', streamData.content);
if (markdownEnabled) { if (markdownEnabled) {
textDiv.innerHTML = marked.parse(streamData.content); textDiv.innerHTML = renderMarkdown(streamData.content);
textDiv.querySelectorAll('pre code').forEach((block) => { textDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block); hljs.highlightElement(block);
}); });
@@ -1419,7 +1430,7 @@
const rawContent = msgDiv.getAttribute('data-raw-content'); const rawContent = msgDiv.getAttribute('data-raw-content');
if (rawContent) { if (rawContent) {
if (markdownEnabled) { if (markdownEnabled) {
msgDiv.innerHTML = marked.parse(rawContent); msgDiv.innerHTML = renderMarkdown(rawContent);
msgDiv.classList.add('markdown-body'); msgDiv.classList.add('markdown-body');
addCopyButtonsToCodeBlocks(msgDiv); addCopyButtonsToCodeBlocks(msgDiv);
} else { } else {
+29 -8
View File
@@ -8,10 +8,12 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- highlight.js 代码高亮 + marked markdown解析 --> <!-- highlight.js 代码高亮 + marked markdown解析 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> <!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js" integrity="sha384-odPBjvtXVM/5hOYIr3A1dB+flh0c3wAT3bSesIOqEGmyUA4JoKf/YTWy0XKOYAY7" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js" integrity="sha384-g4mRvs7AO0/Ol5LxcGyz4Doe21pVhGNnC3EQw5shw+z+aXDN86HqUdwXWO+Gz2zI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" integrity="sha384-eEu5CTj3qGvu9PdJuS+YlkNi7d2XxQROAFYOr59zgObtlcux1ae1Il3u7jvdCSWu" crossorigin="anonymous"></script>
<!-- Socket.IO 客户端库 --> <!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.5.0/socket.io.min.js" integrity="sha384-7EyYLQZgWBi67fBtVxw60/OWl1kjsfrPFcaU0pp0nAh+i8FD068QogUvg85Ewy1k" crossorigin="anonymous"></script>
<style> <style>
/* ========== 融合小猫风格 + WebSocket增强样式 ========== */ /* ========== 融合小猫风格 + WebSocket增强样式 ========== */
* { * {
@@ -498,6 +500,15 @@
<script> <script>
(function(){ (function(){
// J2(审计 2026-08-19):marked 输出经 DOMPurify 净化——AI 输出/命令
// 执行结果是不可信输入,含原始 HTML 即可注入脚本;DOMPurify 缺失时
// 退化为转义文本(只显示不执行,防 XSS 兜底)
function renderMarkdown(content) {
const html = marked.parse(content);
if (window.DOMPurify) return DOMPurify.sanitize(html);
return html.replace(/</g, '&lt;');
}
// ---------- DOM 元素 ---------- // ---------- DOM 元素 ----------
const bgFile = document.getElementById('bgFile'); const bgFile = document.getElementById('bgFile');
const roleImg = document.getElementById('roleImg'); const roleImg = document.getElementById('roleImg');
@@ -533,7 +544,12 @@
const div = document.createElement('div'); const div = document.createElement('div');
div.className = isError ? 'error-msg' : 'system-msg'; div.className = isError ? 'error-msg' : 'system-msg';
div.style.cssText = 'background:rgba(0,0,0,0.6); border-radius:16px; padding:6px 12px; margin:6px 0; font-size:12px;'; div.style.cssText = 'background:rgba(0,0,0,0.6); border-radius:16px; padding:6px 12px; margin:6px 0; font-size:12px;';
div.innerHTML = `<i class="fas ${isError ? 'fa-exclamation-triangle' : 'fa-info-circle'}"></i> ${text}`; // J2:text 为服务端内容(命令执行结果/AI 输出),禁止拼 innerHTML——
// 原实现 `${text}` 模板拼接使任意 HTML 注入执行;图标为静态字面量
const icon = document.createElement('i');
icon.className = `fas ${isError ? 'fa-exclamation-triangle' : 'fa-info-circle'}`;
div.appendChild(icon);
div.appendChild(document.createTextNode(' ' + text));
chatBox.appendChild(div); chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight; chatBox.scrollTop = chatBox.scrollHeight;
} }
@@ -545,7 +561,7 @@
if(textDiv) { if(textDiv) {
textDiv.setAttribute('data-raw', stream.content); textDiv.setAttribute('data-raw', stream.content);
if(markdownEnabled) { if(markdownEnabled) {
textDiv.innerHTML = marked.parse(stream.content); textDiv.innerHTML = renderMarkdown(stream.content);
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block)); textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
} else textDiv.innerText = stream.content; } else textDiv.innerText = stream.content;
} }
@@ -572,7 +588,12 @@
if(!streamingTasks[eid]) { if(!streamingTasks[eid]) {
const sep = document.createElement('div'); const sep = document.createElement('div');
sep.className = 'task-separator'; sep.className = 'task-separator';
sep.innerHTML = `<i class="fas fa-bolt"></i> 定时任务 #${taskMeta.taskId} (${taskMeta.taskTitle || '执行'}) 第${taskMeta.triggerCount||1}次执行`; // J2:taskMeta.taskTitle 为服务端可控内容,禁止拼 innerHTML
const bolt = document.createElement('i');
bolt.className = 'fas fa-bolt';
sep.appendChild(bolt);
sep.appendChild(document.createTextNode(
` 定时任务 #${taskMeta.taskId} (${taskMeta.taskTitle || '执行'}) 第${taskMeta.triggerCount||1}次执行`));
chatBox.appendChild(sep); chatBox.appendChild(sep);
const taskDiv = document.createElement('div'); const taskDiv = document.createElement('div');
taskDiv.className = 'task-msg streaming'; taskDiv.className = 'task-msg streaming';
@@ -590,7 +611,7 @@
if(st && st.element) { if(st && st.element) {
const textDiv = st.element.querySelector('.message-text'); const textDiv = st.element.querySelector('.message-text');
if(textDiv) { if(textDiv) {
if(markdownEnabled) textDiv.innerHTML = marked.parse(st.content); if(markdownEnabled) textDiv.innerHTML = renderMarkdown(st.content);
else textDiv.innerText = st.content; else textDiv.innerText = st.content;
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block)); textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
} }
@@ -613,7 +634,7 @@
if(task.timer) clearTimeout(task.timer); if(task.timer) clearTimeout(task.timer);
if(task.element) { if(task.element) {
const txtDiv = task.element.querySelector('.message-text'); const txtDiv = task.element.querySelector('.message-text');
if(txtDiv && markdownEnabled) txtDiv.innerHTML = marked.parse(task.content); if(txtDiv && markdownEnabled) txtDiv.innerHTML = renderMarkdown(task.content);
task.element.classList.remove('streaming'); task.element.classList.remove('streaming');
} }
globalMsgCount++; globalMsgCount++;
+15 -4
View File
@@ -6,8 +6,10 @@
<title>AI智能助手 - WebSocket直连</title> <title>AI智能助手 - WebSocket直连</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> <!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js" integrity="sha384-odPBjvtXVM/5hOYIr3A1dB+flh0c3wAT3bSesIOqEGmyUA4JoKf/YTWy0XKOYAY7" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js" integrity="sha384-g4mRvs7AO0/Ol5LxcGyz4Doe21pVhGNnC3EQw5shw+z+aXDN86HqUdwXWO+Gz2zI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" integrity="sha384-eEu5CTj3qGvu9PdJuS+YlkNi7d2XxQROAFYOr59zgObtlcux1ae1Il3u7jvdCSWu" crossorigin="anonymous"></script>
<style> <style>
/* 样式保持不变,完全使用原有的样式 */ /* 样式保持不变,完全使用原有的样式 */
* { * {
@@ -951,6 +953,15 @@
</template> </template>
<script> <script>
// J2(审计 2026-08-19):marked 输出经 DOMPurify 净化——AI 输出/命令
// 执行结果是不可信输入,含原始 HTML 即可注入脚本;DOMPurify 缺失时
// 退化为转义文本(只显示不执行,防 XSS 兜底)
function renderMarkdown(content) {
const html = marked.parse(content);
if (window.DOMPurify) return DOMPurify.sanitize(html);
return html.replace(/</g, '&lt;');
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// 配置marked // 配置marked
marked.setOptions({ marked.setOptions({
@@ -1048,7 +1059,7 @@
textDiv.setAttribute('data-raw-content', streamData.content); textDiv.setAttribute('data-raw-content', streamData.content);
if (markdownEnabled) { if (markdownEnabled) {
textDiv.innerHTML = marked.parse(streamData.content); textDiv.innerHTML = renderMarkdown(streamData.content);
textDiv.querySelectorAll('pre code').forEach((block) => { textDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block); hljs.highlightElement(block);
}); });
@@ -1336,7 +1347,7 @@
const rawContent = msgDiv.getAttribute('data-raw-content'); const rawContent = msgDiv.getAttribute('data-raw-content');
if (rawContent) { if (rawContent) {
if (markdownEnabled) { if (markdownEnabled) {
msgDiv.innerHTML = marked.parse(rawContent); msgDiv.innerHTML = renderMarkdown(rawContent);
msgDiv.classList.add('markdown-body'); msgDiv.classList.add('markdown-body');
addCopyButtonsToCodeBlocks(msgDiv); addCopyButtonsToCodeBlocks(msgDiv);
} else { } else {
+8 -3
View File
@@ -25,6 +25,7 @@ from typing import Dict, List, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import ConfigManager # noqa: E402 from common import ConfigManager # noqa: E402
from llm_client import _sanitize_log_msg # noqa: E402
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@@ -195,7 +196,9 @@ def test_profile(config: ConfigManager, name: str, logger=None) -> str:
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)" return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}" return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
except Exception as e: except Exception as e:
return f"❌ 档案 '{name}' 验证失败:{str(e)}" # J4(审计 2026-08-19):异常消息可能含 response.text 片段,网关/WAF
# 错误页会回显 Authorization 头——脱敏 Bearer 令牌后再回显终端
return f"❌ 档案 '{name}' 验证失败:{_sanitize_log_msg(str(e))}"
# ==================== 交互式菜单 ==================== # ==================== 交互式菜单 ====================
@@ -325,10 +328,12 @@ def _add_flow(config: ConfigManager):
print("已取消") print("已取消")
return return
# J8(审计 2026-08-19):原始字符串直传——此前交互层先 float()/int(),
# 非数字输入直接 traceback 退出,add_profile 内部的容错成为死代码
msgs = add_profile(config, name, api_url, model, msgs = add_profile(config, name, api_url, model,
api_key_env=api_key_env, display_name=display_name, api_key_env=api_key_env, display_name=display_name,
temperature=float(temperature_raw) if temperature_raw else None, temperature=temperature_raw or None,
timeout=int(timeout_raw) if timeout_raw else None, timeout=timeout_raw or None,
reasoning_enabled=reasoning, fallback=fallback, reasoning_enabled=reasoning, fallback=fallback,
set_default=set_default) set_default=set_default)
for m in msgs: for m in msgs:
+4 -1
View File
@@ -3,6 +3,7 @@
一个基于python-nmap的局域网Ping扫描工具 一个基于python-nmap的局域网Ping扫描工具
""" """
import argparse import argparse
import shlex
import sys import sys
import socket import socket
@@ -25,7 +26,9 @@ def scan_network(network="192.168.1.0/24", timeout=3, verbose=False):
print("-" * 50) print("-" * 50)
try: try:
result = nm.scan(hosts=network, arguments=arguments) # J12(审计 2026-08-19):python-nmap 内部把 hosts 拼进 shell 命令
# 执行——LLM/AI 可控的 network 参数需 shlex.quote,防参数/命令注入
result = nm.scan(hosts=shlex.quote(network), arguments=arguments)
except Exception as e: except Exception as e:
print(f"❌ 扫描失败: {e}") print(f"❌ 扫描失败: {e}")
print("💡 提示:请确保已安装 nmap (sudo apt install nmap) 并有足够权限") print("💡 提示:请确保已安装 nmap (sudo apt install nmap) 并有足够权限")
+6 -2
View File
@@ -41,6 +41,10 @@ def take_screenshot_windows(output_path, delay=0):
if delay > 0: if delay > 0:
time.sleep(delay) time.sleep(delay)
# J12(审计 2026-08-19):output_path 经 PowerShell 单引号转义——
# 路径中的单引号翻倍(PS 字符串转义),AI/用户可控文件名无法闭合
# 字符串注入脚本(此前 `${output_path}` 直拼,`");calc;#` 即注入)
ps_path = str(output_path).replace("'", "''")
try: try:
ps_script = f''' ps_script = f'''
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Windows.Forms
@@ -53,11 +57,11 @@ $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap) $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size)
$bitmap.Save("{output_path}", [System.Drawing.Imaging.ImageFormat]::Png) $bitmap.Save('{ps_path}', [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose() $graphics.Dispose()
$bitmap.Dispose() $bitmap.Dispose()
Write-Output "截图已保存: {output_path}" Write-Output "截图已保存: {ps_path}"
''' '''
result = subprocess.run( result = subprocess.run(
+4 -5
View File
@@ -22,6 +22,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional from typing import TYPE_CHECKING, Dict, List, Optional
from common import ConfigManager, Logger, constants from common import ConfigManager, Logger, constants
from common.atomic import atomic_write_text
if TYPE_CHECKING: if TYPE_CHECKING:
# 仅用于类型注解,避免运行时循环导入;MemoryDatabase 来自同包 subsystems.db # 仅用于类型注解,避免运行时循环导入;MemoryDatabase 来自同包 subsystems.db
@@ -73,11 +74,9 @@ class ConversationHistory:
def _atomic_write_json(path: Path, data) -> None: def _atomic_write_json(path: Path, data) -> None:
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再 """原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏 os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
后仅静默置 None,历史内容丢失)""" 后仅静默置 None,历史内容丢失)。J5(2026-08-19):委托 common.atomic
tmp = path.with_suffix(path.suffix + ".tmp") (mkstemp 随机名),修复旧实现固定 .tmp 名在多进程并发写同路径互踩。"""
with open(tmp, 'w', encoding='utf-8') as f: atomic_write_text(str(path), json.dumps(data, ensure_ascii=False, indent=2))
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _save_history_index(self): def _save_history_index(self):
"""保存历史索引""" """保存历史索引"""
+88 -23
View File
@@ -141,11 +141,14 @@ class MemoryDatabase:
"""删除记忆""" """删除记忆"""
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor() cursor = conn.cursor()
# J3(审计 2026-08-19):必须先删 fts 再删内容表——外部内容表
# (content=memories)删除索引条目需从内容表回读旧值,内容行
# 先删会导致索引条目永久残留(MATCH 仍可命中已删 rowid)
cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,))
cursor.execute('DELETE FROM memories WHERE id = ?', (memory_id,)) cursor.execute('DELETE FROM memories WHERE id = ?', (memory_id,))
deleted = cursor.rowcount > 0 deleted = cursor.rowcount > 0
if deleted: if deleted:
cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,))
conn.commit() conn.commit()
self.logger.info(f"已删除记忆 ID: {memory_id}") self.logger.info(f"已删除记忆 ID: {memory_id}")
@@ -274,11 +277,12 @@ class MemoryDatabase:
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholders = ','.join(['?'] * len(memory_ids)) placeholders = ','.join(['?'] * len(memory_ids))
# J3:同 delete_memory——先删 fts 再删内容表,防索引条目残留
cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids)
cursor.execute(f'DELETE FROM memories WHERE id IN ({placeholders})', memory_ids) cursor.execute(f'DELETE FROM memories WHERE id IN ({placeholders})', memory_ids)
deleted_count = cursor.rowcount deleted_count = cursor.rowcount
if deleted_count > 0: if deleted_count > 0:
cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids)
conn.commit() conn.commit()
self.logger.info(f"已批量删除 {deleted_count} 条记忆") self.logger.info(f"已批量删除 {deleted_count} 条记忆")
@@ -300,10 +304,19 @@ class MemoDatabase:
self.logger = Logger("MemoDB") self.logger = Logger("MemoDB")
self._init_database() self._init_database()
def _connect(self) -> sqlite3.Connection:
"""统一连接工厂(J9,审计 2026-08-19):busy_timeout 覆盖全部写路径,
不再只有 mark_as_triggered 一条路径等待锁释放"""
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA busy_timeout = 10000")
return conn
def _init_database(self): def _init_database(self):
"""初始化备忘录表""" """初始化备忘录表"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# J9:WAL 显式设置——与 MemoryDatabase 同库文件时不再依赖创建顺序
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS memos ( CREATE TABLE IF NOT EXISTS memos (
@@ -349,10 +362,34 @@ class MemoDatabase:
repeat_interval_unit: str = 'days', repeat_end_time: str = None, repeat_interval_unit: str = 'days', repeat_end_time: str = None,
is_immediate: bool = False) -> int: is_immediate: bool = False) -> int:
"""添加任务 - 所有任务都是对话任务,不再需要metadata和任务类型""" """添加任务 - 所有任务都是对话任务,不再需要metadata和任务类型"""
# J1(审计 2026-08-19):入库校验——畸形时间/数字字段此前原样入库,
# 下游无防护解析点引发调度器失败热循环与新连接握手崩溃(审计报告
# §13.1 J1)。CLI/WS/memo 工具三入口均经本方法,校验一处覆盖。
if reminder_time:
try:
datetime.strptime(reminder_time, constants.DATETIME_FORMAT)
except (ValueError, TypeError):
raise ValueError(
f"提醒时间格式非法: {reminder_time!r}(应为 {constants.DATETIME_FORMAT})")
if repeat_end_time:
try:
datetime.strptime(repeat_end_time, constants.DATETIME_FORMAT)
except (ValueError, TypeError):
raise ValueError(
f"重复结束时间格式非法: {repeat_end_time!r}(应为 {constants.DATETIME_FORMAT})")
if repeat_type not in ("none", "daily", "weekly", "monthly", "custom"):
raise ValueError(f"无效的重复类型: {repeat_type!r}")
try:
repeat_interval = int(repeat_interval)
except (ValueError, TypeError):
raise ValueError(f"重复间隔必须是整数: {repeat_interval!r}")
if repeat_type == "custom" and repeat_interval <= 0:
raise ValueError("自定义重复需要设置有效的 repeat_interval (>0)")
created_at = datetime.now().strftime(constants.DATETIME_FORMAT) created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
tags_str = ",".join(tags) if tags else "" tags_str = ",".join(tags) if tags else ""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
INSERT INTO memos INSERT INTO memos
@@ -377,7 +414,7 @@ class MemoDatabase:
def get_memo(self, memo_id: int) -> Optional[Dict]: def get_memo(self, memo_id: int) -> Optional[Dict]:
"""获取单个任务""" """获取单个任务"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,)) cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
@@ -392,7 +429,7 @@ class MemoDatabase:
def get_pending_memos(self, limit: int = None, before_time: str = None, include_immediate: bool = False) -> List[Dict]: def get_pending_memos(self, limit: int = None, before_time: str = None, include_immediate: bool = False) -> List[Dict]:
"""获取待执行的任务 - 严格使用当前时间比较""" """获取待执行的任务 - 严格使用当前时间比较"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
@@ -458,7 +495,7 @@ class MemoDatabase:
def get_immediate_memos(self, limit: int = None) -> List[Dict]: def get_immediate_memos(self, limit: int = None) -> List[Dict]:
"""获取待执行的即时任务""" """获取待执行的即时任务"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
@@ -494,7 +531,7 @@ class MemoDatabase:
def get_all_memos(self, include_completed: bool = False, limit: int = 50) -> List[Dict]: def get_all_memos(self, include_completed: bool = False, limit: int = 50) -> List[Dict]:
"""获取所有任务""" """获取所有任务"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
@@ -533,13 +570,11 @@ class MemoDatabase:
now = datetime.now() now = datetime.now()
next_reminder = None next_reminder = None
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度) # J9:busy_timeout 已由 _connect() 工厂统一设置(全部写路径一致),
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让 # 多进程同库(CLI --memo-add + 服务调度)写竞争等待锁释放而非立即失败
# SQLite 等待锁释放(最长 10s)而不是立即失败
cursor.execute("PRAGMA busy_timeout = 10000")
cursor.execute("BEGIN IMMEDIATE") cursor.execute("BEGIN IMMEDIATE")
try: try:
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,)) cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
@@ -608,7 +643,11 @@ class MemoDatabase:
def _should_end_repeat(self, memo: Dict, current_time: datetime) -> bool: def _should_end_repeat(self, memo: Dict, current_time: datetime) -> bool:
"""检查是否应该结束重复""" """检查是否应该结束重复"""
if memo['repeat_end_time']: if memo['repeat_end_time']:
try:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
except (ValueError, TypeError):
# J1:存量脏数据防御——解析失败视为无结束时间,不打断触发事务
return False
if current_time > end_time: if current_time > end_time:
return True return True
return False return False
@@ -638,24 +677,37 @@ class MemoDatabase:
next_time = current_time.replace(year=current_time.year + 1, month=1, day=1) - timedelta(days=1) next_time = current_time.replace(year=current_time.year + 1, month=1, day=1) - timedelta(days=1)
else: else:
next_time = current_time.replace(month=current_time.month + 1, day=1) - timedelta(days=1) next_time = current_time.replace(month=current_time.month + 1, day=1) - timedelta(days=1)
elif memo['repeat_type'] == 'custom' and memo['repeat_interval'] > 0: elif memo['repeat_type'] == 'custom':
# J1:repeat_interval 类型异常(存量脏数据)按 0 处理,
# 不再抛 TypeError 打崩触发事务引发热循环
try:
repeat_interval = int(memo.get('repeat_interval') or 0)
except (ValueError, TypeError):
repeat_interval = 0
if repeat_interval > 0:
unit = memo.get('repeat_interval_unit', 'days') unit = memo.get('repeat_interval_unit', 'days')
if unit == 'minutes': if unit == 'minutes':
# 每N分钟重复:当前时间 + N分钟 # 每N分钟重复:当前时间 + N分钟
next_time = current_time + timedelta(minutes=memo['repeat_interval']) next_time = current_time + timedelta(minutes=repeat_interval)
elif unit == 'hours': elif unit == 'hours':
# 每N小时重复:当前时间 + N小时 # 每N小时重复:当前时间 + N小时
next_time = current_time + timedelta(hours=memo['repeat_interval']) next_time = current_time + timedelta(hours=repeat_interval)
else: # days else: # days
# 每N天重复:当前时间 + N天 # 每N天重复:当前时间 + N天
next_time = current_time + timedelta(days=memo['repeat_interval']) next_time = current_time + timedelta(days=repeat_interval)
else:
return None
else: else:
return None return None
# 检查是否超过重复结束时间 # 检查是否超过重复结束时间
if memo['repeat_end_time']: if memo['repeat_end_time']:
try:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
if next_time > end_time: except (ValueError, TypeError):
# J1:存量脏数据防御——解析失败视为无结束时间
end_time = None
if end_time and next_time > end_time:
return None return None
return next_time return next_time
@@ -672,7 +724,7 @@ class MemoDatabase:
completed_at = datetime.now().strftime(constants.DATETIME_FORMAT) completed_at = datetime.now().strftime(constants.DATETIME_FORMAT)
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
@@ -691,7 +743,7 @@ class MemoDatabase:
def delete_memo(self, memo_id: int) -> bool: def delete_memo(self, memo_id: int) -> bool:
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记 """物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
完成不删行,重复任务还会被推进而非移除,stats 计数失真)""" 完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,)) cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
conn.commit() conn.commit()
@@ -699,7 +751,7 @@ class MemoDatabase:
def get_memo_stats(self) -> Dict: def get_memo_stats(self) -> Dict:
"""获取任务统计信息""" """获取任务统计信息"""
with sqlite3.connect(self.db_path) as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM memos') cursor.execute('SELECT COUNT(*) FROM memos')
@@ -733,7 +785,8 @@ class MemoDatabase:
def add_user_task(self, title: str, content: str = None, reminder_time: str = None, def add_user_task(self, title: str, content: str = None, reminder_time: str = None,
repeat_type: str = 'none', repeat_interval: int = 0, repeat_type: str = 'none', repeat_interval: int = 0,
repeat_interval_unit: str = 'days', is_immediate: bool = False) -> int: repeat_interval_unit: str = 'days', repeat_end_time: str = None,
is_immediate: bool = False) -> int:
"""添加用户任务(前端直接调用的任务)- 所有任务都是对话任务""" """添加用户任务(前端直接调用的任务)- 所有任务都是对话任务"""
if not constants.MEMO_ALLOW_USER_TASKS: if not constants.MEMO_ALLOW_USER_TASKS:
raise Exception("用户任务功能已禁用") raise Exception("用户任务功能已禁用")
@@ -745,11 +798,23 @@ class MemoDatabase:
repeat_type=repeat_type, repeat_type=repeat_type,
repeat_interval=repeat_interval, repeat_interval=repeat_interval,
repeat_interval_unit=repeat_interval_unit, repeat_interval_unit=repeat_interval_unit,
repeat_end_time=repeat_end_time,
created_by="user", created_by="user",
source="websocket", source="websocket",
is_immediate=is_immediate is_immediate=is_immediate
) )
@staticmethod
def _is_overdue_time(reminder_time: str, now: datetime) -> bool:
"""J1:展示路径时间解析防御——格式异常按到期处理(对齐 Q5 语义),
不再抛 ValueError 打崩新连接握手流程"""
if not reminder_time:
return False
try:
return datetime.strptime(reminder_time, constants.DATETIME_FORMAT) < now
except (ValueError, TypeError):
return True
def get_pending_tasks_for_user(self) -> List[Dict]: def get_pending_tasks_for_user(self) -> List[Dict]:
"""获取待执行任务列表(用于前端显示)""" """获取待执行任务列表(用于前端显示)"""
pending = self.get_pending_memos(include_immediate=False) pending = self.get_pending_memos(include_immediate=False)
@@ -764,7 +829,7 @@ class MemoDatabase:
"content": memo['content'], "content": memo['content'],
"reminder_time": memo['reminder_time'], "reminder_time": memo['reminder_time'],
"priority": memo['priority'], "priority": memo['priority'],
"is_overdue": datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < now if memo['reminder_time'] else False, "is_overdue": self._is_overdue_time(memo['reminder_time'], now),
"repeat_type": memo['repeat_type'], "repeat_type": memo['repeat_type'],
"repeat_interval": memo.get('repeat_interval', 0), "repeat_interval": memo.get('repeat_interval', 0),
"repeat_interval_unit": memo.get('repeat_interval_unit', 'days'), "repeat_interval_unit": memo.get('repeat_interval_unit', 'days'),
+2
View File
@@ -144,6 +144,8 @@ class SubprocessCommandExecutor(CommandExecutor):
shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻 shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻
while True: while True:
# 检查超时(readline 阻塞不再影响本判断) # 检查超时(readline 阻塞不再影响本判断)
# J16(审计 2026-08-19):timeout<=0 语义为"不设超时"(有意为之,
# 调用方显式传 0 禁用);registry 层 3600s 流式兜底仍生效
if timeout > 0 and time.time() - start_time > timeout: if timeout > 0 and time.time() - start_time > timeout:
self._terminate_tree(process) self._terminate_tree(process)
timeout_occurred = True timeout_occurred = True
+11 -6
View File
@@ -23,6 +23,7 @@ from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional
from common import ConfigManager from common import ConfigManager
from common.atomic import atomic_write_text
class PromptManager: class PromptManager:
@@ -60,6 +61,10 @@ class PromptManager:
return cls._instance return cls._instance
def __init__(self): def __init__(self):
# J6(审计 2026-08-19):__new__ 的锁只防双实例,多线程首例并发
# 同时进入 __init__ 会使 _load_prompts 双重执行,_prompts(类属性)
# 整份翻倍——初始化检查与加载收进同一把锁
with self._instance_lock:
if not self._prompts: if not self._prompts:
self._load_prompts() self._load_prompts()
@@ -172,8 +177,8 @@ class PromptManager:
【更新记录】 【更新记录】
- 上次更新:{timestamp} - 上次更新:{timestamp}
""" """
with open(identity_path, 'w', encoding='utf-8') as f: # J5:原子写,崩溃不留半截提示词文件
f.write(updated_content) atomic_write_text(identity_path, updated_content)
self.reload() self.reload()
return True return True
except Exception as e: except Exception as e:
@@ -195,8 +200,8 @@ class PromptManager:
【更新记录】 【更新记录】
- 上次更新:{timestamp} - 上次更新:{timestamp}
""" """
with open(status_path, 'w', encoding='utf-8') as f: # J5:原子写,崩溃不留半截提示词文件
f.write(updated_content) atomic_write_text(status_path, updated_content)
self.reload() self.reload()
return True return True
except Exception as e: except Exception as e:
@@ -218,8 +223,8 @@ class PromptManager:
【更新记录】 【更新记录】
- 上次更新:{timestamp} - 上次更新:{timestamp}
""" """
with open(soul_path, 'w', encoding='utf-8') as f: # J5:原子写,崩溃不留半截提示词文件
f.write(updated_content) atomic_write_text(soul_path, updated_content)
self.reload() self.reload()
return True return True
except Exception as e: except Exception as e:
+25 -2
View File
@@ -27,6 +27,9 @@ class SkillManager:
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容 2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
""" """
# J16(审计 2026-08-19):--help 输出上限——内容会进系统提示词
MAX_HELP_LENGTH = 2000
def __init__(self, logger: Optional[Logger] = None): def __init__(self, logger: Optional[Logger] = None):
self.logger = logger or Logger("SkillManager") self.logger = logger or Logger("SkillManager")
# name -> {path, file, summary, content, is_markdown} # name -> {path, file, summary, content, is_markdown}
@@ -38,7 +41,11 @@ class SkillManager:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _run_help(self, skill_path: str) -> str: def _run_help(self, skill_path: str) -> str:
"""执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。""" """执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。
J16(审计 2026-08-19):输出截断到 MAX_HELP_LENGTH——技能 --help
输出会进系统提示词,异常/恶意脚本可撑爆上下文。
"""
try: try:
result = subprocess.run( result = subprocess.run(
[skill_path, "--help"], [skill_path, "--help"],
@@ -50,6 +57,8 @@ class SkillManager:
output = result.stdout.strip() output = result.stdout.strip()
if not output: if not output:
output = result.stderr.strip() output = result.stderr.strip()
if len(output) > self.MAX_HELP_LENGTH:
output = output[:self.MAX_HELP_LENGTH] + "\n…(已截断)"
return output return output
except Exception as e: except Exception as e:
self.logger.warning(f"运行 {skill_path} --help 失败: {e}") self.logger.warning(f"运行 {skill_path} --help 失败: {e}")
@@ -129,12 +138,26 @@ class SkillManager:
and not f.startswith('.') and not f.startswith('.')
] ]
for skill_file in skill_files: # J16(审计 2026-08-19):同名键冲突(foo 可执行文件与 foo.md)此前
# 由 listdir 顺序决定谁覆盖谁,行为不确定。改为可执行优先 + 确定性:
# 先加载可执行技能,Markdown 遇到已占用的同名键时跳过并告警
executable_files = [
f for f in skill_files
if not self._is_markdown_file(f)
]
markdown_files = [f for f in skill_files if self._is_markdown_file(f)]
for skill_file in executable_files + markdown_files:
skill_path = os.path.join(skills_dir, skill_file) skill_path = os.path.join(skills_dir, skill_file)
skill_name = os.path.splitext(skill_file)[0] skill_name = os.path.splitext(skill_file)[0]
is_markdown = self._is_markdown_file(skill_file) is_markdown = self._is_markdown_file(skill_file)
if is_markdown: if is_markdown:
if skill_name in self.skills:
self.logger.warning(
f"Markdown 技能 '{skill_file}' 与同名可执行技能冲突,已跳过"
f"(可执行技能优先)")
continue
# Markdown 格式技能:直接读取内容 # Markdown 格式技能:直接读取内容
summary, content = self._read_markdown_skill(skill_path) summary, content = self._read_markdown_skill(skill_path)
if content: if content:
+7 -2
View File
@@ -231,14 +231,19 @@ class TestL2ProxySaveFailure:
from common import ConfigManager from common import ConfigManager
cm = ConfigManager() cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json") cm._config_path = str(tmp_path / "config.json")
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")): # J5(审计 2026-08-19):写路径改为原子写(mkstemp+os.replace),
# 磁盘失败改由 patch 原子写函数模拟(builtins.open 已不在路径上)
with mock.patch("common.config.atomic_write_text",
side_effect=PermissionError("disk ro")):
ok = cm.set_proxy("127.0.0.1", 1080, save=True) ok = cm.set_proxy("127.0.0.1", 1080, save=True)
assert ok is False assert ok is False
def test_proxy_tool_reports_save_failure(self, tmp_path): def test_proxy_tool_reports_save_failure(self, tmp_path):
from tools import SetProxyTool from tools import SetProxyTool
tool = SetProxyTool(None) tool = SetProxyTool(None)
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")): # J5:磁盘失败经原子写函数模拟
with mock.patch("common.config.atomic_write_text",
side_effect=PermissionError("disk ro")):
r = tool.execute(host="127.0.0.1", port=1080, save=True, r = tool.execute(host="127.0.0.1", port=1080, save=True,
skip_check=True, enabled=True) skip_check=True, enabled=True)
assert "保存失败" in r assert "保存失败" in r
+11 -1
View File
@@ -174,7 +174,17 @@ class TestF5LightSubagentCancel:
class TestQ5MalformedReminderTime: class TestQ5MalformedReminderTime:
def test_malformed_time_task_returned_as_due(self, tmp_path): def test_malformed_time_task_returned_as_due(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db")) db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间") # J1(审计 2026-08-19):add_memo 已拒绝畸形时间(入库校验),
# 模拟校验落地前的存量脏数据需绕过 add_memo 直接 SQL 插入
import sqlite3
with sqlite3.connect(str(tmp_path / "m.db")) as conn:
cur = conn.cursor()
cur.execute('''INSERT INTO memos
(title, content, created_at, reminder_time, is_completed, source,
repeat_type, repeat_interval, repeat_interval_unit, is_immediate)
VALUES (?, ?, ?, ?, 0, 'test', 'none', 0, 'days', 0)''',
("格式异常", "c", "2026-08-18 00:00:00", "不是时间"))
mid = cur.lastrowid
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00") pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert any(m["id"] == mid for m in pending) assert any(m["id"] == mid for m in pending)
+540
View File
@@ -0,0 +1,540 @@
"""aibox 第五轮审计(Batch K)修复回归测试(2026-08-19)
波次 1:
- J3:FTS 外部内容表删除换序后索引无残留(delete_memory / delete_memories_by_ids)
- J4:model_config 连通性验证异常消息 Bearer 令牌脱敏
- J6:PromptManager/ConfigManager 单例并发初始化不双重加载
- J8:add_profile 对非法温度/超时的容错(交互层不再抢先 float()/int())
波次 2(J1 任务系统投毒):
- add_memo 入库校验:畸形时间/重复间隔/重复类型拒绝,三入口一处覆盖
- 存量脏数据防御:调度器预检、mark_as_triggered、展示路径不再打崩
- WS task add 友好错误 + register 握手异常可回收
"""
import asyncio
import json
import os
import sqlite3
import sys
import threading
import types
from unittest import mock
import pytest
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
import model_config # noqa: E402
from common import ConfigManager # noqa: E402
from subsystems.db import MemoDatabase, MemoryDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from test_model_config import _cfg # noqa: E402
# ==================== J3:FTS 删除顺序 ====================
def _fts_hits(db_path, keyword):
conn = sqlite3.connect(str(db_path))
try:
return conn.execute(
"SELECT rowid FROM memories_fts WHERE memories_fts MATCH ?",
(f'"{keyword}"',)).fetchall()
finally:
conn.close()
def _add_mem(db, content):
return db.add_memory(topic="话题", summary="摘要", content=content,
start_time="2026-08-19 00:00:00",
end_time="2026-08-19 00:00:01", importance=2)
class TestJ3FtsDeleteOrder:
def test_delete_memory_leaves_no_fts_orphan(self, tmp_path):
"""单条删除后 FTS 索引不得残留(修复前 MATCH 仍命中已删 rowid)"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
mid = _add_mem(db, "机密内容甲")
assert _fts_hits(tmp_path / "mem.db", "机密内容甲")
assert db.delete_memory(mid) is True
assert _fts_hits(tmp_path / "mem.db", "机密内容甲") == []
def test_batch_delete_leaves_no_fts_orphan(self, tmp_path):
"""批量删除同样不留索引残留"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
ids = [_add_mem(db, f"机密内容乙{i}") for i in range(3)]
assert db.delete_memories_by_ids(ids) == 3
for i in range(3):
assert _fts_hits(tmp_path / "mem.db", f"机密内容乙{i}") == []
def test_search_after_delete_no_hit(self, tmp_path):
"""删除后搜索(FTS 路径)不再命中已删内容"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
mid = _add_mem(db, "独特关键词丙")
assert db.search_memories(["独特关键词丙"])
db.delete_memory(mid)
assert db.search_memories(["独特关键词丙"]) == []
# ==================== J4:验证异常脱敏 ====================
class TestJ4TestProfileSanitize:
def _models_cfg(self, tmp_path):
return _cfg(tmp_path, {"models": {"bad": {
"api_url": "https://api.example.com/v1/chat/completions",
"model": "m", "api_key_env": "K"}}})
def test_bearer_token_masked_in_failure_message(self, tmp_path):
"""网关错误页回显 Authorization 头时,令牌不得出现在终端回显"""
cm = self._models_cfg(tmp_path)
err = RuntimeError("HTTP 502: Authorization: Bearer sk-secret12345")
with mock.patch("llm_client.create_llm_client", side_effect=err):
result = model_config.test_profile(cm, "bad")
assert "sk-secret12345" not in result
assert "Bearer ***" in result
def test_plain_error_message_preserved(self, tmp_path):
"""无敏感内容的错误消息原样保留(脱敏不误伤)"""
cm = self._models_cfg(tmp_path)
with mock.patch("llm_client.create_llm_client",
side_effect=RuntimeError("连接被拒绝")):
result = model_config.test_profile(cm, "bad")
assert "连接被拒绝" in result
# ==================== J6:单例并发初始化 ====================
class TestJ6SingletonInitRace:
def test_prompt_manager_concurrent_init_no_duplication(self, tmp_path):
"""多线程首例并发:单实例且提示词只加载一份(修复前整份翻倍)"""
PromptManager._instance = None
PromptManager._prompts = []
PromptManager._prompts_dir = None
prompts_dir = tmp_path / "prompts"
prompts_dir.mkdir()
try:
barrier = threading.Barrier(8)
instances = [None] * 8
def worker(k):
barrier.wait()
instances[k] = PromptManager()
with mock.patch.object(ConfigManager, "get",
return_value=str(prompts_dir)):
threads = [threading.Thread(target=worker, args=(k,))
for k in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len({id(i) for i in instances}) == 1
files = [p["file"] for p in PromptManager._prompts]
assert files, "提示词应已加载"
assert len(files) == len(set(files)), f"提示词被重复加载: {files}"
finally:
PromptManager._instance = None
PromptManager._prompts = []
PromptManager._prompts_dir = None
def test_config_manager_concurrent_init_single_load(self, tmp_path):
"""多线程首例并发:单实例且 _load_config 只执行一次"""
cfg_file = tmp_path / "config.json"
calls = {"n": 0}
original_load = ConfigManager._load_config
def counting_load(self):
calls["n"] += 1
original_load(self)
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
try:
barrier = threading.Barrier(8)
instances = [None] * 8
def worker(k):
barrier.wait()
instances[k] = ConfigManager()
with mock.patch.dict(os.environ, {"AI_CONFIG_PATH": str(cfg_file)}), \
mock.patch.object(ConfigManager, "_load_config", counting_load):
threads = [threading.Thread(target=worker, args=(k,))
for k in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len({id(i) for i in instances}) == 1
assert calls["n"] == 1
finally:
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
# ==================== J8:交互输入容错 ====================
class TestJ8NumericTolerance:
def test_invalid_temperature_returns_friendly_error(self, tmp_path):
"""非数字温度 → 友好错误而非 ValueError(交互层不再抢先 float())"""
cm = _cfg(tmp_path, {"models": {}})
msgs = model_config.add_profile(
cm, "p1", "https://api.example.com/v1/chat/completions", "m1",
api_key_env="K", temperature="not-a-number")
assert any(m.startswith("❌") and "温度" in m for m in msgs)
assert "p1" not in model_config.get_models(cm)
def test_invalid_timeout_returns_friendly_error(self, tmp_path):
cm = _cfg(tmp_path, {"models": {}})
msgs = model_config.add_profile(
cm, "p1", "https://api.example.com/v1/chat/completions", "m1",
api_key_env="K", timeout="abc")
assert any(m.startswith("❌") and "超时" in m for m in msgs)
assert "p1" not in model_config.get_models(cm)
def test_string_numbers_accepted_and_clamped(self, tmp_path):
"""数字字符串正常接受并钳制(交互层原始字符串直传语义)"""
cm = _cfg(tmp_path, {"models": {}})
msgs = model_config.add_profile(
cm, "p1", "https://api.example.com/v1/chat/completions", "m1",
api_key_env="K", temperature="0.7", timeout="120")
assert all(not m.startswith("❌") for m in msgs)
profile = model_config.get_models(cm)["p1"]
assert profile["temperature"] == 0.7
assert profile["timeout"] == 120
# ==================== J1 波次 2:任务系统投毒 ====================
def _insert_memo_raw(db_path, reminder_time="2026-08-18 00:00:00",
repeat_type="none", repeat_interval=0,
repeat_interval_unit="days", repeat_end_time=None):
"""直接 SQL 插入任务行——模拟校验落地前的存量脏数据(绕过 add_memo)"""
with sqlite3.connect(str(db_path)) as conn:
cur = conn.cursor()
cur.execute('''INSERT INTO memos
(title, content, created_at, reminder_time, tags, priority, created_by,
is_completed, source, repeat_type, repeat_interval, repeat_interval_unit,
repeat_end_time, is_immediate)
VALUES ('t', 'c', '2026-08-18 00:00:00', ?, '', 1, 'test',
0, 'test', ?, ?, ?, ?, 0)''',
(reminder_time, repeat_type, repeat_interval, repeat_interval_unit,
repeat_end_time))
return cur.lastrowid
class TestJ1AddMemoValidation:
def test_malformed_reminder_time_rejected(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="提醒时间格式非法"):
db.add_memo(title="t", reminder_time="不是时间")
with sqlite3.connect(str(tmp_path / "m.db")) as conn:
assert conn.execute("SELECT COUNT(*) FROM memos").fetchone()[0] == 0
def test_malformed_repeat_end_time_rejected(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="重复结束时间格式非法"):
db.add_memo(title="t", reminder_time="2026-08-18 00:00:00",
repeat_type="daily", repeat_end_time="不是时间")
def test_invalid_repeat_type_rejected(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="无效的重复类型"):
db.add_memo(title="t", reminder_time="2026-08-18 00:00:00",
repeat_type="every-second")
def test_non_numeric_repeat_interval_rejected(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="重复间隔必须是整数"):
db.add_memo(title="t", reminder_time="2026-08-18 00:00:00",
repeat_type="custom", repeat_interval="垃圾")
def test_custom_repeat_requires_positive_interval(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="repeat_interval"):
db.add_memo(title="t", reminder_time="2026-08-18 00:00:00",
repeat_type="custom", repeat_interval=0)
def test_numeric_string_interval_coerced(self, tmp_path):
"""数字字符串被强转接受(WS 传 JSON 数字/字符串均安全)"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="t", reminder_time="2026-08-18 00:00:00",
repeat_type="custom", repeat_interval="30",
repeat_interval_unit="minutes")
assert db.get_memo(mid)["repeat_interval"] == 30
def test_add_user_task_propagates_validation(self, tmp_path):
"""WS 路径(add_user_task 薄包装 add_memo)同样被校验拦截"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
with pytest.raises(ValueError, match="提醒时间格式非法"):
db.add_user_task(title="t", reminder_time="不是时间")
class TestJ1DirtyDataDefense:
def test_mark_as_triggered_completes_malformed_one_shot(self, tmp_path):
"""存量畸形 reminder_time 一次性任务:触发不再抛异常,正常完成"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = _insert_memo_raw(tmp_path / "m.db", reminder_time="不是时间")
assert db.mark_as_triggered(mid) is None
assert db.get_memo(mid)["is_completed"] == 1
def test_mark_as_triggered_handles_garbage_repeat_interval(self, tmp_path):
"""存量 custom 任务 repeat_interval 为文本:按非重复处理,不打崩事务"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = _insert_memo_raw(tmp_path / "m.db", reminder_time="2026-08-18 00:00:00",
repeat_type="custom", repeat_interval="垃圾")
assert db.mark_as_triggered(mid) is None
assert db.get_memo(mid)["is_completed"] == 1
def test_malformed_repeat_end_time_treated_as_no_end(self, tmp_path):
"""存量畸形 repeat_end_time:解析失败视为无结束时间,不抛异常"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = _insert_memo_raw(tmp_path / "m.db", reminder_time="2026-08-19 00:00:00",
repeat_type="daily", repeat_end_time="不是时间")
memo = db.get_memo(mid)
from datetime import datetime
base = datetime.strptime("2026-08-19 00:00:00", "%Y-%m-%d %H:%M:%S")
assert db._should_end_repeat(memo, base) is False
assert db._calculate_next_reminder(memo, base) is not None
def test_pending_tasks_for_user_malformed_time_overdue(self, tmp_path):
"""展示路径:畸形 reminder_time 行按到期展示,不再打崩握手"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = _insert_memo_raw(tmp_path / "m.db", reminder_time="不是时间")
tasks = db.get_pending_tasks_for_user()
row = next(t for t in tasks if t["id"] == mid)
assert row["is_overdue"] is True
class TestJ1WsAndScheduler:
def test_ws_task_add_rejects_malformed_repeat_end(self, tmp_path):
"""WS task add 传畸形 repeat_end_time:友好错误且不入库"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
ai_manager = mock.MagicMock()
ai_manager.memo_db = db
handler = aibox.WebSocketHandler(ai_manager)
try:
ws = mock.AsyncMock()
asyncio.run(handler.handle_task_message(ws, {
"type": "task", "action": "add", "title": "t", "delay": 5,
"repeat_type": "daily", "repeat_end_time": "不是时间"}))
sent = json.loads(ws.send.call_args[0][0])
assert sent["type"] == "error"
assert "任务添加失败" in sent["content"]
with sqlite3.connect(str(tmp_path / "m.db")) as conn:
assert conn.execute("SELECT COUNT(*) FROM memos").fetchone()[0] == 0
finally:
handler.shutdown()
def test_scheduler_precheck_malformed_time_executes_not_loops(self, tmp_path):
"""调度器预检:畸形 reminder_time 按到期执行并标记触发(不再热循环)"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = _insert_memo_raw(tmp_path / "m.db", reminder_time="不是时间")
loop = asyncio.new_event_loop()
executed = {"n": 0}
try:
scheduler = aibox.TaskScheduler(loop=loop, logger=None)
scheduler.broadcast_enabled = False
async def fake_execute(memo, memo_db):
executed["n"] += 1
return "ok"
scheduler.execute_task_as_conversation = fake_execute
loop.run_until_complete(scheduler.check_and_execute_tasks(db))
finally:
loop.close()
assert executed["n"] == 1
assert db.get_memo(mid)["is_completed"] == 1
# ==================== J5 波次 4:原子写 + 损坏备份 ====================
class TestJ5AtomicWrites:
def test_save_config_atomic_no_tmp_leftover(self, tmp_path):
"""_save_config 原子写:无 .aibox_tmp_ 残留且 JSON 合法"""
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
cm._config = {"api": {"deepseek_model": "m"}, "models": {}}
assert cm._save_config() is True
import json as _json
data = _json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert data["api"]["deepseek_model"] == "m"
assert list(tmp_path.glob(".aibox_tmp_*")) == []
def test_corrupted_config_backed_up_before_fallback(self, tmp_path):
"""损坏配置加载:备份 .corrupt.* 文件后回退默认,原内容不丢"""
cfg_file = tmp_path / "config.json"
cfg_file.write_text('{"api": {"broken": ', encoding="utf-8") # 半截 JSON
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
try:
with mock.patch.dict(os.environ, {"AI_CONFIG_PATH": str(cfg_file)}):
cm = ConfigManager()
assert cm._config is not None
backups = list(tmp_path.glob("config.json.corrupt.*"))
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == '{"api": {"broken": '
finally:
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
def test_prompt_write_atomic(self, tmp_path):
"""prompt 更新原子写:内容生效且无临时文件残留"""
from subsystems.prompt_manager import PromptManager
PromptManager._instance = None
PromptManager._prompts = []
PromptManager._prompts_dir = None
prompts_dir = tmp_path / "prompts"
prompts_dir.mkdir()
try:
with mock.patch.object(ConfigManager, "get",
return_value=str(prompts_dir)):
pm = PromptManager()
assert pm.update_identity("新身份内容") is True
identity = prompts_dir / "00_IDENTITY.md"
assert "新身份内容" in identity.read_text(encoding="utf-8")
assert list(tmp_path.glob(".aibox_tmp_*")) == []
finally:
PromptManager._instance = None
PromptManager._prompts = []
PromptManager._prompts_dir = None
# ==================== J7 波次 4:broadcast 超时回收 ====================
class TestJ7BroadcastTimeout:
def test_stuck_client_timed_out_and_unregistered(self):
"""半开 TCP 僵死客户端:broadcast 在超时内返回并回收该连接"""
from aibox import WebSocketHandler
handler = WebSocketHandler(ai_manager=mock.MagicMock())
handler.broadcast_send_timeout = 0.1
try:
ws = mock.AsyncMock()
async def stuck_send(*args, **kwargs):
await asyncio.sleep(30)
ws.send.side_effect = stuck_send
handler.connected_clients.add(ws)
asyncio.run(handler.broadcast({"type": "ping"}))
assert ws not in handler.connected_clients
finally:
handler.shutdown()
def test_healthy_client_stays_connected(self):
"""正常客户端:发送成功且不被回收"""
from aibox import WebSocketHandler
handler = WebSocketHandler(ai_manager=mock.MagicMock())
handler.broadcast_send_timeout = 1.0
try:
ws = mock.AsyncMock()
handler.connected_clients.add(ws)
asyncio.run(handler.broadcast({"type": "ping"}))
assert ws in handler.connected_clients
assert ws.send.called
finally:
handler.shutdown()
# ==================== J9 波次 5:MemoDatabase 连接层 ====================
class TestJ9MemoDbConnect:
def test_wal_enabled_after_init(self, tmp_path):
"""WAL 显式设置——不再依赖与 MemoryDatabase 的创建顺序"""
MemoDatabase(db_path=str(tmp_path / "m.db"))
with sqlite3.connect(str(tmp_path / "m.db")) as conn:
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == "wal"
def test_connection_factory_sets_busy_timeout(self, tmp_path):
"""统一连接工厂:busy_timeout 覆盖全部写路径"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
conn = db._connect()
try:
assert conn.execute("PRAGMA busy_timeout").fetchone()[0] == 10000
finally:
conn.close()
# ==================== J10 波次 5:memo 批量删除 ====================
class TestJ10BatchDelete:
def test_batch_delete_via_memo_tool(self, tmp_path):
"""schema 声明的 memo_ids 批量删除真正生效(此前静默忽略)"""
from tools.db_tools import MemoTool
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
ids = [db.add_memo(title=f"任务{i}",
reminder_time="2026-08-18 00:00:00") for i in range(3)]
tool = MemoTool(db)
r = tool.execute(action="delete", confirm=True, memo_ids=ids)
assert "3/3" in r
with sqlite3.connect(str(tmp_path / "m.db")) as conn:
assert conn.execute("SELECT COUNT(*) FROM memos").fetchone()[0] == 0
def test_batch_delete_requires_list(self, tmp_path):
from tools.db_tools import MemoTool
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
tool = MemoTool(db)
r = tool.execute(action="delete", confirm=True, memo_ids="1")
assert "列表" in r
# ==================== J15 波次 5:未知 task action ====================
class TestJ15UnknownTaskAction:
def test_unknown_action_returns_error(self):
"""未知 task action 明确报错(此前静默吞掉)"""
from aibox import WebSocketHandler
handler = WebSocketHandler(ai_manager=mock.MagicMock())
try:
ws = mock.AsyncMock()
asyncio.run(handler.handle_task_message(ws, {"action": "explode"}))
sent = json.loads(ws.send.call_args[0][0])
assert sent["type"] == "error"
assert "未知的任务操作" in sent["content"]
finally:
handler.shutdown()
# ==================== J16 波次 5:skill_manager ====================
class TestJ16SkillManager:
def test_markdown_skipped_when_executable_same_name(self, tmp_path, monkeypatch):
"""同名键冲突:可执行技能确定性优先(此前由 listdir 顺序决定)"""
from common import constants
from subsystems.skill_manager import SkillManager
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
exe = skills_dir / "foo"
exe.write_text("#!/bin/sh\necho '可执行技能简介'", encoding="utf-8")
exe.chmod(0o755)
(skills_dir / "foo.md").write_text("# Markdown 简介\n内容", encoding="utf-8")
monkeypatch.setattr(type(constants), "SKILLS_DIR",
property(lambda self: str(skills_dir)))
sm = SkillManager()
assert "foo" in sm.skills
assert sm.skills["foo"]["is_executable"] is True
def test_help_output_truncated(self, tmp_path, monkeypatch):
"""--help 输出截断(内容进系统提示词,防撑爆上下文)"""
from common import constants
from subsystems.skill_manager import SkillManager
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
exe = skills_dir / "big"
exe.write_text("#!/bin/sh\necho '" + "x" * 5000 + "'", encoding="utf-8")
exe.chmod(0o755)
monkeypatch.setattr(type(constants), "SKILLS_DIR",
property(lambda self: str(skills_dir)))
sm = SkillManager()
assert len(sm.skills["big"]["content"]) <= SkillManager.MAX_HELP_LENGTH + 20
+3 -1
View File
@@ -192,7 +192,9 @@ class TestTestProfile:
class TestSaveFailure: class TestSaveFailure:
def test_add_reports_save_failure(self, tmp_path): def test_add_reports_save_failure(self, tmp_path):
cm = _cfg(tmp_path, {}) cm = _cfg(tmp_path, {})
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")): # J5:写路径改为原子写,磁盘失败经原子写函数模拟
with mock.patch("common.config.atomic_write_text",
side_effect=PermissionError("disk ro")):
msgs = model_config.add_profile( msgs = model_config.add_profile(
cm, "a", "https://x/v1", "m1", api_key_env="K") cm, "a", "https://x/v1", "m1", api_key_env="K")
assert any("保存失败" in m for m in msgs) assert any("保存失败" in m for m in msgs)
+178
View File
@@ -0,0 +1,178 @@
"""run_full_subagent 最大轮次(max_turns)行为回归测试(2026-08-19)
覆盖 aibox.py run_full_subagent 的轮次限制链路(think_with_limit 补丁):
- 超限拦截:任务轮 LLM 调用被限制在 max_turns 次,超限递归返回哨兵串
- 哨兵串文本契约("已达到最大对话轮次,任务结束。")
- 收尾报告:超限/正常结束均由 original_think 绕过轮次限制生成,作为返回值
- 入参钳制:max_turns 为 0/None 回退默认 10,负数钳制下限 1
- 超限不置 conversation_active=False(收尾保留完整上下文,aibox.py 763-765 注释)
- 收尾失败兜底链:最后一条 assistant 消息 → "子代理未产生有效对话内容。"
- SubAgentTool.execute 的 max_turns 参数透传(默认 10)
"""
import sys
import threading
import types
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
from aibox import SubAgentTool, run_full_subagent # noqa: E402
# 与 aibox.py think_with_limit 超限返回值一致的哨兵串(文本契约)
SENTINEL = "已达到最大对话轮次,任务结束。"
WRAP_UP_MARKER = "【收尾】"
def _make_fake_chat(task_rounds=999, final_answer=None, wrap_report="收尾报告"):
"""模拟 DeepSeekChat:think_and_respond 的"原始实现"。
每次任务轮调用递归一次(模拟 aibox.py:1801 工具调用后递归继续,
经由补丁后的 think_and_respond 走轮次计数);累计 task_rounds 轮后
若给出 final_answer 则正常结束(模拟 AI 返回纯文本)。task_rounds
设为大数且不给 final_answer 时永不自行收敛——只有轮次限制能终止它。
检测到收尾 prompt(含【收尾】标记)时返回 wrap_report。
"""
fake = mock.MagicMock()
state = {"n": 0, "task_inputs": [], "recurse_returns": []}
def original_think(input_text, output_callback=None, is_continuation=False):
if WRAP_UP_MARKER in input_text:
return wrap_report
state["task_inputs"].append(input_text)
state["n"] += 1
if final_answer is not None and state["n"] >= task_rounds:
return final_answer
ret = fake.think_and_respond("请基于以上工具执行结果继续回答。",
output_callback, is_continuation=True)
state["recurse_returns"].append(ret)
return ret
fake.think_and_respond = original_think
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
fake.conversation_active = True
fake._state = state
return fake
def _run(fake, **kwargs):
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
return run_full_subagent("角色", "任务", None, **kwargs)
class TestMaxTurnsCutoff:
def test_task_turns_capped_at_max_turns(self):
"""永不收敛的任务:任务轮 LLM 调用恰为 max_turns 次,第 max_turns+1 次被拦截"""
fake = _make_fake_chat(task_rounds=999)
result = _run(fake, max_turns=3)
assert len(fake._state["task_inputs"]) == 3
assert result == "收尾报告"
def test_overrun_recursion_returns_sentinel(self):
"""超限那轮递归返回哨兵串(不调 LLM),并沿递归链上抛"""
fake = _make_fake_chat(task_rounds=999)
_run(fake, max_turns=3)
assert fake._state["recurse_returns"]
assert all(r == SENTINEL for r in fake._state["recurse_returns"])
def test_wrap_up_runs_after_overrun(self):
"""超限后收尾报告仍被请求(original_think 绕过轮次限制),且为最终返回值"""
fake = _make_fake_chat(task_rounds=999, wrap_report="最终报告")
result = _run(fake, max_turns=2)
assert result == "最终报告"
def test_max_turns_zero_clamped_to_default(self):
"""max_turns=0 → 回退默认 10(对齐 max_tokens 的 0/空回退惯例)"""
fake = _make_fake_chat(task_rounds=999)
_run(fake, max_turns=0)
assert len(fake._state["task_inputs"]) == 10
def test_negative_max_turns_clamped_to_one(self):
"""max_turns 为负 → 钳制下限 1(至少执行一个任务轮,不再首轮即拦截)"""
fake = _make_fake_chat(task_rounds=999)
_run(fake, max_turns=-3)
assert len(fake._state["task_inputs"]) == 1
def test_max_turns_none_falls_back_to_default(self):
"""max_turns=None → 默认 10"""
fake = _make_fake_chat(task_rounds=999)
_run(fake, max_turns=None)
assert len(fake._state["task_inputs"]) == 10
def test_overrun_does_not_deactivate_conversation(self):
"""超限不置 conversation_active=False(否则收尾前上下文被重置清空)"""
fake = _make_fake_chat(task_rounds=999)
_run(fake, max_turns=2)
assert fake.conversation_active is True
class TestNormalCompletion:
def test_result_is_wrap_up_report_not_final_answer(self):
"""正常收敛时返回值同样是收尾报告(最终回答不作为工具结果返回)"""
fake = _make_fake_chat(task_rounds=2, final_answer="最终答案")
result = _run(fake, max_turns=10)
assert len(fake._state["task_inputs"]) == 2
# 第一轮递归的返回值即第 2 轮的最终回答(无哨兵串、无超限拦截)
assert fake._state["recurse_returns"] == ["最终答案"]
assert result == "收尾报告"
def test_sentinel_never_appears_when_within_limit(self):
"""未达上限:任何递归都不返回哨兵串"""
fake = _make_fake_chat(task_rounds=3, final_answer="最终答案")
_run(fake, max_turns=10)
assert SENTINEL not in fake._state["recurse_returns"]
class TestWrapUpFallback:
def test_empty_wrap_up_falls_back_to_last_assistant_message(self):
"""收尾返回空 → 兜底取最后一条有内容的 assistant 消息"""
fake = _make_fake_chat(task_rounds=1, final_answer="x", wrap_report="")
fake.messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "中间回答"},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "最终回答"},
]
assert _run(fake, max_turns=10) == "【子代理最终回答】\n最终回答"
def test_no_wrap_up_and_no_messages(self):
"""收尾为空且无任何 assistant 消息 → 固定兜底文案"""
fake = _make_fake_chat(task_rounds=1, final_answer="x", wrap_report="")
assert _run(fake, max_turns=10) == "子代理未产生有效对话内容。"
def test_wrap_up_exception_falls_back(self):
"""收尾调用抛异常 → 不中断,走 assistant 消息兜底"""
fake = _make_fake_chat(task_rounds=1, final_answer="x")
original = fake.think_and_respond
def boom_on_wrap_up(input_text, output_callback=None, is_continuation=False):
if WRAP_UP_MARKER in input_text:
raise RuntimeError("wrap-up boom")
return original(input_text, output_callback, is_continuation)
fake.think_and_respond = boom_on_wrap_up
fake.messages = [{"role": "assistant", "content": "最终回答"}]
assert _run(fake, max_turns=10) == "【子代理最终回答】\n最终回答"
class TestSubAgentToolMaxTurns:
def test_max_turns_passed_through(self):
"""SubAgentTool.execute 的 max_turns 透传给 run_full_subagent"""
tool = SubAgentTool(logger=None)
with mock.patch("aibox.run_full_subagent", return_value="ok") as m:
tool.execute(role="r", task="t", max_turns=4)
assert m.call_args.kwargs["max_turns"] == 4
def test_max_turns_default_is_10(self):
tool = SubAgentTool(logger=None)
with mock.patch("aibox.run_full_subagent", return_value="ok") as m:
tool.execute(role="r", task="t")
assert m.call_args.kwargs["max_turns"] == 10
+11 -2
View File
@@ -745,11 +745,20 @@ class MemoTool(Tool):
return f"❌ 无法完成任务 #{memo_id}" return f"❌ 无法完成任务 #{memo_id}"
def _delete_memo(self, kwargs: Dict) -> str: def _delete_memo(self, kwargs: Dict) -> str:
"""删除任务""" """删除任务(单条 memo_id 或批量 memo_ids)"""
confirm = kwargs.get("confirm", False) confirm = kwargs.get("confirm", False)
if not confirm: if not confirm:
return "❌ 需要设置 confirm=True 来确认删除操作" return "❌ 需要设置 confirm=True 来确认删除操作"
memo_ids = kwargs.get("memo_ids")
if memo_ids:
# J10(审计 2026-08-19):schema 声明了 memo_ids 参数却从未实现,
# 批量请求此前被静默忽略
if not isinstance(memo_ids, (list, tuple)):
return "❌ memo_ids 必须是列表"
deleted = sum(1 for mid in memo_ids if self.memo_db.delete_memo(mid))
return self.format_result(True, f"已删除 {deleted}/{len(memo_ids)} 条任务")
memo_id = kwargs.get("memo_id") memo_id = kwargs.get("memo_id")
if memo_id: if memo_id:
# M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除, # M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
@@ -759,7 +768,7 @@ class MemoTool(Tool):
else: else:
return f"❌ 无法删除任务 #{memo_id}" return f"❌ 无法删除任务 #{memo_id}"
else: else:
return "❌ 需要提供 memo_id" return "❌ 需要提供 memo_id 或 memo_ids"
def _search_memos(self, kwargs: Dict) -> str: def _search_memos(self, kwargs: Dict) -> str:
"""搜索任务""" """搜索任务"""
+14 -2
View File
@@ -181,6 +181,9 @@ class WebSocketClient:
self.connected = False self.connected = False
print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}") print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}")
except Exception as e: except Exception as e:
# J14:异常退出同样标记断开(此前 connected 恒 True,用户继续
# "成功"发送但永远收不到回复,成僵尸连接)
self.connected = False
print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}") print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}")
async def handle_message(self, data: dict, tts: TextToSpeech): async def handle_message(self, data: dict, tts: TextToSpeech):
@@ -266,12 +269,13 @@ class WebSocketClient:
tts: TextToSpeech = None, task_info: dict = None): tts: TextToSpeech = None, task_info: dict = None):
if msg_type == 'chat': if msg_type == 'chat':
stream = self.streaming_messages['chat'] stream = self.streaming_messages['chat']
stream.add_chunk(content) # J14(审计 2026-08-19):前缀判断必须在 add_chunk 之前——
# add_chunk 会置 is_active=True,原顺序使 "AI助手:" 前缀永不打印
if not stream.is_active: if not stream.is_active:
print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True) print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True)
stream.is_active = True stream.is_active = True
stream.add_chunk(content)
print(content, end='', flush=True) print(content, end='', flush=True)
elif msg_type == 'task' and task_info: elif msg_type == 'task' and task_info:
@@ -428,6 +432,7 @@ class VoiceInterface:
return None return None
print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}") print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}")
temp_file = None # J14:录音临时文件,任何异常路径统一清理
try: try:
if self.use_arecord: if self.use_arecord:
@@ -506,6 +511,13 @@ class VoiceInterface:
return None return None
except Exception as e: except Exception as e:
print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}") print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}")
# J14:录音临时文件(含用户语音)任何异常路径都清理——
# 此前 wave.open/whisper 解析失败、arecord 超时等滞留 /tmp
if temp_file and os.path.exists(temp_file):
try:
os.unlink(temp_file)
except OSError:
pass
return None return None
async def handle_command(self, command: str): async def handle_command(self, command: str):
+170 -2
View File
@@ -1,7 +1,7 @@
# aibox 模块安全与质量审计报告 # aibox 模块安全与质量审计报告
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复) - **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复)/ 2026-08-19(第五轮:4 路并行审读,聚焦历届未覆盖区域)
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后) - **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后)→ 第五轮 HEAD `d513c5f`(Batch I + model_config.py 后,含工作区未提交的 max_turns 钳制)
- **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件) - **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6) - **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6)
@@ -435,3 +435,171 @@ N9 ×5(manager 兜底/身份/灵魂/状态超长拒绝 + 正常写入)、Q7b
### 12.4 测试基线(Linux,`aibox/venv`) ### 12.4 测试基线(Linux,`aibox/venv`)
全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。 全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。
---
## 十三、第五轮审计(2026-08-19,HEAD `d513c5f`)
方法:4 路并行审读(① aibox.py 的 WS/调度器 async 区域 ② model_config.py + client.py + voice_client.py ③ subsystems/ + tools/ 回归与残留面 ④ 前端 HTML + skills 脚本)+ 主审逐条复核关键候选 + Linux PoC 实证(2/2)。审计焦点为历届未覆盖区域:`WebSocketHandler`(前四轮仅点修、从未逐行深审)、`model_config.py`(新文件)、前端与技能脚本(首次覆盖)。
### 13.1 新发现——高危(2 项)
#### J1:畸形时间/数字字段打穿任务系统——调度器失败热循环 + 每个新连接握手即崩(系统性)
三个入库路径对时间与数字字段**零校验**:CLI `--memo-add`(`aibox.py:3157-3181`,reminder 段原样入库)、WS `task add`(`aibox.py:2366-2394`,`repeat_interval`/`repeat_end_time` 原样入库,未认证单条消息即可复现)、memo 工具(`tools/db_tools.py:518`)。而 I 批 Q5 修复让格式异常的 `reminder_time` 按"到期"返回(`db.py:447-452`),使这类行进入下游多个**无防护解析点**:
| 解析点 | 后果 |
|---|---|
| 调度器执行前验证 `datetime.strptime`(`aibox.py:186`) | ValueError 在 `mark_as_triggered` 之前抛出 → 任务既不触发也不完成,每个 `check_interval`(默认 1s)重复 pick→异常→向全体客户端广播 failed,**永久热循环** |
| `repeat_end_time` strptime(`db.py:611/657`) | `mark_as_triggered` 事务 rollback+raise(`db.py:602-604`)→ 未标记触发 → 同一热循环 |
| `repeat_interval` 比较(`db.py:641`) | WS 传 `null` → None `> 0` 抛 TypeError → 同上 |
| `get_pending_tasks_for_user` strptime(`db.py:767`) | 该方法经 `send_welcome_info` 在 `register` 内调用(`aibox.py:2165→2237`),而 `register` 位于 `handle_connection` 的 try 块**之外**(`aibox.py:2944`)→ **每个新连接在握手阶段即抛 ValueError**,且 finally 不执行,`connected_clients`/`client_sessions` 登记永久泄漏 |
一条畸形任务行即可同时触发调度器热循环与全部新连接握手失败(服务事实不可用,需手动清库)。修复建议:① 入库端校验(`add_memo`/WS add 对时间串 strptime 预检、`repeat_interval` 强制 int 化,非法即拒绝并报错);② 下游解析点 try/except 兜底(调度器验证失败 → 强制完成或挂起该任务并告警,`db.py:767` 解析失败按 `is_overdue=True`);③ `register` 调用移入 try/finally。
#### J2:前端 XSS——AI 输出与命令执行结果全链路 innerHTML 渲染(提示注入 → 浏览器代码执行)
三个前端页面均用 marked 9.1.6(该版本起**移除内置 sanitize**)且全程无 DOMPurify:
- `frontend.html:1051/1339`、`entry.html:1127/1422`、`entry1.html:548/593/616`:`textDiv.innerHTML = marked.parse(content)` 直渲 AI 流式输出(chunk/task_process)
- `entry1.html:536`:`addSystemMessage` 将 **command_result(任意命令执行结果)**/task_status/error 模板拼接进 innerHTML,连 markdown 开关都绕不过
- frontend/entry 的未知消息类型兜底分支同样送入该渲染(`frontend.html:1493`、`entry.html:1583`)
攻击链成立:被网页内容/用户消息诱导的 AI 输出(或命令输出)含 `<img src=x onerror=...>` 即在前端执行脚本,可窃取会话、代发指令。修复:marked 输出统一经 DOMPurify(或本地化 marked + 禁用原始 HTML),`addSystemMessage` 改 textContent/转义。
### 13.2 新发现——中危(5 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| J3 | FTS 外部内容表删除顺序错误,索引条目永久残留(**PoC 实证**) | `db.py:144-148`、`:277-281` | `delete_memory` 先 `DELETE FROM memories` 再删 `memories_fts`——外部内容表(content=memories)删除需从内容表回读旧值才能摘除索引项,内容行先删导致摘除无效。PoC(SQLite 3.46):现序删除后 MATCH 仍命中已删 rowid;反序则干净。后果:索引随删除次数膨胀。搜索经 inner JOIN(`db.py:188`)不返回脏结果,故影响限于膨胀/卫生。修复:调换两条 DELETE 顺序 |
| J4 | model_config 连通性验证异常回显响应体,Bearer 令牌可进终端 | `model_config.py:197-198` | `test_profile` 直接 `str(e)` 回显;429/5xx 的 `TransientLLMError` 消息含 `response.text[:200]`,网关/WAF 错误页可能回显 Authorization 头(`llm_client.py:29-31` 注释自认);`_sanitize_log_msg` 只护 logger 路径不护异常文本。修复:异常消息复用脱敏函数 |
| J5 | 配置文件非原子写 + 损坏静默回退默认 → 整文件洗掉链 | `common/config.py:300-301`、`prompt_manager.py:175/198/221` | `open('w')+json.dump` 直写,崩溃留半截 JSON;`_load_config` 解析失败静默改用全默认(`config.py:184-186`),此时任一 save 用"默认+本次改动"整体覆盖——proxy/api/context 等所有手工配置丢失。Q8 原子写只覆盖了 conversation_history。修复:tmp+os.replace 推广至 config/prompt 写路径,损坏时备份并拒绝覆盖 |
| J6 | PromptManager/ConfigManager 单例初始化竞态 → 系统提示词翻倍 | `prompt_manager.py:62-64`、`common/config.py:22-29` | `__new__` 有锁但 `__init__` 的 `if not self._prompts: self._load_prompts()` 无锁,`_prompts` 为类属性——多线程首例并发双重加载,整份系统提示词翻倍(token 浪费 + 行为异常);ConfigManager 单例完全无锁同型。修复:初始化纳入同一把锁 |
| J7 | broadcast 串行 await + send_message 吞异常 → 僵死客户端可卡死全局广播 | `aibox.py:2274-2281`、`:2269-2272` | `send_message` 吞掉全部异常,broadcast 的 except→unregister 为死代码(发送失败永不回收);`ping_interval=None`(`:3000`,有意禁用)下半开 TCP 无法探测,backpressure 下 `websocket.send` 无超时可无限期阻塞,卡住广播与调度器循环。修复:每发送 `asyncio.wait_for` 超时,超时即 unregister |
### 13.3 新发现——低危与信息级
| # | 级别 | 问题 | 位置 |
|---|---|---|---|
| J8 | 低 | model_config 交互输入 `float()`/`int()` 在容错逻辑之前执行,非数字输入 traceback 退出 | `model_config.py:330-331` |
| J9 | 低 | MemoDatabase 初始化不设 WAL(依赖与 MemoryDatabase 的创建顺序);`busy_timeout=10000` 仅 `mark_as_triggered` 一条路径,add/complete/delete 仍默认 5s | `db.py:303-343, :542` |
| J10 | 低 | memo 批量删除未实现:schema 声明 `memo_ids`,实现只处理 `memo_id`,批量请求静默忽略 | `db_tools.py:375-379, :753-762` |
| J11 | 低 | `/new` 取消-重置竞态:cancel 后仅轮询 2s `is_busy()`,新回合与 reset 无序抢 `_msg_lock`(RLock 无公平性),消息可能"消失" | `aibox.py:2466-2484` |
| J12 | 低 | skills 注入面:nmapskills.py 的 `-n` 参数直入 python-nmap(内部拼 shell);screenshot.py 的 `--filename/--output-dir` 拼进 PowerShell 脚本串(仅 Windows 分支);ssh-exec/sftp-transfer `AutoAddPolicy` + 密码经命令行参数(ps 可见) | `skills/` |
| J13 | 低 | CDN 脚本(marked/hljs/socket.io)无 SRI integrity 属性 | 三个前端页 |
| J14 | 信息 | voice_client:接收循环异常后成僵尸连接(无重连)、录音 tmp 文件(`delete=False`)异常路径滞留 /tmp、`add_chunk` 先置 active 致聊天前缀永不打印(死代码) | `voice_client.py:183, 440-484, 268-273` |
| J15 | 信息 | aibox.py:`full_response_queue` 收集后从未消费(`:289-296`);未知 task action 无 else 静默吞掉;批量/任务级广播门控不一致(禁用广播后任务级仍发);流式热路径每 chunk 两条 INFO 日志;once 模式 `except KeyboardInterrupt` 死代码(SIGINT handler 已替换) | `aibox.py` 多处 |
| J16 | 信息 | executors `timeout<=0` 完全禁用超时,registry 兜底超时只放弃等待不 kill → 孤儿进程;skill_manager 同名技能(foo 与 foo.md)键互覆盖、`--help` 输出无长度上限 | `executors.py:147`、`registry.py:124`、`skill_manager.py:134, :50-53` |
### 13.4 Batch E~I 修复回归检查(21 项全部在位)
N7 FTS 引号翻倍、L1 LIMIT 参数化、N10 BEGIN IMMEDIATE、N8 流式预算、M1 heredoc 定界符、read_file sed 强转、web scheme 白名单、_save_config 透传、compressor 除零、InstallSkill shlex.quote、ask_user required、Q5 异常按到期(引出 J1)、Q8 对话原子写、Q7b realpath、N9 万字符上限、get_recent_memories limit 上限、memo 物理删除、PromptManager.reload 原子替换、limit=0 语义、日志打码包含匹配、F5/F6/F9——均在位无回归。唯一"单例加锁"项部分在位(PromptManager `__new__` 有锁、`__init__` 无锁,见 J6)。
### 13.5 实证记录
```
[PASS] J3-a 现序删除(先 memories 后 fts):MATCH 仍命中已删 rowid → 索引残留确认
[PASS] J3-b 反序删除(先 fts 后 memories):MATCH 干净 → 修复方向确认
(J1 链路各环节经代码级核验:入库零校验 ×3 路径 + 下游无防护解析 ×4 点位)
```
### 13.6 修复建议(按优先级)
1. **K1(高,J1)**:任务字段入口校验(`add_memo` strptime 预检 + `repeat_interval` int 化,WS add/CLI/memo 工具三入口统一经 db 层拒绝非法值)+ 调度器预检与 `db.py:767` try/except 兜底 + `register` 移入 try/finally
2. **K2(高,J2)**:三个前端页接入 DOMPurify(本地化部署避免新增 CDN 依赖,顺带解 J13);entry1 `addSystemMessage` 改转义/textContent
3. **K3(中,J3/J5/J6)**:FTS 删除换序(两行改动);config/prompt 写路径推广 `_atomic_write_json` + 损坏备份拒绝覆盖;PromptManager/ConfigManager 初始化纳入锁
4. **K4(中,J4/J7)**:test_profile 异常消息脱敏;broadcast 每发送 wait_for 超时回收
5. **K5(低,择机)**:J8~J12、J14~J16 随批处理;skills 注入面(J12)建议文档化"技能为可信代码"边界并对 nmap/screenshot 参数加 shlex.quote
### 13.7 测试基线(Linux)
全量 **455 passed** = 424 + 子代理 max_turns 回归 14(本轮审计前落地的钳制修复)+ 期间环境修复。零回归;2 个预存 Windows 向失败同前。
---
## 十四、Batch K 波次 1 修复落地(2026-08-19,快赢批)
按 §13.6 波次 1 实施四项快赢修复(J3/J4/J6/J8)。
### 14.1 修复清单
| # | 修复内容 | 文件/位置 |
|---|---|---|
| J3 | FTS 外部内容表删除换序:先删 `memories_fts` 再删 `memories`(索引摘除需从内容表回读旧值,原顺序致索引条目永久残留)——单条与批量删除两处 | `subsystems/db.py:140-153, :277-289` |
| J4 | 连通性验证异常消息脱敏:`test_profile` 回显前经 `_sanitize_log_msg` 抹掉 Bearer 令牌(网关/WAF 错误页回显 Authorization 头不再进终端) | `model_config.py:197-200` |
| J6 | 单例初始化竞态:PromptManager `__init__` 的检查+加载收进 `_instance_lock`(此前 `__new__` 锁只防双实例,并发首例 `__init__` 双重加载致系统提示词整份翻倍);ConfigManager 补 `_instance_lock`,`__new__`/`__init__` 均加锁 | `subsystems/prompt_manager.py:62-68`、`common/config.py:22-37` |
| J8 | 交互输入容错:温度/超时原始字符串直传 `add_profile`(此前交互层抢先 `float()`/`int()`,非数字输入 traceback 退出,内部容错为死代码) | `model_config.py:328-336` |
### 14.2 新增测试(`tests/test_k_fixes.py` 10 例,全部通过)
J3 ×3(单条/批量删除无 FTS 残留 + 删后搜索不命中)、J4 ×2(令牌脱敏 + 普通错误不误伤)、J6 ×2(PromptManager/ConfigManager 8 线程 barrier 并发首例:单实例且仅加载一次)、J8 ×3(非法温度/超时友好报错 + 数字字符串接受并钳制)。
### 14.3 测试基线(Linux)
全量 **465 passed** = 455 + Batch K 波次 1 新增 10。零回归;2 个预存 Windows 向失败同前。
### 14.4 波次 2 落地(2026-08-19,K1 任务系统投毒)
按 §13.6 建议 ①②③ 实施,另顺带修复一个审计未覆盖的存量 bug。
| # | 修复内容 | 文件/位置 |
|---|---|---|
| ① 入库校验 | `add_memo` 开头校验并抛 ValueError:`reminder_time`/`repeat_end_time` strptime 预检、`repeat_interval` 强转 int(数字字符串接受)、`repeat_type` 白名单、custom 必须 interval>0——CLI/WS/memo 工具三入口(`add_user_task` 为薄包装)一处覆盖 | `subsystems/db.py:349-380` |
| ① WS 提示 | `handle_task_message` 的 add 分支捕获 ValueError → 发送"任务添加失败: …"友好错误(此前落入顶层笼统"处理失败") | `aibox.py:2392-2406` |
| ② 调度器预检 | `aibox.py:186` strptime 包 try/except:格式异常按到期执行(Q5 语义,走 mark_as_triggered 完成/续算),不再抛 ValueError 致任务永不触发、每秒重试热循环 | `aibox.py:184-199` |
| ② 触发事务防御 | `_should_end_repeat`/`_calculate_next_reminder` 的 `repeat_end_time` strptime 解析失败视为无结束时间;`custom` 分支 `repeat_interval` int() 失败按 0 处理(不再 TypeError 打崩 BEGIN IMMEDIATE 事务) | `subsystems/db.py:590-680` |
| ② 展示路径防御 | 新增 `_is_overdue_time` 静态方法,`get_pending_tasks_for_user` 解析失败按到期展示——不再抛 ValueError 打崩新连接握手(aibox.py:2944 register 的崩溃源之一) | `subsystems/db.py:760-780` |
| ③ register 移入 try | `handle_connection` 的 `register` 移入 try 块:握手期异常(展示路径崩溃等)不再绕过 finally,会话登记由 unregister(discard 幂等)正常回收 | `aibox.py:2941-2947` |
| **附带修复** | `add_user_task` 签名缺失 `repeat_end_time` 参数:WS task add 传该字段必 TypeError(落入顶层"处理失败",任务静默失败且用户困惑)——补参数转发 | `subsystems/db.py:738-760` |
### 14.5 波次 2 新增测试(`tests/test_k_fixes.py` 追加 13 例,文件共 23 例全过)
- add_memo 校验 ×7:畸形 reminder_time / repeat_end_time / repeat_type / repeat_interval / custom+0 拒绝且零行入库、数字字符串 "30" 强转接受、add_user_task 透传
- 存量脏数据防御 ×4:mark_as_triggered 完成畸形一次性任务 / 处理文本 repeat_interval / 畸形 repeat_end_time 视为无结束、展示路径 is_overdue=True
- WS + 调度器 ×2:畸形 repeat_end_time 友好错误不入库;调度器预检按到期执行并标记触发(stub execute_task_as_conversation)
- Q5 旧用例适配:`test_malformed_time_task_returned_as_due` 改用 SQL 直插构造存量脏数据(add_memo 已拒绝畸形时间)
### 14.6 测试基线(Linux)
全量 **478 passed** = 465 + 波次 2 新增 13。零回归;2 个预存 Windows 向失败同前。
### 14.7 波次 3 落地(2026-08-19,K2 前端 XSS + J13 SRI)
| # | 修复内容 | 文件/位置 |
|---|---|---|
| J2 渲染净化 | 三页面新增 `renderMarkdown()` helper:`DOMPurify.sanitize(marked.parse(content))`;DOMPurify 缺失时退化为转义文本(只显示不执行,防 XSS 兜底)——替换全部 7 处 `marked.parse` 直渲(AI 流式输出/历史重渲染/任务流输出) | `frontend.html`、`entry.html`、`entry1.html` |
| J2 系统消息 | `entry1.addSystemMessage` 由 innerHTML 模板拼接改为 DOM 构建 + textContent(command_result/error 等任意命令输出不再可注入 HTML) | `entry1.html:532-547` |
| J2 任务分隔条 | 任务标题(服务端可控)改 createTextNode 插入 | `entry1.html:573-584` |
| J13 SRI | 全部 CDN 脚本(marked 9.1.6 / hljs 11.8.0 / socket.io 4.5.0 / DOMPurify 3.2.4 新增)补 `integrity` + `crossorigin="anonymous"`,哈希由实际下载文件计算 | 三页面 `<head>` |
**验证记录**:HTML 结构解析全部 OK;`marked.parse` 仅存于 helper 内部;残留 innerHTML 均为静态字面量(空壳 div/复制按钮);SRI 哈希与实际 CDN 下载文件一致。**本机无浏览器/node 运行时,未做实机页面验证**——DOMPurify 行为为业界标准,建议部署后人工开一次页面确认渲染正常。
### 14.8 波次 4 落地(2026-08-19,J5 原子写推广 + J7 broadcast 超时)
| # | 修复内容 | 文件/位置 |
|---|---|---|
| J5 | 新增 `common/atomic.py`:`atomic_write_text`(mkstemp 随机名 + fsync + os.replace,失败清理临时文件);config 的 `_save_config`/`_save_default_config`、prompt_manager 三处写路径(identity/soul/status)、conversation_history 的 `_atomic_write_json` 全部接入(顺带修复旧固定 .tmp 名多进程互踩) | `common/atomic.py`(新)、`common/config.py`、`subsystems/prompt_manager.py`、`subsystems/conversation_history.py` |
| J5 | `_load_config` 损坏备份:JSON 解析失败先复制 `config.json.corrupt.<时间戳>` 再回退默认——杜绝"半截文件 → 静默默认 → 下次保存整体覆盖"链 | `common/config.py:190-200` |
| J7 | `broadcast` 改每客户端并发发送 + `asyncio.wait_for` 超时(`broadcast_send_timeout`,默认 5s,测试可调),超时即 `unregister` 回收——此前 `send_message` 吞异常使 except→unregister 为死代码,半开 TCP(ping 禁用)卡死广播与调度器循环 | `aibox.py:2276-2302` |
**测试**:`tests/test_k_fixes.py` 追加 5 例(原子写无残留 / 损坏备份 / prompt 原子写 / 僵死客户端超时回收 / 正常客户端不误伤),文件共 28 例全过。3 个既有保存失败用例(L2/test_model_config)改用 patch 原子写函数模拟磁盘失败(写路径不再经过 builtins.open)。
全量 **483 passed** = 478 + 波次 4 新增 5。零回归;2 个预存 Windows 向失败同前。
### 14.9 波次 5 落地(2026-08-19,K5 低危随批)
| # | 修复内容 | 文件/位置 |
|---|---|---|
| J9 | MemoDatabase 新增 `_connect()` 统一连接工厂(busy_timeout=10000 覆盖全部写路径,仅剩 mark_as_triggered 一条路径的旧状消除);`_init_database` 显式 `PRAGMA journal_mode=WAL`(不再依赖与 MemoryDatabase 的创建顺序) | `subsystems/db.py` |
| J10 | memo 批量删除补实现:`_delete_memo` 支持 `memo_ids` 列表(schema 早已声明却从未实现,批量请求此前静默忽略),返回删除计数 | `tools/db_tools.py:747-770` |
| J11 | `/new` 取消-重置竞态:排队 reset 前等待该连接 `is_processing` 清零(≤2s)——避免新回合在旧上下文执行后被 reset 抹掉(消息"消失") | `aibox.py:2510-2518` |
| J12 | skills 注入面:nmapskills 的 hosts 参数 `shlex.quote`(python-nmap 内部拼 shell);screenshot.py PowerShell 分支路径单引号转义(`'` → `''`);SKILLS_FORMAT.md 新增"安全边界"节(技能即可信代码:参数引号转义、凭据禁入命令行、AutoAddPolicy MITM 风险) | `skills/nmapskills.py`、`skills/screenshot.py`、`SKILLS_FORMAT.md` |
| J14 | voice_client:`add_chunk` 死代码修复(前缀判断前移,此前"AI助手:"前缀永不打印);录音临时文件任何异常路径清理(wave.open/whisper 失败、arecord 超时滞留含语音 wav);接收循环异常退出标记断开(此前成僵尸连接) | `voice_client.py` |
| J15 | `full_response_queue` 死代码移除(收集后从未消费);未知 task action 明确报错(此前静默吞掉);任务级广播门控对齐 `broadcast_enabled`(6 处);流式热路径每 chunk INFO 日志降为 debug;once 模式死代码 `except KeyboardInterrupt` 移除(SIGINT 由自定义 handler 接管) | `aibox.py` 多处 |
| J16 | `SkillManager.MAX_HELP_LENGTH=2000`(--help 输出进系统提示词,防撑爆上下文);同名键冲突确定性解决(可执行优先,Markdown 跳过并告警,此前由 listdir 顺序决定);executors `timeout<=0` 语义注释(有意禁用,registry 3600s 兜底仍在) | `subsystems/skill_manager.py`、`subsystems/executors.py` |
**测试**:`tests/test_k_fixes.py` 追加 7 例(WAL/busy_timeout、批量删除 ×2、未知 action、同名键 ×1、--help 截断),文件共 35 例全过。
全量 **490 passed** = 483 + 波次 5 新增 7。零回归;2 个预存 Windows 向失败同前。
**第五轮审计(Batch K)至此全部落地**:J1~J16 高危/中危/低危全部修复(J14 的 voice_client 重连设计、J15 的 once 模式 SIGINT 二次 Ctrl+C 升级退出等未采纳项已在报告记录)。
+2 -2
View File
@@ -416,9 +416,9 @@ function renderMarkdown(element, content) {
html: false, html: false,
highlight: function(code, lang) { highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
return `<pre><code class="hljs language-${lang}"><div style="font-size: 11px;">${lang}</div>${hljs.highlight(code, {language: lang}).value}</code></pre>` return `<pre><code class="hljs language-${lang}">${hljs.highlight(code, {language: lang}).value}</code></pre>`
} else { } else {
return `<pre><code class="hljs"><div style="font-size: 11px;">${lang}</div>\n${md.utils.escapeHtml(code)}</code></pre>` return `<pre><code class="hljs">${md.utils.escapeHtml(code)}</code></pre>`
} }
} }
}); });
+2 -1
View File
@@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
exec ./build/memdb --instance "${INSTANCE:-memdb}" --log-level "${LOG_LEVEL:-info}" "$@" # 配置全部经 CLI 传递(core/services.yaml 的 args 声明),与 db2/ca/captcha2 的 _run.sh 惯例一致
exec ./build/memdb "$@"