fix(aibox): Batch N 波次4+5 LLM 健壮性与权限隔离,Batch N 收束

波次 4 LLM 健壮性(T10/T19/T20/T21/T23 + T14 复现证伪):
- T10 调度器 await 改 wait_for 硬超时兜底(task_timeout+30s 宽限),
  软取消够不到的卡死路径不再永久停摆调度循环
- T19 FailoverClient 异常收窄为瞬时白名单(Connection/Timeout/
  ChunkedEncoding),MissingSchema 等永久配置错误不再污染冷却表
- T20 流式未收到 [DONE] 抛 TransientLLMError,回答无声截断可感知
- T21 env 覆盖黑名单 filter_env_overrides(LD_*/DYLD_*/PATH 等),
  executors 两处 + process_manager 接入
- T23 压缩后 tool 配对双向修复(失配 tool_calls 剥离防 API 400)
- T14 FTS5 复现证伪:JOIN 别名场景表名 MATCH 正常,无需修复

波次 5 权限隔离(T22/T-L17/T-L18;T1/T2 产品决策:仅告警/维持现状):
- T22a TOOL_PROFILE_TASK 定时任务精简工具面(无 update_identity/
  update_soul/set_proxy)
- T22b ProcessManager owner 隔离:bg_write/bg_kill 属主校验,
  观察面共享;DeepSeekChat 新增 owner_id 传导
- T1 绑定非回环地址时启动显著告警
- T-L17 model_config env 名按域名推荐(防跨厂商凭据外发)
- T-L18 list_profiles 过滤控制字符(防 ANSI 注入)

测试:test_n_fixes.py 增至 62 项;全量 598 passed / 1 failed
(Windows 向 ping 用例,环境项),零回归。审计报告补记 §24.7~24.11,
README 同步 owner 隔离语义。八轮审计 T 系列 28 项全部处置完毕。
This commit is contained in:
qing committed 2026-08-21 14:53:42 +00:00
1 parent 7e44025671
commit 67acae708c
10 files changed
+814 -97

No files matched your search

+5 -5
View File
@@ -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(平台环境项,与代码无关)
+65 -11
View File
@@ -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):
@@ -318,14 +323,24 @@ 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,18 +1219,22 @@ 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),
]
@@ -1204,6 +1245,7 @@ class DeepSeekChat:
if self.memo_db:
builtin_tools.append(MemoTool(self.memo_db, self.logger))
if not is_task_profile:
# 更新身份工具
builtin_tools.append(UpdateIdentityTool(self.prompt_manager, self.logger))
@@ -1224,6 +1266,8 @@ class DeepSeekChat:
builtin_tools.append(InstallSkillTool(self.logger))
builtin_tools.append(CreateSkillTool(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))
+26 -4
View File
@@ -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:
+43 -5
View File
@@ -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
+42 -53
View File
@@ -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)
+40 -4
View File
@@ -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
+39 -5
View File
@@ -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:属主标识(观察面可见,写/杀需匹配)
}
+496 -1
View File
@@ -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 # 可打印部分保留
+13 -5
View File
@@ -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", "未知错误")})
+45 -4
View File
@@ -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-<id>`) | `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 等信息级)未在本批处理。