Update connector to use ws://host:5410/rpc/ws instead of ws://host:5411. Update review docs to reflect the single-port architecture.
17 KiB
Box 系统架构深度分析
更新日期: 2026-04-16 分支:
feat/sandbox(LangBot + langbot-plugin-sdk) 相关文档: 问题清单 | Runtime 对比 | 测试覆盖 | toB 分析
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 + WebSocket(Windows 不支持 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。
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保护_sessionsdict 的读写 - 兼容性检查:比较 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),通过路径区分:
- Action RPC (
/rpc/ws):BoxServerHandler处理 11 种 action(HEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输。WS 模式使用AiohttpWSConnection适配层。 - 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_pathhost_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。
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 初始化失败处理
# 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
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
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。