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:
1 parent
f4fcf175ec
commit
457bb900fb
22 files changed
+1147
-126
No files matched your search
@@ -227,3 +227,14 @@ except ImportError:
|
||||
```
|
||||
|
||||
**`--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
@@ -178,13 +178,20 @@ class TaskScheduler:
|
||||
for memo in pending:
|
||||
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']))
|
||||
|
||||
# 【关键修复】执行前最后一次验证
|
||||
# J1(审计 2026-08-19):格式异常按到期执行(Q5 语义),
|
||||
# 不再抛 ValueError 致任务既不触发也不完成、每秒重试热循环
|
||||
if memo.get('reminder_time'):
|
||||
reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT)
|
||||
if reminder_time > now:
|
||||
try:
|
||||
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}),跳过执行")
|
||||
continue
|
||||
|
||||
@@ -196,7 +203,7 @@ class TaskScheduler:
|
||||
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)
|
||||
|
||||
executed_tasks.append({
|
||||
@@ -211,7 +218,7 @@ class TaskScheduler:
|
||||
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))
|
||||
|
||||
executed_tasks.append({
|
||||
@@ -285,17 +292,11 @@ class TaskScheduler:
|
||||
# 获取当前事件循环
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# 创建一个队列来收集完整的响应
|
||||
full_response_queue = queue.Queue()
|
||||
|
||||
# 定义输出回调函数 - 直接广播,同时收集
|
||||
# 定义输出回调函数 - 直接广播
|
||||
def output_callback(msg_type, content):
|
||||
"""AI输出的回调函数 - 直接广播,同时收集"""
|
||||
# 收集完整响应
|
||||
if msg_type == "complete":
|
||||
full_response_queue.put(content)
|
||||
|
||||
# 直接广播给所有客户端 - 必须带上 task_id 和 execution_id
|
||||
"""AI输出的回调函数 - 直接广播"""
|
||||
# J15(审计 2026-08-19):full_response_queue 收集后从未被消费,
|
||||
# 属死代码,已移除;广播路径保留
|
||||
if self.ws_handler and self.broadcast_enabled:
|
||||
# 使用 run_coroutine_threadsafe 在正确的循环中运行异步函数
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
@@ -420,7 +421,7 @@ class TaskScheduler:
|
||||
executed_tasks = []
|
||||
for memo in immediate:
|
||||
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']))
|
||||
|
||||
self.logger.info(f"标记即时任务 #{memo['id']} 为已触发")
|
||||
@@ -428,7 +429,7 @@ class TaskScheduler:
|
||||
|
||||
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)
|
||||
|
||||
executed_tasks.append({
|
||||
@@ -442,7 +443,7 @@ class TaskScheduler:
|
||||
except Exception as 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))
|
||||
|
||||
executed_tasks.append({
|
||||
@@ -703,6 +704,9 @@ def run_full_subagent(role, task, config, logger=None, model=None,
|
||||
_watch_done = threading.Event() # N5:取消观察者线程退出信号(finally 置位)
|
||||
try:
|
||||
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
|
||||
|
||||
# 创建子代理:完整 DeepSeekChat 实例,role 作为 system_prompt
|
||||
@@ -2099,6 +2103,8 @@ class WebSocketHandler:
|
||||
# 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理)
|
||||
self._cmd_executor = ThreadPoolExecutor(max_workers=2,
|
||||
thread_name_prefix="ws-cmd")
|
||||
# J7(审计 2026-08-19):broadcast 单客户端发送超时(半开 TCP 防卡死)
|
||||
self.broadcast_send_timeout = 5.0
|
||||
|
||||
def shutdown(self):
|
||||
"""I 批:释放命令专用线程池(服务退出时调用)"""
|
||||
@@ -2269,13 +2275,30 @@ class WebSocketHandler:
|
||||
self.logger.error(f"发送消息失败: {e}")
|
||||
|
||||
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:
|
||||
await self.send_message(websocket, message)
|
||||
await asyncio.wait_for(
|
||||
self.send_message(websocket, message),
|
||||
timeout=self.broadcast_send_timeout)
|
||||
except Exception:
|
||||
# N13:不再裸 except——吞掉 CancelledError 会破坏任务取消语义
|
||||
# 超时/发送异常:回收该连接(取消事件被 wait_for 传播,
|
||||
# CancelledError 为 BaseException 不在此列,不影响任务取消语义)
|
||||
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):
|
||||
"""广播任务状态"""
|
||||
@@ -2379,16 +2402,26 @@ class WebSocketHandler:
|
||||
else:
|
||||
reminder_time = (datetime.now() + timedelta(seconds=delay)).strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
memo_id = self.ai_manager.memo_db.add_user_task(
|
||||
title=title,
|
||||
content=content,
|
||||
reminder_time=reminder_time,
|
||||
repeat_type=repeat_type,
|
||||
repeat_interval=repeat_interval,
|
||||
repeat_interval_unit=repeat_interval_unit,
|
||||
repeat_end_time=repeat_end_time,
|
||||
is_immediate=is_immediate
|
||||
)
|
||||
# J1(审计 2026-08-19):add_memo 入库校验抛 ValueError(畸形时间/
|
||||
# 重复间隔等),此处转为友好错误消息,不让 handle_message 顶层
|
||||
# 兜底以笼统的"处理失败"收场
|
||||
try:
|
||||
memo_id = self.ai_manager.memo_db.add_user_task(
|
||||
title=title,
|
||||
content=content,
|
||||
reminder_time=reminder_time,
|
||||
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 ""
|
||||
repeat_msg = ""
|
||||
@@ -2437,6 +2470,12 @@ class WebSocketHandler:
|
||||
"type": "error",
|
||||
"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):
|
||||
"""处理命令消息"""
|
||||
@@ -2467,6 +2506,14 @@ class WebSocketHandler:
|
||||
await asyncio.sleep(0.1)
|
||||
if not ai.is_busy():
|
||||
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(繁忙对话持锁
|
||||
# 可达数十秒),直接调用会冻结整个事件循环(所有连接的心跳/
|
||||
@@ -2716,7 +2763,7 @@ class WebSocketHandler:
|
||||
# 定义输出回调函数 - 实时发送
|
||||
def output_callback(msg_type, content):
|
||||
"""AI输出的回调函数 - 实时发送每个chunk"""
|
||||
self.logger.info(f"输出回调被调用: {msg_type}, 内容长度: {len(content)}")
|
||||
self.logger.debug(f"输出回调被调用: {msg_type}, 内容长度: {len(content)}")
|
||||
|
||||
# 创建发送消息的协程
|
||||
async def send_msg():
|
||||
@@ -2938,8 +2985,11 @@ class EnhancedAIManager:
|
||||
async def handle_connection(self, websocket, path):
|
||||
"""处理WebSocket连接 - 消息接收与处理解耦,确保中断信号能被即时处理"""
|
||||
self.logger.info(f"新连接: {websocket.remote_address}, path: {path}")
|
||||
await self.ws_handler.register(websocket)
|
||||
try:
|
||||
# J1(审计 2026-08-19):register 移入 try——此前在 try 外,握手期
|
||||
# 异常(如任务展示路径解析失败)逃逸时 finally 不执行,会话登记
|
||||
# 永久泄漏;unregister 为 discard 幂等,提前失败也会被正常清理
|
||||
await self.ws_handler.register(websocket)
|
||||
async for message in websocket:
|
||||
# 【关键修复】用create_task异步处理消息,不await阻塞接收循环
|
||||
# 这样当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 response is None or (isinstance(response, str) and not response.strip()):
|
||||
state["exit_code"] = 2
|
||||
except KeyboardInterrupt:
|
||||
state["interrupted"] = True
|
||||
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)
|
||||
state["exit_code"] = 2
|
||||
finally:
|
||||
|
||||
@@ -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
@@ -9,8 +9,12 @@ common.config - aibox 配置管理器
|
||||
import os
|
||||
import json
|
||||
import copy
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .atomic import atomic_write_text
|
||||
from .i18n import i18n
|
||||
class ConfigManager:
|
||||
"""配置管理器 - 单例模式,负责加载和保存配置"""
|
||||
@@ -18,15 +22,20 @@ class ConfigManager:
|
||||
_instance = None
|
||||
_config = None
|
||||
_config_path = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
# J6(审计 2026-08-19):此前单例创建与初始化均无锁——多线程首例
|
||||
# 并发可双实例、双重加载配置
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._config is None:
|
||||
self._load_config()
|
||||
with self._instance_lock:
|
||||
if self._config is None:
|
||||
self._load_config()
|
||||
|
||||
def _get_default_config_path(self) -> str:
|
||||
env_config = os.environ.get("AI_CONFIG_PATH")
|
||||
@@ -183,6 +192,10 @@ class ConfigManager:
|
||||
print(i18n.get('config_loaded', config_path))
|
||||
except Exception as 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()
|
||||
else:
|
||||
print(i18n.get('config_not_found', config_path))
|
||||
@@ -190,6 +203,15 @@ class ConfigManager:
|
||||
self._save_default_config(config_path)
|
||||
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:
|
||||
result = default.copy()
|
||||
for key, value in user.items():
|
||||
@@ -201,9 +223,9 @@ class ConfigManager:
|
||||
|
||||
def _save_default_config(self, config_path: str):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._get_default_config(), f, ensure_ascii=False, indent=2)
|
||||
atomic_write_text(
|
||||
config_path,
|
||||
json.dumps(self._get_default_config(), ensure_ascii=False, indent=2))
|
||||
print(i18n.get('config_created', config_path))
|
||||
except:
|
||||
pass
|
||||
@@ -296,9 +318,10 @@ class ConfigManager:
|
||||
socks = safe_config.get("proxy", {}).get("socks5")
|
||||
if socks and socks.get("password"):
|
||||
socks["password"] = None
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(safe_config, f, ensure_ascii=False, indent=2)
|
||||
# J5:原子写(mkstemp + fsync + os.replace),崩溃不留半截 JSON
|
||||
atomic_write_text(
|
||||
config_path,
|
||||
json.dumps(safe_config, ensure_ascii=False, indent=2))
|
||||
print(f"✅ 配置已保存到 {config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
+16
-5
@@ -6,10 +6,12 @@
|
||||
<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/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>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
||||
<!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
|
||||
<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 客户端库 -->
|
||||
<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>
|
||||
/* 样式保持不变,完全使用原有的样式 */
|
||||
* {
|
||||
@@ -982,6 +984,15 @@
|
||||
</template>
|
||||
|
||||
<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, '<');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 配置marked
|
||||
marked.setOptions({
|
||||
@@ -1124,7 +1135,7 @@
|
||||
textDiv.setAttribute('data-raw-content', streamData.content);
|
||||
|
||||
if (markdownEnabled) {
|
||||
textDiv.innerHTML = marked.parse(streamData.content);
|
||||
textDiv.innerHTML = renderMarkdown(streamData.content);
|
||||
textDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
@@ -1419,7 +1430,7 @@
|
||||
const rawContent = msgDiv.getAttribute('data-raw-content');
|
||||
if (rawContent) {
|
||||
if (markdownEnabled) {
|
||||
msgDiv.innerHTML = marked.parse(rawContent);
|
||||
msgDiv.innerHTML = renderMarkdown(rawContent);
|
||||
msgDiv.classList.add('markdown-body');
|
||||
addCopyButtonsToCodeBlocks(msgDiv);
|
||||
} else {
|
||||
|
||||
+29
-8
@@ -8,10 +8,12 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<!-- highlight.js 代码高亮 + marked markdown解析 -->
|
||||
<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>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
||||
<!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
|
||||
<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 客户端库 -->
|
||||
<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>
|
||||
/* ========== 融合小猫风格 + WebSocket增强样式 ========== */
|
||||
* {
|
||||
@@ -498,6 +500,15 @@
|
||||
|
||||
<script>
|
||||
(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, '<');
|
||||
}
|
||||
|
||||
// ---------- DOM 元素 ----------
|
||||
const bgFile = document.getElementById('bgFile');
|
||||
const roleImg = document.getElementById('roleImg');
|
||||
@@ -533,7 +544,12 @@
|
||||
const div = document.createElement('div');
|
||||
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.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.scrollTop = chatBox.scrollHeight;
|
||||
}
|
||||
@@ -545,7 +561,7 @@
|
||||
if(textDiv) {
|
||||
textDiv.setAttribute('data-raw', stream.content);
|
||||
if(markdownEnabled) {
|
||||
textDiv.innerHTML = marked.parse(stream.content);
|
||||
textDiv.innerHTML = renderMarkdown(stream.content);
|
||||
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
|
||||
} else textDiv.innerText = stream.content;
|
||||
}
|
||||
@@ -572,7 +588,12 @@
|
||||
if(!streamingTasks[eid]) {
|
||||
const sep = document.createElement('div');
|
||||
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);
|
||||
const taskDiv = document.createElement('div');
|
||||
taskDiv.className = 'task-msg streaming';
|
||||
@@ -590,7 +611,7 @@
|
||||
if(st && st.element) {
|
||||
const textDiv = st.element.querySelector('.message-text');
|
||||
if(textDiv) {
|
||||
if(markdownEnabled) textDiv.innerHTML = marked.parse(st.content);
|
||||
if(markdownEnabled) textDiv.innerHTML = renderMarkdown(st.content);
|
||||
else textDiv.innerText = st.content;
|
||||
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
|
||||
}
|
||||
@@ -613,7 +634,7 @@
|
||||
if(task.timer) clearTimeout(task.timer);
|
||||
if(task.element) {
|
||||
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');
|
||||
}
|
||||
globalMsgCount++;
|
||||
|
||||
+15
-4
@@ -6,8 +6,10 @@
|
||||
<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/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>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
||||
<!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
|
||||
<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>
|
||||
/* 样式保持不变,完全使用原有的样式 */
|
||||
* {
|
||||
@@ -951,6 +953,15 @@
|
||||
</template>
|
||||
|
||||
<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, '<');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 配置marked
|
||||
marked.setOptions({
|
||||
@@ -1048,7 +1059,7 @@
|
||||
textDiv.setAttribute('data-raw-content', streamData.content);
|
||||
|
||||
if (markdownEnabled) {
|
||||
textDiv.innerHTML = marked.parse(streamData.content);
|
||||
textDiv.innerHTML = renderMarkdown(streamData.content);
|
||||
textDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
@@ -1336,7 +1347,7 @@
|
||||
const rawContent = msgDiv.getAttribute('data-raw-content');
|
||||
if (rawContent) {
|
||||
if (markdownEnabled) {
|
||||
msgDiv.innerHTML = marked.parse(rawContent);
|
||||
msgDiv.innerHTML = renderMarkdown(rawContent);
|
||||
msgDiv.classList.add('markdown-body');
|
||||
addCopyButtonsToCodeBlocks(msgDiv);
|
||||
} else {
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import Dict, List, Optional
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
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_-]+$")
|
||||
|
||||
@@ -195,7 +196,9 @@ def test_profile(config: ConfigManager, name: str, logger=None) -> str:
|
||||
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
|
||||
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
|
||||
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("已取消")
|
||||
return
|
||||
|
||||
# J8(审计 2026-08-19):原始字符串直传——此前交互层先 float()/int(),
|
||||
# 非数字输入直接 traceback 退出,add_profile 内部的容错成为死代码
|
||||
msgs = add_profile(config, name, api_url, model,
|
||||
api_key_env=api_key_env, display_name=display_name,
|
||||
temperature=float(temperature_raw) if temperature_raw else None,
|
||||
timeout=int(timeout_raw) if timeout_raw else None,
|
||||
temperature=temperature_raw or None,
|
||||
timeout=timeout_raw or None,
|
||||
reasoning_enabled=reasoning, fallback=fallback,
|
||||
set_default=set_default)
|
||||
for m in msgs:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
一个基于python-nmap的局域网Ping扫描工具
|
||||
"""
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
import socket
|
||||
|
||||
@@ -25,7 +26,9 @@ def scan_network(network="192.168.1.0/24", timeout=3, verbose=False):
|
||||
print("-" * 50)
|
||||
|
||||
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:
|
||||
print(f"❌ 扫描失败: {e}")
|
||||
print("💡 提示:请确保已安装 nmap (sudo apt install nmap) 并有足够权限")
|
||||
|
||||
@@ -41,6 +41,10 @@ def take_screenshot_windows(output_path, delay=0):
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
# J12(审计 2026-08-19):output_path 经 PowerShell 单引号转义——
|
||||
# 路径中的单引号翻倍(PS 字符串转义),AI/用户可控文件名无法闭合
|
||||
# 字符串注入脚本(此前 `${output_path}` 直拼,`");calc;#` 即注入)
|
||||
ps_path = str(output_path).replace("'", "''")
|
||||
try:
|
||||
ps_script = f'''
|
||||
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.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()
|
||||
$bitmap.Dispose()
|
||||
|
||||
Write-Output "截图已保存: {output_path}"
|
||||
Write-Output "截图已保存: {ps_path}"
|
||||
'''
|
||||
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from common import ConfigManager, Logger, constants
|
||||
from common.atomic import atomic_write_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# 仅用于类型注解,避免运行时循环导入;MemoryDatabase 来自同包 subsystems.db
|
||||
@@ -73,11 +74,9 @@ class ConversationHistory:
|
||||
def _atomic_write_json(path: Path, data) -> None:
|
||||
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
|
||||
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
|
||||
后仅静默置 None,历史内容丢失)"""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
后仅静默置 None,历史内容丢失)。J5(2026-08-19):委托 common.atomic
|
||||
(mkstemp 随机名),修复旧实现固定 .tmp 名在多进程并发写同路径互踩。"""
|
||||
atomic_write_text(str(path), json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
def _save_history_index(self):
|
||||
"""保存历史索引"""
|
||||
|
||||
+98
-33
@@ -141,11 +141,14 @@ class MemoryDatabase:
|
||||
"""删除记忆"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
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,))
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,))
|
||||
conn.commit()
|
||||
self.logger.info(f"已删除记忆 ID: {memory_id}")
|
||||
|
||||
@@ -274,11 +277,12 @@ class MemoryDatabase:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
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)
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
if deleted_count > 0:
|
||||
cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids)
|
||||
conn.commit()
|
||||
self.logger.info(f"已批量删除 {deleted_count} 条记忆")
|
||||
|
||||
@@ -300,10 +304,19 @@ class MemoDatabase:
|
||||
self.logger = Logger("MemoDB")
|
||||
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):
|
||||
"""初始化备忘录表"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.cursor()
|
||||
# J9:WAL 显式设置——与 MemoryDatabase 同库文件时不再依赖创建顺序
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memos (
|
||||
@@ -349,10 +362,34 @@ class MemoDatabase:
|
||||
repeat_interval_unit: str = 'days', repeat_end_time: str = None,
|
||||
is_immediate: bool = False) -> int:
|
||||
"""添加任务 - 所有任务都是对话任务,不再需要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)
|
||||
tags_str = ",".join(tags) if tags else ""
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO memos
|
||||
@@ -377,7 +414,7 @@ class MemoDatabase:
|
||||
|
||||
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
|
||||
cursor = conn.cursor()
|
||||
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]:
|
||||
"""获取待执行的任务 - 严格使用当前时间比较"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -458,7 +495,7 @@ class MemoDatabase:
|
||||
|
||||
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
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -494,7 +531,7 @@ class MemoDatabase:
|
||||
|
||||
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
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -533,13 +570,11 @@ class MemoDatabase:
|
||||
now = datetime.now()
|
||||
next_reminder = None
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度)
|
||||
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让
|
||||
# SQLite 等待锁释放(最长 10s)而不是立即失败
|
||||
cursor.execute("PRAGMA busy_timeout = 10000")
|
||||
# J9:busy_timeout 已由 _connect() 工厂统一设置(全部写路径一致),
|
||||
# 多进程同库(CLI --memo-add + 服务调度)写竞争等待锁释放而非立即失败
|
||||
cursor.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
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:
|
||||
"""检查是否应该结束重复"""
|
||||
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:
|
||||
return True
|
||||
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)
|
||||
else:
|
||||
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:
|
||||
unit = memo.get('repeat_interval_unit', 'days')
|
||||
if unit == 'minutes':
|
||||
# 每N分钟重复:当前时间 + N分钟
|
||||
next_time = current_time + timedelta(minutes=memo['repeat_interval'])
|
||||
elif unit == 'hours':
|
||||
# 每N小时重复:当前时间 + N小时
|
||||
next_time = current_time + timedelta(hours=memo['repeat_interval'])
|
||||
else: # days
|
||||
# 每N天重复:当前时间 + N天
|
||||
next_time = current_time + timedelta(days=memo['repeat_interval'])
|
||||
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')
|
||||
if unit == 'minutes':
|
||||
# 每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:
|
||||
return None
|
||||
|
||||
# 检查是否超过重复结束时间
|
||||
if memo['repeat_end_time']:
|
||||
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
|
||||
if next_time > end_time:
|
||||
try:
|
||||
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 next_time
|
||||
@@ -672,7 +724,7 @@ class MemoDatabase:
|
||||
|
||||
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.execute('''
|
||||
UPDATE memos
|
||||
@@ -691,7 +743,7 @@ class MemoDatabase:
|
||||
def delete_memo(self, memo_id: int) -> bool:
|
||||
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
|
||||
完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
|
||||
conn.commit()
|
||||
@@ -699,7 +751,7 @@ class MemoDatabase:
|
||||
|
||||
def get_memo_stats(self) -> Dict:
|
||||
"""获取任务统计信息"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
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,
|
||||
repeat_type: str = 'none', repeat_interval: int = 0,
|
||||
repeat_interval_unit: str = 'days', is_immediate: bool = False) -> int:
|
||||
repeat_type: str = 'none', repeat_interval: int = 0,
|
||||
repeat_interval_unit: str = 'days', repeat_end_time: str = None,
|
||||
is_immediate: bool = False) -> int:
|
||||
"""添加用户任务(前端直接调用的任务)- 所有任务都是对话任务"""
|
||||
if not constants.MEMO_ALLOW_USER_TASKS:
|
||||
raise Exception("用户任务功能已禁用")
|
||||
@@ -745,11 +798,23 @@ class MemoDatabase:
|
||||
repeat_type=repeat_type,
|
||||
repeat_interval=repeat_interval,
|
||||
repeat_interval_unit=repeat_interval_unit,
|
||||
repeat_end_time=repeat_end_time,
|
||||
created_by="user",
|
||||
source="websocket",
|
||||
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]:
|
||||
"""获取待执行任务列表(用于前端显示)"""
|
||||
pending = self.get_pending_memos(include_immediate=False)
|
||||
@@ -764,7 +829,7 @@ class MemoDatabase:
|
||||
"content": memo['content'],
|
||||
"reminder_time": memo['reminder_time'],
|
||||
"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_interval": memo.get('repeat_interval', 0),
|
||||
"repeat_interval_unit": memo.get('repeat_interval_unit', 'days'),
|
||||
|
||||
@@ -144,6 +144,8 @@ class SubprocessCommandExecutor(CommandExecutor):
|
||||
shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻
|
||||
while True:
|
||||
# 检查超时(readline 阻塞不再影响本判断)
|
||||
# J16(审计 2026-08-19):timeout<=0 语义为"不设超时"(有意为之,
|
||||
# 调用方显式传 0 禁用);registry 层 3600s 流式兜底仍生效
|
||||
if timeout > 0 and time.time() - start_time > timeout:
|
||||
self._terminate_tree(process)
|
||||
timeout_occurred = True
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from common import ConfigManager
|
||||
from common.atomic import atomic_write_text
|
||||
|
||||
|
||||
class PromptManager:
|
||||
@@ -60,8 +61,12 @@ class PromptManager:
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._prompts:
|
||||
self._load_prompts()
|
||||
# J6(审计 2026-08-19):__new__ 的锁只防双实例,多线程首例并发
|
||||
# 同时进入 __init__ 会使 _load_prompts 双重执行,_prompts(类属性)
|
||||
# 整份翻倍——初始化检查与加载收进同一把锁
|
||||
with self._instance_lock:
|
||||
if not self._prompts:
|
||||
self._load_prompts()
|
||||
|
||||
def _get_prompts_dir(self) -> str:
|
||||
config = ConfigManager()
|
||||
@@ -172,8 +177,8 @@ class PromptManager:
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(identity_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
# J5:原子写,崩溃不留半截提示词文件
|
||||
atomic_write_text(identity_path, updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -195,8 +200,8 @@ class PromptManager:
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(status_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
# J5:原子写,崩溃不留半截提示词文件
|
||||
atomic_write_text(status_path, updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -218,8 +223,8 @@ class PromptManager:
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(soul_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
# J5:原子写,崩溃不留半截提示词文件
|
||||
atomic_write_text(soul_path, updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -27,6 +27,9 @@ class SkillManager:
|
||||
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
|
||||
"""
|
||||
|
||||
# J16(审计 2026-08-19):--help 输出上限——内容会进系统提示词
|
||||
MAX_HELP_LENGTH = 2000
|
||||
|
||||
def __init__(self, logger: Optional[Logger] = None):
|
||||
self.logger = logger or Logger("SkillManager")
|
||||
# name -> {path, file, summary, content, is_markdown}
|
||||
@@ -38,7 +41,11 @@ class SkillManager:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
result = subprocess.run(
|
||||
[skill_path, "--help"],
|
||||
@@ -50,6 +57,8 @@ class SkillManager:
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
output = result.stderr.strip()
|
||||
if len(output) > self.MAX_HELP_LENGTH:
|
||||
output = output[:self.MAX_HELP_LENGTH] + "\n…(已截断)"
|
||||
return output
|
||||
except Exception as e:
|
||||
self.logger.warning(f"运行 {skill_path} --help 失败: {e}")
|
||||
@@ -129,12 +138,26 @@ class SkillManager:
|
||||
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_name = os.path.splitext(skill_file)[0]
|
||||
is_markdown = self._is_markdown_file(skill_file)
|
||||
|
||||
if is_markdown:
|
||||
if skill_name in self.skills:
|
||||
self.logger.warning(
|
||||
f"Markdown 技能 '{skill_file}' 与同名可执行技能冲突,已跳过"
|
||||
f"(可执行技能优先)")
|
||||
continue
|
||||
# Markdown 格式技能:直接读取内容
|
||||
summary, content = self._read_markdown_skill(skill_path)
|
||||
if content:
|
||||
|
||||
@@ -231,14 +231,19 @@ class TestL2ProxySaveFailure:
|
||||
from common import ConfigManager
|
||||
cm = ConfigManager()
|
||||
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)
|
||||
assert ok is False
|
||||
|
||||
def test_proxy_tool_reports_save_failure(self, tmp_path):
|
||||
from tools import SetProxyTool
|
||||
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,
|
||||
skip_check=True, enabled=True)
|
||||
assert "保存失败" in r
|
||||
|
||||
@@ -174,7 +174,17 @@ class TestF5LightSubagentCancel:
|
||||
class TestQ5MalformedReminderTime:
|
||||
def test_malformed_time_task_returned_as_due(self, tmp_path):
|
||||
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
|
||||
# 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")
|
||||
assert any(m["id"] == mid for m in pending)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -192,7 +192,9 @@ class TestTestProfile:
|
||||
class TestSaveFailure:
|
||||
def test_add_reports_save_failure(self, 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(
|
||||
cm, "a", "https://x/v1", "m1", api_key_env="K")
|
||||
assert any("保存失败" in m for m in msgs)
|
||||
@@ -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
@@ -745,11 +745,20 @@ class MemoTool(Tool):
|
||||
return f"❌ 无法完成任务 #{memo_id}"
|
||||
|
||||
def _delete_memo(self, kwargs: Dict) -> str:
|
||||
"""删除任务"""
|
||||
"""删除任务(单条 memo_id 或批量 memo_ids)"""
|
||||
confirm = kwargs.get("confirm", False)
|
||||
if not confirm:
|
||||
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")
|
||||
if memo_id:
|
||||
# M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
|
||||
@@ -759,7 +768,7 @@ class MemoTool(Tool):
|
||||
else:
|
||||
return f"❌ 无法删除任务 #{memo_id}"
|
||||
else:
|
||||
return "❌ 需要提供 memo_id"
|
||||
return "❌ 需要提供 memo_id 或 memo_ids"
|
||||
|
||||
def _search_memos(self, kwargs: Dict) -> str:
|
||||
"""搜索任务"""
|
||||
|
||||
+15
-3
@@ -181,6 +181,9 @@ class WebSocketClient:
|
||||
self.connected = False
|
||||
print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}")
|
||||
except Exception as e:
|
||||
# J14:异常退出同样标记断开(此前 connected 恒 True,用户继续
|
||||
# "成功"发送但永远收不到回复,成僵尸连接)
|
||||
self.connected = False
|
||||
print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}")
|
||||
|
||||
async def handle_message(self, data: dict, tts: TextToSpeech):
|
||||
@@ -266,12 +269,13 @@ class WebSocketClient:
|
||||
tts: TextToSpeech = None, task_info: dict = None):
|
||||
if msg_type == '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:
|
||||
print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True)
|
||||
stream.is_active = True
|
||||
|
||||
|
||||
stream.add_chunk(content)
|
||||
print(content, end='', flush=True)
|
||||
|
||||
elif msg_type == 'task' and task_info:
|
||||
@@ -428,6 +432,7 @@ class VoiceInterface:
|
||||
return None
|
||||
|
||||
print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}")
|
||||
temp_file = None # J14:录音临时文件,任何异常路径统一清理
|
||||
|
||||
try:
|
||||
if self.use_arecord:
|
||||
@@ -506,6 +511,13 @@ class VoiceInterface:
|
||||
return None
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
async def handle_command(self, command: str):
|
||||
|
||||
Reference in new issue
Block a user