Files
LangBot/docs/review/box-architecture.md
Junyan Qin aa40151964 refactor(box): use single port with path-based routing for Box WS
Update connector to use ws://host:5410/rpc/ws instead of ws://host:5411.
Update review docs to reflect the single-port architecture.
2026-05-04 21:33:03 +08:00

384 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**: Unix/macOS 无特殊配置时fork `python -m langbot_plugin.box.server --port {port}` 子进程
- **远程 WebSocket**: Docker / `--standalone-box` / 显式 `runtime_url` 时,连接 `ws://{host}:{port}/rpc/ws`(同一端口,路径区分)
- **Windows**: subprocess + WebSocketWindows 不支持 async stdio pipe
- **同步等待**: 使用 `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 行)
单端口 aiohttp 服务(默认 5410通过路径区分
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 11 种 actionHEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输。WS 模式使用 `AiohttpWSConnection` 适配层。
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws`): 双向桥接 WebSocket ↔ managed process stdin/stdout
端口分配:
- Port N (默认 5410): 所有 WebSocket 端点Action RPC + managed process relay
### 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。