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:
1 parent
7e44025671
commit
67acae708c
10 files changed
+824
-107
No files matched your search
+5
-5
@@ -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(平台环境项,与代码无关)
|
||||
|
||||
|
||||
+75
-21
@@ -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))
|
||||
|
||||
|
||||
+26
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 # 可打印部分保留
|
||||
@@ -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", "未知错误")})
|
||||
|
||||
@@ -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 等信息级)未在本批处理。
|
||||
Reference in new issue
Block a user