961 lines
31 KiB
Markdown
961 lines
31 KiB
Markdown
# Yuxi Sandbox 技术设计文档
|
||
|
||
## 文档目标
|
||
|
||
这份文档描述的是 **Yuxi 当前已经落地的沙盒系统设计**,不是一个理想化方案,也不是 API 参考手册。
|
||
|
||
::: warning 预发布
|
||
此部分所涉及到的技术路线和方案文档,均可能会随时改变。
|
||
:::
|
||
|
||
|
||
重点回答五个问题:
|
||
|
||
1. 当前沙盒到底解决了什么问题
|
||
2. 当前沙盒在系统里是怎么接入的
|
||
3. Agent、工作台文件浏览器、Skills 与沙盒之间的关系是什么
|
||
4. 线程级隔离、路径隔离、容器生命周期是如何实现的
|
||
5. 当前方案的边界、局限性和后续演进方向是什么
|
||
|
||
如果只想快速建立整体认识,建议先看:
|
||
|
||
- 第 1 节:设计结论
|
||
- 第 2 节:系统边界
|
||
- 第 3 节:整体架构
|
||
- 第 5 节:生命周期
|
||
- 第 11 节:当前局限性
|
||
|
||
---
|
||
|
||
## 1. 设计结论
|
||
|
||
当前 Yuxi 的沙盒不是“泛化的安全执行平台”,而是一套 **围绕线程工作区、Agent 文件工具、工作台文件浏览器** 设计的受控执行环境。
|
||
|
||
它的核心特征可以概括为:
|
||
|
||
- **线程级隔离**:资源归属从早期的 user 级沙盒收敛为 `thread_id -> sandbox_id`
|
||
- **容器级执行**:默认用本机 Docker 容器承载执行环境
|
||
- **命名空间约束**:Agent 和前端主要通过 `/mnt/user-data` 与 `/mnt/skills` 看到文件系统
|
||
- **惰性初始化**:不再依赖公开的 `/api/sandbox/*` 生命周期接口;首次访问时自动获取沙盒
|
||
- **双视图设计**:
|
||
- Agent 工具侧使用 composite backend,兼顾技能只读路由
|
||
- 工作台侧使用 viewer-oriented filesystem service,强调真实目录浏览和原始文件内容
|
||
|
||
这意味着它更接近:
|
||
|
||
- “给 Agent 提供一个线程隔离的工作目录和有限 shell 执行能力”
|
||
|
||
而不是:
|
||
|
||
- “一个对外承诺强安全边界的多租户通用沙箱平台”
|
||
|
||
这个定位非常重要。后续阅读整份文档时,所有设计取舍都建立在这个现实目标之上。
|
||
|
||
---
|
||
|
||
## 2. 系统边界
|
||
|
||
### 2.1 当前沙盒负责什么
|
||
|
||
当前沙盒主要负责以下能力:
|
||
|
||
- 为某个 thread 提供独立的用户数据目录
|
||
- 在 Docker 容器中执行 Agent 发起的命令
|
||
- 为 Agent 文件工具提供受限文件系统
|
||
- 为工作台文件浏览器提供可视化文件访问入口
|
||
- 将 skills 以只读目录暴露给 Agent 和工作台
|
||
|
||
### 2.2 当前沙盒不负责什么
|
||
|
||
当前实现并不试图完整解决以下问题:
|
||
|
||
- 强多租户对抗场景下的高强度安全隔离
|
||
- 资源配额的精细控制
|
||
- 审计级别的命令执行记录
|
||
- 持久化的沙盒元数据控制平面
|
||
- 面向通用用户的文件编辑、删除、重命名、上传管理 API
|
||
|
||
这不是缺陷描述,而是范围定义。文档后面提到的“限制”,也应放在这个范围下理解。
|
||
|
||
---
|
||
|
||
## 3. 整体架构
|
||
|
||
### 3.1 架构分层
|
||
|
||
当前沙盒相关实现大致分为五层:
|
||
|
||
1. **sandbox 子包**(位于 `backends/sandbox/`)
|
||
- [sandbox_executor.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_executor.py) — 执行器
|
||
- [sandbox_provisioner.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner.py) — 资源调配器
|
||
- [sandbox_provisioner_base.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner_base.py) — provisioner 抽象基类
|
||
- [sandbox_local_container.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_local_container.py) — 本地容器管理器
|
||
- [sandbox_remote.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_remote.py) — 远程沙盒管理器
|
||
- [sandbox_config.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_config.py) — 配置常量
|
||
- [sandbox_info.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_info.py) — 沙盒信息
|
||
- [path_security.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/path_security.py) — 路径安全
|
||
- [docker_api.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/docker_api.py) — Docker API 封装
|
||
2. **Agent / Viewer 接入层**
|
||
- [composite.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/composite.py) — Agent filesystem 路由
|
||
- [filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/filesystem_service.py) — Agent 文件系统服务
|
||
- [viewer_filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/viewer_filesystem_service.py) — 工作台文件浏览服务
|
||
3. **HTTP Router / UI 使用层**
|
||
- [filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/filesystem_router.py)
|
||
- [viewer_filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/viewer_filesystem_router.py)
|
||
- [AgentPanel.vue](/Users/wenjie/Documents/projects/Yuxi-Know/web/src/components/AgentPanel.vue)
|
||
4. **Agent / Viewer 接入层**
|
||
- [composite.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/composite.py)
|
||
- [filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/filesystem_service.py)
|
||
- [viewer_filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/viewer_filesystem_service.py)
|
||
5. **HTTP Router / UI 使用层**
|
||
- [filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/filesystem_router.py)
|
||
- [viewer_filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/viewer_filesystem_router.py)
|
||
- [AgentPanel.vue](/Users/wenjie/Documents/projects/Yuxi-Know/web/src/components/AgentPanel.vue)
|
||
|
||
### 3.2 一张图看清调用关系
|
||
|
||
```text
|
||
Agent / Frontend
|
||
|
|
||
+-- Agent filesystem tool
|
||
| |
|
||
| +-- create_agent_composite_backend(...)
|
||
| |
|
||
| +-- default: YuxiSandboxBackend
|
||
| +-- route /mnt/skills/: SelectedSkillsReadonlyBackend
|
||
|
|
||
+-- Workbench viewer panel
|
||
|
|
||
+-- /api/viewer/filesystem/*
|
||
|
|
||
+-- viewer_filesystem_service
|
||
|
|
||
+-- thread ownership check
|
||
+-- resolve agent config / visible skills
|
||
+-- provider.acquire(thread_id)
|
||
+-- sandbox backend or skills backend
|
||
|
||
Provider
|
||
|
|
||
+-- LocalContainerBackend (default)
|
||
| |
|
||
| +-- docker run / docker exec / docker stop
|
||
| +-- Docker API fallback when no docker CLI
|
||
|
|
||
+-- RemoteSandboxBackend (reserved for remote provisioner mode)
|
||
```
|
||
|
||
### 3.3 当前最重要的设计分叉
|
||
|
||
当前系统里有两套“文件系统视图”,这不是重复实现,而是目标不同:
|
||
|
||
- **Agent 文件系统视图**
|
||
- 关注工具调用和 prompt 约束
|
||
- 通过 composite backend 暴露 `/mnt/skills`
|
||
- 语义偏 agent-oriented
|
||
|
||
- **工作台文件浏览视图**
|
||
- 关注真实目录浏览、原始文件内容、下载
|
||
- 通过独立 viewer service 暴露
|
||
- 语义偏 viewer-oriented
|
||
|
||
这套分离是当前架构里一个非常关键的改动。它解决了之前”工作台直接复用 agent backend 导致语义错位”的问题。
|
||
|
||
### 3.4 四种 Backend 的角色区分
|
||
|
||
理解这四个类的职责边界非常重要,它们分属不同层次:
|
||
|
||
#### `SandboxBackend` — 抽象基类,生命周期接口
|
||
|
||
[sandbox_provisioner_base.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner_base.py) 定义了沙盒管理器的**抽象生命周期接口**:
|
||
|
||
```python
|
||
class SandboxBackend(ABC):
|
||
def create(self, *, thread_id, sandbox_id, extra_mounts) -> SandboxInfo: ...
|
||
def destroy(self, info: SandboxInfo) -> None: ...
|
||
def is_alive(self, info: SandboxInfo) -> bool: ...
|
||
def discover(self, sandbox_id: str) -> SandboxInfo | None: ...
|
||
```
|
||
|
||
它只回答”如何创建一个沙盒实例、如何销毁它、沙盒是否还活着”——**不涉及任何文件操作或命令执行**。
|
||
|
||
#### `LocalContainerBackend` — 本地容器管理器
|
||
|
||
继承 `SandboxBackend`,是**本地 Docker 容器**的创建者和管理者:
|
||
|
||
- 通过 `docker run` 启动沙盒容器
|
||
- 通过 `docker exec` 在容器中执行命令(实际委托给 `YuxiSandboxBackend`)
|
||
- 通过 `docker stop` 销毁容器
|
||
- 支持 Docker CLI 和 Docker Unix Socket API 两种调用方式
|
||
- 负责端口分配、容器发现、warm pool
|
||
|
||
#### `RemoteSandboxBackend` — 远程沙盒管理器
|
||
|
||
同样继承 `SandboxBackend`,但通过 **HTTP API** 与远程沙盒 provisioner 交互:
|
||
|
||
- `create()` → POST `/api/sandboxes`
|
||
- `destroy()` → DELETE `/api/sandboxes/{id}`
|
||
- `is_alive()` → GET `/api/sandboxes/{id}` 检查状态
|
||
- `discover()` → GET `/api/sandboxes/{id}`
|
||
|
||
适用于无法在本地管理容器的场景(如云端沙盒服务)。
|
||
|
||
#### `YuxiSandboxBackend` — 执行后端,不是 SandboxBackend
|
||
|
||
继承自 deepagents 的 `BaseSandbox`,是**在已有容器内执行操作的具体实现**:
|
||
|
||
- `execute(command)` — 在容器内执行 shell 命令
|
||
- `read() / write() / edit()` — 文件读写编辑
|
||
- `ls_info() / glob_info()` — 目录扫描和 glob
|
||
- `upload_files() / download_files()` — 文件上传下载
|
||
- 路径规范化与输出遮蔽(防止宿主机路径泄露)
|
||
|
||
**关键区别**:`YuxiSandboxBackend` 不负责创建/销毁容器,它工作在容器创建好之后。它的定位是”执行器”,而 `LocalContainerBackend` / `RemoteSandboxBackend` 的定位是”容器管理器”。
|
||
|
||
#### 关系总结
|
||
|
||
```
|
||
SandboxProvider
|
||
│
|
||
├── LocalContainerBackend — 创建/销毁本地 Docker 容器
|
||
│ │
|
||
│ └── YuxiSandboxBackend — 在容器内执行命令和文件操作
|
||
│
|
||
└── RemoteSandboxBackend — 通过 HTTP 与远程 provisioner 交互
|
||
│
|
||
└── 远程沙盒(由外部服务管理)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 运行拓扑
|
||
|
||
### 4.1 本地 Docker Compose 模式
|
||
|
||
在默认开发模式中,运行拓扑是:
|
||
|
||
```text
|
||
宿主机
|
||
├── Docker Daemon
|
||
│ ├── api-dev
|
||
│ ├── web-dev
|
||
│ └── yuxi-sandbox-<sandbox_id>
|
||
└── project workspace
|
||
|
||
api-dev 容器
|
||
└── Yuxi 后端进程
|
||
└── 通过 docker CLI 或 Docker API 管理 yuxi-sandbox-* 容器
|
||
```
|
||
|
||
关键点:
|
||
|
||
- `api-dev` 并不在自身进程里执行用户命令
|
||
- 真正的执行发生在独立的 `yuxi-sandbox-*` 容器中
|
||
- `api-dev` 只是控制面,负责创建、发现、复用、销毁沙盒容器
|
||
|
||
### 4.2 本地容器模式与远程 provisioner 模式
|
||
|
||
[sandbox_provisioner.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner.py) 会根据配置决定底层 backend:
|
||
|
||
- 如果设置了 `YUXI_SANDBOX_PROVISIONER_URL`
|
||
- 使用 `RemoteSandboxBackend`
|
||
- 否则
|
||
- 使用 `LocalContainerBackend`
|
||
|
||
当前主路径和测试覆盖都以 `LocalContainerBackend` 为主。
|
||
|
||
### 4.3 Docker CLI 与 Docker API 双路径
|
||
|
||
[sandbox_local_container.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_local_container.py) 和 [sandbox_executor.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_executor.py) 都支持两种运行方式:
|
||
|
||
- 有 `docker` CLI 时,优先走 `docker run`、`docker exec`、`docker stop`
|
||
- 没有 `docker` CLI 时,退回到 Docker Unix Socket API
|
||
|
||
这样做的原因很现实:
|
||
|
||
- 开发机和容器内环境不总是完全一致
|
||
- 有些部署只挂载了 docker socket,没有安装 docker CLI
|
||
|
||
这套 fallback 的目标不是优雅,而是保证控制面在不同运行环境下还能工作。
|
||
|
||
---
|
||
|
||
## 5. 生命周期设计
|
||
|
||
### 5.1 当前资源归属:thread-local
|
||
|
||
当前 provider 的核心规则是:
|
||
|
||
```text
|
||
thread_id -> deterministic sandbox_id -> sandbox container
|
||
```
|
||
|
||
对应实现见 [sandbox_provisioner.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner.py)。
|
||
|
||
`sandbox_id` 由 thread_id 的 SHA256 前 8 位生成:
|
||
|
||
```python
|
||
hashlib.sha256(thread_id.encode()).hexdigest()[:8]
|
||
```
|
||
|
||
这个设计带来的性质是:
|
||
|
||
- 同一个 thread 多次访问时,倾向复用同一个沙盒
|
||
- 不同 thread 不共享容器
|
||
- thread 级的工作目录和运行上下文被绑定在一起
|
||
|
||
这与旧方案最大的差异在于:
|
||
|
||
- **旧方案倾向 user 级复用**
|
||
- **当前方案明确是 thread 级隔离**
|
||
|
||
### 5.2 acquire 的完整逻辑
|
||
|
||
`provider.acquire(thread_id)` 的流程不是单纯“起一个容器”,而是按顺序尝试:
|
||
|
||
1. 看当前 thread 是否已经绑定活跃沙盒
|
||
2. 看 warm pool 里是否有这个 sandbox_id
|
||
3. 看 Docker 里是否已经存在同名运行中容器
|
||
4. 如果都没有,才真正创建新容器
|
||
|
||
这个流程很重要,因为它同时兼顾了:
|
||
|
||
- 惰性初始化
|
||
- 同线程复用
|
||
- 进程重启后的容器发现
|
||
- warm pool 热复用
|
||
|
||
### 5.3 release 与 destroy 的语义
|
||
|
||
当前 provider 仍然保留了 `release` 和 `destroy` 语义,但它们已经不再作为前端公开 API 的一部分。
|
||
|
||
- `release(thread_id)`
|
||
- 从活跃映射中移除
|
||
- 放入 `_warm_pool`
|
||
- 容器保持运行
|
||
|
||
- `destroy(thread_id)`
|
||
- 同时处理 active 和 warm pool 中的对应沙盒
|
||
- 真正调用底层 backend 停止容器
|
||
|
||
这里有一个历史经验教训:如果 `destroy()` 只认 active map,不认 warm pool,就会出现“释放后无法真正停掉容器”的泄漏问题。当前实现已经修正了这类问题。
|
||
|
||
### 5.4 空闲回收
|
||
|
||
provider 在初始化时会启动一个 idle checker 线程:
|
||
|
||
- 间隔:`IDLE_CHECK_INTERVAL`
|
||
- 默认空闲超时:`DEFAULT_IDLE_TIMEOUT = 600`
|
||
|
||
回收对象有两类:
|
||
|
||
- 活跃但长期无访问的沙盒
|
||
- 已经释放到 warm pool 且超时的沙盒
|
||
|
||
### 5.5 为什么现在不再依赖 `/api/sandbox/*`
|
||
|
||
当前公开路由注册见 [server/routers/__init__.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/__init__.py)。可以看到:
|
||
|
||
- 已注册:
|
||
- `/api/filesystem/*`
|
||
- `/api/viewer/filesystem/*`
|
||
- 未注册:
|
||
- `/api/sandbox/*`
|
||
|
||
这代表生命周期已经被收回到系统内部,不再由前端显式驱动。
|
||
|
||
这么做的核心理由有三个:
|
||
|
||
1. 避免前端为了“预热容器”而直接参与控制面
|
||
2. 避免 thread ownership 校验分散在多个入口
|
||
3. 让 Agent 工具调用和工作台访问都走同一种惰性初始化模型
|
||
|
||
---
|
||
|
||
## 6. 文件系统模型
|
||
|
||
### 6.1 真实宿主机目录
|
||
|
||
每个 thread 在宿主机侧都有独立的数据目录,根路径大致是:
|
||
|
||
```text
|
||
saves/threads/<thread_id>/user-data/
|
||
```
|
||
|
||
provider 会确保下面这些子目录存在:
|
||
|
||
- `workspace`
|
||
- `outputs`
|
||
- `uploads`
|
||
- `uploads/attachments`
|
||
- `large_tool_results`
|
||
|
||
对应代码在 [sandbox_provisioner.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_provisioner.py) 的 `ensure_thread_dirs()`。
|
||
|
||
### 6.2 容器内固定命名空间
|
||
|
||
宿主机目录挂载进容器后,Agent 和工作台看到的是固定命名空间:
|
||
|
||
- `/mnt/user-data`
|
||
- `/mnt/user-data/workspace`
|
||
- `/mnt/user-data/uploads`
|
||
- `/mnt/user-data/outputs`
|
||
- `/mnt/user-data/large_tool_results`
|
||
- `/mnt/skills`
|
||
|
||
这里要区分两个概念:
|
||
|
||
- **标准命名空间**
|
||
- 用于约束、归一化、提示词和工具行为
|
||
- **真实 listing**
|
||
- 用于工作台展示实际文件结构
|
||
|
||
### 6.3 为什么工作台现在显示真实 listing
|
||
|
||
之前工作台复用 agent filesystem 视图时,一个典型问题是:
|
||
|
||
- Agent 在 `/mnt/user-data/bubble_sort.py` 创建了文件
|
||
- 工作台却只显示硬编码的 `workspace/uploads/outputs`
|
||
- 用户会误以为文件没有创建成功
|
||
|
||
所以当前 viewer service 的策略是:
|
||
|
||
- `/mnt/user-data` 按真实目录扫描结果返回
|
||
- 不再人为注入固定目录
|
||
|
||
这使得工作台真正展示“当前线程在后端实际看到的文件系统”。
|
||
|
||
### 6.4 兼容路径为什么还存在
|
||
|
||
[path_security.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/path_security.py) 中仍然保留了若干兼容映射,例如:
|
||
|
||
- `/workspace` -> `/mnt/user-data/workspace`
|
||
- `/uploads` -> `/mnt/user-data/uploads`
|
||
- `/outputs` -> `/mnt/user-data/outputs`
|
||
- `/skills` -> `/mnt/skills`
|
||
- `/attachments` -> `/mnt/user-data/uploads/attachments`
|
||
- `/home` -> `/mnt/user-data/workspace`
|
||
|
||
它们存在的原因不是鼓励继续使用旧路径,而是为了兼容:
|
||
|
||
- 模型自己生成的老路径
|
||
- 某些工具或 prompt 里的历史路径语义
|
||
- 迁移期间尚未完全收敛的调用方
|
||
|
||
文档层面应该明确:
|
||
|
||
- **标准输出和标准认知应基于 `/mnt/...` 命名空间**
|
||
- 兼容映射只是过渡层
|
||
|
||
---
|
||
|
||
## 7. Skills 挂载与可见性模型
|
||
|
||
### 7.1 Skills 不是简单的本地目录暴露
|
||
|
||
skills 在物理上会被只读挂载到容器的 `/mnt/skills`,但“能否看见全部 skills”不是由挂载本身决定,而是由上层 backend 路由决定。
|
||
|
||
当前相关逻辑分成两层:
|
||
|
||
1. **物理挂载**
|
||
- provider 在 `_get_extra_mounts()` 中将 skills 根目录以只读形式挂进容器
|
||
2. **逻辑可见性**
|
||
- [composite.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/composite.py) 和 [viewer_filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/viewer_filesystem_service.py) 基于当前 runtime context 的 `skills` 选择可见 skills
|
||
|
||
### 7.2 Agent 侧如何看到 Skills
|
||
|
||
Agent 侧创建 backend 时,会调用:
|
||
|
||
- [graph.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/buildin/chatbot/graph.py)
|
||
- [composite.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/composite.py)
|
||
|
||
结果是:
|
||
|
||
- 默认文件系统后端使用沙盒 backend
|
||
- `/mnt/skills/` 路由到 `SelectedSkillsReadonlyBackend`
|
||
|
||
所以 Agent 看到的 `/mnt/skills` 是“当前 agent config 允许它看到的 skills 子集”,而不是物理目录的全量镜像。
|
||
|
||
### 7.3 工作台为什么也要按 agent config 过滤 Skills
|
||
|
||
工作台的目标不是管理员视角的“看所有技能”,而是:
|
||
|
||
- “当前这个 thread 对应的 agent,在此刻实际能看到什么”
|
||
|
||
因此 viewer service 会先解析:
|
||
|
||
- 当前用户
|
||
- 当前 thread
|
||
- 当前 agent_id
|
||
- 可选的 agent_config_id
|
||
|
||
然后再决定 `/mnt/skills` 下暴露哪些内容。
|
||
|
||
这保证了两件事:
|
||
|
||
1. 工作台与 Agent 的 skills 可见范围一致
|
||
2. 前端不会看到“实际 Agent 用不到的 skills”
|
||
|
||
---
|
||
|
||
## 8. 路径安全与命令执行约束
|
||
|
||
### 8.1 核心约束原则
|
||
|
||
当前安全模型并不是一个完整的系统调用级隔离模型,而是建立在以下几层约束之上:
|
||
|
||
1. 线程级独立容器
|
||
2. 受限文件命名空间
|
||
3. 绝对路径白名单检查
|
||
4. 只读 skills 挂载
|
||
5. 前端/服务层的 thread ownership 校验
|
||
|
||
### 8.2 路径白名单
|
||
|
||
[path_security.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/path_security.py) 中定义了允许的命名空间前缀:
|
||
|
||
- `/mnt/user-data`
|
||
- `/mnt/skills`
|
||
|
||
只要请求落在这两个根之外,就会被拒绝。
|
||
|
||
同时还会显式拦截:
|
||
|
||
- `..` 路径遍历
|
||
- 非允许命名空间访问
|
||
|
||
### 8.3 命令执行时的路径检查
|
||
|
||
`validate_execute_command_paths()` 会对 shell 命令做 token 级别的绝对路径检查:
|
||
|
||
- `/mnt/...` 允许,但仍要经过 `ensure_path_allowed()`
|
||
- `/bin/`、`/usr/bin/`、`/usr/local/bin/` 放行
|
||
- `/usr/lib/` 作为运行时依赖路径放行
|
||
- 其他绝对路径拒绝
|
||
|
||
这意味着当前模型允许类似:
|
||
|
||
```bash
|
||
python3 /mnt/user-data/workspace/app.py
|
||
ls /mnt/user-data
|
||
/bin/sh -c '...'
|
||
```
|
||
|
||
但不允许:
|
||
|
||
```bash
|
||
cat /etc/passwd
|
||
python /tmp/evil.py
|
||
```
|
||
|
||
### 8.4 输出中的宿主机路径遮蔽
|
||
|
||
为了减少宿主机路径泄露,`sandbox_backend` 会对输出做一次映射遮蔽:
|
||
|
||
- 宿主机 thread 目录 -> `/mnt/user-data`
|
||
- skills 宿主机目录 -> `/mnt/skills`
|
||
|
||
这是一个很实用的细节。它不能提供真正的安全保证,但可以降低“内部物理路径暴露到模型上下文或前端界面”的概率。
|
||
|
||
### 8.5 需要明确的现实边界
|
||
|
||
当前这套安全模型是“工程上可控”,不是“高强度对抗级别安全”。
|
||
|
||
例如,当前仍然没有做到:
|
||
|
||
- 细粒度 CPU / memory / pid 限制
|
||
- 默认禁网
|
||
- 严格只读 rootfs
|
||
- seccomp / apparmor 的定制化收敛
|
||
- 进程树和系统调用级审计
|
||
|
||
当前本地容器启动只设置了:
|
||
|
||
- `--security-opt seccomp=unconfined`
|
||
|
||
这从强安全角度看其实是偏宽松的。文档必须如实反映这一点,而不是把当前方案描述成“高安全沙盒”。
|
||
|
||
---
|
||
|
||
## 9. Agent、旧 filesystem API 与 viewer API 的关系
|
||
|
||
### 9.1 Agent 侧接入
|
||
|
||
以 [chatbot/graph.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/buildin/chatbot/graph.py) 为例,FilesystemMiddleware 的 backend 由 `_create_fs_backend()` 动态创建。
|
||
|
||
关键流程是:
|
||
|
||
1. 从 runtime context 里拿到 `thread_id`
|
||
2. `provider.acquire(thread_id)`
|
||
3. 用 `create_agent_composite_backend()` 组装:
|
||
- default = sandbox backend
|
||
- `/mnt/skills/` = SelectedSkillsReadonlyBackend
|
||
|
||
这代表:
|
||
|
||
- Agent 的工具调用第一次触发文件系统时,就会惰性初始化沙盒
|
||
- 不需要先调用某个 prepare API
|
||
|
||
### 9.2 旧 `/api/filesystem/*` 的定位
|
||
|
||
[filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/filesystem_service.py) 和 [filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/filesystem_router.py) 仍然存在。
|
||
|
||
它们的定位是:
|
||
|
||
- 提供兼容性的 filesystem 访问接口
|
||
- 继续基于 agent-oriented composite backend 工作
|
||
|
||
它们会:
|
||
|
||
1. 校验 thread ownership
|
||
2. 解析当前用户可用的 agent config context
|
||
3. acquire sandbox
|
||
4. 组装 composite backend
|
||
5. 调用 `ls_info` 或 `download_files`
|
||
|
||
### 9.3 新 `/api/viewer/filesystem/*` 的定位
|
||
|
||
viewer API 对应:
|
||
|
||
- [viewer_filesystem_service.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/services/viewer_filesystem_service.py)
|
||
- [viewer_filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/server/routers/viewer_filesystem_router.py)
|
||
|
||
这组接口的设计目标非常明确:
|
||
|
||
- 为工作台文件浏览器服务
|
||
- 强调真实目录浏览
|
||
- 返回原始文件内容
|
||
- 支持下载
|
||
- 与 Agent 的 skills 可见范围一致
|
||
|
||
当前提供三类只读能力:
|
||
|
||
- `tree`
|
||
- `file`
|
||
- `download`
|
||
|
||
为什么说它不是“API 参考”而是“技术设计”的一部分?因为它的重要性不在于 URL 形状,而在于 **它代表了工作台视图从 agent backend 中解耦** 这个架构决定。
|
||
|
||
### 9.4 为什么不让工作台继续复用旧 filesystem API
|
||
|
||
之前工作台直接复用 agent filesystem 逻辑,会遇到一串语义问题:
|
||
|
||
- 文件读取带行号,因为底层 `read()` 更偏向 Agent 阅读语义
|
||
- 目录展示不够真实,更偏向工具侧规范目录
|
||
- skills 可见性容易与当前 agent config 脱节
|
||
- 前端不得不理解更多 backend 细节
|
||
|
||
viewer API 的存在,实质上是承认:
|
||
|
||
- “Agent 文件系统”与“用户浏览器文件系统”是两个不同产品对象
|
||
|
||
这是当前沙盒周边设计里最重要的一个收敛。
|
||
|
||
---
|
||
|
||
## 10. 典型执行流程
|
||
|
||
### 10.1 Agent 首次使用文件系统
|
||
|
||
```text
|
||
用户发起对话
|
||
-> Agent graph 构建 middleware
|
||
-> Agent 首次触发 filesystem tool
|
||
-> _create_fs_backend()
|
||
-> provider.acquire(thread_id)
|
||
-> 若无现成容器则创建新容器
|
||
-> 返回 composite backend
|
||
-> Agent 开始读写 /mnt/user-data 或访问 /mnt/skills
|
||
```
|
||
|
||
### 10.2 工作台首次打开文件系统
|
||
|
||
```text
|
||
前端打开 AgentPanel
|
||
-> 请求 /api/viewer/filesystem/tree?thread_id=...&path=/
|
||
-> 后端校验 thread ownership
|
||
-> 解析当前 agent config
|
||
-> provider.acquire(thread_id)
|
||
-> 构造 sandbox backend + skills backend
|
||
-> 返回根目录条目
|
||
```
|
||
|
||
### 10.3 点击进入某个目录
|
||
|
||
```text
|
||
前端点击目录
|
||
-> 请求 viewer tree(path=<that dir>)
|
||
-> 后端仅列当前层目录
|
||
-> 前端懒加载子节点
|
||
```
|
||
|
||
这意味着工作台不会一次性扫完整棵树,而是按层级逐步展开。
|
||
|
||
### 10.4 读取文件内容
|
||
|
||
```text
|
||
前端点击文件
|
||
-> 请求 /api/viewer/filesystem/file
|
||
-> 后端 download_files([path])
|
||
-> 取原始 bytes
|
||
-> UTF-8 decode,失败时 replace
|
||
-> 返回纯文本内容
|
||
```
|
||
|
||
这里特意不走 deepagents 的 `read()`,就是为了避免“所有文件都被自动加行号”的 agent-oriented 行为污染工作台体验。
|
||
|
||
---
|
||
|
||
## 11. 容器管理细节
|
||
|
||
### 11.1 命名规则
|
||
|
||
容器名格式:
|
||
|
||
```text
|
||
yuxi-sandbox-<sandbox_id>
|
||
```
|
||
|
||
其中 `<sandbox_id>` 是 thread_id 的确定性哈希截断值。
|
||
|
||
### 11.2 启动方式
|
||
|
||
本地容器 backend 典型启动命令等价于:
|
||
|
||
```bash
|
||
docker run \
|
||
--security-opt seccomp=unconfined \
|
||
--rm \
|
||
-d \
|
||
-p <port>:8080 \
|
||
--name yuxi-sandbox-<sandbox_id> \
|
||
-v <thread_user_data_dir>:/mnt/user-data \
|
||
-v <skills_root>:/mnt/skills:ro \
|
||
<sandbox_image> \
|
||
sh -c "sleep infinity"
|
||
```
|
||
|
||
为什么是 `sleep infinity`:
|
||
|
||
- 容器被创建后保持存活
|
||
- 真正的执行通过后续 `docker exec` 进入
|
||
- 这样可以避免每次命令都重新起一个新容器
|
||
|
||
### 11.3 容器发现
|
||
|
||
当 provider 启动后,如果内存态映射丢失,但 Docker 中容器还活着,provider 会尝试 `discover(sandbox_id)`:
|
||
|
||
- 根据容器名判断是否存在
|
||
- 解析暴露端口
|
||
- 重建 `SandboxInfo`
|
||
|
||
这是一种“弱恢复”能力,不是严格的持久化控制面。
|
||
|
||
### 11.4 端口分配
|
||
|
||
本地 backend 会从 `base_port` 开始找空闲端口,并向后扫描。
|
||
|
||
需要诚实指出:
|
||
|
||
- 这是典型的“先探测端口再绑定”的 TOCTOU 模式
|
||
- 在高并发/高竞争环境下并不完美
|
||
|
||
当前之所以可接受,是因为它主要服务于本地开发和较小规模部署,而不是大规模沙盒平台。
|
||
|
||
---
|
||
|
||
## 12. 配置项
|
||
|
||
当前配置核心定义见 [sandbox_config.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/package/yuxi/agents/backends/sandbox/sandbox_config.py)。
|
||
|
||
常用项包括:
|
||
|
||
- `YUXI_SANDBOX_IMAGE`
|
||
- 沙盒镜像
|
||
- `YUXI_SANDBOX_BASE_PORT`
|
||
- 本地容器端口起始值
|
||
- `YUXI_SANDBOX_CONTAINER_PREFIX`
|
||
- 容器名前缀
|
||
- `YUXI_SANDBOX_IDLE_TIMEOUT`
|
||
- 空闲超时
|
||
- `YUXI_SANDBOX_MAX_REPLICAS`
|
||
- 最大并发沙盒数
|
||
- `YUXI_SANDBOX_HOST`
|
||
- 宿主机访问沙盒容器的 host
|
||
- `YUXI_SANDBOX_PROVISIONER_URL`
|
||
- 远程 provisioner 模式入口
|
||
- `YUXI_DOCKER_API_BASE`
|
||
- Docker API base
|
||
- `YUXI_DOCKER_API_SOCKET`
|
||
- Docker Unix Socket 路径
|
||
|
||
理解这些配置时,要把它们分成三类:
|
||
|
||
- **容器资源寻址**
|
||
- **provider 生命周期控制**
|
||
- **Docker 控制面访问方式**
|
||
|
||
---
|
||
|
||
## 13. 测试与验证策略
|
||
|
||
### 13.1 单元与集成测试
|
||
|
||
当前沙盒和 viewer 相关测试覆盖包括:
|
||
|
||
- [test_sandbox_provider_lifecycle.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/test_sandbox_provider_lifecycle.py)
|
||
- [test_sandbox_path_compat.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/test_sandbox_path_compat.py)
|
||
- [test_filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/api/test_filesystem_router.py)
|
||
- [test_viewer_filesystem_router.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/api/test_viewer_filesystem_router.py)
|
||
|
||
覆盖点主要包括:
|
||
|
||
- provider 生命周期逻辑
|
||
- 路径兼容与路径安全
|
||
- 旧 filesystem API 的 thread ownership 和命名空间行为
|
||
- viewer API 的真实目录浏览、原始文件读取、下载行为
|
||
|
||
### 13.2 端到端测试
|
||
|
||
脚本式 E2E 见:
|
||
|
||
- [test_sandbox_e2e_no_skip.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/api/test_sandbox_e2e_no_skip.py)
|
||
- [test_sandbox_e2e_reuse.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/api/test_sandbox_e2e_reuse.py)
|
||
- [test_viewer_filesystem_e2e.py](/Users/wenjie/Documents/projects/Yuxi-Know/backend/test/api/test_viewer_filesystem_e2e.py)
|
||
|
||
这些脚本验证的是更接近真实使用链路的场景:
|
||
|
||
- thread 创建
|
||
- provider acquire
|
||
- 容器内写文件
|
||
- API 层读取/浏览/下载
|
||
- 同线程复用
|
||
- 不同线程隔离
|
||
|
||
### 13.3 为什么测试里要主动清理 sandbox 容器
|
||
|
||
由于本地容器模式本质上会真正起 `yuxi-sandbox-*` 容器,如果测试没有处理好清理,会出现:
|
||
|
||
- 端口耗尽
|
||
- warm pool / discover 干扰后续用例
|
||
- 测试之间状态污染
|
||
|
||
因此测试基建里增加了 session 级 sandbox cleanup。这个细节本身也说明:
|
||
|
||
- 当前 provider 的控制面仍偏内存态
|
||
- 真实容器状态与测试进程状态可能漂移
|
||
|
||
---
|
||
|
||
## 14. 当前局限性
|
||
|
||
### 14.1 安全强度仍然偏工程化
|
||
|
||
当前方案不是强隔离安全产品,其局限包括:
|
||
|
||
- 没有严格资源配额
|
||
- 没有默认禁网策略
|
||
- 容器启动参数偏宽松
|
||
- 依赖路径白名单而不是系统调用级控制
|
||
|
||
### 14.2 Provider 状态未持久化
|
||
|
||
provider 的核心状态都在进程内:
|
||
|
||
- `_sandboxes`
|
||
- `_sandbox_infos`
|
||
- `_thread_sandboxes`
|
||
- `_warm_pool`
|
||
|
||
这意味着:
|
||
|
||
- 进程重启后要依赖 `discover()`
|
||
- 不能把当前方案理解为“有完整控制数据库的沙盒平台”
|
||
|
||
### 14.3 viewer 仍然是只读
|
||
|
||
当前 viewer-oriented filesystem service 只做了:
|
||
|
||
- tree
|
||
- file
|
||
- download
|
||
|
||
没有做:
|
||
|
||
- rename
|
||
- move
|
||
- delete
|
||
- upload
|
||
- inline edit
|
||
|
||
这是刻意收敛范围的结果,不是遗漏。
|
||
|
||
### 14.4 兼容路径仍然存在
|
||
|
||
兼容路径仍保留,说明路径模型虽然已经大致收敛,但还没有完全把所有调用方都清到统一规范。
|
||
|
||
### 14.5 本地容器 backend 仍有工程折中
|
||
|
||
例如:
|
||
|
||
- 端口分配不是完全无竞争
|
||
- Docker CLI / Docker API 双实现会增加维护成本
|
||
- 文件上传下载与命令执行仍不是完全同一种底层路径
|
||
|
||
这些都是当前方案的真实成本。
|
||
|
||
---
|
||
|
||
## 15. 后续演进方向
|
||
|
||
如果未来继续演进,这条线最值得做的事情不是再堆更多 viewer 功能,而是继续收敛基础模型:
|
||
|
||
### 15.1 强化控制面
|
||
|
||
- 将 sandbox 元数据持久化
|
||
- 更清晰地区分 active、warm、destroyed 状态
|
||
- 降低 discover 对系统一致性的依赖
|
||
|
||
### 15.2 强化安全边界
|
||
|
||
- 增加资源限制
|
||
- 明确网络策略
|
||
- 收紧容器安全选项
|
||
- 评估更适合沙盒场景的运行时
|
||
|
||
### 15.3 继续收敛路径模型
|
||
|
||
- 逐步减少兼容别名
|
||
- 统一让 `/mnt/...` 成为唯一规范路径
|
||
- 减少模型与前端对历史路径的依赖
|
||
|
||
### 15.4 继续拆分用户视图与 Agent 视图
|
||
|
||
viewer service 证明了一件事:
|
||
|
||
- “给 Agent 用的 backend”和“给用户看的文件浏览器后端”应该明确区分
|
||
|
||
如果以后要做:
|
||
|
||
- 文件编辑器
|
||
- 差异查看
|
||
- 搜索
|
||
- 批量下载
|
||
- 二进制预览
|
||
|
||
应该继续沿 viewer-oriented 的方向扩展,而不是回退去复用 agent-oriented backend 语义。
|
||
|
||
---
|
||
|
||
## 16. 一句话总结
|
||
|
||
Yuxi 当前的沙盒方案,本质上是一套 **线程级容器工作区 + `/mnt` 命名空间 + 惰性获取的 provider + Agent/Viewer 双视图文件系统接入模型**。
|
||
|
||
它已经足够支撑:
|
||
|
||
- Agent 在独立线程工作区中执行命令
|
||
- Skills 以只读方式注入
|
||
- 工作台查看真实后端文件系统
|
||
|
||
但它仍然是一个 **面向当前产品场景的工程化沙盒**,而不是一个已经完成强安全与强控制面建设的通用沙箱平台。
|