diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md new file mode 100644 index 00000000..13f7ce10 --- /dev/null +++ b/docs/review/box-architecture.md @@ -0,0 +1,383 @@ +# Box 系统架构深度分析 + +> 更新日期: 2026-04-16 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> 相关文档: [问题清单](./box-issues.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) + +--- + +## 1. 全局架构 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ LangBot 主进程 │ +│ │ +│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │ +│ │ │ │ │ +│ │ │ exec/read/write/edit │ +│ │ │ │ │ +│ │ ├──> MCPLoader ──> BoxStdioSession │ +│ │ │ │ +│ │ └──> PluginToolLoader │ +│ │ │ +│ BoxService (门面) │ +│ ├─ Profile 管理 (locked 字段) │ +│ ├─ Host mount 校验 (allowed_roots) │ +│ ├─ Workspace quota 检查 │ +│ ├─ 输出截断 (head+tail) │ +│ └─ BoxRuntimeConnector │ +│ └─ ActionRPCBoxClient │ +│ │ Action RPC (stdio 或 WebSocket) │ +└──────────────┼─────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Box Runtime 进程 (SDK 侧) │ +│ │ +│ BoxServerHandler (Action RPC 处理) │ +│ │ │ +│ BoxRuntime (session 管理/进程生命周期) │ +│ │ │ +│ Backend (启动时选择一个): │ +│ PodmanBackend ─┐ │ +│ DockerBackend ─┤── CLISandboxBackend │ +│ NsjailBackend ─┘ │ +│ │ +│ aiohttp WS Relay (:5410) │ +│ /v1/sessions/{id}/managed-process/ws │ +│ (managed process stdin/stdout 双向中继) │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ 容器/沙箱 (Podman/Docker container 或 nsjail sandbox) │ +│ - 隔离文件系统、网络、PID 命名空间 │ +│ - 资源限制 (CPU, 内存, PID 数) │ +│ - exec: 用户命令在此执行 │ +│ - managed process: MCP Server 等长驻进程在此运行 │ +└──────────────────────────────────────────────────────────────┘ +``` + +**核心设计原则**: Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信。两者复用 SDK 的 IO 层(Handler → Connection → Controller)。 + +--- + +## 2. LangBot 侧模块 + +### 2.1 BoxService (`pkg/box/service.py`, 514 行) + +应用层门面,协调 Profile、安全校验、配额、连接: + +``` +BoxService + ├─ initialize() + │ ├─ _ensure_default_host_workspace() 创建默认工作目录 + │ └─ connector.initialize() 连接 Box Runtime + │ + ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 + │ ├─ _build_spec(parameters, query) 合并 Profile + 参数 + locked 覆盖 + │ ├─ _validate_host_mount(spec) 校验 host_path 在 allowed_roots 内 + │ ├─ _enforce_workspace_quota(spec) 前置磁盘配额检查 + │ ├─ client.execute(spec) RPC 调用 + │ ├─ _enforce_workspace_quota(spec) 后置检查(超额则销毁 session) + │ └─ _truncate_output(result) 截断 stdout/stderr + │ + ├─ execute_spec_payload(payload) 内部调用(BoxWorkspaceSession 用) + ├─ create_session(payload) 显式创建 session + ├─ start_managed_process(session_id, payload) 启动长驻进程 + ├─ get_managed_process(session_id) 查询进程状态 + ├─ get_managed_process_websocket_url(sid) 获取 WS attach URL + ├─ get_system_guidance() 返回 LLM 系统提示词 + └─ get_status() / get_recent_errors() 可观测性 +``` + +**Profile 系统**: 4 个内置 Profile(`default`/`offline_readonly`/`network_basic`/`network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。 + +**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。 + +### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 160 行) + +管理与 Box Runtime 的通信连接: + +- **本地 stdio**: 无 `runtime_url` 时,fork `python -m langbot_plugin.box.server --port {port}` 子进程 +- **远程 WebSocket**: 有 `runtime_url` 时,连接 `ws://{host}:{port+1}`(+1 偏移,5410 是 relay,5411 是 RPC) +- **同步等待**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接 + +### 2.3 BoxWorkspaceSession (`pkg/box/workspace.py`, 404 行) + +在 BoxService 上的高级抽象,用于 Skill 和 MCP 场景: + +- **路径重写**: `host_path → /workspace` 映射 +- **Venv 重写**: `.venv/bin/python → python` +- **Python 环境自举**: 检测 `requirements.txt`/`pyproject.toml`,生成 shell 脚本自动创建 venv 并安装依赖 +- **Session 作用域**: 每个 Skill/MCP Server 有独立 session_id + +### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 死代码 + +三层安全策略设计(SandboxPolicy/ToolPolicy/ElevatedPolicy),但**全项目无任何调用**。详见 [问题清单 #1](./box-issues.md)。 + +--- + +## 3. SDK 侧模块 + +### 3.1 BoxRuntime (`box/runtime.py`, 388 行) + +核心编排器,管理 session 生命周期: + +``` +Session 生命周期: + + Client EXEC/CREATE_SESSION + │ + ▼ + _get_or_create_session(spec) + ├─ _reap_expired_sessions_locked() 清理 TTL 过期 session + ├─ 已存在? → _assert_session_compatible() → 复用 + └─ 新建? → backend.start_session(spec) → 创建容器 + │ + ▼ + execute(spec) + ├─ 获取 session lock (每 session 独立) + ├─ backend.exec(session, spec) 在容器中执行命令 + ├─ 更新 last_used_at + └─ 超时? → 销毁 session + │ + ▼ + Session 保持存活直到: + ├─ TTL 过期 (默认 300s,下次操作时清理) + ├─ 执行超时 (自动销毁) + ├─ 客户端 DELETE_SESSION + └─ SHUTDOWN +``` + +**关键设计**: +- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行 +- 全局 `_lock` 保护 `_sessions` dict 的读写 +- 兼容性检查:比较 10 个字段(network/image/host_path/mount_path/cpus/memory_mb/pids_limit/read_only_rootfs/host_path_mode/workspace_quota_mb) + +### 3.2 Backend 系统 + +#### CLISandboxBackend (`box/backend.py`, 389 行) + +Podman/Docker 的公共基类: + +``` +start_session(spec): + 1. validate_sandbox_security(spec) 安全校验 + 2. docker/podman run -d --rm --name + --network none (可选) + --cpus/--memory/--pids-limit 资源限制 + --read-only + --tmpfs /tmp 只读根文件系统 + -v :: 工作区挂载 + sh -lc 'while true; do sleep 3600; done' 保活进程 + 3. 返回 BoxSessionInfo + +exec(session, spec): + docker/podman exec -e KEY=VAL + sh -lc 'mkdir -p && cd && ' + +start_managed_process(session, spec): + docker/podman exec -i + sh -lc 'mkdir -p && cd && exec ' + 返回 asyncio.subprocess.Process (stdin/stdout PIPE) +``` + +容器以 idle 进程启动(`while true; do sleep 3600; done`),实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。 + +**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。 + +#### NsjailBackend (`box/nsjail_backend.py`, 510 行) + +轻量级 Linux 沙箱(无容器引擎依赖): + +- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net) +- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目 +- 每 session 创建独立目录(workspace/tmp/home) +- 资源限制: cgroup v2 优先,fallback 到 rlimit +- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'` + +**后端选择优先级**: Podman → Docker → nsjail(启动时逐个探测,首个可用的胜出,不做运行时 failover) + +### 3.3 Server (`box/server.py`, 268 行) + +两个服务共存: + +1. **Action RPC**: `BoxServerHandler` 处理 11 种 action(HEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输 +2. **WS Relay** (aiohttp, port 5410): `GET /v1/sessions/{id}/managed-process/ws`,双向桥接 WebSocket ↔ managed process stdin/stdout + +端口分配: +- Port N (默认 5410): WS relay(managed process I/O) +- Port N+1 (5411): Action RPC WebSocket(仅远程模式使用) + +### 3.4 Client (`box/client.py`, 177 行) + +`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用: + +- 每个方法对应一个 RPC action +- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型 +- `execute()` timeout = 300s,其他默认 15s + +### 3.5 Models (`box/models.py`, 302 行) + +核心数据模型: + +| 模型 | 用途 | +|------|------| +| `BoxSpec` | 执行请求(cmd/workdir/timeout/network/session_id/image/host_path/资源限制) | +| `BoxProfile` | 预设配置 + `locked` frozenset | +| `BoxSessionInfo` | Session 状态(含 backend_name/backend_session_id/created_at/last_used_at) | +| `BoxManagedProcessSpec` | 长驻进程启动参数(command/args/env/cwd) | +| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) | +| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) | + +`BoxSpec` 校验器: +- `workdir` 默认继承 `mount_path` +- `host_path` 支持 POSIX 和 Windows 路径 +- 设置了 `host_path` 时,`workdir` 必须在 `mount_path` 下 + +### 3.6 Security (`box/security.py`, 54 行) + +`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。 + +**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [问题清单 #5](./box-issues.md)。 + +--- + +## 4. 工具系统集成 + +### 4.1 ToolManager 编排 (`toolmgr.py`) + +``` +ToolManager.initialize() + ├─ NativeToolLoader (exec/read/write/edit) + ├─ PluginToolLoader (插件工具) + ├─ MCPLoader (MCP Server 工具) + └─ SkillAuthoringToolLoader (Skill CRUD) + +工具调用优先级: native → plugin → mcp → skill_authoring +``` + +### 4.2 Native Tools (`native.py`) + +| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 | +|------|:---:|:---:| +| `exec` | 是 | 否 | +| `read` | **否** | **是** — 直接 `open()` 宿主文件 | +| `write` | **否** | **是** — 直接 `open()` 宿主文件 | +| `edit` | **否** | **是** — 直接 `open()` 宿主文件 | + +**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit` 绕过沙箱以获得性能(避免容器 I/O 开销),但意味着 LLM 可以直接读写 allowed_roots 下的任何文件。 + +exec 对 Skill 的特殊处理: 如果 `workdir` 引用 `/workspace/.skills/`,会创建 `BoxWorkspaceSession`,将 Skill 的 `package_root` 作为 `host_path`,并可选做 Python 环境自举。 + +### 4.3 MCP-in-Box (`mcp_stdio.py`) + +`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行: + +``` +initialize() + 1. 创建 BoxWorkspaceSession + 2. workspace.create_session() 创建容器 + 3. workspace.execute_raw(install_cmd) 安装依赖 (可选) + 4. workspace.start_managed_process(...) 启动 MCP Server + 5. websocket_client(ws_url) 通过 WS relay 连接 + 6. ClientSession.initialize() MCP 协议握手 +``` + +配置 (`MCPServerBoxConfig`): `network='on'`(MCP 服务器通常需要网络),`host_path_mode='ro'`(默认只读),`startup_timeout_sec=120`(留时间给 pip install)。 + +--- + +## 5. 启动与生命周期 + +### 5.1 启动顺序 (`build_app.py`) + +``` +BuildAppStage.run(ap) + ├─ ... (persistence, models, sessions) ... + │ + ├─ BoxService(ap) line 134 + ├─ box_service.initialize() line 135 + │ └─ connector.initialize() + │ ├─ [local] fork box.server subprocess + │ └─ [remote] connect WS + ├─ ap.box_service = box_service line 136 + │ + ├─ ToolManager(ap) line 138 + ├─ tool_mgr.initialize() line 139 + │ ├─ NativeToolLoader (检查 box_service.available) + │ ├─ PluginToolLoader + │ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱) + │ └─ SkillAuthoringToolLoader + ├─ ap.tool_mgr = tool_mgr line 140 + │ + ├─ ... (platform, pipeline) ... + ├─ SkillManager.initialize() line 160 + └─ ... (RAG, HTTP, plugins) ... +``` + +BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。 + +### 5.2 初始化失败处理 + +```python +# service.py:68-81 +try: + await self._runtime_connector.initialize() + self._available = True +except Exception as e: + self._available = False + logger.warning(f"Box runtime unavailable: {e}") +``` + +**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 4 个 native tool 不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 + +### 5.3 销毁流程 + +``` +app.dispose() + └─ box_service.dispose() + ├─ connector.dispose() + │ ├─ cancel _handler_task + │ ├─ cancel _ctrl_task + │ └─ terminate subprocess (SIGTERM) + └─ loop.create_task(client.shutdown()) + └─ RPC SHUTDOWN → Box Runtime 清理所有容器 +``` + +Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。 + +--- + +## 6. 配置 + +### config.yaml + +```yaml +box: + profile: 'default' # 内置 Profile 名 + runtime_url: '' # 空 = 自动 fork 子进程 + shared_host_root: './data/box' # Docker 部署时用 '/workspaces' + default_host_workspace: '' # 默认为 /default + allowed_host_mount_roots: # 安全白名单 + - './data/box' + - '/tmp' +``` + +### docker-compose.yaml + +```yaml +volumes: + - ./data/box:/workspaces # 工作区挂载 + - /var/run/docker.sock:/var/run/docker.sock # Docker backend +``` + +### REST API (3 个端点) + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | +| `/api/v1/box/sessions` | GET | 活跃 session 列表 | +| `/api/v1/box/errors` | GET | 最近 50 条错误 | + +**注意**: 前端目前未接入这 3 个 API。 diff --git a/docs/review/box-issues.md b/docs/review/box-issues.md new file mode 100644 index 00000000..9510862b --- /dev/null +++ b/docs/review/box-issues.md @@ -0,0 +1,152 @@ +# Box 系统架构问题清单 + +> 更新日期: 2026-04-16 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## P0 — 合并前建议修复 + +### 1. policy.py 是死代码 + +- **位置**: `pkg/box/policy.py` (98 行) +- **现状**: `SandboxPolicy`、`ToolPolicy`、`ElevatedPolicy` 三个类已定义,但全项目无任何导入或调用 +- **影响**: 三层安全策略(sandbox 模式/工具白名单/权限提升)完全未生效,当前的实际策略是 "Box 可用就暴露全部 4 个 native tool,不可用就全部隐藏" +- **建议**: 要么删除死代码,要么接入 NativeToolLoader 调用链 + +### 2. WebSocket relay 无认证 + +- **位置**: SDK `box/server.py` `create_ws_relay_app()` + `handle_managed_process_ws()` +- **现状**: 任何能访问 5410 端口的客户端都可以 attach managed process 的 stdin/stdout +- **影响**: 网络内的攻击者可直接向 MCP Server 发送任意指令 +- **建议**: 至少加 token 认证(从 RPC 通道获取临时 token,WS 连接时验证) + +### 3. Box 无重连机制 + +- **位置**: `pkg/box/connector.py` `_make_connection_callback()` — Handler 创建时未设置 `disconnect_callback` +- **现状**: 连接断开后 Handler loop 直接退出,Box 功能永久不可用直到应用重启 +- **对比**: Plugin 在 WS 模式下有 `sleep(3) -> re-initialize` 自动重连 +- **建议**: 参考 Plugin 的 `runtime_disconnect_callback`,至少 WS 模式加重连 + +### 4. Box 无心跳 + +- **位置**: `pkg/box/connector.py` — 无 `heartbeat_loop()` 方法 +- **现状**: 初始握手后无定期探活,连接断开只能在下次 RPC 调用时被动发现 +- **对比**: Plugin 有 20s 间隔的 ping loop +- **建议**: 加 30s 间隔心跳,失败时触发重连 + +### 5. security.py 根路径未拦截 + +- **位置**: SDK `box/security.py` `BLOCKED_HOST_PATHS_POSIX` +- **现状**: 黑名单中没有 `/`,`host_path="/"` 可通过校验并挂载整个主机文件系统 +- **建议**: 将 `/` 加入黑名单,或改用白名单策略 + +--- + +## P1 — 合并后优先跟进 + +### 6. Session 数量无上限 + +- **位置**: SDK `box/runtime.py` `_get_or_create_session()` +- **现状**: `_sessions` dict 无容量限制,恶意或异常调用可创建无限 session +- **建议**: 加 `max_sessions` 配置项,达到上限时拒绝新建或清理最老 session + +### 7. Quota 检查存在 TOCTOU + +- **位置**: `pkg/box/service.py` `_enforce_workspace_quota()` +- **现状**: 应用层先读磁盘大小再执行命令,两步之间有竞态窗口 +- **建议**: 短期用 Docker `--storage-opt size=` 做内核级限制;长期用 Redis 原子计数器做预留式配额 + +### 8. 全局锁持有期间执行慢操作 + +- **位置**: SDK `box/runtime.py` `_get_or_create_session()` — `self._lock` 下调用 `backend.start_session()` (即 `docker run`) +- **影响**: `docker run` 可能耗时数秒(含镜像拉取),期间阻塞所有并发请求 +- **建议**: 在 `_lock` 下仅做状态检查和 session 注册,容器创建在锁外执行 + +### 9. Session 清理是机会性的 + +- **位置**: SDK `box/runtime.py` `_reap_expired_sessions_locked()` — 仅在 `_get_or_create_session()` 时调用 +- **影响**: 如果长时间无新 session 请求,过期 session(含容器)不会被清理 +- **建议**: 加一个独立的 `asyncio.create_task` 定时清理(如每 60s 一次) + +### 10. 缺少 Windows 兼容处理 + +- **位置**: `pkg/box/connector.py` — 无 `win32` 分支 +- **现状**: Windows 的 asyncio ProactorEventLoop 不支持 subprocess stdio pipe +- **对比**: Plugin 专门加了 Win32 分支(subprocess + WS 通信) +- **建议**: 加 Windows 分支,或在文档/代码中明确声明不支持 + +### 11. server.py 直接访问 runtime 私有字段 + +- **位置**: SDK `box/server.py:139` — `handle_managed_process_ws` 直接读 `runtime._sessions` +- **影响**: 绕过锁和封装,在并发场景下可能读到不一致状态 +- **建议**: 在 BoxRuntime 上增加公共方法(如 `get_session_managed_process(session_id)`) + +### 12. nsjail image 字段与兼容性检查冲突 + +- **位置**: SDK `box/nsjail_backend.py:148` 设 `image='host'`;`runtime.py:284` 检查 `image` 字段一致性 +- **影响**: 用 nsjail 后端时,如果调用方 BoxSpec 指定了 `image='python:3.11-slim'`(默认值),存储的 `image='host'` 与后续请求的默认值不匹配,永远冲突 +- **建议**: nsjail 后端的兼容性检查应跳过 `image` 字段,或统一忽略 image 当 backend 不支持自定义镜像时 + +--- + +## P2 — 后续迭代 + +### 13. 重复的 `_is_path_under` 函数 + +- **位置**: `pkg/box/service.py` 行 30 和行 36 — 同名函数定义两次 +- **建议**: 删除重复定义 + +### 14. Skill 激活协议无递归保护 + +- **位置**: `pkg/skill/activation.py` +- **影响**: LLM 在第二次调用中可再次输出 `[ACTIVATE_SKILL:]` 标记,触发无限循环 +- **建议**: 加 `max_activation_depth` 检查 + +### 15. localagent.py 工具循环无迭代上限 + +- **位置**: `pkg/provider/runners/localagent.py` `while pending_tool_calls` 循环 +- **影响**: 恶意或混乱的 LLM 可无限产生 tool call,消耗资源 +- **建议**: 加 `max_tool_iterations` 配置项(如默认 50 次) + +### 16. localagent.py 中的死代码 + +- **位置**: `pkg/provider/runners/localagent.py:29-35` — `SANDBOX_EXEC_TOOL_NAME` 和 `SANDBOX_EXEC_SYSTEM_GUIDANCE` +- **现状**: 旧命名方案的遗留常量,从未被引用(实际使用 `EXEC_TOOL_NAME` from native.py) +- **建议**: 删除 + +### 17. @loader_class 装饰器未使用 + +- **位置**: `pkg/provider/tools/loader.py` — `preregistered_loaders` 列表和 `@loader_class` 装饰器 +- **现状**: MCPLoader 和 PluginToolLoader 的 `@loader_class` 被注释掉,ToolManager 手动实例化所有 loader +- **建议**: 要么启用装饰器自动注册,要么删除未用的机制 + +### 18. 工具名冲突风险 + +- **位置**: `pkg/provider/tools/toolmgr.py` `execute_func_call()` — 按优先级 native -> plugin -> mcp -> skill_authoring 分发 +- **影响**: 如果 plugin 或 MCP 有名为 `exec`/`read`/`write`/`edit` 的工具,会被 native loader 静默遮蔽 +- **建议**: 加命名空间前缀或冲突检测告警 + +### 19. workspace quota 检查阻塞事件循环 + +- **位置**: `pkg/box/service.py` `_get_workspace_size_bytes()` — 使用同步 `os.scandir` 递归遍历 +- **影响**: 大工作区可能阻塞 asyncio event loop +- **建议**: 用 `asyncio.to_thread()` 包装,或用 `aiofiles` 异步扫描 + +### 20. client.py 反序列化不一致 + +- **位置**: SDK `box/client.py:118-126` — `execute()` 手动逐字段构建 `BoxExecutionResult` +- **对比**: `start_managed_process()` 使用 `model_validate(data)` 自动反序列化 +- **建议**: 统一使用 `model_validate` + +### 21. 错误类型还原基于字符串前缀匹配 + +- **位置**: SDK `box/client.py:59-82` `_translate_action_error()` +- **影响**: 如果 server 端错误消息格式变化,client 会回退到通用 `BoxError` +- **建议**: 在 ActionResponse 中增加结构化的错误类型字段 + +### 22. 前端无 Box 相关 UI + +- **位置**: `web/src/` — 无任何 Box 组件、类型定义或 API 调用 +- **现状**: 后端有 3 个 REST API(`/api/v1/box/{status,sessions,errors}`)但前端未接入 +- **建议**: 后续迭代加 Box 状态面板(至少展示可用性、活跃 session、最近错误) diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md new file mode 100644 index 00000000..18b8f835 --- /dev/null +++ b/docs/review/box-test-coverage.md @@ -0,0 +1,103 @@ +# Box 系统测试覆盖分析 + +> 更新日期: 2026-04-16 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 测试文件清单 + +### LangBot 仓库 + +| 文件 | 行数 | 测试数 | CI 运行 | 覆盖范围 | +|------|------|--------|---------|---------| +| `tests/unit_tests/box/test_box_connector.py` | 79 | 6 | 是 | Connector 传输决策、WS relay URL、dispose | +| `tests/unit_tests/box/test_box_service.py` | 1168 | 40+ | 是 | Service 核心逻辑(最全面) | +| `tests/unit_tests/box/test_workspace.py` | 144 | 7 | 是 | WorkspaceSession 路径重写、payload 构建 | +| `tests/unit_tests/provider/test_mcp_box_integration.py` | 642 | 22 | 是 | MCP Box 配置、路径重写、payload、runtime info | +| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 5 | 是 | LocalAgent exec 流程、流式、Skill 激活 | +| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 14 | 是 | ToolManager 路由、native tool CRUD、路径穿越 | +| `tests/unit_tests/provider/test_skill_tools.py` | 569 | 20 | 是 | Skill 管理、激活、路径、authoring CRUD | +| `tests/integration_tests/box/test_box_integration.py` | 324 | 6 | **否** | 真实容器执行、超时、网络隔离 | +| `tests/integration_tests/box/test_box_mcp_integration.py` | 361 | 6 | **否** | Managed process、WS attach、session 清理 | + +### SDK 仓库 + +| 文件 | 行数 | 测试数 | CI 运行 | 覆盖范围 | +|------|------|--------|---------|---------| +| `tests/box/test_nsjail_backend.py` | 384 | 18 | 是 | nsjail 可用性、session、arg 构建、资源限制 | + +**总计**: 10 个测试文件, ~4400 行, ~144 个测试; 其中 12 个集成测试在 CI 中不运行。 + +--- + +## 2. 覆盖良好的区域 + +| 区域 | 质量 | 说明 | +|------|------|------| +| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置 | +| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp | +| BoxService host mount 安全 | 优秀 | allowed_roots、disallowed_roots、shared_host_root | +| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | +| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | +| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | +| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | +| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、dispose | +| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写 | +| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 | +| NsjailBackend | 良好 | 可用性、session 生命周期、arg 构建、资源限制 | +| MCP Box 集成 | 良好 | config model、路径重写 (6 case)、payload | +| Native tool loader | 良好 | 文件 CRUD、目录列表、路径穿越拦截 | +| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入 | +| Skill 系统 | 良好 | 加载、激活、marker、路径解析、authoring CRUD | + +--- + +## 3. 覆盖缺失的区域 + +### 3.1 零测试 (Critical) + +| 区域 | 源文件 | 影响 | +|------|--------|------| +| **`security.py`** | SDK `box/security.py` | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 | +| **`policy.py`** | `pkg/box/policy.py` | 三层安全策略(SandboxPolicy/ToolPolicy/ElevatedPolicy)无测试(也是死代码) | + +### 3.2 未测试的关键路径 + +| 区域 | 说明 | +|------|------| +| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 | +| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 | +| **Container backend (Podman/Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend | +| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 | +| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 | +| **WS relay** | 仅在集成测试中覆盖(CI 不运行) | +| **NsjailBackend _run_nsjail** | 总是被 mock,实际 subprocess 调用未验证 | +| **NsjailBackend managed process** | 完全未测试 | +| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 重试 | +| **BoxService start_managed_process** | 仅集成测试覆盖 | + +### 3.3 边缘情况缺失 + +| 区域 | 说明 | +|------|------| +| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 | +| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 | +| 多后端 fallback | 仅单后端配置,无 Podman 不可用 → fallback Docker 测试 | +| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 | + +--- + +## 4. 集成测试 vs CI 的差距 + +CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**: + +- 真实容器的创建/执行/销毁 +- 容器网络隔离(`--network none`) +- 容器资源限制生效 +- Managed process 的 WS 双向 I/O +- 孤儿容器清理 +- Session 删除清理容器 +- 进程退出检测 + +**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径。 diff --git a/docs/review/box-tob-analysis.md b/docs/review/box-tob-analysis.md new file mode 100644 index 00000000..81890470 --- /dev/null +++ b/docs/review/box-tob-analysis.md @@ -0,0 +1,162 @@ +# Box 系统 toB 商业化分析 + +> 更新日期: 2026-04-16 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 现有优势 + +| 能力 | toB 价值 | 代码位置 | +|------|---------|---------| +| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` | +| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail) | SDK `box/runtime.py` `_select_backend()` | +| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` | +| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` | +| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` | +| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` | +| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` | +| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session | +| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session | +| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` | + +--- + +## 2. toB 差距分析 + +### 2.1 安全与合规 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** | +| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** | +| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** | +| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** | +| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** | + +### 2.2 多租户 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** | +| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** | +| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** | +| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** | + +### 2.3 可靠性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **连接恢复** | 无重连、无心跳 | 自动重连 + 健康检查 | **P0** | +| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** | +| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡 | **P1** | +| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 | + +### 2.4 可观测性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** | +| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** | +| **前端面板** | 无 Box UI | 状态面板:可用性/活跃 session/错误/资源用量 | **P2** | + +--- + +## 3. SaaS 部署架构建议 + +### 3.1 方案 A: 共享 Box Runtime Pool (快速上线) + +``` +LangBot Instance ──> Box Runtime (共享) + ├─ tenant_id 标签隔离 + ├─ Redis 配额计数器 + └─ Container labels: langbot.tenant_id=xxx +``` + +- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可 +- **缺点**: 容器引擎共享,安全隔离弱 + +### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期) + +``` +LangBot ──> K8s API + ├─ namespace: tenant-xxx + │ ├─ RuntimeClass: gVisor (runsc) + │ ├─ ResourceQuota + │ └─ NetworkPolicy + └─ namespace: tenant-yyy + └─ ... +``` + +- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额 +- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高 + +### 3.3 方案 C: K8s Job 直接编排 (长期) + +``` +LangBot ──> K8s Job per execution + ├─ 每次执行创建 Job + ├─ Pod Security Standards + ├─ 自动调度和资源分配 + └─ Job TTL Controller 自动清理 +``` + +- **优点**: 最强隔离,天然水平扩展 +- **缺点**: 冷启动延迟,架构重写 + +**推荐演进路径**: A → B → C + +--- + +## 4. 配额体系建议 + +### 三层配额 + +| 层 | 实现 | 作用 | +|----|------|------| +| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 | +| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 | +| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) | + +### Profile 与套餐映射 + +| 套餐 | Profile | locked 字段 | 配额 | +|------|---------|------------|------| +| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB | +| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB | +| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 | + +### TOCTOU 配额修复 + +当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决: + +1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚 +2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小 + +--- + +## 5. 优先实施路线 + +### Phase 1 (2-4 周): 安全基线 + +- [ ] WS relay 加 token 认证 +- [ ] 接入或删除 policy.py +- [ ] Box 加重连和心跳 +- [ ] 审计日志持久化(至少写文件/数据库) +- [ ] `security.py` 加 `/` 拦截,考虑白名单 + +### Phase 2 (4-8 周): 多租户基础 + +- [ ] BoxSpec 加 `tenant_id` 字段 +- [ ] 容器 labels 加 tenant 标识 +- [ ] Redis 配额计数器(并发/执行次数/时间) +- [ ] RBAC 基础框架 +- [ ] 定时 session reaper + +### Phase 3 (8-16 周): 生产就绪 + +- [ ] Prometheus metrics exporter +- [ ] 前端 Box 状态面板 +- [ ] K8s backend 支持 (方案 B) +- [ ] 结构化日志 (JSON, trace_id) +- [ ] 水平扩展支持 diff --git a/docs/review/box-vs-plugin-runtime.md b/docs/review/box-vs-plugin-runtime.md new file mode 100644 index 00000000..093a46c5 --- /dev/null +++ b/docs/review/box-vs-plugin-runtime.md @@ -0,0 +1,222 @@ +# Box Runtime vs Plugin Runtime: 连接架构对比 + +> 更新日期: 2026-04-16 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 总体差异 + +| 维度 | Plugin Runtime | Box Runtime | +|------|---------------|-------------| +| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) | +| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 2 条 (本地 stdio, 远程 WS) | +| **心跳** | 20s ping loop | **无** | +| **重连** | WS 模式: sleep 3s → re-initialize | **无** | +| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` (311 行, 0 自定义 action) | +| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler | +| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) | +| **初始化失败** | 异常上抛 | 静默降级 `_available=False` | +| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 | + +--- + +## 2. 传输决策 + +### Plugin: 3-路决策 + +```python +# pkg/plugin/connector.py:106-165 +if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime(): + # Docker/WS → ws://langbot_plugin_runtime:5400/control/ws +elif get_platform() == 'win32': + # Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws +else: + # Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s) +``` + +### Box: 2-路决策 + +```python +# pkg/box/connector.py:56-60 +if self.manages_local_runtime: # = not configured_runtime_url + await self._start_local_stdio() # StdioClientController +else: + await self._connect_remote_ws() # ws://{host}:{port+1} +``` + +### 决策矩阵 + +| 环境 | Plugin | Box | +|------|--------|-----| +| Docker | WS → `:5400` | WS → `:{port+1}` (5411) | +| Windows 非 Docker | subprocess + WS (`:5400`) | **stdio (可能失败!)** | +| Unix/Mac 非 Docker | stdio | stdio | +| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL | + +**Box 的 Windows 问题**: 无 Win32 分支,asyncio ProactorEventLoop 不支持 subprocess stdio pipe。Plugin 为此专门做了处理。 + +--- + +## 3. 连接建立 + +### 同步模式差异 + +**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。 + +**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。 + +### Box stdio 路径 + +``` +connector._start_local_stdio() + ├─ connected = asyncio.Event() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.box.server', '--port', N]) + ├─ _ctrl_task = create_task(ctrl.run(callback)) + │ callback: + │ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback + │ client.set_handler(handler) + │ _handler_task = create_task(handler.run()) + │ call_action(PING, {}) ← 握手, timeout=15s + │ connected.set() ← 通知外层 + │ await _handler_task ← 阻塞直到断开 + └─ await wait_for(connected.wait(), 30s) ← 同步等待 +``` + +### Plugin stdio 路径 + +``` +connector.initialize() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s']) + ├─ task = ctrl.run(callback) + │ callback: + │ disconnect_callback: + │ [WS] → runtime_disconnect_callback → 重连 + │ [stdio] → 仅日志, 不重连 + │ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap) + │ create_task(handler.run()) + │ handler.ping() ← 握手, timeout=10s + │ await handler_task ← 阻塞直到断开 + ├─ create_task(heartbeat_loop()) ← 20s ping loop + └─ create_task(task) ← 不等待连接 +``` + +--- + +## 4. 心跳与重连 + +### 心跳 + +| 维度 | Plugin | Box | +|------|--------|-----| +| 有心跳? | 是 (`connector.py:69-76`) | **否** | +| 间隔 | 20s | N/A | +| 失败处理 | 仅 DEBUG 日志,不触发重连 | N/A | +| 生命周期 | 整个应用生命周期,跨越重连 | N/A | + +### 重连 + +| 维度 | Plugin | Box | +|------|--------|-----| +| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | Handler loop 退出,**永久不可用** | +| WS 连接失败 | 同上 | 存储错误 → `initialize()` 抛异常 → `_available=False` | +| stdio 断开 | 仅日志,不重连 | Handler loop 退出,永久不可用 | +| 重连退避 | 固定 3s,无 backoff | N/A | + +**Box 断开后的效果链**: +1. `handler.run()` 捕获 `ConnectionClosedError` +2. `_disconnect_callback is None` → break +3. `_handler_task` 完成 → `_make_connection_callback` 返回 +4. 后续 `client._call()` → `BoxRuntimeUnavailableError` +5. Box 功能永久不可用 + +--- + +## 5. 共享 IO 层 + +两者复用同一套 SDK IO 基础设施: + +``` +Handler ← ABC (runtime/io/handler.py) + ├── RuntimeConnectionHandler (Plugin 用, LangBot 侧) + ├── ControlConnectionHandler (Plugin 用, SDK 侧) + ├── BoxServerHandler (Box 用, SDK 侧) + └── 匿名 Handler 实例 (Box 用, LangBot 侧) + +Connection ← ABC + ├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议) + └── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧) + +Controller ← ABC + ├── StdioClientController (fork 子进程, pipe stdin/stdout) + ├── StdioServerController (接管当前进程 stdin/stdout) + ├── WebSocketClientController (连接 WS 服务端) + └── WebSocketServerController (监听 WS 端口) +``` + +共享的核心机制: +- `call_action()` / `call_action_generator()` — RPC 调用/流式调用 +- `ActionRequest` / `ActionResponse` — 请求/响应协议 +- `seq_id` 关联 — 并发请求复用单连接 +- `CommonAction.PING` — 两者都用于初始握手 +- 文件传输 (`send_file`) — Plugin 用,Box 不用 + +--- + +## 6. 端口方案 + +| 服务 | Plugin | Box | +|------|--------|-----| +| Action RPC (stdio) | stdin/stdout | stdin/stdout | +| Action RPC (WS) | `:5400` | `:{port+1}` (默认 5411) | +| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410` | + +**Box 特点**: 即使在 stdio 模式,也额外在 `:5410` 启动 aiohttp WS 服务用于 managed process attach。Plugin 在 stdio 模式不开额外端口。 + +--- + +## 7. 销毁对比 + +### Plugin + +```python +dispose(): + if stdio: ctrl.process.terminate() + _dispose_subprocess() # Windows 子进程 + heartbeat_task.cancel() +``` + +### Box + +```python +connector.dispose(): + _handler_task.cancel() + _ctrl_task.cancel() + _subprocess.terminate() + +service.dispose(): + connector.dispose() + loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器 +``` + +Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。 + +--- + +## 8. 改进建议 + +### P0 + +1. **Box 加重连**: 在 `_make_connection_callback` 中设置 `disconnect_callback`,WS 模式 sleep 3s → re-initialize +2. **Box 加心跳**: 30s 间隔 ping loop,参考 `PluginRuntimeConnector.heartbeat_loop()` + +### P1 + +3. **Box 加 Windows 支持**: 像 Plugin 一样加 Win32 分支 (subprocess + WS) +4. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess`/`_wait_until_ready`/`_dispose_subprocess` +5. **两者都加 WS 认证**: 至少 token 认证 + +### P2 + +6. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议指数退避 +7. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种