Files
blackbird/aibox/common/config.py
T
qing eb88a77238 fix(aibox): never persist SOCKS5 proxy password to disk (F1/N1, +N11)
- config.set_proxy gains password_env (aligned with api_key_env
  convention): config file persists only the env var name; plaintext
  password lives in memory for the current session only
- _save_config deep-copies and nulls proxy.socks5.password before
  json.dump, so every save path is sanitized (defense in depth)
- get_proxy resolves password as in-memory plaintext first, then via
  password_env from os.environ; downstream consumers (llm_client,
  web tool curl args) unchanged
- legacy migration on load: plaintext found on disk is kept in memory
  for the session, file is immediately rewritten sanitized
- SetProxyTool exposes password_env and reports password source
- registry debug logs mask password/api_key/token/secret/authorization
  kwargs (N11)

Tests: new tests/test_config.py (3 cases: no plaintext on save,
password_env resolution, legacy migration); full suite 267 passed.
Audit report updated with Batch F progress.
2026-08-17 22:37:30 +08:00

314 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
common.config - aibox 配置管理器
抽离自 aibox.py 的 ConfigManager。仅依赖 common.i18n(在运行期方法内部使用),
不反向依赖 aibox 任何符号,可安全独立导入。
"""
import os
import json
import copy
from typing import Dict, Optional
from .i18n import i18n
class ConfigManager:
"""配置管理器 - 单例模式,负责加载和保存配置"""
_instance = None
_config = None
_config_path = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._config is None:
self._load_config()
def _get_default_config_path(self) -> str:
env_config = os.environ.get("AI_CONFIG_PATH")
if env_config:
return env_config
return os.path.expanduser("~/.aibox/config.json")
def _get_default_config(self) -> Dict:
return {
# 系统参数
"system": {
"save_dir": "~/.aibox", # 储存目录
"datetime_format": "%Y-%m-%d %H:%M:%S", # 时间格式
"date_format": "%Y%m%d_%H%M%S", # 另一个时间格式
"debug_level": 1, # 调试等级
"load_history_by_default": True, # 默认加载历史记录
"log_file": "~/.aibox/ai.log", # 日志文件
"log_max_size": 10485760, # 日志最大大小
"log_backup_count": 5, # 日志备份数量
"skills_dir": "~/.aibox/skills", # 技能目录
"prompts_dir": "~/.aibox/prompts", # 提示词目录
"workspace_dir": "~/.aibox/workspace", # 工作空间目录
"websocket_host": "localhost", # websocket监听地址
"websocket_port": 8765, # websocket监听端口
},
# 调度程序
"scheduler": {
"enabled": True, # 开启调度
"check_interval": 1.0, # 检查间隔
"max_tasks_per_run": 10, # 每次运行最大任务量
"broadcast_enabled": True # 开启广播
},
# 大模型调用接口
"api": {
"provider": "openai_compatible", # 提供商:deepseek / openai_compatible(OpenAI Chat Completions 协议兼容)
"deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL
"deepseek_model": "deepseek-v4-flash", # 模型名字
"reasoning_enabled": False, # 开启思维链
"api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量
"timeout": 120, # 调用大模型请求超时时间
"temperature": 0.7, # 大模型对话温度(类似思维发散程度)
"default_model": "deepseek" # 默认模型档案名(对应 models 节的 key)
},
# 多模型档案:可配置多个 OpenAI Chat Completions 兼容端点,按名称选择
# 示例(在 ~/.aibox/config.json 中添加):
# "models": {
# "kimi": {
# "api_url": "https://api.moonshot.cn/v1/chat/completions",
# "model": "kimi-k2",
# "api_key_env": "MOONSHOT_API_KEY", # 未配置回退 api.api_key_env;空串 "" 为免密端点
# "reasoning_enabled": false,
# "timeout": 120
# },
# "local": {
# "api_url": "http://localhost:11434/v1/chat/completions",
# "model": "qwen3:32b",
# "api_key_env": ""
# }
# }
# 选择优先级:--model <档案名> > api.default_model > 回退旧 api 扁平节。
# 默认留空:用户未配置档案时走旧 api 节(零破坏)。
"models": {},
# 大模型上下文
"context": {
"max_history_messages": 500, # 最大历史消息数量
"max_context_messages": 120, # 最大内容消息数量(?)
"max_tokens": 8192, # 最大输出token量
"max_history_conversations": 5, # 最大历史对话
"preserve_system_prompts": True, # 开启前置系统提示词
# 对话压缩配置
"compress_enabled": True, # 开启压缩
"compress_message_threshold": 200, # 压缩消息条数阈值
"compress_token_threshold": 120000, # 压缩消息词元阈值
"compress_ratio": 0.2, # 压缩率
"compress_keep_recent": 16 # 压缩保留最近的几条
},
# 定时任务参数
"memo": {
"default_reminder_minutes": 60,
"max_memos_per_check": 10, # 每次检查定时任务数量
"allow_user_tasks": True # 允许用户任务
},
# 文件
"files": {
"max_file_size": 10485710 # 最大文件大小
},
# 指令
"commands": {
"enabled": True # 开启指令
},
# 网页
"web": {
"timeout": 10, # 超时时间
"delay": 0, # 延时
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"respect_robots": False, #
"max_download_size": 10485760 # 最大下载大小
},
# 记忆
"memory": {
"db_name": "memories.db", # 数据库名
"max_search_results": 100, # 最大搜索结果
"default_importance": 2 # ?
},
# 对话
"conversation": {
"current_file": "current_conversation.json", # 当前对话文件
"history_file": "conversation_history.json", # 历史对话文件
"archive_dir": "archive" # ?
},
# 工具
"tools": {
"max_result_length": 10000, # 结果最大长度
"save_result_to_file": False, # 保存结果到文件
"tool_results_dir": "tool_results" # 工具结果存储目录
},
# 压缩器
"compressor": {
"min_messages_to_compress": 5, # 最小压缩数量
"weight_decay_factor": 0.01, # ?
"max_input_chars": 6000, # 最大输入字符
"llm_max_tokens": 1000 # 大模型最大输出词元
},
# 子代理输出
"subagent_stream_output": True, # 是否将子代理的实时输出(思考/工具调用/回答)转发到主对话侧栏
}
def _load_config(self):
config_path = self._get_default_config_path()
self._config_path = config_path
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
user_config = json.load(f)
default_config = self._get_default_config()
self._config = self._merge_configs(default_config, user_config)
print(i18n.get('config_loaded', config_path))
except Exception as e:
print(f"⚠️ {i18n.get('error_prefix')}{e},使用默认配置")
self._config = self._get_default_config()
else:
print(i18n.get('config_not_found', config_path))
self._config = self._get_default_config()
self._save_default_config(config_path)
self._migrate_legacy_proxy_password()
def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy()
for key, value in user.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._merge_configs(result[key], value)
else:
result[key] = value
return result
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)
print(i18n.get('config_created', config_path))
except:
pass
def get(self, key_path: str, default=None):
keys = key_path.split('.')
value = self._config
try:
for key in keys:
value = value[key]
return value
except (KeyError, TypeError):
return default
def reload(self):
self._load_config()
def _resolve_proxy_password(self, proxy_conf: Dict) -> Optional[str]:
"""解析代理密码:内存明文优先,否则按 password_env 查环境变量。
对齐 api_key_env 惯例:配置文件只持久化环境变量名,明文密码不落盘。
"""
password = proxy_conf.get("password")
if password:
return password
env_name = proxy_conf.get("password_env")
if env_name:
return os.environ.get(env_name) or None
return None
def _migrate_legacy_proxy_password(self):
"""一次性迁移:旧版把代理密码明文落盘。加载后明文保留在内存供本会话
使用,同时立即重写脱敏配置文件,清除盘上存量明文。"""
socks = (self._config or {}).get("proxy", {}).get("socks5")
if socks and socks.get("password"):
print("⚠️ 检测到配置文件中的代理明文密码:已保留供本次会话使用,不再落盘。"
"跨重启请将密码存入环境变量,并把 proxy.socks5.password_env 指向该变量名")
self._save_config()
def get_proxy(self) -> Optional[Dict[str, str]]:
"""获取当前代理配置(返回 requests 可用的 proxies 字典)"""
proxy_conf = self.get("proxy.socks5")
if not proxy_conf or not proxy_conf.get("enabled", False):
return None
host = proxy_conf.get("host", "127.0.0.1")
port = proxy_conf.get("port", 1080)
username = proxy_conf.get("username")
password = self._resolve_proxy_password(proxy_conf)
if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else:
proxy_url = f"socks5://{host}:{port}"
return {"http": proxy_url, "https": proxy_url}
def set_proxy(self, host: str, port: int, username: str = None, password: str = None,
password_env: str = None, enabled: bool = True, save: bool = True):
"""动态设置代理配置(内存中更新,并可选择写入配置文件)。
password 明文仅存内存(_save_config 落盘时强制脱敏);
跨重启持久化请用 password_env 指向存有密码的环境变量名。
"""
# 确保 proxy 节点存在
if "proxy" not in self._config:
self._config["proxy"] = {}
self._config["proxy"]["socks5"] = {
"enabled": enabled,
"host": host,
"port": port,
"username": username,
"password": password,
"password_env": password_env
}
if save:
self._save_config()
return True
def _save_config(self):
"""将当前配置保存到配置文件(覆盖)。
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
"""
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}")
def get_proxy_info(self) -> Dict:
"""获取代理配置的详细信息(用于显示,不回传任何密码值)"""
proxy_conf = self.get("proxy.socks5", {})
return {
"enabled": proxy_conf.get("enabled", False),
"host": proxy_conf.get("host", ""),
"port": proxy_conf.get("port", 0),
"username": proxy_conf.get("username"),
"password": self._resolve_proxy_password(proxy_conf) is not None, # 只显示是否存在
"password_env": proxy_conf.get("password_env") or ""
}