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 截断
This commit is contained in:
qing committed 2026-08-19 07:01:13 +08:00
1 parent f4fcf175ec
commit 457bb900fb
22 files changed
+1147 -126

No files matched your search

+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/`)已按此规范处理;新增技能请遵守同一条例
+86 -36
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'):
reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) try:
if reminder_time > now: reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT)
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,13 +2275,30 @@ 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):
"""广播任务状态""" """广播任务状态"""
@@ -2379,16 +2402,26 @@ 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)
memo_id = self.ai_manager.memo_db.add_user_task( # J1(审计 2026-08-19):add_memo 入库校验抛 ValueError(畸形时间/
title=title, # 重复间隔等),此处转为友好错误消息,不让 handle_message 顶层
content=content, # 兜底以笼统的"处理失败"收场
reminder_time=reminder_time, try:
repeat_type=repeat_type, memo_id = self.ai_manager.memo_db.add_user_task(
repeat_interval=repeat_interval, title=title,
repeat_interval_unit=repeat_interval_unit, content=content,
repeat_end_time=repeat_end_time, reminder_time=reminder_time,
is_immediate=is_immediate repeat_type=repeat_type,
) repeat_interval=repeat_interval,
repeat_interval_unit=repeat_interval_unit,
repeat_end_time=repeat_end_time,
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
+33 -10
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,15 +22,20 @@ 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):
if cls._instance is None: # J6(审计 2026-08-19):此前单例创建与初始化均无锁——多线程首例
cls._instance = super().__new__(cls) # 并发可双实例、双重加载配置
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
if self._config is None: with self._instance_lock:
self._load_config() if self._config is None:
self._load_config()
def _get_default_config_path(self) -> str: def _get_default_config_path(self) -> str:
env_config = os.environ.get("AI_CONFIG_PATH") env_config = os.environ.get("AI_CONFIG_PATH")
@@ -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):
"""保存历史索引""" """保存历史索引"""
+98 -33
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']:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) try:
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':
unit = memo.get('repeat_interval_unit', 'days') # J1:repeat_interval 类型异常(存量脏数据)按 0 处理,
if unit == 'minutes': # 不再抛 TypeError 打崩触发事务引发热循环
# 每N分钟重复:当前时间 + N分钟 try:
next_time = current_time + timedelta(minutes=memo['repeat_interval']) repeat_interval = int(memo.get('repeat_interval') or 0)
elif unit == 'hours': except (ValueError, TypeError):
# 每N小时重复:当前时间 + N小时 repeat_interval = 0
next_time = current_time + timedelta(hours=memo['repeat_interval']) if repeat_interval > 0:
else: # days unit = memo.get('repeat_interval_unit', 'days')
# 每N天重复:当前时间 + N天 if unit == 'minutes':
next_time = current_time + timedelta(days=memo['repeat_interval']) # 每N分钟重复:当前时间 + N分钟
next_time = current_time + timedelta(minutes=repeat_interval)
elif unit == 'hours':
# 每N小时重复:当前时间 + N小时
next_time = current_time + timedelta(hours=repeat_interval)
else: # days
# 每N天重复:当前时间 + N天
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']:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) try:
if next_time > end_time: end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
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')
@@ -732,8 +784,9 @@ 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
+13 -8
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,8 +61,12 @@ class PromptManager:
return cls._instance return cls._instance
def __init__(self): def __init__(self):
if not self._prompts: # J6(审计 2026-08-19):__new__ 的锁只防双实例,多线程首例并发
self._load_prompts() # 同时进入 __init__ 会使 _load_prompts 双重执行,_prompts(类属性)
# 整份翻倍——初始化检查与加载收进同一把锁
with self._instance_lock:
if not self._prompts:
self._load_prompts()
def _get_prompts_dir(self) -> str: def _get_prompts_dir(self) -> str:
config = ConfigManager() config = ConfigManager()
@@ -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:
"""搜索任务""" """搜索任务"""
+15 -3
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):