Compare commits

...
Author SHA1 Message Date
shufeng2012 121e155248 Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012 2026-08-18 15:39:16 +08:00
qing d513c5f892 feat(aibox): interactive model profile manager (model_config.py)
Menu-driven list/add/delete/connectivity-test for config.json models
section - no hand-written JSON needed. Key convention preserved: env
var name only, never plaintext (or empty = keyless local endpoint).
Delete auto-cleans dangling fallback refs; default profile via
api.default_model; save failure reported. 17 unit tests, 441 passed.
2026-08-18 03:26:53 +00:00
qing 4cb97318b5 docs: sync aibox audit batches G/H/I in root README and aibox README
- root README: aibox section notes four audit rounds (Batch E/F/G/H/I)
- aibox README: v3.5 changelog (Batch G/H/I), test suite count 424,
  test file listing and aibox.py line count refreshed
2026-08-18 03:15:10 +00:00
qing 31b2a4d6ca docs: batch I audit fixes record in aibox audit report
Batch I (remaining-item sweep): 10 fixes landed, 16 new tests
(test_i_fixes.py), 424 passed. Q7a / M4 / Q9 / F8 kept as-is with
rationales recorded.
2026-08-18 03:09:18 +00:00
qing db54921066 fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes
Remaining-item sweep (audit report §12):
- N9: cap prompt file writes (identity/soul/status, 10k chars) to bound
  persistent-injection surface
- F5: run_light_subagent switches to stream_chat + parent cancel_event
  (mid-stream interrupt); ExpressEmotionTool injects parent event
- Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention)
- Q5: malformed reminder_time tasks returned as due (were silently dropped
  by SQL-level string filter, which is now Python-side authoritative check)
- Q7b: load_conversation realpath prefix check (../ traversal defense)
- Q8: atomic JSON writes (tmp+os.replace) for index/current/archive
- F6: dedicated ws command executor for /new, /reload_prompts
- F9: --once hard-exit backstop after soft timeout
- PromptManager singleton lock; limit=0 semantics (None = no LIMIT)
2026-08-18 03:09:05 +00:00
qing 9fee9a1478 docs: batch H audit fixes record in aibox audit report
Fourth round (HEAD d1f780e): 3-way parallel review + main-reviewer
verification + Linux PoC 6/6; 13 fixes landed, 26 new tests
(test_h_fixes.py), 408 passed.
2026-08-18 02:33:34 +00:00
qing d3acccd146 fix(aibox): batch H audit fixes - subagent cancel propagation, scheduler timeout, exec process-group reaping
Fourth audit round (see docs/reports/aibox_audit_20260817.md §11):
- H1: shared parent cancel event no longer cleared on subagent first think
  (N5 regression); parent-already-cancelled skips subagent start
- M1: /reload_prompts moved off event loop (run_in_executor, N3 sibling)
- M2: skill --help scan prewarmed before serving connections
- M3: scheduler task-level timeout (scheduler.task_timeout, default 600s)
- M4: get_recent_memories(None) capped instead of full-table load
- M5: memo delete now physically removes rows (was complete-only)
- M6: PromptManager.reload builds new list then atomically swaps
- L1-L12: read_file arg coercion, proxy save failure reported, SSE
  utf-8 tolerance, persona realpath check, truncation char boundary,
  compressor div0, skill path quoting, ask_user required, POSIX
  process-group kill (pgid captured at Popen), web scheme whitelist,
  registry redact substring match, Bearer redaction in failover logs
2026-08-18 02:33:17 +00:00
qing d1f780e381 feat(aibox): verify SOCKS5 connectivity before saving proxy config
- set_proxy 先做完整 SOCKS5 握手 + generate_204 探测(trust_env=False,超时 4s/6s),验证失败不落盘并按类型归因提示(auth/timeout/connect/missing_dependency/probe_failed)

- 异常类型优先分类,消息关键词兜底:Windows 本地化错误消息不影响归因

- 新增 skip_check 逃生口(代理未启动的预配置场景);禁用代理不验证;password_env 指向空环境变量时提前精准提示

- 测试:+9 用例(mock 网络 + 真实异常类型),全量 383 passed 零回归
2026-08-18 07:44:17 +08:00
shufeng2012 d3a672a2a8 ✨ 添加代码块语言显示 2026-08-15 20:46:44 +08:00
24 changed files with 2041 additions and 115 deletions

No files matched your search

