fix(aibox): round-5 audit batch K - max_turns clamp, task input validation, XSS hardening, atomic writes, broadcast timeout

第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制:
- 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试
- J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务
  防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传
- J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务
  分隔条改 textContent、CDN 脚本补 SRI
- J3 FTS 删除换序(先 fts 后内容表,索引不再残留)
- J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/
  history 全部接入;损坏配置备份后回退
- J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载)
- J7 broadcast 并发发送 + wait_for 超时回收僵死连接
- J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、
  memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、
  skill_manager 同名键/help 截断
This commit is contained in:
qing committed 2026-08-19 07:01:13 +08:00
1 parent f4fcf175ec
commit 457bb900fb
22 files changed
+1147 -126

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
common.atomic - 原子写文件工具(J5,审计 2026-08-19)
同目录 mkstemp 临时文件 + fsync + os.replace:进程中断/断电不留半截文件;
mkstemp 随机文件名避免多进程并发写同路径互踩(conversation_history 旧实现
的固定 .tmp 名在并发场景会互相覆盖)。仅依赖标准库,供 common/subsystems
各写路径复用。
"""
import os
import tempfile
def atomic_write_text(path: str, content: str) -> None:
"""原子写文本文件。失败时清理临时文件并抛出原异常。"""
path = os.path.expanduser(path)
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".aibox_tmp_", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
+33 -10
View File
@@ -9,8 +9,12 @@ common.config - aibox 配置管理器
import os
import json
import copy
import shutil
import threading
from datetime import datetime
from typing import Dict, Optional
from .atomic import atomic_write_text
from .i18n import i18n
class ConfigManager:
"""配置管理器 - 单例模式,负责加载和保存配置"""
@@ -18,15 +22,20 @@ class ConfigManager:
_instance = None
_config = None
_config_path = None
_instance_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
# J6(审计 2026-08-19):此前单例创建与初始化均无锁——多线程首例
# 并发可双实例、双重加载配置
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._config is None:
self._load_config()
with self._instance_lock:
if self._config is None:
self._load_config()
def _get_default_config_path(self) -> str:
env_config = os.environ.get("AI_CONFIG_PATH")
@@ -183,6 +192,10 @@ class ConfigManager:
print(i18n.get('config_loaded', config_path))
except Exception as e:
print(f"⚠️ {i18n.get('error_prefix')}{e},使用默认配置")
# J5(审计 2026-08-19):损坏文件先备份再继续——此前直接改用
# 默认配置,任一后续保存会用"默认+本次改动"整体覆盖原文件,
# proxy/api/context 等全部手工配置永久丢失
self._backup_corrupted_config(config_path)
self._config = self._get_default_config()
else:
print(i18n.get('config_not_found', config_path))
@@ -190,6 +203,15 @@ class ConfigManager:
self._save_default_config(config_path)
self._migrate_legacy_proxy_password()
def _backup_corrupted_config(self, config_path: str):
"""J5:损坏配置先复制备份(带时间戳),防止后续保存整体覆盖丢失"""
try:
backup = f"{config_path}.corrupt.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
shutil.copy2(config_path, backup)
print(f"⚠️ 已备份损坏的配置文件到 {backup}")
except Exception as e:
print(f"⚠️ 备份损坏配置文件失败: {e}")
def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy()
for key, value in user.items():
@@ -201,9 +223,9 @@ class ConfigManager:
def _save_default_config(self, config_path: str):
try:
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(self._get_default_config(), f, ensure_ascii=False, indent=2)
atomic_write_text(
config_path,
json.dumps(self._get_default_config(), ensure_ascii=False, indent=2))
print(i18n.get('config_created', config_path))
except:
pass
@@ -296,9 +318,10 @@ class ConfigManager:
socks = safe_config.get("proxy", {}).get("socks5")
if socks and socks.get("password"):
socks["password"] = None
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(safe_config, f, ensure_ascii=False, indent=2)
# J5:原子写(mkstemp + fsync + os.replace),崩溃不留半截 JSON
atomic_write_text(
config_path,
json.dumps(safe_config, ensure_ascii=False, indent=2))
print(f"✅ 配置已保存到 {config_path}")
return True
except Exception as e: