docs(review): update Box architecture review documents

Replace old review docs with 5 focused documents:
- box-architecture.md: deep architecture analysis (LangBot + SDK)
- box-issues.md: 22 issues rated P0/P1/P2
- box-test-coverage.md: test coverage analysis
- box-tob-analysis.md: toB commercialization analysis
- box-vs-plugin-runtime.md: Box vs Plugin runtime comparison
This commit is contained in:
Junyan Qin
2026-04-17 17:28:29 +08:00
committed by WangCham
parent bae6535005
commit b2ae4a6a82
5 changed files with 1022 additions and 0 deletions

View File

@@ -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 是 relay5411 是 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 <name>
--network none (可选)
--cpus/--memory/--pids-limit 资源限制
--read-only + --tmpfs /tmp 只读根文件系统
-v <host>:<mount>:<mode> 工作区挂载
<image> sh -lc 'while true; do sleep 3600; done' 保活进程
3. 返回 BoxSessionInfo
exec(session, spec):
docker/podman exec -e KEY=VAL <container>
sh -lc 'mkdir -p <workdir> && cd <workdir> && <cmd>'
start_managed_process(session, spec):
docker/podman exec -i <container>
sh -lc 'mkdir -p <cwd> && cd <cwd> && exec <command> <args>'
返回 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 种 actionHEALTH/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 relaymanaged 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/<name>`,会创建 `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: '' # 默认为 <shared_host_root>/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。

152
docs/review/box-issues.md Normal file
View File

@@ -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 通道获取临时 tokenWS 连接时验证)
### 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、最近错误

View File

@@ -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至少覆盖核心执行路径。

View File

@@ -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)
- [ ] 水平扩展支持

View File

@@ -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),考虑收敛为一种