+1
View File
@@ -236,6 +236,7 @@
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换(档案级 reasoning/温度/展示名/`fallback` 失败转移),`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
- **四轮安全审计修复(2026-08,Batch E/F/G/H/I 共 60+ 项)**:命令执行保护链(1MB/超时/中断)、凭据脱敏(代理密码不落盘、日志打码、Bearer 脱敏)、取消传播(子代理/情绪子代理/调度器超时)、DB 事务与 FTS5 转义、POSIX 进程组终止等;审计报告见 [docs/reports/aibox_audit_20260817.md](docs/reports/aibox_audit_20260817.md)。
### BotOps(aibox 智能体 TUI 前端)
+47 -3
View File
@@ -57,13 +57,28 @@ python aibox.py
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
> 注意:本模块入口文件为 `aibox.py`(当前约 3018 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
### 管理大模型接入点(不用手写配置)
不想手写 `config.json` 的 `models` 节?用交互式菜单工具:
```bash
python model_config.py # 交互式菜单:列出 / 添加 / 删除 / 连通性验证
python model_config.py --list # 非交互:列出当前接入点
```
- **添加接入点**:按提示填写档案名、API 地址(附常见厂商端点参考)、模型名;Key 两种方式——环境变量名(推荐,Key 不落盘)或免密本地端点(Ollama 等);可选显示名/温度/超时/思考模式/失败转移(从已有接入点多选)/设为默认
- **删除接入点**:编号选择 + 确认,自动清理其他档案的失败转移引用
- **连通性验证**:对选中接入点发一次最小请求,直接给出成败原因
- 添加后可立即使用:`python aibox.py --model <档案名>` 指定,或设为默认后无需 `--model`
> 注意:本模块入口文件为 `aibox.py`(当前约 3408 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
## 📁 目录结构
```
aibox/
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager)
├── model_config.py # 大模型接入点交互式管理工具(列表/添加/删除/连通性验证,不用手写配置)
├── client.py # 示例 WebSocket 客户端
├── voice_client.py # 语音客户端
├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议)
@@ -92,7 +107,7 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(355 项全过)
├── tests/ # 单元测试套件(441 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项
@@ -103,7 +118,13 @@ aibox/
│ ├── test_conversation_compressor.py # 对话压缩器
│ ├── test_skill_manager.py # 技能管理器
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
│ └── test_models_profile.py # 多模型档案(Batch D 17 项)
│ ├── test_models_profile.py # 多模型档案(Batch D 17 项)
│ ├── test_config.py # 配置脱敏(F1 3 项)
│ ├── test_f2_fixes.py # 超时清理/线程收尾(F2 5 项)
│ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项)
│ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项)
│ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项)
│ └── test_model_config.py # 模型接入点管理工具(17 项)
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
│ ├── hello-world.py
│ ├── aes-crypto.py
@@ -890,6 +911,29 @@ elif command == "/mycommand":
## 📄 更新日志
### v3.5
- ✅ **安全审计与修复(第三轮 Batch G + 第四轮 Batch H + 遗留收尾 Batch I)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
- **Batch G(llm 加固 + 线程生命周期 + 取消传播 + DB 事务)**:
- llm_client:非瞬时 HTTP 错误体截断 `[:200]`(密钥/错误页不进异常栈)、Retry-After 上限 cap 3600s、fallback 档案缺 model 跳过并告警、fallback 去重、删除 `LLMClient._cancelled` 死状态、FailoverClient 并发契约文档化
- 子代理取消传播:`run_full_subagent` 共享父 `_cancel_event` + 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程);`SubAgentTool` 构造注入父事件
- drain 线程重构:批次级单线程 + stop 事件 + try/finally 统一回收(消除每次工具调用泄漏一个常驻线程);`/new` 的 `reset_conversation` 移入线程池(不再同步获取 `_msg_lock` 冻结事件循环);broadcast 收窄 except + unregister 改 discard;`_processing_tasks` 改按连接的任务集合登记(关闭时取消全部在途任务)
- DB:FTS5 关键词双引号翻倍转义(`"` → `""`)、两处 `LIMIT` f-string 参数化、`mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭并发触发竞态);历史索引裸 except 收窄 + 归档文件名同秒加序号
- **Batch H(第四轮审计,3 路并行审读 + Linux PoC 6/6 实证)**:
- **H1(高危)**:修复 N5 回归——子代理共享父取消事件后首轮 think 无条件 `clear()` 会吞掉父级已置位的取消信号(用户中断 / --once 超时被绕过),共享事件时跳过 clear + 父已取消直接早退
- `/reload_prompts` 移入线程池(N3 同类遗漏);技能扫描(串行 `--help` 子进程)启动前预热,不再在首条消息/首个任务时冻结事件循环
- 调度器任务级超时(`scheduler.task_timeout`,默认 600s)——AI think 卡死不再停摆整个调度循环
- `get_recent_memories(limit=None)` 默认上限(不再全表加载);`memo delete` 改物理删除(此前只标记完成);`PromptManager.reload` 本地构建后原子替换(并发读者不再读到空/半截系统提示词)
- read_file 的 sed 参数强转 int(防引号注入);`set_proxy` 落盘失败如实报告;SSE 非 UTF-8 行容错;persona 路径 realpath 校验(软链越界拒绝);1MB 截断无半个字符;compressor 除零防护;InstallSkillTool 指引命令路径引用;ask_user 缺参明确报错
- **POSIX 进程组终止**:executor 的 `Popen` 加 `start_new_session` + 组信号 kill(组 ID 在 Popen 后立即捕获)——`(sleep 300 &)` 形态的孙进程持管道不再悬挂 reader 线程/泄漏 fd(实证修复前孙进程残留)
- web 工具 scheme 白名单(http/https,`file://` 等拒绝);registry 日志打码改包含匹配(apiKey/passwd 变体);FailoverClient 日志 Bearer 令牌正则脱敏(网关回显 Authorization 头不再进日志)
- **Batch I(遗留收尾)**:
- **N9**:prompt 写入内容长度上限(`MAX_CONTENT_LENGTH=10000`,identity/soul/status 超长拒绝)——约束持久化注入面
- **F5**:情绪子代理取消传播——`run_light_subagent` 改走 stream_chat + 观察者线程,用户中断可即时终止(此前阻塞至请求超时)
- 提醒时间格式异常任务按"到期"返回(此前被 SQL 层字符串比较静默丢弃,任务永不执行);`mark_as_triggered` 加 `busy_timeout=10000`(多进程同库写竞争等待而非立即报 locked)
- 对话文件原子写(tmp + os.replace,崩溃不留半截 JSON);`load_conversation` realpath 越界防护;WS 命令专用线程池(`/new`、`/reload_prompts` 不再占用默认池);--once 硬超时兜底(软超时后宽限一个周期强杀进程)
- PromptManager 单例加锁;`limit=0` 语义修正(None 才不加 LIMIT)
- ✅ **测试**:424 项全过(Linux 环境;Batch G 20 + Batch H 26 + Batch I 16 项新增;`test_execute_timeout`/`test_flat_api_still_reads_key_file` 为 Windows 向用例,Linux 预存失败与本轮无关)
### v3.4
- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**:
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
+129 -18
View File
@@ -26,6 +26,7 @@ import locale
import asyncio
import websockets
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set
import argparse
@@ -328,14 +329,35 @@ class TaskScheduler:
ai_thread.daemon = True
ai_thread.start()
# 等待 AI 完成,同时处理可能的取消
# M3(审计 2026-08-18):任务级超时兜底。此前 await ai_future
# 无任何超时/取消路径——AI think 卡死(LLM 挂起、子进程不可杀)
# 时该任务永不返回,调度器 run 循环顺序 await 被阻塞,后续全部
# 定时/即时任务不再执行且任务已 mark_as_triggered 不会重试。
# 到点 call_later 触发 ai.cancel()(置取消事件 + 关在途 HTTP 流),
# worker 线程自然收尾,调度循环继续。
task_timeout = float(ConfigManager().get("scheduler.task_timeout", 600))
timed_out = threading.Event()
def _timeout_cancel():
timed_out.set()
try:
ai.cancel()
except Exception:
pass
timeout_handle = loop.call_later(task_timeout, _timeout_cancel)
try:
response = await ai_future
result = response or "AI无响应"
except Exception as e:
self.logger.error(f"AI对话失败: {e}")
result = f"任务执行但AI对话失败: {str(e)}"
if timed_out.is_set():
self.logger.error(f"任务执行超时({task_timeout}秒),AI 已取消")
result = f"任务执行超时({task_timeout}秒),AI 已取消"
else:
self.logger.error(f"AI对话失败: {e}")
result = f"任务执行但AI对话失败: {str(e)}"
finally:
timeout_handle.cancel()
# 确保线程结束
ai_thread.join(timeout=1.0)
@@ -490,6 +512,12 @@ class TaskScheduler:
class ExpressEmotionTool(Tool):
def __init__(self, logger=None, cancel_event: threading.Event = None):
super().__init__(logger)
# I 批(审计 2026-08-18):父代理取消事件注入——情绪子代理此前
# 用独立 client 且不可中断,父级 cancel 后仍阻塞至请求自然结束
self.cancel_event = cancel_event
def get_name(self) -> str:
return "express_emotion"
@@ -534,10 +562,11 @@ class ExpressEmotionTool(Tool):
if not name:
return ""
path = os.path.join(constants.SKILLS_DIR, name + ".md")
# N12:persona 为 LLM 可控输入,做路径边界校验——normcase 统一大小写与
# 分隔符后要求解析结果仍在 SKILLS_DIR 内,防止 ../../ 越界读取并注入
# N12 + L4(审计 2026-08-18):persona 为 LLM 可控输入。abspath 前缀
# 检查拦截 ../ 越界,但符号链接可绕过(skills 目录内软链指向任意文件
# 即被读取注入);realpath 解析软链后再做前缀校验。
skills_root = os.path.normcase(os.path.abspath(constants.SKILLS_DIR))
resolved = os.path.normcase(os.path.abspath(path))
resolved = os.path.normcase(os.path.realpath(path))
if not resolved.startswith(skills_root + os.sep):
self.logger.warning(f"人格技能名非法(越界路径): {name}")
return ""
@@ -572,7 +601,8 @@ class ExpressEmotionTool(Tool):
task = "\n".join(task_lines)
# 情绪子代理:独立上下文/人格,生成情绪化回应
return run_light_subagent(role, task, self.config, logger=self.logger, max_tokens=2048)
return run_light_subagent(role, task, self.config, logger=self.logger,
max_tokens=2048, cancel_event=self.cancel_event)
except Exception as e:
self.logger.error(f"情绪子代理执行异常: {e}")
return f"❌ 情绪子代理执行失败: {str(e)}"
@@ -584,12 +614,20 @@ class ExpressEmotionTool(Tool):
def run_light_subagent(role, task, config, logger=None, model=None,
temperature=None, max_tokens=2048) -> str:
temperature=None, max_tokens=2048,
cancel_event: threading.Event = None) -> str:
"""轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。
供 express_emotion 情绪子代理使用。
返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。
cancel_event(I 批,审计 2026-08-18):父代理取消事件。此前本函数用
非流式 chat_completion,父级 cancel 只能等请求自然结束(最长 timeout,
期间持锁阻塞);改走 stream_chat 后由观察者线程转发 sub_client.cancel()
强关在途 HTTP 流,用户中断可即时终止。
"""
if cancel_event is not None and cancel_event.is_set():
return "❌ 子代理已因父级取消而终止"
try:
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
@@ -605,8 +643,33 @@ def run_light_subagent(role, task, config, logger=None, model=None,
]
if logger is not None:
logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...")
result = sub_client.chat_completion(messages, temperature=temp, max_tokens=max_tokens)
if result is None or not str(result).strip():
_watch_done = threading.Event()
if cancel_event is not None:
def _watch_parent_cancel():
while not _watch_done.wait(timeout=0.2):
if cancel_event.is_set():
try:
sub_client.cancel()
except Exception:
pass
return
threading.Thread(target=_watch_parent_cancel, daemon=True,
name="light-subagent-cancel-watch").start()
# I 批:改走流式(可被 cancel 强关在途 HTTP),拼接 content 事件
parts = []
try:
for event in sub_client.stream_chat(messages, temperature=temp,
max_tokens=max_tokens):
if event.get("type") == "content":
parts.append(event.get("text") or "")
finally:
_watch_done.set()
result = "".join(parts)
if not str(result).strip():
return "❌ 子代理执行失败:未获得有效响应"
return str(result).strip()
except Exception as e:
@@ -673,6 +736,14 @@ def run_full_subagent(role, task, config, logger=None, model=None,
# 深度中断(sub_chat.cancel() 强关在途 HTTP 流并 kill 其子进程)。
if cancel_event is not None:
sub_chat._cancel_event = cancel_event
# H1(审计 2026-08-18):共享父事件后禁止子代理首轮 clear——
# _think_and_respond_impl 首轮无条件 clear() 会吞掉父级已置位的
# 取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清掉,
# 观察者线程随之失效,子代理跑完全程)。置位后立即检查一次,
# 父级已取消则不再启动子代理。
sub_chat._shared_cancel_event = True
if cancel_event.is_set():
return "❌ 子代理已因父级取消而终止"
def _watch_parent_cancel():
while not _watch_done.wait(timeout=0.2):
@@ -1131,8 +1202,9 @@ class DeepSeekChat:
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
# 添加询问用户的工具
# I 批:ExpressEmotionTool 注入父取消事件(情绪子代理可被中断)
builtin_tools.append(AskUserTool(self.logger))
builtin_tools.append(ExpressEmotionTool(self.logger))
builtin_tools.append(ExpressEmotionTool(self.logger, cancel_event=self._cancel_event))
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
@@ -1456,8 +1528,9 @@ class DeepSeekChat:
def _think_and_respond_impl(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]:
"""think_and_respond 的实际实现,在 _msg_lock 保护下执行。"""
# 首轮开始时清除取消标志
if not is_continuation:
# 首轮开始时清除取消标志(H1:共享父事件的子代理不清除——清除会
# 吞掉父级已置位的取消信号,使中断/--once 超时失效)
if not is_continuation and not getattr(self, "_shared_cancel_event", False):
self._cancel_event.clear()
# 立即检查是否已被取消
@@ -2021,6 +2094,17 @@ class WebSocketHandler:
self.client_sessions = {}
self._processing_tasks = {} # websocket -> {asyncio.Task}(N14:集合登记,支持并发多任务)
self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用)
# I 批(审计 2026-08-18):/new、/reload_prompts 等命令专用线程池——
# 此前 run_in_executor(None) 与 AI think 共用默认池,繁忙时 reset 任务
# 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理)
self._cmd_executor = ThreadPoolExecutor(max_workers=2,
thread_name_prefix="ws-cmd")
def shutdown(self):
"""I 批:释放命令专用线程池(服务退出时调用)"""
if self._cmd_executor is not None:
self._cmd_executor.shutdown(wait=False)
self._cmd_executor = None
def _get_shared_skill_manager(self):
"""获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)"""
@@ -2394,7 +2478,7 @@ class WebSocketHandler:
# 重新初始化提示词管理器(确保最新)
self.ai_manager.prompt_manager.reload()
await loop.run_in_executor(None, _do_reset)
await loop.run_in_executor(self._cmd_executor, _do_reset)
# 重置该连接对话进行中标记
if session:
session["dialogue_in_progress"] = False
@@ -2492,10 +2576,17 @@ class WebSocketHandler:
elif command == "/reload_prompts":
session = self._get_session(websocket)
ai = session.get("ai") if session else None
if ai:
new_count = ai.reload_prompts()
else:
new_count = self.ai_manager.prompt_manager.reload()
# M1(审计 2026-08-18):reload_prompts 内部同步获取 _msg_lock
# (繁忙对话持锁可达数十秒),直接调用会冻结整个事件循环——
# N3 只修了 /new,这里同类遗漏。移入线程池执行。
loop = asyncio.get_running_loop()
def _do_reload_prompts():
if ai:
return ai.reload_prompts()
return self.ai_manager.prompt_manager.reload()
new_count = await loop.run_in_executor(self._cmd_executor, _do_reload_prompts)
await self.send_message(websocket, {
"type": "command_result",
"content": i18n.get('prompts_reloaded', new_count)
@@ -2883,12 +2974,17 @@ class EnhancedAIManager:
port = constants.WEBSOCKET_PORT
self.ws_handler = WebSocketHandler(self)
# M2(审计 2026-08-18):技能扫描对每个可执行技能串行跑 --help
# 子进程(各 ≤10s),懒加载于首条消息/首个任务时在事件循环线程
# 执行会冻结整个服务。启动前预热,把扫描成本移到连接建立之前。
self.ws_handler._get_shared_skill_manager()
# 如果启用了任务调度器,创建并启动
if constants.SCHEDULER_ENABLED:
self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger)
self.scheduler.set_websocket_handler(self.ws_handler)
self.scheduler.set_ai_manager(self) # 设置AI管理器引用
self.scheduler._get_shared_skill_manager()
async def handler(websocket):
await self.handle_connection(websocket, None)
@@ -2914,6 +3010,8 @@ class EnhancedAIManager:
finally:
if self.scheduler:
self.scheduler.stop()
# I 批:释放命令专用线程池
self.ws_handler.shutdown()
def run(self):
"""运行WebSocket服务器"""
@@ -3186,6 +3284,19 @@ def handle_once_mode(manager, args) -> bool:
def _on_timeout():
state["timed_out"] = True
manager.cancel()
# I 批(F9,审计 2026-08-18):软超时兜底——cancel 后若 AI 卡死在
# 不可中断路径(HTTP 请求空窗、忽略信号的子进程),think 可能永不
# 返回、进程挂起(退出码 4 永远到不了)。宽限一个超时周期后强杀进程,
# 保证 --once 有硬性结束语义
def _hard_exit():
print(f"\n⏰ 一次性对话强制超时({args.once_timeout * 2} 秒),已终止",
file=sys.stderr)
sys.stderr.flush()
os._exit(4)
hard_timer = threading.Timer(max(1, args.once_timeout), _hard_exit)
hard_timer.daemon = True
hard_timer.start()
timer = threading.Timer(max(1, args.once_timeout), _on_timeout)
timer.daemon = True
timer.start()
+20 -15
View File
@@ -278,27 +278,32 @@ class ConfigManager:
"password_env": password_env
}
if save:
self._save_config()
return self._save_config()
return True
def _save_config(self):
"""将当前配置保存到配置文件(覆盖)。
def _save_config(self) -> bool:
"""将当前配置保存到配置文件(覆盖)。返回是否成功落盘。
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
L2(审计 2026-08-18):此前异常仅 print,set_proxy 无条件报"已保存",
磁盘只读等场景下用户以为已持久化,重启后配置丢失。
"""
config_path = self._config_path
if config_path:
try:
safe_config = copy.deepcopy(self._config)
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)
print(f"✅ 配置已保存到 {config_path}")
except Exception as e:
print(f"⚠️ 保存配置失败: {e}")
if not config_path:
return False
try:
safe_config = copy.deepcopy(self._config)
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)
print(f"✅ 配置已保存到 {config_path}")
return True
except Exception as e:
print(f"⚠️ 保存配置失败: {e}")
return False
def get_proxy_info(self) -> Dict:
"""获取代理配置的详细信息(用于显示,不回传任何密码值)"""
+16 -4
View File
@@ -22,9 +22,21 @@ from typing import Dict, Iterator, List, Optional
import json
import os
import re
import time
import requests
# L12(审计 2026-08-18):瞬时错误体可能回显网关/WAF 错误页中的
# Authorization 头,抹掉 Bearer 令牌后再进日志,防密钥落盘
_AUTH_HEADER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+")
def _sanitize_log_msg(msg: str) -> str:
"""日志脱敏:替换错误消息中的 Bearer 令牌。"""
if not msg:
return msg
return _AUTH_HEADER_RE.sub("Bearer ***", msg)
class LLMClient(ABC):
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
@@ -64,7 +76,7 @@ class LLMClient(ABC):
def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level):
try:
getattr(self.logger, level)(msg)
getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception:
pass
@@ -219,7 +231,7 @@ class DeepSeekClient(LLMClient):
for line in response.iter_lines():
if not line:
continue
line_text = line.decode("utf-8")
line_text = line.decode("utf-8", errors="replace") # L3:非 UTF-8 行不中断流
if not line_text.startswith("data: "):
continue
data = line_text[6:]
@@ -352,7 +364,7 @@ class FailoverClient:
def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level):
try:
getattr(self.logger, level)(msg)
getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception:
pass
@@ -382,7 +394,7 @@ class FailoverClient:
self._stats["failovers"] += 1
try:
if self.on_failover is not None:
self.on_failover(from_name, to_name, reason)
self.on_failover(from_name, to_name, _sanitize_log_msg(reason))
except Exception as e:
self._log("warning", f"on_failover 回调异常(忽略): {e}")
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型接入点(模型档案)交互式管理工具
不用手写 config.json:菜单式列出 / 添加 / 删除 / 连通性验证
`models` 节的模型接入点(OpenAI Chat Completions 兼容端点:
DeepSeek / Kimi / Qwen / GLM / 本地 Ollama 等)。
用法:
python model_config.py # 交互式菜单
python model_config.py --list # 非交互:列出当前接入点
安全约定(与 aibox 审计一致):API Key 一律不写入配置文件——
只保存环境变量名(api_key_env),或留空表示免密本地端点。
"""
import argparse
import os
import re
import sys
from typing import Dict, List, Optional
# 允许从仓库根目录或 aibox/ 目录启动
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import ConfigManager # noqa: E402
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# 常见厂商端点提示(首行是默认建议值)
PROVIDER_HINTS = {
"DeepSeek": "https://api.deepseek.com/v1/chat/completions",
"Kimi (Moonshot)": "https://api.moonshot.cn/v1/chat/completions",
"Qwen (阿里云百炼)": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"GLM (智谱)": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
"Ollama 本地": "http://localhost:11434/v1/chat/completions",
}
# ==================== 核心逻辑(可单测,不依赖 input) ====================
def get_models(config: ConfigManager) -> Dict:
"""取 models 节(不存在则初始化为空 dict)。"""
models = config.get("models") or {}
if not isinstance(models, dict):
models = {}
return models
def list_profiles(config: ConfigManager) -> List[Dict]:
"""列出所有档案的归一化展示数据。"""
models = get_models(config)
default = config.get("api.default_model") or ""
profiles = []
for name, profile in models.items():
if not isinstance(profile, dict):
continue
profiles.append({
"name": name,
"is_default": name == default,
"display_name": profile.get("display_name") or name,
"api_url": profile.get("api_url", ""),
"model": profile.get("model", ""),
"api_key_env": profile.get("api_key_env", ""),
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
"temperature": profile.get("temperature"),
"timeout": profile.get("timeout"),
"fallback": profile.get("fallback") or [],
})
return profiles
def validate_new_profile(config: ConfigManager, name: str, api_url: str,
model: str) -> Optional[str]:
"""校验新档案字段,返回错误信息(合法返回 None)。"""
if not name:
return "档案名不能为空"
if not PROFILE_NAME_RE.match(name):
return f"档案名只能包含字母/数字/下划线/短横线(当前: {name})"
models = get_models(config)
if name in models:
return f"档案名 '{name}' 已存在"
if not api_url or not str(api_url).strip().lower().startswith(("http://", "https://")):
return "API 地址必须是 http:// 或 https:// 开头的完整 URL"
if not model or not str(model).strip():
return "模型名不能为空(如 deepseek-chat / gpt-4o / qwen-plus)"
return None
def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
api_key_env: str = "", display_name: str = None,
temperature: float = None, timeout: int = None,
reasoning_enabled: bool = False,
fallback: Optional[List[str]] = None,
set_default: bool = False) -> List[str]:
"""添加档案。返回提示信息列表;字段非法时返回带 ❌ 的错误信息。"""
err = validate_new_profile(config, name, api_url, model)
if err:
return [f"❌ {err}"]
models = get_models(config)
profile = {
"api_url": str(api_url).strip(),
"model": str(model).strip(),
}
if api_key_env:
profile["api_key_env"] = api_key_env.strip()
else:
# 空串 = 免密本地端点(Ollama/vLLM),显式声明
profile["api_key_env"] = ""
if display_name:
profile["display_name"] = display_name.strip()
if temperature is not None:
try:
profile["temperature"] = max(0.0, min(2.0, float(temperature)))
except (TypeError, ValueError):
return ["❌ 温度必须是 0.0~2.0 的数字"]
if timeout is not None:
try:
profile["timeout"] = max(5, int(timeout))
except (TypeError, ValueError):
return ["❌ 超时必须是整数秒"]
if reasoning_enabled:
profile["reasoning_enabled"] = True
if fallback:
# 只保留已存在的档案,避免悬空引用(L4a 启动期硬性报错)
known = set(models.keys())
invalid = [f for f in fallback if f not in known or f == name]
if invalid:
return [f"❌ 失败转移包含不存在的档案或自身: {invalid},请重新选择"]
profile["fallback"] = list(fallback)
models[name] = profile
config._config["models"] = models
if set_default:
config._config.setdefault("api", {})["default_model"] = name
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
if not saved:
msgs.append("⚠️ 保存失败(仅本次运行生效,重启后丢失)——请检查配置文件目录权限")
if set_default:
msgs.append(f"⭐ 已设为默认接入点(--model 不指定时使用)")
if not api_key_env:
msgs.append("ℹ️ 免密端点:请求不携带 API Key(适用于 Ollama/vLLM 等本地服务)")
else:
msgs.append(f"🔑 Key 从环境变量 {api_key_env} 读取(未设置时该档案不可用)")
return msgs
def delete_profile(config: ConfigManager, name: str) -> List[str]:
"""删除档案,并自动清理其他档案 fallback 中的悬空引用。"""
models = get_models(config)
if name not in models:
return [f"❌ 档案 '{name}' 不存在"]
del models[name]
# 清理其他档案对该档案的 fallback 引用(悬空引用会导致启动期报错)
cleaned = []
for other, profile in models.items():
if not isinstance(profile, dict):
continue
fb = profile.get("fallback") or []
if name in fb:
profile["fallback"] = [f for f in fb if f != name]
cleaned.append(other)
config._config["models"] = models
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
if config.get("api.default_model") == name:
config._config.setdefault("api", {}).pop("default_model", None)
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已删除"]
if cleaned:
msgs.append(f"ℹ️ 已同时清理 {len(cleaned)} 个档案的失败转移引用: {', '.join(cleaned)}")
if not saved:
msgs.append("⚠️ 保存失败——请检查配置文件目录权限")
return msgs
def test_profile(config: ConfigManager, name: str, logger=None) -> str:
"""连通性验证:向该档案端点发一次最小请求。"""
models = get_models(config)
if name not in models:
return f"❌ 档案 '{name}' 不存在"
try:
from llm_client import create_llm_client
client = create_llm_client(config, api_key=None, logger=logger, model=name)
result = client.chat_completion(
[{"role": "user", "content": "ping"}], max_tokens=5)
if result is None or not str(result).strip():
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
except Exception as e:
return f"❌ 档案 '{name}' 验证失败:{str(e)}"
# ==================== 交互式菜单 ====================
def _ask(prompt: str, default: str = "") -> str:
if default:
val = input(f"{prompt}(回车默认: {default}): ").strip()
return val if val else default
return input(f"{prompt}: ").strip()
def _ask_yes_no(prompt: str, default: bool = False) -> bool:
hint = "y/n" if not default else "Y/n"
while True:
val = input(f"{prompt} [{hint}] ").strip().lower()
if not val:
return default
if val in ("y", "yes", "是"):
return True
if val in ("n", "no", "否"):
return False
print(" 请输入 y 或 n")
def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]:
"""编号选择(单选或多选)。多选用逗号分隔,直接回车 = 不选。"""
for i, opt in enumerate(options, 1):
print(f" {i}. {opt}")
if multi:
while True:
raw = input(f"{prompt}(多选,逗号分隔,直接回车不选): ").strip()
if not raw:
return []
try:
idxs = [int(x) for x in raw.replace(",", ",").split(",") if x.strip()]
except ValueError:
print(" 请输入数字编号,逗号分隔")
continue
if all(1 <= i <= len(options) for i in idxs):
return [options[i - 1] for i in idxs]
print(f" 编号超出范围(1~{len(options)})")
else:
while True:
raw = input(f"{prompt}(1~{len(options)},直接回车不选): ").strip()
if not raw:
return []
try:
idx = int(raw)
except ValueError:
print(" 请输入数字编号")
continue
if 1 <= idx <= len(options):
return [options[idx - 1]]
print(f" 编号超出范围(1~{len(options)})")
def _print_profiles(profiles: List[Dict]):
if not profiles:
print(" (当前没有任何接入点。旧版 api 节仍可用,或选 2 添加)")
return
for p in profiles:
mark = " ⭐默认" if p["is_default"] else ""
key_desc = f"环境变量 {p['api_key_env']}" if p["api_key_env"] else "免密"
fb = f",失败转移 → {', '.join(p['fallback'])}" if p["fallback"] else ""
print(f" {p['name']}{mark} [{p['display_name']}]")
print(f" 地址: {p['api_url']} 模型: {p['model']}")
print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}")
def _add_flow(config: ConfigManager):
print("\n── 添加接入点 ──")
print("常见厂商端点参考:")
for k, v in PROVIDER_HINTS.items():
print(f" {k}: {v}")
name = _ask("档案名(英文,如 deepseek/kimi/local)")
if not name:
print("❌ 已取消(档案名不能为空)")
return
if not PROFILE_NAME_RE.match(name):
print(f"❌ 档案名只能包含字母/数字/下划线/短横线")
return
if name in get_models(config):
print(f"❌ 档案名 '{name}' 已存在")
return
api_url = _ask("API 地址")
model = _ask("模型名(如 deepseek-chat / qwen-plus)")
print("Key 方式:")
print(" 1. 环境变量名(推荐,如 DEEPSEEK_API_KEY——Key 不落盘,重启不丢)")
print(" 2. 免密本地端点(如 Ollama,不需要 Key)")
key_mode = _ask("选择 [1/2]", "1")
api_key_env = ""
if key_mode == "1":
api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY")
elif key_mode != "2":
print("❌ 已取消(Key 方式无效)")
return
display_name = _ask("显示名(可选,回车用档案名)")
temperature_raw = _ask("温度 0.0~2.0(可选,越低越确定)")
timeout_raw = _ask("超时秒数(可选)")
reasoning = _ask_yes_no("启用思考模式?", default=False)
profiles = list_profiles(config)
fallback = []
if profiles:
print("失败转移:主接入点故障时按顺序尝试的备用接入点(可选)")
fallback = _choose("选择备用接入点", [p["name"] for p in profiles], multi=True)
set_default = _ask_yes_no("设为默认接入点?", default=(len(profiles) == 0))
print("\n── 确认 ──")
print(f" 档案名: {name}")
print(f" 地址: {api_url} 模型: {model}")
print(f" Key: {'环境变量 ' + api_key_env if api_key_env else '免密(不携带)'}")
if display_name:
print(f" 显示名: {display_name}")
if temperature_raw:
print(f" 温度: {temperature_raw}")
if timeout_raw:
print(f" 超时: {timeout_raw}s")
if reasoning:
print(" 思考模式: 开启")
if fallback:
print(f" 失败转移: {', '.join(fallback)}")
if not _ask_yes_no("确认保存?", default=True):
print("已取消")
return
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,
reasoning_enabled=reasoning, fallback=fallback,
set_default=set_default)
for m in msgs:
print(m)
def _delete_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 删除接入点 ──")
picked = _choose("选择要删除的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
name = picked[0]
if not _ask_yes_no(f"确认删除 '{name}'?(其他档案的失败转移引用会自动清理)", default=False):
print("已取消")
return
for m in delete_profile(config, name):
print(m)
def _test_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 连通性验证 ──")
picked = _choose("选择要验证的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
print(" 正在请求端点(最长 20 秒)...")
print(test_profile(config, picked[0]))
def run_interactive(config: ConfigManager = None):
"""交互式主循环。config 可注入(测试用)。"""
config = config or ConfigManager()
config_path = getattr(config, "_config_path", None) or "(默认位置)"
print("=" * 56)
print(" aibox 大模型接入点管理工具")
print(f" 配置文件: {config_path}")
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
print("=" * 56)
while True:
print("\n请选择操作:")
print(" 1. 📋 列出接入点")
print(" 2. ➕ 添加接入点")
print(" 3. 🗑️ 删除接入点")
print(" 4. 🔍 验证接入点连通性")
print(" 0. 退出")
choice = input("> ").strip()
if choice == "1":
_print_profiles(list_profiles(config))
elif choice == "2":
_add_flow(config)
elif choice == "3":
_delete_flow(config)
elif choice == "4":
_test_flow(config)
elif choice in ("0", "q", "exit", "退出"):
print("再见")
return
else:
print("无效选择,请输入 0~4")
def main():
parser = argparse.ArgumentParser(description="aibox 大模型接入点管理工具")
parser.add_argument("--list", action="store_true", help="非交互:列出当前接入点")
args = parser.parse_args()
config = ConfigManager()
if args.list:
_print_profiles(list_profiles(config))
return
run_interactive(config)
if __name__ == "__main__":
main()
+2 -1
View File
@@ -514,7 +514,8 @@ class ConversationCompressor:
final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"])
print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}")
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens/original_tokens:.1%})")
# L6:压缩块全空时 original_tokens 可能为 0,除零防护
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens / max(original_tokens, 1):.1%})")
# 检查是否压缩到阈值以下
if final_count >= threshold:
+21 -7
View File
@@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器
"""
import json
import os
import time
from datetime import datetime
from pathlib import Path
@@ -68,10 +69,19 @@ class ConversationHistory:
return {"conversations": [], "last_id": 0}
return {"conversations": [], "last_id": 0}
@staticmethod
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)
def _save_history_index(self):
"""保存历史索引"""
with open(self.history_file, 'w', encoding='utf-8') as f:
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
self._atomic_write_json(self.history_file, self.history_index)
def _load_current_conversation(self):
"""加载当前的对话(如果有)"""
@@ -86,8 +96,7 @@ class ConversationHistory:
def save_current_conversation(self):
"""保存当前对话"""
if self.current_conversation:
with open(self.current_file, 'w', encoding='utf-8') as f:
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
self._atomic_write_json(self.current_file, self.current_conversation)
def start_new_conversation(self, topic: str = None) -> Dict:
"""开始新对话"""
@@ -145,8 +154,7 @@ class ConversationHistory:
counter += 1
archive_path = self.archive_dir / archive_filename
with open(archive_path, 'w', encoding='utf-8') as f:
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
self._atomic_write_json(archive_path, self.current_conversation)
self.history_index["conversations"].append({
"id": self.current_conversation["id"],
@@ -350,7 +358,13 @@ class ConversationHistory:
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
return self.current_conversation
file_path = self.archive_dir / file_name
# I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
if not target.startswith(archive_root + os.sep):
return None
file_path = Path(target)
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
+37 -12
View File
@@ -232,14 +232,15 @@ class MemoryDatabase:
return results
def get_recent_memories(self, limit: int = 10) -> List[Dict]:
"""获取最近的记忆。limit=None 时不限制返回条数(供分页使用)。"""
"""获取最近的记忆。limit=None 时使用默认上限(M4:此前 None 无 LIMIT
全表加载,记忆库增长后单次调用 fetchall 全量入内存;与 search_memories
的 None→默认上限语义对齐)。"""
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if limit is None:
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC')
else:
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
results = []
for row in cursor.fetchall():
@@ -401,11 +402,14 @@ class MemoDatabase:
# 如果传入了 before_time,使用它,否则使用当前时间
compare_time = before_time if before_time else now
# I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤——
# 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层,
# Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。
# 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。
query = '''
SELECT * FROM memos
WHERE is_completed = 0
AND reminder_time IS NOT NULL
AND reminder_time <= ? -- 严格小于等于比较时间
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
'''
@@ -414,11 +418,13 @@ class MemoDatabase:
query += ' ORDER BY reminder_time ASC, priority DESC'
params = [compare_time, compare_time]
params = [compare_time]
if limit:
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(int(limit))
params.append(max(0, int(limit)))
cursor.execute(query, params)
@@ -439,7 +445,11 @@ class MemoDatabase:
# 调试日志
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
except (ValueError, TypeError) as e:
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}")
# I 批(审计 2026-08-18):格式异常此前被静默丢弃——
# 任务永不执行且用户无感知。改为按"到期"返回并告警,
# 至少保证任务被处理而非永久丢失
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}")
results.append(memo)
else:
# 没有提醒时间的任务不应该在待执行列表中
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
@@ -460,9 +470,11 @@ class MemoDatabase:
'''
params = []
if limit:
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(int(limit))
params.append(max(0, int(limit)))
cursor.execute(query, params)
@@ -524,6 +536,10 @@ class MemoDatabase:
with sqlite3.connect(self.db_path) 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")
cursor.execute("BEGIN IMMEDIATE")
try:
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
@@ -672,6 +688,15 @@ class MemoDatabase:
return True
return False
def delete_memo(self, memo_id: int) -> bool:
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
conn.commit()
return cursor.rowcount > 0
def get_memo_stats(self) -> Dict:
"""获取任务统计信息"""
with sqlite3.connect(self.db_path) as conn:
+69 -18
View File
@@ -13,6 +13,7 @@ subsystems.executors - 命令执行器
import os
import queue
import signal
import subprocess
import threading
import time
@@ -71,17 +72,44 @@ class SubprocessCommandExecutor(CommandExecutor):
def __init__(self, logger=None):
super().__init__(logger)
self._current_process = None
self._current_pgid = None # L9:Popen 后立即捕获,shell 死亡后 getpgid 会失败
self._process_lock = threading.Lock()
def _terminate_tree(self, proc, force: bool = False):
"""终止整个进程组(L9:POSIX 下 shell=True 只杀 shell 时,孙进程
(`(x &)` 等)仍持管道写端——reader 阻塞无 EOF、fd/线程悬挂至孙
进程退出,写满 64KB 管道缓冲后孙进程也卡死。start_new_session 使
shell 成为会话首进程,按组信号可一次覆盖整棵进程树)。
组 ID 在 Popen 后立即保存:shell 死亡后 os.getpgid(pid) 会抛
ProcessLookupError,届时无法再从进程反查组。
Windows 无进程组概念,退回单进程 terminate/kill。"""
try:
if os.name == "posix":
sig = signal.SIGKILL if force else signal.SIGTERM
with self._process_lock:
pgid = self._current_pgid
if pgid is None:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, sig)
elif force:
proc.kill()
else:
proc.terminate()
except Exception:
try:
proc.kill()
except Exception:
pass
def cancel(self):
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
"""取消当前正在运行的命令(kill子进程组)。线程安全。"""
with self._process_lock:
proc = self._current_process
if proc is not None:
try:
if proc.poll() is None:
proc.kill()
self.logger.info("已终止正在运行的子进程")
self._terminate_tree(proc, force=True)
self.logger.info("已终止正在运行的子进程组")
except Exception:
pass
@@ -117,14 +145,14 @@ class SubprocessCommandExecutor(CommandExecutor):
while True:
# 检查超时(readline 阻塞不再影响本判断)
if timeout > 0 and time.time() - start_time > timeout:
process.terminate()
self._terminate_tree(process)
timeout_occurred = True
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
process.terminate()
self._terminate_tree(process)
break
try:
@@ -140,6 +168,12 @@ class SubprocessCommandExecutor(CommandExecutor):
if shell_dead_since is None:
shell_dead_since = time.time()
elif time.time() - shell_dead_since > 1.0:
# L9:shell 已死但孙进程仍持管道写端(`(sleep 300 &) ;
# echo done` 形态)。只靠 daemon reader 兜底会悬挂线程
# 与管道 fd 至孙进程退出,写满 64KB 缓冲后孙进程自身
# 也卡死。此处强杀进程组收尾(nohup 重定向输出的
# 真正脱离场景不持管道,不会走到这里)。
self._terminate_tree(process, force=True)
break
continue
@@ -147,10 +181,12 @@ class SubprocessCommandExecutor(CommandExecutor):
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
# L5:decode 用 errors='ignore'——按字节截断可能落在多字节
# 字符中间,errors='replace' 会在行尾留下半个字符(�)
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='ignore'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
self._terminate_tree(process)
break
output_chunks.append(line)
@@ -209,25 +245,30 @@ class SubprocessCommandExecutor(CommandExecutor):
bufsize=1,
universal_newlines=True,
env=exec_env,
cwd=os.path.expanduser(work_dir)
cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
)
# 保存进程引用以便cancel()可以kill它
with self._process_lock:
self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time()
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
# 确保进程终止
if process.poll() is None:
if timeout_occurred or truncated:
process.kill()
else:
process.terminate()
self._terminate_tree(process, force=timeout_occurred or truncated)
try:
process.wait(timeout=2)
except:
process.kill()
self._terminate_tree(process, force=True)
return_code = process.returncode if process.returncode is not None else -1
@@ -255,10 +296,11 @@ class SubprocessCommandExecutor(CommandExecutor):
return f"❌ 执行错误: {str(e)}"
finally:
if process and process.poll() is None:
process.kill()
self._terminate_tree(process, force=True)
# 清除当前进程引用
with self._process_lock:
self._current_process = None
self._current_pgid = None
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
work_dir: str = None, env: Dict = None, **kwargs):
@@ -296,12 +338,20 @@ class SubprocessCommandExecutor(CommandExecutor):
bufsize=1,
universal_newlines=True,
env=exec_env,
cwd=os.path.expanduser(work_dir)
cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
)
self.process = process
# 保存进程引用以便cancel()可以kill它
with self._process_lock:
self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time()
# H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
@@ -315,7 +365,7 @@ class SubprocessCommandExecutor(CommandExecutor):
# 如果超时或截断,确保进程被终止
if (timeout_occurred or truncated) and process.poll() is None:
process.kill()
self._terminate_tree(process, force=True)
try:
process.wait(timeout=2)
except:
@@ -352,11 +402,12 @@ class SubprocessCommandExecutor(CommandExecutor):
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
finally:
if process and process.poll() is None:
process.terminate()
self._terminate_tree(process)
try:
process.wait(timeout=2)
except:
process.kill()
self._terminate_tree(process, force=True)
self.process = None
with self._process_lock:
self._current_process = None
self._current_pgid = None
+33 -16
View File
@@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器
import os
import re
import shutil
import threading
from datetime import datetime
from typing import Dict, List, Optional
@@ -35,6 +36,7 @@ class PromptManager:
"""
_instance = None
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
_prompts = []
_prompts_dir = None
@@ -43,12 +45,18 @@ class PromptManager:
STATUS_FILE = "100_STATUS.md"
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
# 超长内容拒绝写入(工具层有更友好的提示)。
MAX_CONTENT_LENGTH = 10000
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
@@ -60,10 +68,16 @@ class PromptManager:
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
return os.path.expanduser(prompts_dir)
def _load_prompts(self):
def _load_prompts(self, target: Optional[List[Dict]] = None):
"""加载提示词到 target 列表(缺省 self._prompts)。
M6:reload 传入新列表本地构建,完成后由 reload 原子替换——
此前先清空 self._prompts 再逐文件重读,并发读者(其他连接构建
system 消息)会读到空/半截提示词列表。
"""
prompts = target if target is not None else self._prompts
self._prompts_dir = self._get_prompts_dir()
os.makedirs(self._prompts_dir, exist_ok=True)
self._prompts = []
# 确保核心文件存在
self._ensure_identity_file()
@@ -86,17 +100,14 @@ class PromptManager:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
self._prompts.append({
prompts.append({
'file': filename,
'content': content
})
except Exception as e:
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE]
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
if not self._prompts:
if not prompts:
self._create_default_prompts()
def _get_template_dir(self) -> str:
@@ -148,6 +159,9 @@ class PromptManager:
def update_identity(self, content: str) -> bool:
"""更新用户身份信息"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -168,6 +182,9 @@ class PromptManager:
def update_status(self, content: str) -> bool:
"""更新智能体状态"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -188,6 +205,9 @@ class PromptManager:
def update_soul(self, content: str) -> bool:
"""更新智能体灵魂定义 - 所有角色定位内容"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -240,10 +260,6 @@ class PromptManager:
return """我是DeepSeek,一个智能AI助手。
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(以1_开头的文件)"""
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
def get_soul_prompt(self) -> Optional[Dict]:
"""获取灵魂提示词(99_SOUL.md)"""
for p in self._prompts:
@@ -296,9 +312,10 @@ class PromptManager:
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
def reload(self):
"""重新加载所有提示词"""
self._prompts = []
self._load_prompts()
"""重新加载所有提示词(M6:构建新列表后原子替换,并发读者始终读到完整列表)"""
new_prompts: List[Dict] = []
self._load_prompts(new_prompts)
self._prompts = new_prompts
return len(self._prompts)
def get_prompt_count(self) -> int:
+155 -1
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
import pytest
from common import constants
from common import ConfigManager, constants
from subsystems import (
MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
SubprocessCommandExecutor,
@@ -425,6 +425,160 @@ def test_get_proxy_when_disabled():
assert "未启用" in r or "启用" in r
# ---------- SetProxyTool 连通性验证(设置前验证,失败不落盘) ----------
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
class _FakeResp:
def __init__(self, status_code):
self.status_code = status_code
def _fake_session_factory(exc=None, resp=None):
"""构造替代 requests.Session 的假类:get() 抛异常或返回假响应"""
calls = []
class _FakeSession:
def __init__(self):
self.trust_env = True
def get(self, *args, **kwargs):
calls.append((args, kwargs))
if exc:
raise exc
return resp
_FakeSession.calls = calls
return _FakeSession
def test_check_connectivity_ok(monkeypatch):
fake = _fake_session_factory(resp=_FakeResp(204))
monkeypatch.setattr("tools.proxy_tools.requests.Session", fake)
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert ok and kind == ""
# trust_env 必须关掉:排除系统 http_proxy 环境变量干扰
assert fake.calls, "应发出探测请求"
def test_check_connectivity_classification(monkeypatch, tmp_path):
"""异常类型/消息 → 错误类型分类(与系统语言无关)"""
import requests as requests_lib
cases = [
# Windows 本地化的连接拒绝消息:靠异常类型而非英文关键词分类
(requests_lib.exceptions.ConnectionError(
"[WinError 10061] 由于目标计算机积极拒绝,无法连接。"), "connect_failed"),
# ConnectTimeout 同时继承 ConnectionError/Timeout,必须按 Timeout 归类
(requests_lib.exceptions.ConnectTimeout("connection timeout"), "timeout"),
(requests_lib.exceptions.ConnectionError(
"SOCKS5 authentication failed"), "auth_failed"),
(requests_lib.exceptions.ConnectionError(
"Missing dependencies for SOCKS support."), "missing_dependency"),
(Exception("Read timed out"), "timeout"), # 兜底:非 requests 异常按消息
(Exception("Connection refused"), "connect_failed"),
(Exception("something weird"), "error"),
]
t = SetProxyTool()
for exc, expect_kind in cases:
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(exc=exc))
ok, kind, _ = t._check_connectivity("127.0.0.1", 1080, "u", "p")
assert not ok
assert kind == expect_kind, f"{exc!r} 应分类为 {expect_kind},实际 {kind}"
def test_check_connectivity_bad_status(monkeypatch):
"""探测端点返回非 200/204 → probe_failed"""
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(resp=_FakeResp(502)))
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert not ok and kind == "probe_failed"
def test_set_proxy_rejected_when_unreachable(fresh_config, monkeypatch):
"""验证失败:返回 ❌ 且配置不落盘"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (False, "connect_failed", "Connection refused"))
t = SetProxyTool()
r = t.execute(host="10.255.255.1", port=1080)
assert "❌" in r
assert "未保存" in r
# 配置未写入:代理仍未启用
assert fresh_config().get_proxy() is None
def test_set_proxy_saved_when_check_passes(fresh_config, monkeypatch):
"""验证通过:配置保存且结果带连通性详情"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (True, "", "探测成功 HTTP 204"))
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080)
assert "✅" in r
assert "探测成功" in r
proxies = fresh_config().get_proxy()
assert proxies and "127.0.0.1:1080" in proxies["http"]
def test_set_proxy_skip_check_bypasses_validation(fresh_config, monkeypatch):
"""skip_check=True:不验证直接保存(代理未启动的预配置场景)"""
called = []
def _no_call(self, *a, **kw):
called.append(1)
raise AssertionError("skip_check=True 时不应调用验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, skip_check=True)
assert "✅" in r
assert not called
assert fresh_config().get_proxy() is not None
def test_set_proxy_disabled_skips_check(fresh_config, monkeypatch):
"""enabled=False:禁用代理无需验证"""
def _no_call(self, *a, **kw):
raise AssertionError("禁用代理不应触发验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, enabled=False)
assert "✅" in r
assert fresh_config().get_proxy() is None # 禁用态 get_proxy 返回 None
def test_set_proxy_empty_env_hint(fresh_config, monkeypatch):
"""username + password_env 指向未设环境变量:精准提示而非裸 auth 失败"""
monkeypatch.delenv("AIBOX_TEST_SOCKS_PW", raising=False)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080,
username="u1", password_env="AIBOX_TEST_SOCKS_PW")
assert "❌" in r
assert "环境变量" in r
assert "未保存" in r
assert fresh_config().get_proxy() is None
# ==================== AskUserTool ====================
def test_ask_user_interface():
+408
View File
@@ -0,0 +1,408 @@
"""aibox 第四轮审计 Batch H 修复回归测试(2026-08-18,审计报告 §十一)
覆盖:
- H1:子代理共享父取消事件后首轮 think 不再清除父事件(N5 回归)+ 父已取消时跳过启动
- M3:调度器任务级超时兜底(AI think 卡死不再停摆调度循环)
- M4:get_recent_memories(limit=None) 使用默认上限(不再全表加载)
- M5:memo delete 物理删除(此前只标记完成)
- M6:PromptManager.reload 原子替换(并发读者不再读到空/半截列表)
- L1:read_file 的 sed 参数强转 int(防引号注入)
- L2:set_proxy 落盘失败如实报告(不再静默报"已保存")
- L3:SSE 非 UTF-8 行不中断流(errors=replace)
- L4:persona 路径校验解析符号链接(软链越界拒绝)
- L5:1MB 截断不再产生半个字符(�)
- L6:compressor 空内容压缩不再除零
- L7:InstallSkillTool 指引命令路径引用(防拆词/注入)
- L8:ask_user 缺参返回明确错误(不再 TypeError)
- L10:web 工具仅允许 http/https(file:// 等拒绝)
- L11:registry 日志打码改包含匹配(apiKey/access_token 等变体)
- L12:FailoverClient 日志 Bearer 令牌脱敏
M1(/reload_prompts 移线程池)/M2(技能扫描启动前预热)为 async WebSocket /
启动路径,依赖事件循环与真实连接对象,由既有 WebSocket 测试面与启动逻辑回归。
"""
import asyncio
import os
import queue
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")
import aibox # noqa: E402
from aibox import DeepSeekChat, TaskScheduler, run_full_subagent # noqa: E402
from llm_client import DeepSeekClient, FailoverClient, _sanitize_log_msg # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_compressor import ConversationCompressor # noqa: E402
from common import constants # noqa: E402
from test_conversation_compressor import FakeLLMClient # noqa: E402
from test_once_mode import _make_ai # noqa: E402
def _fake_sub_chat():
fake = mock.MagicMock()
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
return fake
# ==================== H1:子代理取消事件清除回归 ====================
class TestH1SubagentCancelClear:
def test_shared_event_not_cleared_on_first_think(self):
"""共享父事件后首轮 think 不清除事件——父级已置位的取消信号不被吞掉"""
ai = _make_ai()
parent_event = threading.Event()
parent_event.set()
ai._cancel_event = parent_event
ai._shared_cancel_event = True
with pytest.raises(InterruptedError):
ai._think_and_respond_impl("hi")
assert parent_event.is_set()
def test_own_event_still_cleared_on_first_think(self):
"""未共享(普通会话)时首轮仍清除自身事件——原语义不回归"""
import contextlib
ai = _make_ai()
ai._cancel_event.set()
with contextlib.suppress(Exception):
ai._think_and_respond_impl("hi")
assert not ai._cancel_event.is_set()
def test_subagent_not_started_when_parent_already_cancelled(self):
"""父事件已置位 → 直接返回取消提示,不启动子代理"""
fake = _fake_sub_chat()
parent_event = threading.Event()
parent_event.set()
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
result = run_full_subagent("r", "t", None, cancel_event=parent_event)
assert "取消" in result
fake.think_and_respond.assert_not_called()
# ==================== M3:调度器任务超时 ====================
class _BlockingAI:
"""think 卡死直到被 cancel 的伪 AI(模拟 LLM 挂起)"""
def __init__(self):
self.cancelled = threading.Event()
self.cancel_called = 0
def cancel(self):
self.cancel_called += 1
self.cancelled.set()
def think_and_respond(self, input_text, output_callback=None,
is_continuation=False):
self.cancelled.wait(timeout=15)
raise InterruptedError("用户中断")
class _FakeMemoDB:
def get_memo(self, memo_id):
return None
class TestM3SchedulerTimeout:
def test_stuck_task_times_out_and_scheduler_continues(self):
from aibox import ConfigManager
loop = asyncio.new_event_loop()
try:
scheduler = TaskScheduler(loop=loop, logger=None)
fake_ai = _BlockingAI()
memo = {"id": 7, "title": "卡死任务", "content": "内容", "trigger_count": 0}
orig_get = ConfigManager.get
def fake_get(self, key, default=None):
# 只把任务超时压到 0.3s,其余配置读取走原实现(防止
# DATETIME_FORMAT 等属性读到错误类型)
if key == "scheduler.task_timeout":
return 0.3
return orig_get(self, key, default)
with mock.patch.object(ConfigManager, "get", fake_get), \
mock.patch.object(scheduler, "_create_task_ai", return_value=fake_ai):
result = loop.run_until_complete(
scheduler.execute_task_as_conversation(memo, _FakeMemoDB()))
assert "超时" in result
assert fake_ai.cancel_called >= 1
finally:
loop.close()
# ==================== M4/M5:记忆上限与任务删除 ====================
class TestM4M5Db:
def test_recent_memories_none_capped(self, tmp_path, monkeypatch):
"""limit=None 不再全表加载,使用默认上限(对齐 search_memories)"""
from test_conversation_compressor import patch_const
db = MemoryDatabase(db_path=str(tmp_path / "m.db"))
for i in range(7):
db.add_memory(topic=f"t{i}", summary=f"s{i}", content="c",
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=1)
patch_const(monkeypatch, "MEMORY_MAX_SEARCH_RESULTS", 5)
res = db.get_recent_memories(limit=None)
assert len(res) == 5
def test_delete_memo_physically_removes(self, tmp_path):
"""delete_memo 真实删除行(此前 complete_memo 只标记完成)"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="待删任务", content="内容")
assert db.get_memo(mid) is not None
assert db.delete_memo(mid) is True
assert db.get_memo(mid) is None
assert db.delete_memo(mid) is False # 已不存在
def test_memo_tool_delete_uses_physical_delete(self, tmp_path):
"""MemoTool delete 走 delete_memo:行消失而非仅标记完成"""
from tools import MemoTool
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="重复任务", content="内容", repeat_type="daily")
tool = MemoTool(db, None)
result = tool.execute(action="delete", memo_id=mid, confirm=True)
assert "已删除" in result
assert db.get_memo(mid) is None
stats = db.get_memo_stats()
assert stats["total"] == 0
# ==================== M6:PromptManager 原子 reload ====================
class TestM6PromptReloadAtomic:
def test_reload_builds_new_list_then_swaps(self):
"""reload 在本地新列表构建完成后才原子替换——读者始终看到完整列表"""
pm = PromptManager()
old_list = pm._prompts
seen = []
def fake_load(target):
seen.append(target)
target.append({"file": "X_test.md", "content": "y"})
with mock.patch.object(pm, "_load_prompts", side_effect=fake_load):
n = pm.reload()
assert len(seen) == 1
assert seen[0] is not old_list # 构建用的是新列表
assert pm._prompts is seen[0] # 完成后才替换
assert n == 1
# ==================== L1:read_file 参数强转 ====================
class TestL1ReadFileCoercion:
def test_non_integer_lines_rejected(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line="1'; rm -rf /; echo '",
end_line=5)
assert "参数错误" in r
def test_valid_integers_still_work(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line=1, end_line=2)
assert "sed -n" in r and "参数错误" not in r
# ==================== L2:set_proxy 落盘失败如实报告 ====================
class TestL2ProxySaveFailure:
def test_set_proxy_returns_false_on_save_failure(self, tmp_path):
from common import ConfigManager
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
with mock.patch("builtins.open", 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")):
r = tool.execute(host="127.0.0.1", port=1080, save=True,
skip_check=True, enabled=True)
assert "保存失败" in r
# ==================== L3:SSE 非 UTF-8 行不中断流 ====================
class TestL3SseDecode:
def test_non_utf8_line_does_not_break_stream(self):
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
resp = mock.MagicMock()
resp.status_code = 200
resp.iter_lines.return_value = [
b"data: \xff\xfe",
b'data: {"choices":[{"delta":{"content":"ok"}}]}',
b"data: [DONE]",
]
with mock.patch("llm_client.requests.post", return_value=resp):
events = list(c.stream_chat([{"role": "user", "content": "hi"}]))
assert any(e.get("type") == "content" and e.get("text") == "ok"
for e in events)
# ==================== L4:persona 软链越界 ====================
class TestL4PersonaSymlink:
def test_symlink_outside_skills_dir_rejected(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
outside = tmp_path / "outside.md"
outside.write_text("秘密内容", encoding="utf-8")
(skills_dir / "evil.md").symlink_to(outside)
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("evil", "")
assert content == ""
def test_normal_persona_still_loads(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
(skills_dir / "happy.md").write_text("快乐人格", encoding="utf-8")
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("happy", "")
assert content == "快乐人格"
# ==================== L5:截断无半个字符 ====================
class TestL5NoPartialChar:
def test_truncated_output_has_no_replacement_char(self):
from subsystems.executors import SubprocessCommandExecutor
ex = SubprocessCommandExecutor()
r = ex.execute("python3 -c \"print('中' * 600000)\"", timeout=60)
assert "截断" in r
assert "\ufffd" not in r
@pytest.mark.skipif(os.name != "posix", reason="Linux 进程组语义")
def test_grandchild_holding_pipe_is_reaped(self):
"""`(sleep N &) ; echo done` 形态:shell 退出后孙进程持管道写端,
宽限窗口到期强杀进程组——不悬挂 reader、孙进程被清理"""
import subprocess as sp
from subsystems.executors import SubprocessCommandExecutor
marker = f"sleep {12340}"
ex = SubprocessCommandExecutor()
r = ex.execute(f"({marker} &) ; echo done", timeout=8)
assert "done" in r
leftover = sp.run(["pgrep", "-f", marker],
capture_output=True, text=True).stdout.strip()
assert leftover == ""
# ==================== L6:compressor 除零 ====================
class TestL6CompressorDivZero:
def test_empty_content_compress_no_div0(self, monkeypatch):
comp = ConversationCompressor(llm_client=FakeLLMClient(),
logger=None)
from test_conversation_compressor import patch_const
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = [{"role": "system", "content": "SYS"}] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": ""}
for i in range(15)
]
result = comp.compress_conversation(messages)
assert isinstance(result, list)
# ==================== L7:InstallSkillTool 路径引用 ====================
class TestL7SkillPathQuoting:
def test_guide_commands_quote_paths(self):
from tools import InstallSkillTool
tool = InstallSkillTool()
r = tool.execute(skill_path="/tmp/a b; rm -rf /")
assert "ls -l '/tmp/a b; rm -rf /'" in r
assert "cp '/tmp/a b; rm -rf /' " in r
# ==================== L8:ask_user 缺参 ====================
class TestL8AskUser:
def test_missing_question_returns_error(self):
from tools import AskUserTool
tool = AskUserTool()
assert "需要提供问题" in tool.execute()
def test_question_passed_through(self):
from tools import AskUserTool
tool = AskUserTool()
assert "测试问题" in tool.execute(question="测试问题")
# ==================== L10:web 工具 scheme 白名单 ====================
class TestL10WebScheme:
def test_file_url_rejected(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="file:///etc/shadow")
assert "仅支持 http/https" in r
def test_http_url_accepted(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="https://example.com")
assert "curl" in r and "仅支持" not in r
# ==================== L11:registry 打码包含匹配 ====================
class TestL11SanitizeSubstring:
def test_variant_keys_redacted(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({
"apiKey": "x", "access_token": "y", "passwd": "z",
"url": "https://ok",
})
assert out == {"apiKey": "***", "access_token": "***",
"passwd": "***", "url": "https://ok"}
def test_normal_keys_untouched(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({"filepath": "/tmp/a", "question": "hi"})
assert out == {"filepath": "/tmp/a", "question": "hi"}
# ==================== L12:failover 日志 Bearer 脱敏 ====================
class TestL12LogSanitize:
def test_sanitize_msg_redacts_bearer(self):
msg = "瞬时故障: HTTP 401: Bearer sk-abc123xyz"
out = _sanitize_log_msg(msg)
assert "sk-abc123xyz" not in out
assert "Bearer ***" in out
def test_failover_log_redacts_bearer(self):
logger = mock.MagicMock()
c = FailoverClient([], logger=logger)
c._log("warning", "瞬时故障(Bearer sk-secret456)")
logged = logger.warning.call_args[0][0]
assert "sk-secret456" not in logged
assert "Bearer ***" in logged
+227
View File
@@ -0,0 +1,227 @@
"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二)
覆盖:
- N9:prompt 写入内容长度上限(持久化注入面约束)
- Q7b:load_conversation 越界路径拒绝(realpath 前缀校验)
- F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退)
- Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃)
- Q8:对话文件原子写(无 .tmp 残留、JSON 完整)
- F6:WebSocketHandler 命令专用线程池存在且可释放
- limit=0 语义修正(None 才不加 LIMIT)
Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争),
不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。
"""
import os
import queue
import sqlite3
import sys
import threading
import time
import types
from pathlib import Path
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import run_light_subagent # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.prompt_tools import ( # noqa: E402
UpdateIdentityTool,
UpdateSoulTool,
SaveMemoryAndEndConversationTool,
)
# ==================== N9:prompt 写入长度上限 ====================
class TestN9PromptContentCap:
def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch):
"""manager 层兜底:超长身份内容拒绝写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False
assert not (tmp_path / pm.IDENTITY_FILE).exists()
def test_update_identity_tool_friendly_error(self, tmp_path):
"""工具层:超长内容返回明确提示而非写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateIdentityTool(pm, None)
r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_update_soul_tool_friendly_error(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateSoulTool(pm, None)
r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_save_memory_status_oversized_rejected(self, tmp_path):
"""save_memory_and_end_conversation:状态内容超长拒绝"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = SaveMemoryAndEndConversationTool(
exit_callback=lambda *a: None,
memory_db=None, prompt_manager=pm, logger=None)
r = tool.execute(summary="这是一段足够长的对话总结" * 20,
current_status="x" * PromptManager.MAX_CONTENT_LENGTH)
assert "状态内容过长" in r
def test_normal_content_still_written(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("正常身份内容") is True
assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8")
# ==================== Q7b:load_conversation 路径校验 ====================
class TestQ7bLoadConversationBoundary:
def test_traversal_rejected(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
secret = tmp_path / "secret.json"
secret.write_text('{"leak": true}', encoding="utf-8")
assert h.load_conversation("../secret.json") is None
def test_normal_file_still_loads(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [{"role": "user", "content": "hi"}],
"tool_calls": [], "stats": {},
}
h.save_current_conversation()
h.archive_current_conversation()
archives = list(h.archive_dir.glob("conversation_*.json"))
assert archives
loaded = h.load_conversation(archives[0].name)
assert loaded is not None and loaded["id"] == "1"
# ==================== F5:run_light_subagent 取消传播 ====================
class _BlockingLightClient:
"""流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端"""
model = "fake-model"
default_temperature = None
def __init__(self):
self.cancelled = threading.Event()
def cancel(self):
self.cancelled.set()
def stream_chat(self, messages, **kwargs):
yield {"type": "content", "text": "开头"}
while not self.cancelled.wait(timeout=0.1):
pass
raise RuntimeError("连接已中断(cancel)")
class TestF5LightSubagentCancel:
def test_cancelled_before_start_returns_early(self):
"""父事件已置位 → 直接返回取消提示,不创建客户端"""
parent = threading.Event()
parent.set()
with mock.patch("aibox.create_llm_client") as mk:
r = run_light_subagent("r", "t", None, cancel_event=parent)
assert "取消" in r
mk.assert_not_called()
def test_cancel_during_stream_aborts(self):
"""流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回"""
parent = threading.Event()
fake = _BlockingLightClient()
with mock.patch("aibox.create_llm_client", return_value=fake):
result_holder = {}
def _run():
result_holder["r"] = run_light_subagent(
"r", "t", None, cancel_event=parent)
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(0.5) # 等待首段产出、观察者就绪
parent.set()
th.join(timeout=5)
assert not th.is_alive() # 未挂死
assert "失败" in result_holder.get("r", "")
def test_express_emotion_registered_with_parent_event(self):
"""DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "express_emotion"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== Q5:格式异常任务不丢弃 ====================
class TestQ5MalformedReminderTime:
def test_malformed_time_task_returned_as_due(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert any(m["id"] == mid for m in pending)
def test_normal_pending_still_filtered(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert pending == []
# ==================== Q8:对话文件原子写 ====================
class TestQ8AtomicWrites:
def test_no_tmp_leftover_and_valid_json(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [], "tool_calls": [], "stats": {},
}
h.save_current_conversation()
assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性
import json
json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON
h.archive_current_conversation() # 归档后 current 文件按设计移除
assert list(Path(tmp_path).rglob("*.tmp")) == []
# ==================== F6:WS 命令专用线程池 ====================
class TestF6CmdExecutor:
def test_handler_has_dedicated_executor(self):
from aibox import WebSocketHandler
h = WebSocketHandler(ai_manager=None)
assert h._cmd_executor is not None
h.shutdown()
assert h._cmd_executor is None
# ==================== limit=0 语义 ====================
class TestLimitZeroSemantics:
def test_pending_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00")
assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == []
def test_immediate_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", is_immediate=True)
assert db.get_immediate_memos(limit=0) == []
+198
View File
@@ -0,0 +1,198 @@
"""aibox 模型接入点管理工具(model_config.py)单元测试(2026-08-18)
覆盖:
- 列表归一化(默认标记/免密/失败转移展示字段)
- 添加:字段校验(档案名/地址/模型名/重复)、Key 两种方式、温度/超时钳制、
失败转移引用校验、设为默认、落盘(含脱敏)
- 删除:物理移除、fallback 悬空引用清理、默认档案标记清除
- 连通性验证:成功 / 失败 / 档案不存在
"""
import sys
import types
from unittest import mock
import pytest
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import model_config # noqa: E402
from common import ConfigManager # noqa: E402
def _cfg(tmp_path, initial: dict = None) -> ConfigManager:
"""构造指向临时配置文件的 ConfigManager(避免碰真实 ~/.aibox)"""
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
cm._config = initial if initial is not None else {}
return cm
def _base_models():
return {
"models": {
"deepseek": {
"api_url": "https://api.deepseek.com/v1/chat/completions",
"model": "deepseek-chat",
"api_key_env": "DEEPSEEK_API_KEY",
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3",
"api_key_env": "",
},
},
"api": {"default_model": "deepseek"},
}
# ==================== 列表 ====================
class TestListProfiles:
def test_list_normalized(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
profiles = model_config.list_profiles(cm)
assert len(profiles) == 2
ds = next(p for p in profiles if p["name"] == "deepseek")
assert ds["is_default"] is True
assert ds["api_key_env"] == "DEEPSEEK_API_KEY"
local = next(p for p in profiles if p["name"] == "local")
assert local["api_key_env"] == ""
assert local["is_default"] is False
def test_list_empty_models_section(self, tmp_path):
cm = _cfg(tmp_path, {})
assert model_config.list_profiles(cm) == []
def test_list_ignores_malformed_entries(self, tmp_path):
cm = _cfg(tmp_path, {"models": {"bad": "not-a-dict"}})
assert model_config.list_profiles(cm) == []
# ==================== 校验 ====================
class TestValidateNewProfile:
def test_name_rules(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "", "https://x", "m")
assert model_config.validate_new_profile(cm, "坏名字!", "https://x", "m")
assert model_config.validate_new_profile(cm, "deepseek", "https://x", "m") # 已存在
assert model_config.validate_new_profile(cm, "ok_name", "https://x", "m") is None
def test_url_scheme_and_model_required(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "kimi", "ftp://x", "m")
assert model_config.validate_new_profile(cm, "kimi", "https://x", "")
# ==================== 添加 ====================
class TestAddProfile:
def test_add_with_env_key_and_default(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://api.moonshot.cn/v1/chat/completions",
"moonshot-v1-8k", api_key_env="KIMI_KEY",
display_name="Kimi", set_default=True)
assert any("已添加" in m for m in msgs)
assert any("设为默认" in m for m in msgs)
assert cm.get("api.default_model") == "kimi"
assert cm.get("models.kimi.api_key_env") == "KIMI_KEY"
assert cm.get("models.kimi.api_url").startswith("https://")
# 落盘文件存在且可解析
import json
disk = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert disk["models"]["kimi"]["model"] == "moonshot-v1-8k"
def test_add_without_key_is_exempt_endpoint(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(
cm, "local", "http://localhost:11434/v1/chat/completions",
"qwen3", api_key_env="")
assert any("免密" in m for m in msgs)
assert cm.get("models.local.api_key_env") == ""
def test_add_rejects_invalid_fallback(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://x/v1", "m1",
fallback=["ghost", "deepseek"])
assert any("失败转移" in m for m in msgs)
assert cm.get("models.kimi") is None # 未写入
def test_add_temperature_timeout_clamped(self, tmp_path):
cm = _cfg(tmp_path, {})
model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature=9.9, timeout=1)
assert cm.get("models.a.temperature") == 2.0
assert cm.get("models.a.timeout") == 5
def test_add_invalid_temperature_rejected(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature="abc")
assert any("温度" in m for m in msgs)
assert cm.get("models.a") is None
# ==================== 删除 ====================
class TestDeleteProfile:
def test_delete_cleans_fallback_refs(self, tmp_path):
cm = _cfg(tmp_path, {
"models": {
"main": {"api_url": "https://a", "model": "m1",
"fallback": ["backup"]},
"backup": {"api_url": "https://b", "model": "m2"},
},
})
msgs = model_config.delete_profile(cm, "backup")
assert any("已删除" in m for m in msgs)
assert any("失败转移引用" in m for m in msgs)
assert cm.get("models.main.fallback") == []
def test_delete_default_clears_default_flag(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
model_config.delete_profile(cm, "deepseek")
assert cm.get("api.default_model") is None
def test_delete_nonexistent(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.delete_profile(cm, "ghost")
assert any("不存在" in m for m in msgs)
# ==================== 连通性验证 ====================
class TestTestProfile:
def test_connectivity_ok(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.return_value = "pong"
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "连通正常" in r and "pong" in r
def test_connectivity_failure(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.side_effect = RuntimeError("连接被拒绝")
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "验证失败" in r and "连接被拒绝" in r
def test_connectivity_missing_profile(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert "不存在" in model_config.test_profile(cm, "ghost")
# ==================== 落盘失败感知 ====================
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")):
msgs = model_config.add_profile(
cm, "a", "https://x/v1", "m1", api_key_env="K")
assert any("保存失败" in m for m in msgs)
+16
View File
@@ -96,6 +96,16 @@ class ReadFileTool(FileToolBase):
# 限制max_lines上限,防止请求超大范围
max_lines = min(max_lines, 500)
# L1(审计 2026-08-18):start/end_line 为 LLM 可控参数,此前未强转
# 直拼 sed 单引号模板——LLM 传含引号的字符串可闭合引号注入命令。
# 强转 int(非法输入在此拦截)并夹取合理范围。
try:
start_line = int(start_line)
end_line = int(end_line)
except (TypeError, ValueError):
return "❌ 参数错误: start_line/end_line 必须为整数"
start_line = max(1, start_line)
end_line = max(0, min(end_line, 10_000_000))
quoted = shlex.quote(abs_path)
# 构造等价的命令行读取命令
@@ -320,6 +330,12 @@ class SimpleWebCrawlerTool(Tool):
if action not in ("get", "text", "title", "links", "search"):
return f"❌ 未知操作: {action}"
# L10(审计 2026-08-18):url 为 LLM 可控输入(可被网页内容诱导),
# 此前不限 scheme——file:// 可经生成的 curl 指引读取本地文件(SSRF)。
# 白名单 http/https 后其余协议直接拒绝。
if not url or not str(url).strip().lower().startswith(("http://", "https://")):
return "❌ 仅支持 http/https 协议地址(已拦截 file:// 等非网络协议)"
quoted_url = shlex.quote(url)
timeout = constants.WEB_TIMEOUT
user_agent = shlex.quote(constants.WEB_USER_AGENT)
+3 -1
View File
@@ -752,7 +752,9 @@ class MemoTool(Tool):
memo_id = kwargs.get("memo_id")
if memo_id:
if self.memo_db.complete_memo(memo_id, force_complete=True):
# M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
# 重复任务被推进而非移除,stats 计数失真)
if self.memo_db.delete_memo(memo_id):
return self.format_result(True, f"任务 #{memo_id} 已删除")
else:
return f"❌ 无法删除任务 #{memo_id}"
+5 -2
View File
@@ -39,10 +39,13 @@ class AskUserTool(Tool):
)
}
},
"required": []
# L8:此前 required 为空,LLM 不传 question 时 str + None 抛 TypeError
"required": ["question"]
}
def execute(self, question: str, **kwargs) -> str:
def execute(self, question: str = None, **kwargs) -> str:
if not question:
return "❌ 需要提供问题内容(question 参数)"
return (
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
)
+15
View File
@@ -56,6 +56,11 @@ class UpdateIdentityTool(Tool):
if not content:
return "❌ 需要提供身份信息内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"身份文件作为系统提示词加载,请精简后重试")
if not confirm:
return "❌ 身份更新已取消,如需更新请设置 confirm=true"
@@ -119,6 +124,11 @@ class UpdateSoulTool(Tool):
if not content:
return "❌ 需要提供智能体自我认识定义内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"灵魂文件作为系统提示词加载,请精简后重试")
if not confirm:
return "❌ 自我认识更新已取消,如需更新请设置 confirm=true"
@@ -223,6 +233,11 @@ class SaveMemoryAndEndConversationTool(Tool):
status_content += f"【下一步计划】\n{next_goals}\n\n"
status_content += f"【对话总结】\n{summary}"
# N9:状态文件作为系统提示词加载,超长拒绝写入(约束注入规模)
if len(status_content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 状态内容过长({len(status_content)} > "
f"{PromptManager.MAX_CONTENT_LENGTH} 字符),请精简 current_status/summary 后重试")
# 更新状态文件
status_updated = self.prompt_manager.update_status(status_content)
+112 -6
View File
@@ -3,18 +3,26 @@
tools.proxy_tools - 代理配置工具
抽离自 aibox.py,包含 2 个工具:
- SetProxyTool : 设置 SOCKS5 代理
- SetProxyTool : 设置 SOCKS5 代理(设置前验证连通性)
- GetProxyTool : 查看当前代理配置
依赖:common (ConfigManager) + tools.base.Tool
不反向依赖 aibox 任何符号。
"""
from typing import Dict
import os
from typing import Dict, Optional, Tuple
import requests
from common import ConfigManager
from .base import Tool
# 探测端点:无内容的轻量响应,验证完整 SOCKS5 握手(含认证)与出网连通性
_PROBE_URL = "http://www.gstatic.com/generate_204"
# (连接超时, 读超时):验证总耗时上限约 10s,避免阻塞工具调用过久
_PROBE_TIMEOUT = (4, 6)
class SetProxyTool(Tool):
"""设置 SOCKS5 代理的工具"""
@@ -25,6 +33,8 @@ class SetProxyTool(Tool):
def get_description(self) -> str:
return """设置智能体的 SOCKS5 代理配置。
调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。
启用代理时会先验证连通性(完整 SOCKS5 握手 + 探测请求),验证失败不保存配置;
代理暂未启动等场景可用 skip_check=true 跳过验证强制保存。
可随时修改,即时生效。"""
def get_parameters(self) -> Dict:
@@ -37,15 +47,106 @@ class SetProxyTool(Tool):
"password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"}
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"},
"skip_check": {"type": "boolean", "description": "跳过连通性验证强制保存(默认 false)", "default": False}
},
"required": ["host", "port"]
}
@staticmethod
def _resolve_password(password: Optional[str], password_env: Optional[str]) -> Optional[str]:
"""对齐 ConfigManager._resolve_proxy_password:明文优先,否则查环境变量"""
if password:
return password
if password_env:
return os.environ.get(password_env) or None
return None
def _check_connectivity(self, host: str, port: int,
username: Optional[str], password: Optional[str]) -> Tuple[bool, str, str]:
"""验证代理连通性:走一次完整 SOCKS5 握手 + 探测请求。
只做 TCP connect 验不了用户名/密码,必须发真实请求。
返回 (ok, error_kind, detail);error_kind 取值:
auth_failed / timeout / connect_failed / probe_failed / error
"""
if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else:
proxy_url = f"socks5://{host}:{port}"
try:
# trust_env=False:排除系统环境变量代理(http_proxy 等)干扰,只验证目标代理
session = requests.Session()
session.trust_env = False
resp = session.get(
_PROBE_URL,
proxies={"http": proxy_url, "https": proxy_url},
timeout=_PROBE_TIMEOUT,
)
# generate_204 正常返回 204;部分代理/目标返回 200 也视为连通
if resp.status_code in (200, 204):
return True, "", f"探测成功 HTTP {resp.status_code}"
return False, "probe_failed", f"探测端点返回 HTTP {resp.status_code}"
except requests.exceptions.Timeout as e:
# 含 ConnectTimeout(连接阶段超时):异常类型与系统语言无关,优先于消息匹配
return False, "timeout", str(e)
except requests.exceptions.ConnectionError as e:
# 连接层失败(拒绝/不可达/中断)。PySocks 依赖缺失与认证失败
# 也会以 ConnectionError 形态冒出,按消息细分。
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
return False, "connect_failed", msg
except Exception as e:
# 兜底:仅按库自身(非本地化)消息关键词分类
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
if "timed out" in low or "timeout" in low:
return False, "timeout", msg
if "refused" in low or "unreachable" in low or "closed" in low:
return False, "connect_failed", msg
return False, "error", msg
def execute(self, host: str, port: int, username: str = None, password: str = None,
password_env: str = None, enabled: bool = True, save: bool = True, **kwargs) -> str:
password_env: str = None, enabled: bool = True, save: bool = True,
skip_check: bool = False, **kwargs) -> str:
# 设置前连通性验证:坏地址/凭据在入口一次性暴露,避免静默落盘后在
# 后续 web/API 调用中离场景报错、LLM 难以归因。skip_check 留给
# "代理暂未启动但先写配置"的场景;禁用代理无需验证。
check_detail = ""
if enabled and not skip_check:
pw = self._resolve_password(password, password_env)
# 指定了 password_env 但环境变量为空:认证凭据缺失,提前给出精准提示
if username and not password and password_env and pw is None:
return self.format_result(False, "代理验证失败,未保存配置", {
"错误类型": "环境变量为空",
"详情": f"环境变量 {password_env} 未设置或为空",
"提示": "请先设置该环境变量,或改用 password 参数(仅本次会话有效)",
})
ok, kind, detail = self._check_connectivity(host, port, username, pw)
if not ok:
hints = {
"auth_failed": "用户名/密码错误,请核对凭据",
"timeout": "代理不可达(超时),请检查地址与网络",
"connect_failed": "连接被拒绝或不可达,请检查 host/port",
"missing_dependency": "缺少 SOCKS 支持库,请安装:pip install pysocks",
}
return self.format_result(False, "代理不可达,未保存配置", {
"错误类型": kind,
"详情": detail[:300],
"提示": hints.get(kind, "如确认配置无误可设置 skip_check=true 跳过验证"),
})
check_detail = detail
config = ConfigManager()
config.set_proxy(host, port, username, password, password_env, enabled, save)
saved = config.set_proxy(host, port, username, password, password_env, enabled, save)
proxy_info = config.get_proxy_info()
if proxy_info.get("password_env"):
pw_source = "环境变量 " + proxy_info["password_env"]
@@ -58,8 +159,13 @@ class SetProxyTool(Tool):
"服务器": f"{proxy_info['host']}:{proxy_info['port']}",
"认证": "有" if proxy_info.get("username") else "无",
"密码来源": pw_source,
"持久化": "已保存" if save else "仅内存"
"持久化": "已保存" if saved else "保存失败(仅本次会话有效,重启后丢失)"
}
if check_detail:
data["连通性"] = check_detail
# L2:落盘失败(磁盘只读等)如实报告,不再静默报"已保存"
if save and not saved:
return self.format_result(False, "代理已配置但保存失败(仅本次会话有效,重启后丢失)", data)
return self.format_result(True, "代理设置成功", data)
+7 -2
View File
@@ -16,13 +16,18 @@ from common import Logger, constants, i18n
from .base import Tool
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
_SENSITIVE_PARAM_KEYS = {"password", "api_key", "token", "secret", "authorization"}
# L11(审计 2026-08-18):精确匹配漏掉 apiKey/access_token/passwd 等变体,
# 改包含匹配——日志场景下过度打码可接受,漏打码才是问题。
_SENSITIVE_PARAM_KEYS = (
"password", "passwd", "api_key", "apikey", "token", "secret",
"authorization", "credential",
)
def _sanitize_kwargs(kwargs: Dict) -> Dict:
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
return {
k: ("***" if str(k).lower() in _SENSITIVE_PARAM_KEYS else v)
k: ("***" if any(s in str(k).lower() for s in _SENSITIVE_PARAM_KEYS) else v)
for k, v in kwargs.items()
}
+8 -3
View File
@@ -16,6 +16,7 @@ tools.skill_tools - 技能相关工具
import os
import queue
import re
import shlex
from typing import Dict, Optional
from common import constants, i18n, Logger
@@ -253,10 +254,14 @@ class InstallSkillTool(Tool):
skills_dir = constants.SKILLS_DIR
# 如果给了具体路径,在说明里带入,否则用占位符
src = skill_path.strip() if skill_path else "/path/to/your_skill.md"
raw_src = skill_path.strip() if skill_path else ""
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill"
base = os.path.splitext(os.path.basename(raw_src))[0] if raw_src else "your_skill"
dst = os.path.join(skills_dir, base + ".md")
# L7(审计 2026-08-18):src 为 LLM 可控输入,含空格/元字符时
# 未经引用的命令会被 shell 拆词/注入(指引由子代理经 run_command 执行)
src = shlex.quote(raw_src) if raw_src else "/path/to/your_skill.md"
dst_q = shlex.quote(dst)
guide = f"""📦 扩展技能安装指引
{"=" * 55}
@@ -282,7 +287,7 @@ class InstallSkillTool(Tool):
调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名):
cp {src} {dst}
cp {src} {dst_q}
⚠️ 注意:目标文件名必须带 .md 扩展名,
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
+95 -4
View File
@@ -1,9 +1,9 @@
# aibox 模块安全与质量审计报告
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)
- **审计范围**:`aibox.py`(~3026 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复)
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后)
- **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6)
---
@@ -344,3 +344,94 @@ N3/N13/N14 为 async WebSocket 路径,依赖事件循环与真实连接对象
### 10.3 测试基线
全量 **375 passed**(22.5s,Windows 开发机 `py -3`)= 原 355 + Batch G 新增 20。零回归。
---
## 十一、第四轮审计(2026-08-18,HEAD `d1f780e`)
方法:3 路并行审读(aibox.py 全文 / llm_client+common+tools / subsystems+tests)+ 主审对全部候选发现逐条代码复核,关键项 Linux 动态实证(PoC 6/6)。
### 11.1 新发现与修复(Batch H,13 项全落地)
| # | 级别 | 问题 | 位置 | 修复 |
|---|---|---|---|---|
| H1 | **高危** | **N5 修复引入回归**:子代理共享父取消事件后,首轮 `_think_and_respond_impl` 无条件 `clear()` 吞掉父级已置位的取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清,观察者线程随之失效)——取消传播被绕过,子代理跑完全程 | `aibox.py:675, 1470` | 共享事件时置 `_shared_cancel_event` 跳过 clear;赋事件后立即检查 is_set 直接返回取消 |
| M1 | 中 | `/reload_prompts` 在事件循环线程同步获取 `_msg_lock` 冻结整个事件循环(N3 同类遗漏,只修了 `/new`) | `aibox.py:2492` | 移入 `run_in_executor`(与 /new 一致) |
| M2 | 中 | SkillManager 懒加载在事件循环线程串行执行 `--help` 子进程(每技能 ≤10s),首条消息/首个任务触发时冻结服务 | `aibox.py:2885-2891` | 启动前预热 WS 与调度器两个共享实例 |
| M3 | 中 | 调度器任务无超时:AI think 卡死 → `await ai_future` 永久阻塞 → 调度循环停摆、后续任务全部丢失(已 mark_as_triggered 不重试) | `aibox.py:333` | `call_later` 任务级超时(`scheduler.task_timeout`,默认 600s)→ `ai.cancel()`,循环继续 |
| M4 | 中 | `get_recent_memories(limit=None)` 无 LIMIT 全表加载(db_tools 分页传 None),记忆库增长后单次调用全量入内存 | `db.py:234` | None → 默认上限(对齐 search_memories 语义) |
| M5 | 中 | `memo delete` 实为 `complete_memo(force)`——只标记完成不删行,重复任务被推进而非移除,stats 计数失真 | `db_tools.py:747` | db 层新增 `delete_memo` 物理删除,工具改调 |
| M6 | 中 | `PromptManager.reload()` 先清空 `_prompts` 再逐文件重读:并发连接构建 system 消息时读到空/半截列表(N3 移线程池后放大) | `prompt_manager.py:298` | 本地构建新列表后原子替换 |
| L1 | 低 | read_file 的 `start_line/end_line` 未强转直拼 sed 单引号模板——LLM 传含引号字符串可闭合引号注入生成的指引命令 | `basic_tools.py:97` | int() 强转 + 范围夹取 |
| L2 | 低 | `_save_config` 吞异常仅 print:磁盘只读时 set_proxy 仍报"已保存",重启后配置丢失无感知 | `config.py:284` | `_save_config` 返回落盘结果,set_proxy 透传,工具如实报告 |
| L3 | 低 | SSE 行 `decode("utf-8")` 遇非 UTF-8 字节抛 UnicodeDecodeError,整条流中断且不触发 failover | `llm_client.py:222` | `errors="replace"` |
| L4 | 低 | persona 路径校验用 abspath 未解析符号链接:skills 目录内软链指向任意文件可绕过前缀检查被读取注入 | `aibox.py:536` | realpath 后再做前缀校验 |
| L5 | 低 | 1MB 截断按字节切后 `errors='replace'` 解码,行尾出现半个字符 � | `executors.py:150` | 改 `errors='ignore'` |
| L6 | 低 | compressor 压缩块全空内容时 `original_tokens=0` → 除零崩溃 | `conversation_compressor.py:517` | `max(original_tokens, 1)` |
| L7 | 低 | InstallSkillTool 指引命令 `ls -l {src}`/`cp {src} {dst}` 未引用:LLM 可控路径含空格/元字符时 shell 拆词/注入 | `skill_tools.py:269-285` | shlex.quote 源与目标路径 |
| L8 | 低 | ask_user 的 required 为空,LLM 不传 question 时 `str + None` 抛 TypeError | `misc_tools.py:42` | required 补 question + 缺参明确报错 |
| L9 | 低 | Linux 孙进程持管道泄漏(N4 残余变体):`(sleep 300 &)` 形态 shell 已死但孙进程持写端,reader 阻塞无 EOF;**实证修复前孙进程残留** | `executors.py` | POSIX `start_new_session` + 进程组信号(组 ID 在 Popen 后立即捕获,shell 死亡后 getpgid 不可用);宽限窗口到期强杀进程组 |
| L10 | 低 | web 工具 url 无 scheme 校验(M3 遗留):`file://` 等经 curl 指引可达(SSRF) | `basic_tools.py:319` | http/https 白名单 |
| L11 | 低 | registry 日志打码精确匹配:apiKey/passwd 等变体漏网 | `registry.py:19` | 子串包含匹配(含 apiKey/passwd/credential) |
| L12 | 低 | FailoverClient 异常消息经 `_log` 落 ai.log:网关错误页若在 200 字符内回显 Authorization 头则密钥进日志 | `llm_client.py` | `_log`/on_failover 前 Bearer 令牌正则脱敏 |
### 11.2 修复实证(Linux PoC 6/6 PASS)
```
[PASS] L-1 无输出命令超时: 3.0s 返回,含超时(原 PoC-1 为 Windows powershell 命令)
[PASS] L-3 1MB截断: 结果 1.00MB(Linux 等价命令)
[PASS] L-4 cancel中断: cancel 后 2.0s 结束
[PASS] L-5 工具层1MB贯通: 结果 1.00MB
[PASS] L-9 孙进程持管道不悬挂: 1.1s 返回,reader 正常收尾
[PASS] L-9 孙进程已被清理: 残留无(修复前 `(sleep 300 &)` 孙进程残留至自然退出)
```
L-9 实证过程说明:首版实现按"break 后 killpg"处理,但 shell 已死时 `os.getpgid(pid)` 抛 ProcessLookupError,组 ID 无法反查,孙进程仍残留(实证 FAIL 抓出)。修正为 **Popen 成功后立即捕获组 ID 并保存**,killpg 用保存值——6/6 通过。
### 11.3 遗留项(维持原判 / 择机)
- **N9**(prompt 注入存储):confirm 默认 True、100_STATUS 直写无门槛——涉及交互设计(用户确认门槛),维持 G6 择机项
- **M4/M3(旧编号)**:shlex Windows 转义(Linux 部署不受影响)、Q9 代理认证 curl 指引(修复需把密码嵌入命令串,反而引入密码进日志/会话史的新泄漏,维持现状)
- **F5** express_emotion 不可中断、**F6** /new 占用默认线程池、**F8** WS 限速/空闲回收、**F9** --once 软超时(H1 修复后取消可传播,残余窗口受 timeout 有界)、**Q7a** stream_chat 取消空窗(受 timeout 有界)、Q4 busy_timeout 重试、Q5 提醒格式异常静默、Q7b load_conversation 无调用面、Q8 对话文件原子写、compressor 裸 print / token 双计、单例 __new__ 无锁、db.py `if limit:` limit=0 边界——均为低危/信息级,下批处理
- **测试环境注记**:`test_executors.py::test_execute_timeout` 与 `test_models_profile.py::test_flat_api_still_reads_key_file` 为 Windows 向用例(ping -n 语法 / 依赖 ~/.cert/deepseek.key),Linux 环境预存失败,与本轮改动无关(git stash 实证)
### 11.4 测试基线(Linux,`aibox/venv`)
全量 **408 passed** = 原 382(Linux 环境)+ Batch H 新增 26。零回归;2 个预存环境性失败同前。PoC 回归脚本为 Windows 命令集,Linux 用等价命令实证(见 11.2)。
---
## 十二、Batch I 修复落地(2026-08-18,遗留项收尾)
按 §11.3 遗留清单分 3 波实施:安全项 → 稳定性项 → 信息级顺手项。
### 12.1 修复清单
| # | 级别 | 修复内容 | 文件/位置 |
|---|---|---|---|
| N9 | 中 | prompt 写入内容长度上限(持久化注入面约束):`PromptManager.MAX_CONTENT_LENGTH=10000`,update_identity/update_soul/update_status 超长拒绝(manager 兜底 + 工具友好提示);save_memory_and_end_conversation 状态内容超长拒绝 | `prompt_manager.py`、`prompt_tools.py` |
| Q7b | 低 | `load_conversation` realpath 前缀校验,`../` 越界/软链逃逸返回 None(当前无 LLM 调用面,防御性加固) | `conversation_history.py:348` |
| F5 | 低 | 情绪子代理取消传播:`run_light_subagent` 新增 `cancel_event`,改走 stream_chat(可被 cancel 强关在途 HTTP)+ 观察者线程;父已取消早退;`ExpressEmotionTool` 构造注入父 `_cancel_event` | `aibox.py:513, 608-660, 1177` |
| Q4 | 低 | `mark_as_triggered` BEGIN IMMEDIATE 前 `PRAGMA busy_timeout = 10000`——多进程同库(CLI+服务)写竞争等待锁释放而非 5s 后直接报 locked | `db.py:527` |
| Q5 | 低 | 提醒时间格式异常任务不再静默丢弃:去掉 SQL 层 `reminder_time <= ?` 预过滤(字符串比较会静默排除格式异常行),时间过滤改由 Python 二次验证权威判断;格式异常按"到期"返回并告警 | `db.py:405-411, 442-444` |
| Q8 | 低 | 对话文件原子写:`_atomic_write_json`(tmp + os.replace)覆盖历史索引/当前对话/归档三处写路径,崩溃不留半截 JSON | `conversation_history.py` |
| F6 | 低 | WS 命令专用线程池:`WebSocketHandler._cmd_executor`(max_workers=2),`/new`、`/reload_prompts` 不再占用默认池(繁忙 AI 时多连接并发 /new 不再耗尽线程池);服务退出时 shutdown | `aibox.py` |
| F9 | 低 | --once 硬超时兜底:软超时 cancel 后若 AI 卡死在不可中断路径(HTTP 空窗、忽略信号的子进程),宽限一个超时周期后 `os._exit(4)` 强杀,保证硬性结束语义 | `aibox.py:3283-3299` |
| — | 信息 | PromptManager 单例 `__new__` 加锁(并发首例化竞态防护) | `prompt_manager.py:49-53` |
| — | 信息 | `get_pending_memos`/`get_immediate_memos` 的 `if limit:` → `if limit is not None`(limit=0 语义修正,None 才不加 LIMIT) | `db.py:420, 464` |
### 12.2 维持原判(未改动)
- **Q7a**(stream_chat 取消空窗):POST 返回前 `_current_response` 未赋值,cancel 无法中断——受 timeout 有界;根治需线程化 POST + 轮询取消,复杂度高,维持记录
- **M4(旧编号)**(shlex Windows 转义):Linux 部署不受影响
- **Q9(代理认证 curl 指引)**:修复会把密码嵌入命令串(进日志/会话史),反而引入新泄漏,维持现状
- **F8**(WS 限速/空闲回收):本地单用户产品设计项
- compressor 裸 print(终端过程展示,保留)、`estimate_tokens` 英文双计(测试锚定既有算法,仅影响压缩阈值)
### 12.3 新增测试(`tests/test_i_fixes.py` 16 例,全部通过)
N9 ×5(manager 兜底/身份/灵魂/状态超长拒绝 + 正常写入)、Q7b ×2(越界拒绝 + 正常加载)、F5 ×3(父已取消早退/流式中断/注册注入链路)、Q5 ×2(格式异常按到期返回 + 正常过滤不回归)、Q8 ×1(无 .tmp 残留 + JSON 合法)、F6 ×1(专用线程池存在与释放)、limit=0 ×2。
### 12.4 测试基线(Linux,`aibox/venv`)
全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。
+2 -2
View File
@@ -416,9 +416,9 @@ function renderMarkdown(element, content) {
html: false,
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return `<pre><code class="hljs language-${lang}">${hljs.highlight(code, {language: lang}).value}</code></pre>`
return `<pre><code class="hljs language-${lang}"><div style="font-size: 11px;">${lang}</div>${hljs.highlight(code, {language: lang}).value}</code></pre>`
} else {
return `<pre><code class="hljs">${md.utils.escapeHtml(code)}</code></pre>`
return `<pre><code class="hljs"><div style="font-size: 11px;">${lang}</div>\n${md.utils.escapeHtml(code)}</code></pre>`
}
}
});