diff --git a/aibox/aibox.py b/aibox/aibox.py index 69447b9..5bcc4ef 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -2385,7 +2385,12 @@ class WebSocketHandler: async def handle_task_message(self, websocket, data): """处理任务相关消息 - 简化版,所有任务都是对话任务""" - action = data.get("action", "").strip() + # W1(审计 2026-08-20):字段类型规整——非 str 此前在 .strip() 抛 + # AttributeError,落顶层兜底以笼统"处理失败"收场 + action = data.get("action", "") + if not isinstance(action, str): + action = "" + action = action.strip() if action == "add": # 添加任务 @@ -2400,13 +2405,33 @@ class WebSocketHandler: repeat_interval_unit = data.get("repeat_interval_unit", "days") repeat_end_time = data.get("repeat_end_time") - if not title: + # W1(审计 2026-08-20):title 传 dict 等类型此前直达 sqlite 抛 + # InterfaceError(J1 except ValueError 拦不住) + if not isinstance(title, str) or not title.strip(): await self.send_message(websocket, { "type": "error", "content": "需要提供任务标题" }) return - + if content is not None and not isinstance(content, str): + await self.send_message(websocket, { + "type": "error", + "content": "任务内容必须是字符串" + }) + return + + # W2(审计 2026-08-20):delay 无校验——非数字 TypeError、负数立即 + # 执行、10^18 OverflowError。float 转换 + 钳制 [0, 10 年] + try: + delay = float(delay) + except (TypeError, ValueError): + await self.send_message(websocket, { + "type": "error", + "content": f"任务延迟必须是数字(秒): {delay!r}" + }) + return + delay = min(max(delay, 0.0), 10 * 365 * 86400) + reminder_time = None if is_immediate: reminder_time = datetime.now().strftime(constants.DATETIME_FORMAT) @@ -2481,6 +2506,12 @@ class WebSocketHandler: "type": "error", "content": i18n.get('memo_complete_failed', memo_id) }) + else: + # W3(审计 2026-08-20):缺 task_id 此前静默无响应 + await self.send_message(websocket, { + "type": "error", + "content": i18n.get('memo_complete_missing_id') + }) else: # J15(审计 2026-08-19):未知 action 此前静默吞掉,客户端无任何反馈 await self.send_message(websocket, { @@ -2490,7 +2521,10 @@ class WebSocketHandler: async def handle_command(self, websocket, data): """处理命令消息""" - command = data.get("command", "").strip() + command = data.get("command", "") + if not isinstance(command, str): # W1(审计 2026-08-20):非 str 字段规整 + return + command = command.strip() if not command: return @@ -2609,6 +2643,13 @@ class WebSocketHandler: elif command.startswith("/search "): query = command[8:].strip() + # W4(审计 2026-08-20):空关键词此前 LIKE %% 全表兜底刷屏,回用法提示 + if not query: + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('search_usage') + }) + return results = self.ai_manager.memory_db.search_memories([query]) result = i18n.get('search_results', query, len(results)) + "\n" for mem in results: @@ -2635,9 +2676,20 @@ class WebSocketHandler: elif command == "/reload": self.ai_manager.config.reload() + # W5(审计 2026-08-20):reload 经 _load_config 重建 _config,会静默 + # 丢弃 CLI 进程级覆盖(--debug/--port/--no-scheduler 等)——此处重放 + # main() 暂存在 config._cli_overrides 的覆盖 + overrides = getattr(self.ai_manager.config, "_cli_overrides", None) + note = "" + if overrides: + for section, kv in overrides.items(): + target = self.ai_manager.config._config.get(section) + if isinstance(target, dict): + target.update(kv) + note = i18n.get('config_reloaded_cli_kept') await self.send_message(websocket, { "type": "command_result", - "content": i18n.get('config_reloaded') + "content": i18n.get('config_reloaded') + note }) elif command == "/reload_prompts": @@ -2737,7 +2789,10 @@ class WebSocketHandler: async def handle_user_message(self, websocket, data): """处理用户消息 - 实时流式输出(每个连接使用独立的AI实例,支持多用户并发)""" - content = data.get("content", "").strip() + content = data.get("content", "") + if not isinstance(content, str): # W1(审计 2026-08-20):非 str 字段规整 + return + content = content.strip() if not content: return @@ -3116,10 +3171,24 @@ class EnhancedAIManager: print(f"\n{i18n.get('error_prefix')}{e}") import traceback traceback.print_exc() + # W6(审计 2026-08-20):启动/运行失败此前打印堆栈后以退出码 0 + # 收场,supervisor/脚本误判成功 + sys.exit(1) # ==================== 命令行参数处理 ==================== +def _port_number(value): + """W6(审计 2026-08-20):--port argparse 类型校验,非法值启动前即报错""" + try: + iv = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"端口必须是整数: {value!r}") + if not (1 <= iv <= 65535): + raise argparse.ArgumentTypeError(f"端口必须在 1~65535 之间: {iv}") + return iv + + def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser(description='单AI智能体系统 - WebSocket版本(所有任务都是对话任务)') @@ -3140,7 +3209,7 @@ def parse_args(): # WebSocket选项 parser.add_argument('--host', type=str, help='WebSocket服务器主机') - parser.add_argument('--port', type=int, help='WebSocket服务器端口') + parser.add_argument('--port', type=_port_number, help='WebSocket服务器端口') # 调度器选项 parser.add_argument('--no-scheduler', action='store_true', help='禁用任务调度器') @@ -3224,9 +3293,11 @@ def handle_memo_commands(manager): if args.memo_add: try: parts = args.memo_add.split('|') - title = parts[0] + # W12(审计 2026-08-20):"|x" 空标题此前直接入库;"a|b|" 尾部空段 + # 以 "" 传入绕过 J1 时间校验——空串规整为 None,空标题由 db 层拦 + title = parts[0].strip() content = parts[1] if len(parts) > 1 else "" - reminder = parts[2] if len(parts) > 2 else None + reminder = (parts[2].strip() or None) if len(parts) > 2 else None # 解析重复参数 repeat_type = parts[3] if len(parts) > 3 else "none" @@ -3418,24 +3489,52 @@ def main(): os.environ["AI_CONFIG_PATH"] = args.config config = ConfigManager() - + + # W8(审计 2026-08-20):--temperature 入口钳制,防 99/-1 直达 API 400 + if args.temperature is not None: + args.temperature = min(2.0, max(0.0, args.temperature)) + + # W11(审计 2026-08-20):config.json 的 system/scheduler 节被写成非 dict + # 时,下方 CLI 覆盖赋值 TypeError 崩启动——回退默认节 + _section_defaults = config._get_default_config() + for _section in ("system", "scheduler"): + if not isinstance(config._config.get(_section), dict): + config._config[_section] = _section_defaults.get(_section, {}) + if args.debug is not None: config._config["system"]["debug_level"] = args.debug - + if args.verbose: config._config["system"]["debug_level"] = 3 - + # 覆盖WebSocket主机和端口 if args.host: config._config["system"]["websocket_host"] = args.host if args.port: config._config["system"]["websocket_port"] = args.port - + # 调度器设置 if args.no_scheduler: config._config["scheduler"]["enabled"] = False - if args.scheduler_interval: - config._config["scheduler"]["check_interval"] = args.scheduler_interval + # W7(审计 2026-08-20):负值直达 asyncio.sleep(负) 空转热循环,0 被 falsy + # 静默忽略——统一钳制下限 0.1s + config._config["scheduler"]["check_interval"] = max(0.1, args.scheduler_interval) + + # W5(审计 2026-08-20):暂存 CLI 进程级覆盖,WS /reload 后重放(否则 + # config.reload() 重建 _config 会静默丢弃) + _cli_overrides = {"system": {}, "scheduler": {}} + if args.debug is not None: + _cli_overrides["system"]["debug_level"] = args.debug + if args.verbose: + _cli_overrides["system"]["debug_level"] = 3 + if args.host: + _cli_overrides["system"]["websocket_host"] = args.host + if args.port: + _cli_overrides["system"]["websocket_port"] = args.port + if args.no_scheduler: + _cli_overrides["scheduler"]["enabled"] = False + _cli_overrides["scheduler"]["check_interval"] = config._config["scheduler"]["check_interval"] + config._cli_overrides = {k: v for k, v in _cli_overrides.items() if v} # 确保 ~/.aibox 目录存在 os.makedirs(constants.SAVE_DIR, exist_ok=True) diff --git a/aibox/common/i18n.py b/aibox/common/i18n.py index 0f3db9a..7c96462 100644 --- a/aibox/common/i18n.py +++ b/aibox/common/i18n.py @@ -98,6 +98,7 @@ class I18n: 'memo_overdue': "⚠️", 'memo_pending': "⏰", 'search_results': "🔍 搜索 '{}' 找到 {} 条记忆:", + 'search_usage': "用法: /search 关键词", 'current_config': "⚙️ 当前配置:", 'config_debug': " 调试级别: {}", 'config_data_dir': " 数据目录: {}", @@ -110,6 +111,7 @@ class I18n: 'enabled': "启用", 'disabled': "禁用", 'config_reloaded': "✅ 配置已重新加载", + 'config_reloaded_cli_kept': "(命令行进程级覆盖已保留)", 'prompts_reloaded': "✅ 提示词已重新加载 ({} 个)", 'checking_memos': "🔍 检查任务...", 'overdue_found': "发现 {} 个到期任务:", @@ -157,6 +159,7 @@ class I18n: 'memo_add_failed': "❌ 添加失败: {}", 'memo_completed': "✅ 任务 #{} 已完成", 'memo_complete_failed': "❌ 无法完成任务 #{}", + 'memo_complete_missing_id': "❌ 缺少 task_id,无法完成任务", # 即时任务相关 'immediate_memo_added': "⚡ 已添加为即时任务并立即检查", @@ -258,6 +261,7 @@ class I18n: 'memo_overdue': "⚠️", 'memo_pending': "⏰", 'search_results': "🔍 Searching '{}' found {} memories:", + 'search_usage': "Usage: /search ", 'current_config': "⚙️ Current configuration:", 'config_debug': " Debug level: {}", 'config_data_dir': " Data directory: {}", @@ -270,6 +274,7 @@ class I18n: 'enabled': "enabled", 'disabled': "disabled", 'config_reloaded': "✅ Configuration reloaded", + 'config_reloaded_cli_kept': " (CLI process-level overrides preserved)", 'prompts_reloaded': "✅ Prompts reloaded ({})", 'checking_memos': "🔍 Checking tasks...", 'overdue_found': "Found {} overdue tasks:", @@ -317,6 +322,7 @@ class I18n: 'memo_add_failed': "❌ Failed to add: {}", 'memo_completed': "✅ Task #{} completed", 'memo_complete_failed': "❌ Cannot complete task #{}", + 'memo_complete_missing_id': "❌ Missing task_id, cannot complete task", # Immediate task related 'immediate_memo_added': "⚡ Added as immediate task and checked now", diff --git a/aibox/subsystems/db.py b/aibox/subsystems/db.py index 525907d..22916e0 100644 --- a/aibox/subsystems/db.py +++ b/aibox/subsystems/db.py @@ -365,6 +365,9 @@ class MemoDatabase: # J1(审计 2026-08-19):入库校验——畸形时间/数字字段此前原样入库, # 下游无防护解析点引发调度器失败热循环与新连接握手崩溃(审计报告 # §13.1 J1)。CLI/WS/memo 工具三入口均经本方法,校验一处覆盖。 + # W12(审计 2026-08-20):空标题任务此前可入库(CLI "|x" / 调用方疏忽) + if not isinstance(title, str) or not title.strip(): + raise ValueError("任务标题不能为空") if reminder_time: try: datetime.strptime(reminder_time, constants.DATETIME_FORMAT) diff --git a/docs/reports/aibox_audit_20260817.md b/docs/reports/aibox_audit_20260817.md index 48fd095..20b823a 100644 --- a/docs/reports/aibox_audit_20260817.md +++ b/docs/reports/aibox_audit_20260817.md @@ -930,3 +930,26 @@ Linux 基线(`aibox/venv`)维持 **490 passed** 不受影响。 - 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1/2 基线完全一致,零回归(前端无 pytest 覆盖,语法级验证兜底) **波次 4~5 待落地**:命令面规整(W1~W8/W11/W12 + W5 择优)、客户端收尾(M1~M5/M7/M8)。 + +## 二十一、Batch M 修复记录(波次 4:命令面输入规整,2026-08-20) + +范围:§17.6 波次 4——W1~W8/W11/W12 + W5 择优,全部落在 `aibox.py`(W12 含 `subsystems/db.py`)+ `common/i18n.py` 三个新键(zh/en)。W9/W10(WS payload 体积上限、data 非 dict 归一)按审计口径"可不做/产品决策"跳过。 + +| # | 修复内容 | 关键变更 | +|---|---|---| +| W1 | WS 三入口字段类型规整 | `handle_task_message`/`handle_command`/`handle_user_message` 的 action/command/content 非 str 时规整为 `""`/直接 return(此前 `.strip()` AttributeError 落顶层"处理失败");task add 的 `title` 必须非空 str、`content` 必须 str 或 None(此前 dict 直达 sqlite InterfaceError,J1 except 拦不住) | +| W2 | task add `delay` 校验 | float 转换失败回友好 error;钳制 `[0, 10年]`(此前负数立即执行、`1e18` OverflowError) | +| W3 | task complete 缺 task_id | 新增 else 回 error(i18n `memo_complete_missing_id`),此前静默无响应 | +| W4 | `/search ` 空关键词 | 空 query 回用法提示(i18n `search_usage`),此前 LIKE `%%` 全表兜底刷屏 | +| W5 | `/reload` 丢弃 CLI 进程级覆盖 | `main()` 暂存 `_cli_overrides`(debug/host/port/scheduler 等),`/reload` 后重放并在回包注明"命令行进程级覆盖已保留" | +| W6 | 启动失败退出码 + `--port` 校验 | `run()` 异常分支补 `sys.exit(1)`(此前堆栈后退出码 0);argparse 自定义 `_port_number` 限制 1~65535 | +| W7 | `--scheduler-interval` 钳制 | 统一 `max(0.1, v)`(此前 `-1` 空转热循环、`0` 被 falsy 静默忽略) | +| W8 | `--temperature` 入口钳制 | `main()` 内钳制 `[0.0, 2.0]`(此前 99/-1 直达 API 400) | +| W11 | config 节非 dict 回退 | `main()` CLI 覆盖赋值前 isinstance 检查 `system`/`scheduler` 节,非 dict 回退默认节(此前启动即 TypeError) | +| W12 | `--memo-add` 边界 | CLI:`title` strip、尾段空串 `""`→`None`(此前绕过 J1 时间校验);`db.add_memo` 标题非空 ValueError(`"|x"` 空标题入库拦截) | + +**验证**: +- 冒烟测试 12/12 通过:`--port` 五例(8765 放行 / 0/65536/-1/abc 拒绝)、`add_memo` 空/纯空格标题拒绝、正常标题入库、i18n 三新键解析、`"a|b|"` 尾段规整 None +- 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1~3 基线完全一致,零回归 + +**波次 5 待落地**:客户端收尾(M1~M5/M7/M8,model_config/voice_client/client + aibox.py scheduler 超时/记忆上限,~25 行)。