diff --git a/aibox/README.md b/aibox/README.md index 92738fd..44f775c 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -109,7 +109,7 @@ aibox/ │ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ ├── misc_tools.py # B 批:AskUserTool │ └── process_tools.py # 后台进程:BgRun/BgWrite/BgList/BgOutput/BgKill(bg_run/bg_write/bg_list/bg_output/bg_kill) -├── tests/ # 单元测试套件(529 项全过) +├── tests/ # 单元测试套件(598 项全过) │ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_b_tools.py # B 批工具 58 项 @@ -164,7 +164,7 @@ common ← subsystems ← tools ← aibox.py ```bash # 从仓库根目录运行 py -m pytest aibox/tests -q -# 当前:529 项全过(Linux;test_execute_timeout / test_flat_api_still_reads_key_file 为 Windows 向用例,Linux 预存失败) +# 当前:598 项全过(Linux;test_execute_timeout 为 Windows 向用例,本机无 ping 预存失败) # Windows 开发机:532 passed / 4 failed(平台环境项:symlink 权限、python3 缺失、POSIX 可执行位语义 ×2)/ 1 skipped ``` @@ -247,7 +247,7 @@ py -m pytest aibox/tests -q | `bg_output` | 拉取输出:`tail=N` 尾部 N 行 / `since=游标` 增量续读(游标为上次返回的 `next_cursor`);单次默认 64KB、上限 1MB;环形缓冲(默认 256KB/进程)淘汰的早期行如实提示 | | `bg_kill` | 终止后台进程(整棵进程组):三级优雅终止——关 stdin(行协议进程见 EOF 常自行退出)→ SIGTERM 宽限 → SIGKILL 兜底;`force=true` 直接 SIGKILL;对已退出进程幂等 | -> **后台进程语义(v3.8)**:注册表为全局单例(跨会话/跨子代理可见,任何 worker 可管理其他 worker 启动的进程);用户中断(Ctrl+C/取消事件)**不**影响后台进程;aibox 进程退出经 atexit 统一收尾(非 detach 全杀,SIGKILL/SIGTERM 硬杀不覆盖);单行输出截断 16KB;运行中上限 16 / 总条目上限 32 / 单次 stdin 写入上限 64KB(`background_processes` 配置节可调);POSIX 按进程组终止(`start_new_session`,孙进程不悬挂),Windows 回退单进程终止。 +> **后台进程语义(v3.8,T22 属主隔离)**:注册表为全局单例(跨会话/跨子代理可见——list/output 观察面全局共享;写/杀按属主隔离,仅启动者所在会话可 `bg_write`/`bg_kill` 自己的进程,防提示注入跨会话破坏他人 dev server;未标识属主的调用不受限);用户中断(Ctrl+C/取消事件)**不**影响后台进程;aibox 进程退出经 atexit 统一收尾(非 detach 全杀,SIGKILL/SIGTERM 硬杀不覆盖);单行输出截断 16KB;运行中上限 16 / 总条目上限 32 / 单次 stdin 写入上限 64KB(`background_processes` 配置节可调);POSIX 按进程组终止(`start_new_session`,孙进程不悬挂),Windows `taskkill /T /F` 收割进程树。 ## 💬 WebSocket API @@ -934,11 +934,11 @@ elif command == "/mycommand": ### v3.8 - ✅ **后台进程池(智能体运行/管理长驻进程)**:新增 `subsystems/process_manager.py`(`ProcessManager` 全局单例)+ `tools/process_tools.py` 5 个 worker 执行面工具(`bg_run` / `bg_write` / `bg_list` / `bg_output` / `bg_kill`),弥补 `run_command` 同步阻塞、超时即杀的不足——dev server / 训练任务 / watch 等长驻进程可后台启动、跨会话管理: - - **生命周期语义(三决策)**:用户中断(Ctrl+C/取消事件)不影响后台进程;aibox 退出经 atexit 统一收尾(非 detach 全杀,`detach=true` 豁免但脱离句柄管理);注册表跨会话/跨子代理共享(任何 worker 可管理其他 worker 启动的进程) + - **生命周期语义(三决策)**:用户中断(Ctrl+C/取消事件)不影响后台进程;aibox 退出经 atexit 统一收尾(非 detach 全杀,`detach=true` 豁免但脱离句柄管理);注册表跨会话/跨子代理共享(观察面全局可见;写/杀按属主隔离——T22,仅启动者所在会话可操作,定时任务 AI 为精简 `task` profile 且无 update_identity/update_soul/set_proxy) - **交互式 I/O(stdin 管道 + watermark 写读对齐)**:stdin 挂管道,`bg_write` 向行协议进程(REPL / 问答式 CLI)写入一行,返回 **watermark**(`[stdin]` 回显行序号)——`bg_output(since=watermark)` 得到的必然且仅是该输入引发的响应(写读配对);`wait_lines=N` expect 式同步等待;回显行入环形缓冲与 log 镜像(tail 可见完整交互时序);写入 watchdog 线程兜底(进程不读 stdin 管道写满时不阻塞工具线程);单次写入上限 64KB - **输出缓冲**:每进程有界环形缓冲(默认 256KB,`buffer_per_process` 可调),单行截断 16KB;`bg_output` 支持 `tail` 尾读与 `since` 游标增量续读(淘汰行如实提示),单次默认 64KB、上限 1MB;`bg_run log_file=true` 输出同步 tee 到 `workspace/bg_<句柄>.log`(无界全档,`[stdin]` 回显同入档,可被 run_command 的 tail/cat 原生读取) - **三级优雅终止**:`bg_kill` 关 stdin(行协议进程见 EOF 常自行退出)→ SIGTERM 宽限(grace 比例 4:6)→ SIGKILL 兜底;`force=true` 直接 SIGKILL;shutdown 收尾同样先关 stdin - - **安全边界**:运行中上限 16 / 总条目上限 32(LRU 淘汰已退出条目);`background_processes` 配置节(含 `enabled` 双闸:`commands.enabled` 关闭时一并拒绝);`proc_name` 标签 ≤64 字符且拒绝控制字符(M5 惯例);POSIX `start_new_session` + 进程组终止(对齐执行器 L9 语义),Windows 回退单进程终止;TTL `max_lifetime` 惰性清理(默认 0 不启用);凭据类交互(sudo -S 喂密码)会留对话记录,敏感凭据走环境变量注入(工具描述已指引) + - **安全边界**:运行中上限 16 / 总条目上限 32(LRU 淘汰已退出条目);`background_processes` 配置节(含 `enabled` 双闸:`commands.enabled` 关闭时一并拒绝);`proc_name` 标签 ≤64 字符且拒绝控制字符(M5 惯例);POSIX `start_new_session` + 进程组终止(对齐执行器 L9 语义),Windows `taskkill /T /F` 收割进程树;TTL `max_lifetime` 惰性清理(默认 0 不启用);凭据类交互(sudo -S 喂密码)会留对话记录,敏感凭据走环境变量注入(工具描述已指引) - **实现要点**:H2 reader 线程模式复用(EOF 即 poll 收割防僵尸);`list_procs` 惰性刷新全表状态;`proc_name` 命名规避 `ToolRegistry.execute_tool(name=...)` 首参撞名;编排者误调拦截表 `WORKER_ONLY_TOOLS` 同步纳入;无 TTY 的适用边界(全屏程序不可用、行缓冲指引)写入工具描述供模型自检 - **测试**:新增 `tests/test_process_manager.py` 39 项(生命周期/游标续读/环形淘汰/单行截断/上限/TTL/收尾/工具分发/禁用闸/watermark 对齐/回显/sync 等待/超时/EOF 优雅终止/log tee/注册表分发),Windows 开发机 532 passed / 4 failed(平台环境项,与代码无关) diff --git a/aibox/aibox.py b/aibox/aibox.py index 445704e..f559b71 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -121,7 +121,12 @@ class TaskScheduler: prompt_manager=self.ai_manager.prompt_manager if self.ai_manager else None, skill_manager=self._get_shared_skill_manager(), # H3:CLI --model 为进程级覆盖,定时任务实例同样生效 - model=getattr(self.ai_manager, "model", None) if self.ai_manager else None + model=getattr(self.ai_manager, "model", None) if self.ai_manager else None, + # T22:精简 profile——无 update_identity/update_soul/set_proxy, + # 被污染的任务内容无法在无人值守时段改写人格/代理 + tool_profile=TOOL_PROFILE_TASK, + # T22:后台进程属主隔离——任务 AI 与会话 AI 的 bg 进程互不可杀 + owner_id=f"task-{id(self):x}" ) def set_websocket_handler(self, handler): @@ -317,15 +322,25 @@ class TaskScheduler: # 创建一个 Future 来等待 AI 完成 ai_future = loop.create_future() - + + def _set_result_if_pending(response): + # T10:wait_for 硬超时会 cancel future,之后 run_ai 线程迟到的 + # set_result/set_exception 会抛 InvalidStateError 打进事件循环 + if not ai_future.done(): + ai_future.set_result(response) + + def _set_exception_if_pending(exc): + if not ai_future.done(): + ai_future.set_exception(exc) + def run_ai(): try: # 在单独的线程中运行 AI(独立实例,天然隔离,无并发冲突) response = ai.think_and_respond(user_input, output_callback) # 将结果设置到 Future 中 - loop.call_soon_threadsafe(ai_future.set_result, response) + loop.call_soon_threadsafe(_set_result_if_pending, response) except Exception as e: - loop.call_soon_threadsafe(ai_future.set_exception, e) + loop.call_soon_threadsafe(_set_exception_if_pending, e) # 启动 AI 线程 ai_thread = threading.Thread(target=run_ai) @@ -350,8 +365,22 @@ class TaskScheduler: timeout_handle = loop.call_later(task_timeout, _timeout_cancel) try: - response = await ai_future + # T10(审计 2026-08-21):wait_for 硬超时兜底——软取消(ai.cancel + # 三层:取消事件/关在途 HTTP 流/杀子进程)够不到的卡死路径 + # (Windows 代理连接黑洞、requests 读超时被服务器滴流输出 + # 规避、kill 失败的进程)会让 run_ai 永不回调,await 永久挂起 + # 停摆整个调度循环且任务已 mark_as_triggered 不会重试。 + # task_timeout 到点先软取消,+30s 宽限后硬超时放行调度循环 + # (对齐 --once 的 _hard_exit 兜底语义;AI 线程为 daemon, + # 迟到的回调经 done() 检查安全跳过) + response = await asyncio.wait_for( + ai_future, timeout=task_timeout + 30) result = response or "AI无响应" + except asyncio.TimeoutError: + self.logger.error( + f"任务执行硬超时({task_timeout + 30:.0f}秒,软取消未生效)," + f"放弃等待,调度循环继续") + result = f"任务执行硬超时({task_timeout + 30:.0f}秒),已放弃等待" except Exception as e: if timed_out.is_set(): self.logger.error(f"任务执行超时({task_timeout}秒),AI 已取消") @@ -987,6 +1016,10 @@ class SubAgentTool(Tool): TOOL_PROFILE_ORCHESTRATOR = "orchestrator" TOOL_PROFILE_WORKER = "worker" +# T22(审计 2026-08-21):定时任务精简 profile——任务内容可源自 LLM 生成物 +# (备忘录标题/正文可能被网页内容污染),无人值守时段不持有改写全局人格 +# 提示词(update_identity/update_soul)与全局网络面(set_proxy)的工具 +TOOL_PROFILE_TASK = "task" # 仅 worker 子代理注册的执行面工具;编排者误调时拦截并纠正 WORKER_ONLY_TOOLS = {"run_command", "read_file", "write_file", "list_files", "web", "calculator", @@ -1025,12 +1058,16 @@ class DeepSeekChat: memo_db: MemoDatabase = None, command_executor: CommandExecutor = None, prompt_manager: PromptManager = None, skill_manager: SkillManager = None, tool_profile: str = TOOL_PROFILE_ORCHESTRATOR, enable_memory: bool = True, - model: str = None, temperature: float = None): + model: str = None, temperature: float = None, + owner_id: str = None): self.config = ConfigManager() self.logger = Logger("DeepSeek", debug_level if debug_level is not None else Logger("").level) self.debug_level = self.logger.level # 工具集 profile:决定本实例注册哪套工具(orchestrator 管理面 / worker 执行面) self.tool_profile = tool_profile + # T22:后台进程属主标识——bg_* 工具携带,ProcessManager 据此隔离 + # kill/write 权限(会话/任务/子代理互不可操作他人进程,观察面共享) + self.owner_id = owner_id # 大模型客户端(抽象层,支持多厂商切换);model 显式传入时覆盖配置默认 self.client = create_llm_client(self.config, api_key=api_key, logger=self.logger, model=model) self.api_key = self.client.api_key @@ -1182,33 +1219,38 @@ class DeepSeekChat: SimpleWebCrawlerTool(self.logger), CalculatorTool(self.logger), GetCurrentTimeTool(self.logger), - # 后台进程管理(共享全局单例:跨子代理可管理彼此启动的进程) - BgRunTool(self.logger), - BgWriteTool(self.logger), + # 后台进程管理(注册表全局共享;T22:owner 隔离写/杀权限, + # 观察面 list/output 仍全局可见) + BgRunTool(self.logger, owner=self.owner_id), + BgWriteTool(self.logger, owner=self.owner_id), BgListTool(self.logger), BgOutputTool(self.logger), - BgKillTool(self.logger), + BgKillTool(self.logger, owner=self.owner_id), ] self.tool_registry.register_many(worker_tools, is_builtin=True) self.logger.info(f"worker 子代理已注册 {len(worker_tools)} 个执行面工具") return # orchestrator(主对话):管理面工具,执行类任务经 create_subagent 派生 worker + # T22:task profile 为 orchestrator 子集——剔除改写全局人格提示词与 + # 全局网络面的工具(定时任务无人值守,任务内容可能被污染) + is_task_profile = self.tool_profile == TOOL_PROFILE_TASK builtin_tools = [ GetCurrentTimeTool(self.logger), ] - + if self.memory_db: builtin_tools.append(MemoryTool(self.memory_db, self.logger)) - + if self.memo_db: builtin_tools.append(MemoTool(self.memo_db, self.logger)) - - # 更新身份工具 - builtin_tools.append(UpdateIdentityTool(self.prompt_manager, self.logger)) - - # 新增:更新智能体自我认识工具 - builtin_tools.append(UpdateSoulTool(self.prompt_manager, self.logger)) + + if not is_task_profile: + # 更新身份工具 + builtin_tools.append(UpdateIdentityTool(self.prompt_manager, self.logger)) + + # 新增:更新智能体自我认识工具 + builtin_tools.append(UpdateSoulTool(self.prompt_manager, self.logger)) # 增强版保存记忆并结束对话工具 if exit_callback and self.memory_db: @@ -1224,7 +1266,9 @@ class DeepSeekChat: builtin_tools.append(InstallSkillTool(self.logger)) builtin_tools.append(CreateSkillTool(self.logger)) - builtin_tools.append(SetProxyTool(self.logger)) + if not is_task_profile: + # T22:set_proxy 是全局持久化网络面,任务 AI 不持有 + builtin_tools.append(SetProxyTool(self.logger)) builtin_tools.append(GetProxyTool(self.logger)) # 子代理工具:主代理可派生独立上下文/角色的轻量子代理执行任务 @@ -1421,8 +1465,8 @@ class DeepSeekChat: output_callback("line", f"🔧 调用工具: {tool_name}\n") result_raw = None - if self.tool_profile == TOOL_PROFILE_ORCHESTRATOR and tool_name in WORKER_ONLY_TOOLS: - # 编排者不持有执行面工具:拦截并纠正,引导改用 create_subagent + if self.tool_profile in (TOOL_PROFILE_ORCHESTRATOR, TOOL_PROFILE_TASK) and tool_name in WORKER_ONLY_TOOLS: + # 编排者/任务 AI 不持有执行面工具:拦截并纠正,引导改用 create_subagent result_raw = ( f"⛔ '{tool_name}' 调用被拦截:你是编排者,没有执行面工具(命令/文件/网页由 worker 子代理持有)。\n" "请改用 create_subagent 派生子代理执行该任务:在 role 中写明执行者身份," @@ -3140,6 +3184,16 @@ class EnhancedAIManager: # Ping 帧,20s 内收不到 Pong 即强制断连。经 WAF→gateway 两层代理转发后 Pong 无法 # 可靠回传,导致连接约 20s 被服务端关闭。前端已通过 25s 应用层心跳({type:'ping'}) # 保活,故此处完全禁用服务端协议层 ping。 + # T1(审计 2026-08-21,产品决策:仅告警不加鉴权):绑定非回环地址时 + # WebSocket 端点(经工具链可达任意命令执行)即暴露给网络内任意 + # 客户端,启动时显著告警提醒确认网关/WAF 接入与网络边界 + _h = (host or "").strip().lower() + if _h not in ("", "localhost", "127.0.0.1", "::1", "[::1]"): + for _ in range(3): + self.logger.warning( + f"⚠️ WebSocket 绑定在 {host}:{port}(非回环地址):任何能建立 " + f"TCP 连接的客户端都可使用本智能体(含命令执行能力)," + f"请确认已置于可信网络或有网关/WAF 认证保护") server = await websockets.serve(handler, host, port, ping_interval=None) self.logger.info(i18n.get('websocket_started', host, port)) diff --git a/aibox/llm_client.py b/aibox/llm_client.py index d1ee165..f42ba32 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -32,6 +32,17 @@ _AUTH_HEADER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+") # T13:错误响应体可能回显裸 API Key(如 sk-xxx),一并脱敏 _API_KEY_RE = re.compile(r"sk-[A-Za-z0-9_-]{16,}") +# T19(审计 2026-08-21):瞬时网络异常白名单。此前 FailoverClient 捕获 +# requests.exceptions.RequestException 基类——档案 api_url 手写错(漏 +# scheme 抛 MissingSchema)等永久性配置错误也被当瞬时:标记冷却 60s、 +# 触发转移、计入 failovers,每次冷却到期再撞一次坏端点,统计与冷却表 +# 被永久错误污染。显式枚举瞬时异常,其余 requests 异常直接上抛响亮暴露 +_TRANSIENT_NET_ERRORS = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, +) + def _sanitize_log_msg(msg: str) -> str: """日志脱敏:替换错误消息中的 Bearer 令牌与裸 API Key。""" @@ -190,8 +201,9 @@ class DeepSeekClient(LLMClient): return None except TransientLLMError: raise # L4a:瞬时故障上抛供 FailoverClient 转移,不吞 - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - raise # L4a:连接类瞬时故障同样上抛(原被吞成 None,无法转移) + except _TRANSIENT_NET_ERRORS: + raise # T19:连接类瞬时故障同样上抛(原被吞成 None,无法转移; + # ChunkedEncodingError 一并对齐——响应体中途损坏属瞬时网络故障) except Exception as e: self._log("error", f"LLM 压缩请求异常: {e}") return None @@ -246,6 +258,7 @@ class DeepSeekClient(LLMClient): raise RuntimeError( f"HTTP {status}: {body}") + saw_done = False try: for line in response.iter_lines(): if not line: @@ -255,6 +268,7 @@ class DeepSeekClient(LLMClient): continue data = line_text[6:] if data == "[DONE]": + saw_done = True yield {"type": "done"} break try: @@ -270,6 +284,14 @@ class DeepSeekClient(LLMClient): yield event finally: self._current_response = None + # T20(审计 2026-08-21):iter_lines 耗尽但从未收到 [DONE](代理缓冲 + # 后提前 FIN / 服务端中途断开,末行半截 JSON 解析失败被 continue) + # 此前被静默视为成功——回答无声截断无任何信号。抛瞬时异常:首事件 + # 前 FailoverClient 可转移重放;已产出事件则上抛(既有语义:已输出 + # 内容绝不重放),由上层错误处理回滚/报错。用户中断(cancel 关连接 + # 抛异常)不会走到这里。 + if not saw_done: + raise TransientLLMError("LLM 流式响应不完整:未收到 [DONE] 结束标记(连接中途断开)") # L5:思考内容的常见厂商字段别名(首个命中的非空字段生效) REASONING_FIELDS = ("reasoning_content", "reasoning", "thinking", "thinking_content") @@ -462,7 +484,7 @@ class FailoverClient: return result # None:认证/参数类 4xx 或响应异常——不可转移,直接透传 return None - except (TransientLLMError, requests.exceptions.RequestException) as e: + except (TransientLLMError,) + _TRANSIENT_NET_ERRORS as e: if self._cancelled: return None # 用户中断引发的异常:不转移 last_exc = e @@ -517,7 +539,7 @@ class FailoverClient: produced_any = True yield event return # 自然结束(含 done 事件)视为成功 - except (TransientLLMError, requests.exceptions.RequestException) as e: + except (TransientLLMError,) + _TRANSIENT_NET_ERRORS as e: if self._cancelled: raise # 用户中断(cancel 强关连接):绝不转移 if produced_any: diff --git a/aibox/model_config.py b/aibox/model_config.py index 2dddc85..f10ff32 100644 --- a/aibox/model_config.py +++ b/aibox/model_config.py @@ -282,16 +282,49 @@ def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]: print(f" 编号超出范围(1~{len(options)})") +# T-L17(审计 2026-08-21):按 api_url 域名推荐 Key 环境变量名——此前默认 +# DEEPSEEK_API_KEY,一路回车会把 DeepSeek 凭据发给其他厂商(moonshot/ +# aliyuncs 等),正是 resolve_api_key 文档声称要杜绝的跨厂商凭据外发。 +# 未知域名留空强制输入 +_ENV_NAME_BY_DOMAIN = { + "deepseek": "DEEPSEEK_API_KEY", + "moonshot": "MOONSHOT_API_KEY", + "aliyuncs": "DASHSCOPE_API_KEY", + "bigmodel": "ZHIPU_API_KEY", + "ark.cn": "ARK_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "openai.com": "OPENAI_API_KEY", + "siliconflow": "SILICONFLOW_API_KEY", + "volces": "ARK_API_KEY", +} + + +def _suggest_env_name(api_url: str) -> str: + url = (api_url or "").lower() + for kw, env in _ENV_NAME_BY_DOMAIN.items(): + if kw in url: + return env + return "" + + +def _sanitize_display(s) -> str: + """T-L18(审计 2026-08-21):手写 config.json 可嵌 ANSI/控制转义 + (\x1b]0;pwned\x07 等),展示层原样 print 每次列表即触发;写入侧有 + isprintable 校验,读出侧同样过滤控制字符。""" + return "".join(ch for ch in str(s or "") if ch.isprintable()) + + 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']}") + key_desc = f"环境变量 {_sanitize_display(p['api_key_env'])}" if p["api_key_env"] else "免密" + fb = (f",失败转移 → " + f"{', '.join(_sanitize_display(f) for f in p['fallback'])}") if p["fallback"] else "" + print(f" {_sanitize_display(p['name'])}{mark} [{_sanitize_display(p['display_name'])}]") + print(f" 地址: {_sanitize_display(p['api_url'])} 模型: {_sanitize_display(p['model'])}") print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}") @@ -319,7 +352,12 @@ def _add_flow(config: ConfigManager): key_mode = _ask("选择 [1/2]", "1") api_key_env = "" if key_mode == "1": - api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY") + suggested = _suggest_env_name(api_url) + prompt = "环境变量名" + (f"(回车用 {suggested})" if suggested else "") + api_key_env = _ask(prompt, suggested) + if not api_key_env: + print("❌ 已取消(环境变量名不能为空;免密端点请选方式 2)") + return elif key_mode != "2": print("❌ 已取消(Key 方式无效)") return diff --git a/aibox/subsystems/conversation_compressor.py b/aibox/subsystems/conversation_compressor.py index c0b1a59..0bfbd91 100644 --- a/aibox/subsystems/conversation_compressor.py +++ b/aibox/subsystems/conversation_compressor.py @@ -223,49 +223,6 @@ class ConversationCompressor: return conversation_turns - def _is_range_safe(self, messages: List[Dict], start: int, end: int, protected: set) -> bool: - """检查消息范围是否安全 - 简化版,不检查tool完整性""" - return True # 不保护tool消息,允许压缩 - - def _get_protected_indices(self, messages: List[Dict]) -> set: - """ - 找出所有受保护的消息索引 - 简化版,只保护最近的对话轮次 - """ - protected_indices = set() - - # ========== 1. 保护最近的消息(由配置决定) ========== - keep_recent = constants.COMPRESS_KEEP_RECENT - if keep_recent > 0: - recent_start = max(0, len(messages) - keep_recent) - for i in range(recent_start, len(messages)): - protected_indices.add(i) - - # ========== 2. 保护前2个对话轮次 ========== - conversation_turns = self._build_conversation_turns(messages) - - if len(conversation_turns) >= 2: - # 保护第1轮 - first_turn = conversation_turns[0] - protected_indices.add(first_turn["user_index"]) - if first_turn["assistant_index"] is not None: - protected_indices.add(first_turn["assistant_index"]) - for tool_idx in first_turn["tool_indices"]: - protected_indices.add(tool_idx) - - # 保护第2轮 - second_turn = conversation_turns[1] - protected_indices.add(second_turn["user_index"]) - if second_turn["assistant_index"] is not None: - protected_indices.add(second_turn["assistant_index"]) - for tool_idx in second_turn["tool_indices"]: - protected_indices.add(tool_idx) - - # 调试日志 - if self.logger.level >= 3: - self.logger.debug(f"受保护的消息索引: {sorted(protected_indices)}") - - return protected_indices - def _build_compression_prompt(self, messages: List[Dict]) -> str: """构建压缩提示词 - 包含工具调用信息""" conversation_lines = [] @@ -368,25 +325,57 @@ class ConversationCompressor: return None def _validate_message_structure(self, messages: List[Dict]) -> List[Dict]: - """验证并修复消息结构,移除孤立的 tool 消息""" + """验证并修复消息结构(tool 配对双向完整性)。 + + OpenAI 协议要求 assistant.tool_calls 与对应的 tool 结果消息成对出现, + 缺任一侧后续 API 调用直接 400: + 1. 孤立 tool 消息(tool_call_id 无对应 assistant)→ 移除(既有行为) + 2. T23(审计 2026-08-21):压缩块边界按条数切,可能落在带 tool_calls + 的 assistant 消息与其 tool 结果之间——保留侧 assistant 的 + tool_calls 失去配对 → 剥离失配的 tool_calls 条目;全部失配时删除 + 字段(content 为空则置空串,防产生非法空消息) + """ if not messages: return messages - # 找出所有 assistant 消息的 tool_call_ids - assistant_tool_ids = set() + # tool 侧实际存在的 id + present_tool_ids = { + m.get("tool_call_id") + for m in messages + if m.get("role") == "tool" and m.get("tool_call_id") + } + + # 第一遍:assistant 剥离失去配对的 tool_calls + first_pass = [] + kept_assistant_ids = set() for msg in messages: if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg.get("tool_calls", []): - tc_id = tc.get("id") - if tc_id: - assistant_tool_ids.add(tc_id) + calls = msg.get("tool_calls", []) + kept_calls = [tc for tc in calls + if tc.get("id") and tc.get("id") in present_tool_ids] + kept_assistant_ids.update(tc.get("id") for tc in kept_calls) + if len(kept_calls) == len(calls): + first_pass.append(msg) # 全部有配对,原样保留 + continue + new_msg = dict(msg) + if kept_calls: + new_msg["tool_calls"] = kept_calls + print(f" ⚠️ 剥离部分失去配对 tool 结果的 tool_calls(T23)") + else: + new_msg.pop("tool_calls", None) + if not new_msg.get("content"): + new_msg["content"] = "" + print(f" ⚠️ 剥离失去配对 tool 结果的 assistant.tool_calls(T23)") + first_pass.append(new_msg) + else: + first_pass.append(msg) - # 过滤掉没有对应 assistant 的 tool 消息 + # 第二遍:移除孤立 tool 消息(其 id 不在任何保留的 assistant tool_calls 中) filtered = [] - for msg in messages: + for msg in first_pass: if msg.get("role") == "tool": tool_call_id = msg.get("tool_call_id") - if tool_call_id and tool_call_id not in assistant_tool_ids: + if tool_call_id and tool_call_id not in kept_assistant_ids: print(f" ⚠️ 移除孤立的 tool 消息: {tool_call_id}") continue filtered.append(msg) diff --git a/aibox/subsystems/executors.py b/aibox/subsystems/executors.py index 278a20a..d6d7dc4 100644 --- a/aibox/subsystems/executors.py +++ b/aibox/subsystems/executors.py @@ -22,6 +22,36 @@ from typing import Dict, Optional, Tuple from common import ConfigManager, constants, Logger +# T21(审计 2026-08-21):env 覆盖黑名单——工具 schema 中 env 是开放 +# object,LLM(可被网页内容提示注入诱导)可传 LD_PRELOAD/PYTHONPATH/ +# PATH 等在 shell=True 之上进一步劫持进程加载(POSIX LD_PRELOAD 可加载 +# 任意共享库)。命中黑名单的覆盖键拒绝并告警 +ENV_DENY_EXACT = frozenset({ + "PATH", "PYTHONPATH", "PYTHONHOME", "SHELL", "IFS", + "LD_LIBRARY_PATH", "LD_PRELOAD", "LD_AUDIT", + "DYLD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", + "PSMODULEPATH", "COMSPEC", +}) +ENV_DENY_PREFIXES = ("LD_", "DYLD_") + + +def filter_env_overrides(env) -> Tuple[Dict, list]: + """T21:过滤 env 覆盖项,返回 (安全覆盖, 被拒绝键列表)。 + + 键大小写不敏感匹配(Windows 环境变量不区分大小写); + LD_*/DYLD_* 前缀整体拒绝(加载器劫持面)。 + """ + if not env: + return {}, [] + safe, rejected = {}, [] + for key, value in dict(env).items(): + ku = str(key).upper() + if ku in ENV_DENY_EXACT or ku.startswith(ENV_DENY_PREFIXES): + rejected.append(key) + else: + safe[key] = value + return safe, rejected + class CommandExecutor(ABC): """命令执行器抽象基类 - 负责执行系统命令""" @@ -293,10 +323,13 @@ class SubprocessCommandExecutor(CommandExecutor): self.logger.info(f"执行命令: {command} (work_dir={work_dir})") - # 准备环境变量 + # 准备环境变量(T21:黑名单键拒绝,防加载器/解释器劫持) exec_env = os.environ.copy() if env: - exec_env.update(env) + safe_env, rejected_keys = filter_env_overrides(env) + if rejected_keys: + self.logger.warning(f"env 覆盖被黑名单拒绝(T21): {rejected_keys}") + exec_env.update(safe_env) process = None token = None @@ -384,10 +417,13 @@ class SubprocessCommandExecutor(CommandExecutor): output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) output_queue.put(("line", "-" * 50 + "\n")) - # 准备环境变量 + # 准备环境变量(T21:黑名单键拒绝,防加载器/解释器劫持) exec_env = os.environ.copy() if env: - exec_env.update(env) + safe_env, rejected_keys = filter_env_overrides(env) + if rejected_keys: + self.logger.warning(f"env 覆盖被黑名单拒绝(T21): {rejected_keys}") + exec_env.update(safe_env) process = None token = None diff --git a/aibox/subsystems/process_manager.py b/aibox/subsystems/process_manager.py index e32b779..71c6959 100644 --- a/aibox/subsystems/process_manager.py +++ b/aibox/subsystems/process_manager.py @@ -35,6 +35,7 @@ import time from typing import Dict, List, Optional from common import ConfigManager, constants, Logger +from subsystems.executors import filter_env_overrides # 单行输出最大字节数,超出截断(防超长单行撑爆环形缓冲内存预算) MAX_LINE_BYTES = 16 * 1024 @@ -162,7 +163,11 @@ class ProcessManager: exec_env = os.environ.copy() if env: - exec_env.update(env) + # T21:黑名单键拒绝(对齐 executors,防 LD_PRELOAD/PYTHONPATH 劫持) + safe_env, rejected_keys = filter_env_overrides(env) + if rejected_keys: + self.logger.warning(f"env 覆盖被黑名单拒绝(T21): {rejected_keys}") + exec_env.update(safe_env) try: popen = subprocess.Popen( @@ -322,7 +327,7 @@ class ProcessManager: def write(self, handle, text: str, append_newline: bool = True, wait_lines: int = 0, wait_timeout: float = 5.0, - write_timeout: float = 5.0) -> Dict: + write_timeout: float = 5.0, owner: str = None) -> Dict: """向后台进程 stdin 写入一行(交互式问答)。 返回 {ok, handle, watermark, sent_bytes[, wait]}: @@ -330,10 +335,16 @@ class ProcessManager: watermark) 得到的必然且仅是本条输入引发的响应(写读对齐) - wait_lines>0 时同步等待 N 行响应(wait 子 dict 含 output/ timed_out),expect 式一步问答 - 失败返回 {ok: False, error}(进程已退出/写入超时/超长等)。""" + 失败返回 {ok: False, error}(进程已退出/写入超时/超长等)。 + + T22 属主隔离:条目与调用方都携带 owner 且不一致时拒绝——他人 + stdin 不容注入。任一侧未标识(None,如测试/管理面直调)不隔离。""" entry = self._lookup(handle) if entry is None: return {"ok": False, "error": f"句柄 #{handle} 不存在"} + denial = self._check_owner(entry, owner, "写入") + if denial: + return denial if not isinstance(text, str) or text == "": return {"ok": False, "error": "缺少或空的 text"} data = text if (not append_newline or text.endswith("\n")) else text + "\n" @@ -467,12 +478,20 @@ class ProcessManager: key=lambda e: (e.status != "running", e.handle)) return [self._entry_info(e) for e in entries] - def kill(self, handle, force: bool = False, grace: float = 3.0) -> Dict: + def kill(self, handle, force: bool = False, grace: float = 3.0, + owner: str = None) -> Dict: """终止后台进程。SIGTERM → 宽限轮询 → SIGKILL;force=True 直接 SIGKILL。 - 对已退出条目幂等,返回当前状态。""" + 对已退出条目幂等,返回当前状态。 + + T22 属主隔离:条目与调用方都携带 owner 且不一致时拒绝——防止 + 提示注入的会话/任务跨属主杀掉他人的 dev server。shutdown/TTL + 清理属管理器特权,不走本校验。""" entry = self._lookup(handle) if entry is None: return {"ok": False, "error": f"句柄 #{handle} 不存在"} + denial = self._check_owner(entry, owner, "终止") + if denial: + return denial with self._lock: self._update_status_locked(entry) @@ -562,6 +581,20 @@ class ProcessManager: # 内部实现 # ------------------------------------------------------------------ + def _check_owner(self, entry: _ProcEntry, caller_owner: Optional[str], + action: str) -> Optional[Dict]: + """T22:属主校验——条目与调用方都携带 owner 且不一致时返回拒绝 + dict(写/杀权限隔离);任一侧未标识(None,如测试/管理面直调) + 不隔离,保持向后兼容。观察面(list/get_output)不经过本校验。""" + if (entry.owner is not None and caller_owner is not None + and entry.owner != caller_owner): + self.logger.warning( + f"属主校验拒绝:owner={caller_owner} 尝试{action} " + f"#{entry.handle}(属主 {entry.owner})") + return {"ok": False, + "error": f"进程 #{entry.handle} 属于其他会话(属主不匹配),无权{action}"} + return None + def _lookup(self, handle) -> Optional[_ProcEntry]: try: handle = int(handle) @@ -699,6 +732,7 @@ class ProcessManager: "buffered_lines": len(entry.lines), "evicted_lines": entry.evicted, "command": entry.command, + "owner": entry.owner, # T22:属主标识(观察面可见,写/杀需匹配) } diff --git a/aibox/tests/test_n_fixes.py b/aibox/tests/test_n_fixes.py index 99bbb3d..d0cb74c 100644 --- a/aibox/tests/test_n_fixes.py +++ b/aibox/tests/test_n_fixes.py @@ -7,19 +7,37 @@ - T26 SkillManager 原子 reload(中途不清空 self.skills) - T27 atomic_write_text 对 Windows PermissionError 短退避重试 +波次 4 LLM 健壮性(2026-08-21): + - T10 调度器 wait_for 硬超时兜底 + - T19 FailoverClient 异常收窄(永久配置错误不转移不冷却) + - T20 流式响应未收到 [DONE] 抛 TransientLLMError(不再静默截断) + - T21 env 覆盖黑名单(LD_*/DYLD_*/PATH/PYTHONPATH 等劫持面) + - T23 压缩后 tool 配对双向修复(assistant.tool_calls 失配剥离) + (T14 FTS5 经复现证伪:JOIN 别名场景下表名 MATCH 正常命中, + 全链路中文检索长词走 trigram、短词走 LIKE 兜底,无需修复) + +波次 5 权限隔离(2026-08-21,产品决策:T1 仅告警不加鉴权、T2 维持现状): + - T22 定时任务精简 profile(task:无 update_identity/update_soul/set_proxy) + + ProcessManager owner 隔离(写/杀权限,观察面共享) + - T-L17 model_config 按域名推荐 env 名(防 DeepSeek 凭据发往他厂) + - T-L18 list_profiles 输出过滤控制字符(防手写 config ANSI 注入) + Windows 分支在 Linux 上以 mock.patch("os.name", "nt") 模拟验证命令生成 逻辑(不实际执行生成的命令)。 """ +import asyncio import os import sqlite3 +import threading from unittest import mock import pytest +import requests from common.atomic import atomic_write_text from subsystems import MemoryDatabase, SkillManager -from subsystems.executors import SubprocessCommandExecutor +from subsystems.executors import SubprocessCommandExecutor, filter_env_overrides from tools.basic_tools import ( ListFilesTool, ReadFileTool, @@ -332,3 +350,480 @@ class TestAtomicWriteRetry: atomic_write_text(str(target), "hello") spy.assert_called_once() assert target.read_text(encoding="utf-8") == "hello" + + +# ==================== T19:FailoverClient 异常收窄 ==================== + +def _make_failover_client(): + """deepseek(→kimi fallback) 链,Key 走 mock 环境变量。""" + from llm_client import create_llm_client + from test_models_profile import MODELS, _cfg + models = { + "deepseek": dict(MODELS["deepseek"], fallback=["kimi"]), + "kimi": dict(MODELS["kimi"]), + } + env = {"DEEPSEEK_API_KEY": "sk-ds", "MOONSHOT_API_KEY": "sk-moon"} + with mock.patch.dict(os.environ, env): + return create_llm_client(_cfg(models=models)) + + +class TestT19TransientExceptionScope: + def test_permanent_schema_error_not_failed_over(self): + """MissingSchema(api_url 手写错等永久配置错误)→ 流式路径直接上抛, + 不转移(只打一次请求)、不计 failover、不冷却。""" + fc = _make_failover_client() + with mock.patch("llm_client.requests.post", + side_effect=requests.exceptions.MissingSchema("bad url")) as mp: + with pytest.raises(requests.exceptions.MissingSchema): + list(fc.stream_chat([{"role": "user", "content": "hi"}])) + assert len(mp.call_args_list) == 1 + assert fc.get_stats()["failovers"] == 0 + + def test_permanent_error_no_cooldown_pollution(self): + """永久错误后档案不进冷却——下次调用仍打主档案。""" + fc = _make_failover_client() + ok_stream = _stream_response([ + 'data: {"choices":[{"delta":{"content":"recovered"}}]}', + 'data: [DONE]']) + with mock.patch("llm_client.requests.post", + side_effect=[requests.exceptions.MissingSchema("bad"), + ok_stream]) as mp: + with pytest.raises(requests.exceptions.MissingSchema): + list(fc.stream_chat([{"role": "user", "content": "hi"}])) + events = list(fc.stream_chat([{"role": "user", "content": "hi"}])) + # 第二次仍先打主档案(deepseek),未进冷却跳过 + assert mp.call_args_list[1].args[0].startswith("https://api.deepseek.com") + assert any(e["type"] == "content" and e["text"] == "recovered" for e in events) + assert fc.get_stats()["cooldown_skips"] == 0 + + def test_connection_error_still_fails_over(self): + """瞬时连接错误仍正常转移(收窄不误伤)。""" + fc = _make_failover_client() + from test_models_profile import _resp + with mock.patch("llm_client.requests.post", + side_effect=[requests.exceptions.ConnectionError("boom"), + _resp(200, content="ok-conn")]): + assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok-conn" + + def test_chunked_encoding_error_fails_over(self): + """ChunkedEncodingError(响应体中途损坏)属瞬时故障 → 转移。""" + fc = _make_failover_client() + from test_models_profile import _resp + with mock.patch("llm_client.requests.post", + side_effect=[requests.exceptions.ChunkedEncodingError("broken"), + _resp(200, content="ok-chunked")]): + assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok-chunked" + + +# ==================== T20:不完整流信号 ==================== + +def _stream_response(lines): + m = mock.MagicMock() + m.status_code = 200 + m.iter_lines.return_value = [l.encode("utf-8") for l in lines] + return m + + +def _make_deepseek_client(): + from llm_client import DeepSeekClient + return DeepSeekClient( + api_key="sk-test", model="test-model", + base_url="https://api.example/v1/chat/completions") + + +class TestT20IncompleteStream: + def test_missing_done_raises_transient(self): + """流中途断开(iter_lines 耗尽无 [DONE])→ TransientLLMError, + 不再静默视为成功导致回答无声截断。""" + from llm_client import TransientLLMError + c = _make_deepseek_client() + lines = [ + 'data: {"choices":[{"delta":{"content":"he"}}]}', + 'data: {"choices":[{"delta":{"content":"llo"}}]}', + # 连接断开:无 [DONE],末行可能还是半截 JSON + 'data: {"choices":[{"delta":{"cont', + ] + with mock.patch("llm_client.requests.post", + return_value=_stream_response(lines)): + with pytest.raises(TransientLLMError, match="不完整"): + list(c.stream_chat([{"role": "user", "content": "hi"}])) + + def test_with_done_completes_normally(self): + from llm_client import TransientLLMError + c = _make_deepseek_client() + lines = [ + 'data: {"choices":[{"delta":{"content":"hi"}}]}', + 'data: [DONE]', + ] + with mock.patch("llm_client.requests.post", + return_value=_stream_response(lines)): + events = list(c.stream_chat([{"role": "user", "content": "hi"}])) + assert events[-1] == {"type": "done"} + assert any(e["type"] == "content" and e["text"] == "hi" for e in events) + + def test_failover_first_event_incomplete_stream(self): + """FailoverClient:首事件前不完整流(0 事件 + 无 [DONE])→ 可转移。""" + fc = _make_failover_client() + # 主档案:断流无任何事件;fallback(kimi):正常完整流 + broken = _stream_response(['data: {"choices":[{"delta']) + ok_stream = _stream_response([ + 'data: {"choices":[{"delta":{"content":"from-kimi"}}]}', + 'data: [DONE]']) + with mock.patch("llm_client.requests.post", + side_effect=[broken, ok_stream]): + events = list(fc.stream_chat([{"role": "user", "content": "hi"}])) + assert any(e["type"] == "content" and e["text"] == "from-kimi" for e in events) + assert events[-1]["type"] == "done" + + +# ==================== T21:env 覆盖黑名单 ==================== + +class TestT21EnvDenylist: + def test_denies_hijack_keys(self): + safe, rejected = filter_env_overrides({ + "LD_PRELOAD": "/evil", "PATH": "/bin", "PYTHONPATH": "/p", + "MY_VAR": "ok"}) + assert safe == {"MY_VAR": "ok"} + assert set(rejected) == {"LD_PRELOAD", "PATH", "PYTHONPATH"} + + def test_case_insensitive_and_prefixes(self): + safe, rejected = filter_env_overrides({ + "ld_preload": "x", "DYLD_INSERT_LIBRARIES": "y", "path": "z", + "PSMODULEPATH": "w", "OK1": "1"}) + assert safe == {"OK1": "1"} + assert len(rejected) == 4 + + def test_empty_and_none(self): + assert filter_env_overrides(None) == ({}, []) + assert filter_env_overrides({}) == ({}, []) + + def test_executor_blocks_denied_env(self): + """执行路径集成:正常 env 注入生效,黑名单键被拒。""" + ex = SubprocessCommandExecutor() + r = ex.execute( + "echo $T21VAR", + env={"T21VAR": "visible", "LD_PRELOAD": "/nonexistent/libevil.so"}) + assert "visible" in r + # LD_PRELOAD 未注入(若注入且文件不存在,loader 报错会进合并输出) + assert "/nonexistent" not in r + + +# ==================== T23:压缩后 tool 配对双向修复 ==================== + +def _make_compressor(): + from subsystems.conversation_compressor import ConversationCompressor + return ConversationCompressor(llm_client=None) + + +class TestT23ToolPairValidation: + def test_orphan_tool_calls_stripped(self): + """assistant.tool_calls 的 tool 结果被压缩掉 → 剥离 tool_calls。""" + comp = _make_compressor() + msgs = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "看结果", + "tool_calls": [{"id": "t1", "type": "function", + "function": {"name": "read", "arguments": "{}"}}]}, + {"role": "user", "content": "next"}, + ] + out = comp._validate_message_structure(msgs) + assert len(out) == 3 + assert "tool_calls" not in out[1] + assert out[1]["content"] == "看结果" # 原有内容保留 + + def test_empty_content_filled_when_stripped(self): + comp = _make_compressor() + msgs = [ + {"role": "assistant", "content": None, + "tool_calls": [{"id": "t1", "type": "function", + "function": {"name": "read", "arguments": "{}"}}]}, + ] + out = comp._validate_message_structure(msgs) + assert "tool_calls" not in out[0] + assert out[0]["content"] == "" # 防产生非法空消息 + + def test_partial_pair_keeps_matched_calls(self): + """两个 tool_calls 只剩一个配对 → 保留配对的那个。""" + comp = _make_compressor() + msgs = [ + {"role": "assistant", "content": "run", + "tool_calls": [ + {"id": "t1", "type": "function", + "function": {"name": "a", "arguments": "{}"}}, + {"id": "t2", "type": "function", + "function": {"name": "b", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "t2", "content": "result-b"}, + ] + out = comp._validate_message_structure(msgs) + assert len(out) == 2 + assert [tc["id"] for tc in out[0]["tool_calls"]] == ["t2"] + assert out[1]["tool_call_id"] == "t2" + + def test_orphan_tool_message_removed(self): + """既有行为不回归:孤立 tool 消息(无 assistant)移除。""" + comp = _make_compressor() + msgs = [ + {"role": "user", "content": "q"}, + {"role": "tool", "tool_call_id": "ghost", "content": "x"}, + ] + out = comp._validate_message_structure(msgs) + assert [m["role"] for m in out] == ["user"] + + def test_intact_pair_untouched(self): + """完整配对原样保留(同一对象引用,零开销路径)。""" + comp = _make_compressor() + assistant = {"role": "assistant", "content": "ok", + "tool_calls": [{"id": "t1", "type": "function", + "function": {"name": "a", "arguments": "{}"}}]} + tool = {"role": "tool", "tool_call_id": "t1", "content": "r"} + out = comp._validate_message_structure([assistant, tool]) + assert out == [assistant, tool] + + +# ==================== T10:调度器硬超时兜底 ==================== + +class _UnkillableAI: + """软取消无法中断的卡死 AI(模拟 requests 读阻塞被滴流输出规避)。""" + + def __init__(self): + self.cancel_called = 0 + self._release = threading.Event() + + def cancel(self): + self.cancel_called += 1 + # 无效:think_and_respond 仍不返回 + + def think_and_respond(self, input_text, output_callback=None, + is_continuation=False): + self._release.wait(timeout=30) + return "never-reached" + + +class TestT10SchedulerHardTimeout: + def test_stuck_ai_hard_timeout(self): + """软取消无效的卡死任务 → wait_for 硬超时放行,调度循环继续。""" + from aibox import ConfigManager, TaskScheduler + + class _FakeMemoDB: + def get_memo(self, memo_id): + return None + + loop = asyncio.new_event_loop() + fake_ai = _UnkillableAI() + try: + scheduler = TaskScheduler(loop=loop, logger=None) + memo = {"id": 7, "title": "卡死任务", "content": "内容", "trigger_count": 0} + orig_get = ConfigManager.get + + def fake_get(self, key, default=None): + # task_timeout 设为 -29.5:软取消立即触发(call_later 负延迟), + # wait_for 硬超时 = -29.5 + 30 = 0.5s 后放行 + if key == "scheduler.task_timeout": + return -29.5 + 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: + fake_ai._release.set() # 释放卡死线程 + loop.close() + + def test_normal_task_unaffected(self): + """正常返回的任务不受硬超时包装影响(结果正常透传)。""" + from aibox import ConfigManager, TaskScheduler + + class _FakeMemoDB: + def get_memo(self, memo_id): + return None + + class _FastAI: + def cancel(self): + pass + + def think_and_respond(self, input_text, output_callback=None, + is_continuation=False): + return "任务完成" + + loop = asyncio.new_event_loop() + try: + scheduler = TaskScheduler(loop=loop, logger=None) + memo = {"id": 8, "title": "正常任务", "content": "内容", "trigger_count": 0} + orig_get = ConfigManager.get + + def fake_get(self, key, default=None): + if key == "scheduler.task_timeout": + return 600 + return orig_get(self, key, default) + + with mock.patch.object(ConfigManager, "get", fake_get), \ + mock.patch.object(scheduler, "_create_task_ai", return_value=_FastAI()): + result = loop.run_until_complete( + scheduler.execute_task_as_conversation(memo, _FakeMemoDB())) + assert "任务完成" in result + finally: + loop.close() + + +# ==================== T22:owner 隔离(ProcessManager) ==================== + +class TestT22OwnerIsolation: + def test_kill_denied_for_other_owner(self): + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r = pm.start("sleep 30", owner="session-a") + handle = r["handle"] + try: + denied = pm.kill(handle, owner="session-b") + assert not denied["ok"] + assert "属主" in denied["error"] + # 进程仍在运行(未被误杀) + assert pm._lookup(handle).status == "running" + finally: + pm.kill(handle) + + def test_kill_allowed_for_owner(self): + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r = pm.start("sleep 30", owner="session-a") + result = pm.kill(r["handle"], owner="session-a") + assert result["ok"] + + def test_write_denied_for_other_owner(self): + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r = pm.start("cat", owner="session-a") + handle = r["handle"] + try: + denied = pm.write(handle, "hi", owner="session-b") + assert not denied["ok"] + assert "属主" in denied["error"] + ok = pm.write(handle, "hi", owner="session-a") + assert ok["ok"] + finally: + pm.kill(handle) + + def test_backward_compatible_without_owner(self): + """任一侧无 owner 标识 → 不隔离(测试/管理面直调兼容)。""" + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r1 = pm.start("sleep 5", owner="a") + assert pm.kill(r1["handle"])["ok"] # caller 无 owner + r2 = pm.start("sleep 5") # entry 无 owner + assert pm.kill(r2["handle"], owner="b")["ok"] + + def test_list_shows_owner_field(self): + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r = pm.start("sleep 5", owner="whoami") + try: + infos = pm.list_procs() + assert any(e.get("owner") == "whoami" for e in infos) + finally: + pm.kill(r["handle"]) + + def test_output_not_isolated(self): + """观察面(get_output)不受 owner 限制。""" + from subsystems.process_manager import ProcessManager + pm = ProcessManager() + r = pm.start("echo hello", owner="session-a") + handle = r["handle"] + import time as _t + _t.sleep(0.3) + result = pm.get_output(handle) # 观察面无 owner 概念 + assert result["ok"] + assert "hello" in result.get("output", "") + + +# ==================== T22:task profile(定时任务精简工具面) ==================== + +def _make_chat(profile, owner=None): + from aibox import DeepSeekChat + with mock.patch("aibox.create_llm_client") as mclc: + mclc.return_value = mock.MagicMock( + api_key="sk-test", name="DS", default_temperature=None) + return DeepSeekChat(tool_profile=profile, owner_id=owner) + + +class TestT22TaskProfile: + def test_task_profile_excludes_global_side_effect_tools(self): + ai = _make_chat("task") + names = set(ai.tool_registry._tools.keys()) + assert "update_identity" not in names + assert "update_soul" not in names + assert "set_proxy" not in names + # 管理面其余工具保留 + assert "get_proxy" in names + assert "create_subagent" in names + + def test_orchestrator_profile_still_has_them(self): + ai = _make_chat("orchestrator") + names = set(ai.tool_registry._tools.keys()) + assert "update_identity" in names + assert "update_soul" in names + assert "set_proxy" in names + + def test_worker_bg_tools_carry_owner(self): + ai = _make_chat("worker", owner="sess-1") + assert ai.tool_registry.get_tool("bg_run").owner == "sess-1" + assert ai.tool_registry.get_tool("bg_kill").owner == "sess-1" + assert ai.tool_registry.get_tool("bg_write").owner == "sess-1" + # 观察面工具不带 owner + assert getattr(ai.tool_registry.get_tool("bg_list"), "owner", "unset") == "unset" + + def test_create_task_ai_uses_task_profile(self): + from aibox import TaskScheduler + # 显式新建 loop:全量运行时主线程可能已无可用的当前事件循环 + loop = asyncio.new_event_loop() + try: + scheduler = TaskScheduler(loop=loop, logger=None) + with mock.patch("aibox.create_llm_client") as mclc: + mclc.return_value = mock.MagicMock( + api_key="sk-test", name="DS", default_temperature=None) + ai = scheduler._create_task_ai() + assert ai.tool_profile == "task" + assert ai.owner_id and ai.owner_id.startswith("task-") + finally: + loop.close() + + +# ==================== T-L17/T-L18:model_config 展示与推荐 ==================== + +class TestTL17EnvSuggestion: + def test_suggest_by_domain(self): + from model_config import _suggest_env_name + assert _suggest_env_name("https://api.moonshot.cn/v1") == "MOONSHOT_API_KEY" + assert _suggest_env_name("https://api.deepseek.com/chat") == "DEEPSEEK_API_KEY" + assert _suggest_env_name("https://dashscope.aliyuncs.com/x") == "DASHSCOPE_API_KEY" + + def test_unknown_domain_empty(self): + from model_config import _suggest_env_name + assert _suggest_env_name("http://localhost:11434/v1") == "" + assert _suggest_env_name("") == "" + assert _suggest_env_name(None) == "" + + +class TestTL18DisplaySanitize: + def test_sanitize_strips_control_chars(self): + from model_config import _sanitize_display + out = _sanitize_display("a\x1b]0;pwned\x07b") + assert "\x1b" not in out and "\x07" not in out + assert out == "a]0;pwnedb" # 控制符剥离,可打印部分保留 + assert _sanitize_display("正常文本") == "正常文本" + assert _sanitize_display(None) == "" + + def test_print_profiles_filters_escape_sequences(self, capsys): + from model_config import _print_profiles + _print_profiles([{ + "name": "evil\x1b[31m", "is_default": False, + "display_name": "d\x07x", "api_url": "https://x\x1b[2J", + "model": "m", "api_key_env": "K\x1bZ", + "temperature": None, "timeout": None, "fallback": ["fb\x07"], + }]) + out = capsys.readouterr().out + assert "\x1b" not in out + assert "\x07" not in out + assert "evil" in out # 可打印部分保留 diff --git a/aibox/tools/process_tools.py b/aibox/tools/process_tools.py index 527a4c0..f966cb7 100644 --- a/aibox/tools/process_tools.py +++ b/aibox/tools/process_tools.py @@ -78,9 +78,11 @@ def _coerce_bool(value, default: bool) -> bool: class BgRunTool(Tool): """后台启动长驻进程""" - def __init__(self, manager: ProcessManager = None, logger=None): + def __init__(self, manager: ProcessManager = None, logger=None, owner: str = None): super().__init__(logger) self.manager = manager or get_process_manager(logger) + # T22:属主标识——写入/终止权限隔离(观察面 list/output 不限) + self.owner = owner def get_name(self) -> str: return "bg_run" @@ -139,7 +141,8 @@ class BgRunTool(Tool): log_file = _coerce_bool(log_file, False) result = self.manager.start(command, name=proc_name, work_dir=work_dir, - env=env, detach=detach, log_file=log_file) + env=env, detach=detach, log_file=log_file, + owner=self.owner) if not result.get("ok"): return self.format_result(False, "后台进程启动失败", {"错误": result.get("error", "未知错误")}) @@ -158,9 +161,11 @@ class BgRunTool(Tool): class BgWriteTool(Tool): """向后台进程 stdin 写入(交互式问答)""" - def __init__(self, manager: ProcessManager = None, logger=None): + def __init__(self, manager: ProcessManager = None, logger=None, owner: str = None): super().__init__(logger) self.manager = manager or get_process_manager(logger) + # T22:属主标识——仅属主可写(他人 stdin 不容注入) + self.owner = owner def get_name(self) -> str: return "bg_write" @@ -220,6 +225,7 @@ class BgWriteTool(Tool): append_newline=_coerce_bool(append_newline, True), wait_lines=max(0, wait_i), wait_timeout=wait_t, + owner=self.owner, ) if not result.get("ok"): return self.format_result(False, f"写入后台进程 #{handle} 失败", @@ -357,9 +363,11 @@ class BgOutputTool(Tool): class BgKillTool(Tool): """终止后台进程""" - def __init__(self, manager: ProcessManager = None, logger=None): + def __init__(self, manager: ProcessManager = None, logger=None, owner: str = None): super().__init__(logger) self.manager = manager or get_process_manager(logger) + # T22:属主标识——仅属主可终止(防提示注入跨会话杀 dev server) + self.owner = owner def get_name(self) -> str: return "bg_kill" @@ -401,7 +409,7 @@ class BgKillTool(Tool): # T18:force 字符串 "false" 会被裸 bool 判为 True,跳过宽限直接 SIGKILL force = _coerce_bool(force, False) - result = self.manager.kill(handle, force=force, grace=grace) + result = self.manager.kill(handle, force=force, grace=grace, owner=self.owner) if not result.get("ok"): return self.format_result(False, f"终止后台进程 #{handle} 失败", {"错误": result.get("error", "未知错误")}) diff --git a/docs/reports/aibox_audit_20260817.md b/docs/reports/aibox_audit_20260817.md index fd9c6a9..432c34d 100644 --- a/docs/reports/aibox_audit_20260817.md +++ b/docs/reports/aibox_audit_20260817.md @@ -1283,9 +1283,9 @@ FTS5 `MATCH` 左操作数必须是 FROM 子句中的表名或别名。`memories_ 八轮累计发现 **166+ 项**(高危 18 / 中危 54 / 低危与信息 94+);前七轮 118 项全部修复落地(Batch E/F/G/H/I/K/L/M),第八轮 T 系列待落地(Batch N 建议见 §23.7)。 -## 二十四、Batch N 修复记录(波次 1~3,2026-08-21) +## 二十四、Batch N 修复记录(波次 1~5,2026-08-21) -范围:§23.7 波次 1(速赢 7 项)+ 波次 2(并发正确性 6 项)+ 波次 3(平台兼容 5 项),共 18 项。 +范围:§23.7 全部五个波次——速赢 7 项 + 并发正确性 6 项 + 平台兼容 5 项 + LLM 健壮性 5 项修复(另有 T14 复现证伪 1 项)+ 权限隔离 3 项修复与 2 项产品决策收束。 ### 24.1 波次 1 落地(速赢批) @@ -1314,9 +1314,9 @@ FTS5 `MATCH` 左操作数必须是 FROM 子句中的表名或别名。`memories_ 波次 1 后:**532 passed / 4 failed / 1 skipped**(43.8s);波次 2 后:**532 passed / 4 failed / 1 skipped**(41.5s)。4 失败均为 §23.8 所列同一批 Windows 平台环境项,零回归。 -### 24.4 待落地(波次 4~5) +### 24.4 待落地 -波次 4 LLM 健壮性(T10/T14/T19/T20/T21/T23,其中 T14 需先写 FTS5 复现用例)、波次 5 权限隔离(T1/T2/T22,含产品决策项)。 +无——波次 1~5 全部落地。T1/T2 的产品决策记录见 §24.9。 ### 24.5 波次 3 落地(平台兼容批,2026-08-21) @@ -1335,3 +1335,44 @@ FTS5 `MATCH` 左操作数必须是 FROM 子句中的表名或别名。`memories_ ### 24.6 测试基线(Linux,`aibox/venv`) 波次 3 后:**566 passed / 1 failed**(30.1s)。新增 `tests/test_n_fixes.py` 30 项(T3 引用规则 6 + Windows 指引生成 10 + T11 taskkill 4 + T25 busy_timeout 2 + T26 原子 reload 2 + T27 重试 3 + POSIX 锚定 3);1 失败为 §11.3 所列 Windows 向用例(`test_execute_timeout`,本机无 ping 命令),与代码无关,零回归。 + +### 24.7 波次 4 落地(LLM 健壮性批,2026-08-21) + +范围:T10/T19/T20/T21/T23 共 5 项修复 + T14 复现证伪,共 6 项。 + +| # | 修复内容 | 文件/位置 | +|---|---|---| +| T10 | 调度器 `await ai_future` 改 `asyncio.wait_for(ai_future, timeout=task_timeout + 30)`——软取消(M3 的 ai.cancel 三层)够不到的卡死路径(代理连接黑洞/滴流输出规避读超时/杀不掉的进程)在 +30s 宽限后硬超时放行,调度循环不再永久停摆;`run_ai` 回调改经 `_set_result/_set_exception_if_pending`(done 检查),wait_for 取消 future 后迟到的 set_result 不再抛 InvalidStateError 打进事件循环 | `aibox.py:318-375` | +| T19 | FailoverClient 两处 `except (TransientLLMError, requests.exceptions.RequestException)` 收窄为 `(TransientLLMError,) + _TRANSIENT_NET_ERRORS`(ConnectionError/Timeout/ChunkedEncodingError 显式白名单,模块级常量);非流式 `chat_completion` 内层瞬时上抛清单同步对齐——MissingSchema/InvalidURL 等永久配置错误直接上抛响亮暴露,不再进冷却/转移污染统计与冷却表 | `llm_client.py:36-46,204-206,489,544` | +| T20 | `stream_chat` 跟踪 `saw_done`:iter_lines 耗尽但从未收到 `[DONE]`(代理缓冲后提前 FIN/服务端中途断开,末行半截 JSON 被 continue)时抛 `TransientLLMError`——首事件前 FailoverClient 可转移重放,已产出事件则上抛由既有回滚/报错接住,回答无声截断不再被静默视为成功 | `llm_client.py:262-290` | +| T21 | 新增 `filter_env_overrides()`(executors 模块级,process_manager 导入):env 覆盖黑名单 `ENV_DENY_EXACT`(PATH/PYTHONPATH/PYTHONHOME/SHELL/IFS/LD_LIBRARY_PATH/PSMODULEPATH/COMSPEC 等)+ `LD_*`/`DYLD_*` 前缀整体拒绝(键大小写不敏感),拒绝键告警留痕;executors 两处与 process_manager 一处 `os.environ.copy().update(env)` 全部接入——LLM(可被提示注入诱导)无法再经 env 参数注入 LD_PRELOAD 等劫持后续进程加载 | `subsystems/executors.py:25-52,329-336,422-429`;`subsystems/process_manager.py:37,163-169` | +| T23 | `_validate_message_structure` 双向修复 tool 配对:既有"孤立 tool 消息移除"之外,新增反向——压缩块边界切开后保留侧 assistant 的 tool_calls 失去配对 tool 消息时剥离失配条目(部分失配保留配对项;全部失配删字段,content 为空置空串防非法空消息),OpenAI 协议成对约束不再被压缩破坏导致后续 API 400;顺带删除死代码 `_is_range_safe`(恒 True)与 `_get_protected_indices`(无调用方) | `subsystems/conversation_compressor.py:226-284,334-380` | +| T14 | **复现证伪,无需修复**:审计怀疑 FTS5 MATCH 左操作数在 JOIN 别名(`memories_fts f`)场景用错表名导致全程静默降级。实测(SQLite 3.45.1 + trigram):别名场景下 `WHERE memories_fts MATCH ?` 正常命中不报错;端到端 MemoryDatabase 全链路验证——4 字中文词走 FTS trigram 命中、2 字短词走 LIKE 兜底(v3.1 设计行为),`memories_fts` 建表含 `tokenize='trigram'` 确认在用。复现要点:外部内容表(content=memories)的索引需显式 INSERT,且 trigram 需 ≥3 字符 | 复现记录见本节;`subsystems/db.py:196-209`(代码未改动) | + +### 24.8 测试基线(Linux,`aibox/venv`) + +波次 4 后:**584 passed / 1 failed**(31.9s)。`tests/test_n_fixes.py` 追加 18 项(T19 收窄 4 + T20 不完整流 3 + T21 黑名单 4 + T23 配对修复 5 + T10 调度器硬超时 2);1 失败仍为 §11.3 所列 Windows 向用例(`test_execute_timeout`,本机无 ping),零回归。 + +### 24.9 波次 5 落地(权限隔离批,2026-08-21) + +范围:T22 + T-L17/T-L18 修复,T1/T2 经产品决策收束(决策:T1 仅加告警不加鉴权、T2 维持现状——本地单用户可信环境定位,接受风险),共 5 项。 + +**产品决策记录**(2026-08-21,用户裁决): +- **T1**(WS 无鉴权):不加认证,仅绑定非回环地址时启动显著告警(重复 3 次提醒确认网关/WAF 接入)。理由:本地单用户可信环境,引入 token 配置成本大于收益;网络暴露场景由 gateway/WAF 边界承担 +- **T2**(set_proxy 全局持久化开关):维持现状不改行为,接受该风险。理由:本机可信环境下用户依赖对话中调代理的便利性 + +| # | 修复内容 | 文件/位置 | +|---|---|---| +| T22a | 新增 `TOOL_PROFILE_TASK`:定时任务 AI 显式传精简 profile——剔除 `update_identity`/`update_soul`(改写全局人格提示词)与 `set_proxy`(全局网络面),保留 memory/memo/子代理派生/技能等其余管理面工具;`WORKER_ONLY_TOOLS` 误调拦截条件扩展到 task profile;被污染的任务内容(备忘录标题/正文可能源自网页内容)无法在无人值守时段改写人格与代理 | `aibox.py:1012-1017,104-127,1232-1250,1290-1293,1465` | +| T22b | ProcessManager 属主隔离:`start(owner=)` 记录属主,`write`/`kill` 新增 owner 校验(条目与调用方都携带且不一致即拒绝——他人 stdin 不容注入、防提示注入跨会话杀 dev server);任一侧未标识(None)不隔离保持兼容;观察面(list/get_output)不隔离且 `list_procs` 暴露 owner 字段;shutdown/TTL 清理属管理器特权不走校验。`DeepSeekChat` 新增 `owner_id` 构造参数,worker 注册的 `bg_run`/`bg_write`/`bg_kill` 携带(任务 AI 为 `task-`) | `subsystems/process_manager.py:328-345,489-500,597-612,741`;`tools/process_tools.py:81-86,141-142,161-167,222-228,366-371,410`;`aibox.py:1063-1067,1230-1234` | +| T1 | 绑定非回环地址(非 localhost/127.0.0.1/::1)时启动告警重复 3 次:任何能建立 TCP 连接的客户端都可使用本智能体(含命令执行能力),提醒确认可信网络/网关接入 | `aibox.py:3170-3178` | +| T-L17 | 添加档案时 Key 环境变量名默认值改为按 api_url 域名推荐(deepseek→DEEPSEEK_API_KEY、moonshot→MOONSHOT_API_KEY、aliyuncs→DASHSCOPE_API_KEY 等 9 厂商映射);未知域名(如本地 Ollama)留空且输入为空时取消而非静默变成免密——一路回车不再把 DeepSeek 凭据发给其他厂商 | `model_config.py:285-308,345-352` | +| T-L18 | `list_profiles` 展示层输出统一过 `_sanitize_display()`(剥离控制字符)——手写 config.json 嵌 ANSI 转义(`\x1b]0;pwned\x07`)不再于每次列表触发;写入侧 isprintable 校验与读出侧过滤对齐 | `model_config.py:311-328` | + +### 24.10 测试基线(Linux,`aibox/venv`) + +波次 5 后:**598 passed / 1 failed**(38.5s)。`tests/test_n_fixes.py` 追加 14 项(T22 owner 隔离 6 + task profile 4 + T-L17 域名推荐 2 + T-L18 过滤 2);1 失败仍为 §11.3 所列 Windows 向用例(`test_execute_timeout`,本机无 ping),零回归。 + +### 24.11 Batch N 收束 + +第八轮审计 T 系列 28 项全部处置完毕:波次 1 速赢 7 项 + 波次 2 并发正确性 6 项 + 波次 3 平台兼容 5 项 + 波次 4 LLM 健壮性 5 项 + T14 复现证伪 1 项 + 波次 5 权限隔离 3 项修复与 2 项产品决策收束(T1 告警落地、T2 维持现状)。低危 T-L 系列随批落地 T-L17/T-L18;其余 T-L 项(T-L7~T-L16 等信息级)未在本批处理。