我们最近在构建开源云端托管 Agent 平台 github.com/superduck-a… ,需要让 Sandbox 中的 Agent 像访问本地目录一样读取用户文件、加载 Skill,并保存任务产物。需求看起来只是「把云端文件挂载进 Sandbox」,但落到高并发、短生命周期的 Session 环境后,会遇到命名空间、权限、状态管理和资源清理等一系列工程挑战。
我们借鉴了 Anthropic Sandbox 的 rclone-filestore 架构思路,基于 FUSE(Filesystem in Userspace)实现了一套动态虚拟文件系统:文件内容仍保留在对象存储中,Session 只维护逻辑路径与资源引用;Skill 归档不必在启动前解压;Sandbox 通过短期、作用域受限的 Token 访问 Filestore。摸着 Anthropic 过河。
AI Agent 需要「文件系统」
Agent 在 Sandbox 中执行任务时,仍大量依赖 git、find、grep 等原生 CLI。这些工具要求底层提供稳定的 POSIX 文件接口:输入文件、Skill 与任务产物需要落在可预测的逻辑路径上,访问方式也不应随底层存储形态变化。文件系统还提供了直观的权限边界,例如将 /skills 挂为只读、将 /outputs 挂为可写。
问题在于,传统分布式文件系统(如 NFS、NAS)多为长生命周期、共享工作负载设计,并不契合按 Session 动态创建、短生命周期且高并发的 Sandbox。以阿里云 NAS 为例,官方使用限制包括:
- 单个文件系统最多同时挂载 1000 个计算节点;
- 单账号单地域最多创建 20 个通用型 NAS 文件系统,或 200 个极速型 NAS 文件系统。
若每个 Sandbox 都作为独立计算节点挂载,或为每个 Session 单独申请文件系统,上述配额很快会成为并发扩展与资源编排的瓶颈。
另一条常见路径是用 ossfs 等工具,把对象存储 Bucket 下的子路径直接挂进 Sandbox。这样可以绕过 NAS 的实例与挂载节点配额,并继续用绝对路径访问文件;但对象存储本质是扁平的 Key-Value 空间,ossfs 只是按对象 Key 中的 / 分隔符推导虚拟目录。它解决了「如何用文件路径读远程对象」,却没有回答「当前任务应该看到哪些文件」。
因此,Agent 侧的文件挂载不能直接暴露物理 Bucket 或宿主机目录,而需要一层可编程抽象:按任务动态构建虚拟视图,并在服务端控制权限与生命周期。FUSE(Filesystem in Userspace)刚好适合承担这一层机制——向上继续提供标准 POSIX 接口,向下将命名空间、鉴权与生命周期交由平台处理。
FUSE 介绍
FUSE 为标准 POSIX 文件接口与平台服务提供了可编程抽象层。Agent 通过 open、read、write、readdir 等系统调用透明访问挂载目录,无需感知底层数据源来自数据库、对象存储还是远程 API。
在 Agent 平台中,FUSE 主要提供了两项核心价值:一是通过 /dev/fuse 将 Linux VFS 的系统调用路由到用户态 Daemon,转化为平台远程 HTTP 请求;二是在用户态完成业务逻辑,规避了内核模块开发的高门槛与崩溃风险,使挂载进程的异常不至于影响宿主内核。
整体架构:控制面、挂载客户端与数据面
FUSE 只负责把 Linux 文件操作交给用户态进程。要让这套机制适用于云端 Agent,还需要在 FUSE 之外建立 Session 编排、虚拟命名空间、远程文件协议和凭证边界。
当前架构由五个部分组成:
- Runner:根据可信的 Session 配置解析用户文件与 Skill 版本,创建或更新 Filestore 命名空间,签发短期 Token,并启动 Sandbox;
- agent-fuse-fs:运行在 Sandbox 中,在固定目录建立 FUSE 挂载,把内核回调转换为 Filestore HTTP 请求;
- Filestore:校验 Token 和 filesystem 范围,处理目录、元数据与文件读写请求;
- PostgreSQL:保存 filesystem、目录和文件节点,决定当前 Session 能看到哪些逻辑路径;
- 对象存储:保存用户文件、任务产物与 Skill ZIP 的实际内容。
Session 启动时,Runner 先根据 File resource 和 Skill 配置写入命名空间节点,再为读写挂载和只读挂载分别签发 Token。Sandbox 就绪后,Agent 的文件操作依次经过 Linux VFS、agent-fuse-fs 和 Filestore;Filestore 从 PostgreSQL 解析路径,需要文件内容时再访问对象存储。
总结来看,这套架构将三层职责解耦:
- 对象存储决定「文件内容保存在哪里」;
- PostgreSQL 命名空间决定「当前 Session 能看到什么」;
- FUSE 与 Filestore 协议决定「Agent 如何通过普通文件操作访问这些内容」。
虚拟文件视图:让逻辑路径与物理对象解耦
如果 FUSE 只是把对象存储中的 key 一一映射为本地路径,它解决的仍然只是「如何读取远程对象」。更重要的能力来自对 lookup、readdir、open、read 等文件操作的接管:服务端可以根据数据库中的 namespace 动态生成目录和文件,从而让物理存储位置与 Agent 看到的文件视图相互独立。
单文件映射示例
假设用户通过 Files API 上传了一个 main.py:
file_id: file_ghi789
object_bucket: agent-files
object_key: files/7d9f.../main.py
size: 12,403 bytes
对象存储只负责保存文件内容。此时文件的物理位置是 agent-files/files/7d9f.../main.py,它并不知道 Session、/src 目录或 Agent 最终会从什么路径读取。
创建 Session 时,用户把同一个文件放到逻辑路径 /src/main.py:
{
"type": "file",
"file_id": "file_ghi789",
"mount_path": "/src/main.py"
}
mount_path 虽然以 / 开头,但它不是 Sandbox 根目录下的绝对路径,而是 File resource 在固定 /uploads 命名空间中的资源内路径。平台按照以下规则完成映射:
Filestore path = /uploads + mount_path
Sandbox path = /mnt/session/uploads + mount_path
例如:
因此,本例中的 /src/main.py 会写入 Filestore 的 /uploads/src/main.py,并通过固定挂载点呈现为 Sandbox 中的 /mnt/session/uploads/src/main.py。
这里不需要把对象复制到新的 Key,也不需要进入 Sandbox 执行 mkdir、cp 或创建单独的挂载点。服务端只需在当前 Session 的 Filestore 命名空间中增加一条引用原对象的文件节点。下面只展示这条数据库记录中与虚拟视图直接相关的字段:
固定 mount 在 Sandbox 启动时建立:
Filestore source: /uploads
Sandbox destination: /mnt/session/uploads
这条 entry 将原对象引用到 /uploads/src/main.py。结合已经建立的 /uploads 到 /mnt/session/uploads 固定挂载,最终形成下面的三层路径关系:
Agent 随后可以直接执行:
python /mnt/session/uploads/src/main.py
这条命令会依次变成下面的平台操作:
- FUSE 接收相对于
/uploads挂载根目录的lookup("src/main.py"); - 挂载客户端将其转换为针对
/uploads/src/main.py的readMetadata请求; - 服务端从数据库读取 entry,并确认其属于当前 Token 绑定的 filesystem;
open和read根据 entry 中的 bucket 与 key 打开原对象;- 文件内容通过 Filestore 和 FUSE 返回给 Agent。
遍历目录的数据同样来自数据库。Agent 执行:
find /mnt/session/uploads/src -maxdepth 1 -type f
FUSE 会把 readdir 转换为 listDirectory 请求。Filestore 查询 /uploads/src 的直接子节点,并把数据库中的 main.py 返回为普通目录项。对象存储不需要存在名为 uploads/src/ 的前缀,也不参与目录结构的判定。
同一份源文件还可以在另一个 Session 中显示为不同路径:
两个 Session 拥有各自的 namespace 和权限,但读取的是同一份对象内容。删除其中一条 entry 只会移除对应 Session 的文件视图,不会删除源文件,也不会影响另一个 Session。
因此,调整 File resource 只需修改数据库中的 namespace entry,已建 Mount 关系保持不变,无需重启 Sandbox 或传输文件内容。
ZIP 映射:按需展开 Skill 目录
除了单文件映射,虚拟视图还能直接将 ZIP 归档结构解释为目录树,而不必在 Sandbox 启动前将其完整解压到磁盘。
例如,Agent 运行时要求 Skill 安装在 /root/.claude/skills 下,并通过固定的目录结构发现 SKILL.md、脚本和参考资料:
/root/.claude/skills/
├── pdf/
│ ├── SKILL.md
│ └── references/
│ └── forms.md
└── company-reports/
├── SKILL.md
└── scripts/
└── build-report.sh
这两项 Skill 在对象存储中可以仍然是独立、不可变的 ZIP:
skills/pdf/v7/pdf.zip
└── pdf/
├── SKILL.md
└── references/
└── forms.md
skills/company-reports/v3/company-reports.zip
└── company-reports/
├── SKILL.md
└── scripts/
└── build-report.sh
Agent 配置只保存 Skill 引用,而不是复制归档内容:
[
{
"type": "anthropic",
"skill_id": "pdf",
"version": "latest"
},
{
"type": "custom",
"skill_id": "skill_company_reports",
"version": "v3"
}
]
Sandbox 启动时,Runner 先把 latest 解析为当时确定的具体版本,例如 pdf@v7,再为每个版本写入一条 archive entry:
数据库不需要为 ZIP 中的每个成员分别插入记录。/skills/pdf 和 /skills/company-reports 是持久化的 archive entry,下面的 SKILL.md、references/ 和 scripts/ 则由 Filestore 根据 ZIP 的目录结构按需生成。
随后,Runner 只需要建立一条固定的只读 mount:
Filestore source: /skills
Sandbox destination: /root/.claude/skills
access: readonly
对 Agent 而言,这与一个已经解压好的普通目录没有区别。它仍然可以使用熟悉的命令:
find /root/.claude/skills/pdf -maxdepth 2 -type f
cat /root/.claude/skills/pdf/SKILL.md
背后的请求处理过程如下:
ls /root/.claude/skills触发readdir("/skills")。Filestore 直接从数据库返回两条 archive entry,此时不需要读取 ZIP;ls /root/.claude/skills/pdf首次进入归档目录。Filestore 根据/skills/pdf找到对应 entry,并从对象存储读取压缩后的pdf.zip;- Filestore 校验对象大小和 SHA-256,确认 ZIP 结构安全,再根据目录建立内存索引;
- 索引中的目录和文件被转换为 FUSE 可以识别的虚拟节点,因此
references表现为目录,SKILL.md表现为普通文件; - Agent 打开
SKILL.md时,Filestore 通过zip.File.Open()只对目标成员进行流式解压,并把结果经由 FUSE 返回。
这里的「不提前解压」并不意味着永远不读取归档。当前实现会在首次深入访问某项 Skill 时读取完整的压缩 ZIP,但不会将其中的所有文件提前解压到 Sandbox,也不会生成需要清理和同步的临时目录。后续的 lookup、readdir 和 read 可以复用已建立的归档索引。对 Filestore 来说,ZIP 就是一棵可按需展开的虚拟目录树。
挂载点与 HTTP 调用链
构建好虚拟视图后,系统将其挂载到 Sandbox,并将 Agent 的文件操作转化为 Filestore HTTP 请求。
固定的 FUSE 挂载点
Sandbox 启动后,agent-fuse-fs Daemon 建立 5 个 FUSE 挂载点。不同目录具有各自的生命周期和读写模式:
# mount | grep fuse
agent-fuse-fs on /mnt/user-data/outputs type fuse (rw)
agent-fuse-fs on /mnt/session/uploads type fuse (ro)
agent-fuse-fs on /mnt/user-data/tool_results type fuse (ro)
agent-fuse-fs on /root/.claude/skills type fuse (ro)
agent-fuse-fs on /mnt/transcripts type fuse (ro)
/mnt/user-data/outputs 允许 Agent 写入任务产物,其余目录均为只读,防止用户上传文件、工具结果、Skill 与对话记录被 Agent 修改。
从 FUSE 操作到 Filestore API
Filestore 在 /v1/filestore/fs/ 下提供 10 个 POST 操作。在 Sandbox 运行期间,open / readdir / read 等 FUSE 系统回调与 Filestore HTTP 接口的对应关系如下:
需要注意的是,VFS 系统调用与 Filestore HTTP 请求并非严格的一比一对应。Linux VFS 与挂载客户端都会复用 inode、属性和目录缓存;客户端也可以通过父目录枚举解析未知子节点。因此,lookup 通常对应 readMetadata,但已经由 listDirectory 返回的目录项可以直接进入本地缓存,不必再次请求元数据。
目录遍历:listDirectory
当 Agent 查询一个尚未缓存的深层目录时:
ls -l /mnt/session/uploads/x/y/z
-rw-r--r-- 1 999 user 19 Jul 29 00:39 foo.md
当前挂载客户端会根据父目录逐级调用 listDirectory,每次只获取当前目录的直接子节点,最终解析出下面的虚拟路径:
/uploads
└── x
└── y
└── z
└── foo.md (text/plain, 19 B)
为解析这条路径,客户端会依次查询:
/uploads → /uploads/x → /uploads/x/y → /uploads/x/y/z
每次请求的结构相同,只有 path 和响应目录项发生变化。下面保留查询 /uploads/x/y 的一次请求作为示例:
POST /v1/filestore/fs/listDirectory HTTP/1.1
Host: host.docker.internal:38080
Content-Type: application/json
Authorization: Bearer <filestore-jwt>
{"filesystemId":"<filesystem-id>","path":"/uploads/x/y","limit":"100"}
HTTP/1.1 200 OK
{"entries":[
{"file":{"path":"/uploads/x/y/bar.md","size":"19","mediaType":"text/plain"}},
{"directory":{"path":"/uploads/x/y/z"}}
]}
这些目录项来自 PostgreSQL 命名空间。对象存储不参与目录遍历,也不需要存在与 /uploads/x/y/z 对应的物理前缀。
文件分段读取:readFile
当 Agent 打开并读取 /mnt/session/uploads/x/y/z/foo.md 时,FUSE Daemon 把 open 与 read 转换为分段读取请求:
POST /v1/filestore/fs/readFile HTTP/1.1
Host: host.docker.internal:38080
Authorization: Bearer <filestore-jwt>
Content-Type: application/json
Accept-Encoding: gzip
{"filesystemId":"<filesystem-id>", "path":"/uploads/x/y/z/foo.md", "range":{"length":"134217728"}}
HTTP/1.1 200 OK
Content-Length: 19
Content-Type: text/plain
# Session Context
Filestore 根据命名空间节点中的对象引用读取对应内容,再经由 agent-fuse-fs 返回给发起系统调用的进程。
Token 签发、下发与服务端校验
FUSE 将 Agent 的文件系统操作转化为远程 HTTP 请求,必须携带凭证调用 Filestore。为避免长期凭证(如 Workspace API Key、对象存储 Access Key 或签名私钥)暴露在 Sandbox 中,系统采用了两级凭证模型:签名私钥与存储凭证等长期根凭证仅保存在平台服务端;Runner 则基于可信 Session 上下文动态签发短期、受限的 Filestore JWT,仅供 FUSE 挂载进程使用。
派生的短期 Token 仅在挂载进程中保留,不写入环境变量、进程命令行或运行日志。即使 Token 被截获,其权限也被严格锁定在当前 Session、特定文件系统与 1 小时有效期内。
从可信 Session 上下文签发 Token
Runner 不会直接信任客户端提交的身份标识。创建 Sandbox 时,系统会基于 Workspace ID 和 Session ID 回查数据库校验:
- Session、Workspace 及账号状态正常且未归档或终止;
- Session、Workspace、Organization 与创建凭证具备有效的归属链路;
- Filestore 文件系统关联正确。
校验通过后,Runner 签发两枚有效期为 1 小时的 Ed25519 JWT:
- 读写 Token:供
/outputs挂载使用,不携带readonly字段。 - 只读 Token:固化
readonly=true声明,供其余 4 个只读挂载共享,防止客户端通过传入参数篡改访问语义。
在 agent-fuse-fs 挂载配置中,读写 mount 与只读 mount 分别注入对应的 Token:
{
"mounts": [
{
"auth_token": "<read-write-token>",
"destination": "/mnt/user-data/outputs",
"filesystem_id": "claude_chat_000000000000000000000000000",
"readonly": false,
"source": "/outputs"
},
{
"auth_token": "<readonly-token>",
"destination": "/mnt/session/uploads",
"filesystem_id": "claude_chat_000000000000000000000000000",
"readonly": true,
"source": "/uploads"
}
],
"service_url": "https://filestore.example.com"
}
其余 3 个只读 mount 复用同一枚只读 Token,只需替换 source 与 destination 路径。
Token 如何进入挂载进程
Token 签发发生在平台 Runner 内部,不通过公开 API 向 Sandbox 提供签发能力。Runner 将两枚 Token 写入权限为 0600 的临时配置文件,再以固定命令启动 agent-fuse-fs。启动命令只包含程序路径和配置文件路径,不直接包含 Token;全部挂载点就绪后,Runner 删除临时配置文件。
agent-fuse-fs 随后在每次 Filestore 请求中携带内存中的 Token:
Authorization: Bearer <filestore-jwt>
Filestore 如何校验请求
每个请求均携带专用 Bearer JWT。Filestore 验证 Ed25519 签名与有效期后,会依据 Token 中的资源标识回查数据库,确认 Session、Workspace 与文件系统的归属关系依然合法。
同时,服务端强制校验请求的 filesystemId 是否与授权声明一致。对于带 readonly=true 标记的 Token,所有写操作(创建、修改、删除等)会在路由入口被直接拦截。
JWT 的有效期为 1 小时。Session 终止或删除后,数据库回查会使旧 Token 在下一次请求时立即失效,无需等待 Token 自然过期,也不需要单独维护撤销黑名单。
FUSE Daemon 的代码实现
FUSE Daemon 需要在 Sandbox 内部高效转发 POSIX 请求。在 Go 生态中,推荐基于 github.com/hanwen/go-fuse/v2 实现用户态文件节点。下面的伪代码(隐藏了具体 HTTP 报文序列化细节)展示了 Lookup(路径查找与元数据映射)和 Read(分段读取数据)的核心转译逻辑:
type FilestoreNode struct {
fs.Inode
relPath string // 逻辑路径,如 "src/main.py"
client *FilestoreHTTPClient // 封装好的 HTTP 客户端
}
// Lookup:内核查找当前目录下子节点时触发 -> 转换为 POST /v1/filestore/fs/readMetadata
func (n *FilestoreNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
childPath := path.Join(n.relPath, name)
// 1. 发送 HTTP 请求查询 Filestore 节点元数据
meta, err := n.client.ReadMetadata(ctx, childPath)
if err == ErrNotFound {
return nil, syscall.ENOENT // 404 映射为 POSIX ENOENT
}
// 2. 将 Filestore 元数据转译为 VFS 识别的属性(大小、类型、修改时间、缓存 TTL)
out.Attr.Size = uint64(meta.Size)
out.Attr.Mode = meta.POSIXMode() // 文件 (0644) 或 目录 (0755)
out.Attr.Mtime = uint64(meta.ModTimeSec)
out.SetTimeout(1 * time.Minute) // 设置 1 分钟属性缓存,减少重复网络开销
// 3. 构造并挂载子 Inode 节点
child := &FilestoreNode{relPath: childPath, client: n.client}
return n.NewInode(ctx, child, fs.StableAttr{Mode: out.Attr.Mode}), 0
}
// Read:内核读取文件内容时触发 -> 转换为 POST /v1/filestore/fs/readFile
func (n *FilestoreNode) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
// 4. 按 offset 和 size 分段向 Filestore 请求文件字节流,并写入 VFS dest 缓冲区
data, err := n.client.ReadFile(ctx, n.relPath, off, int64(len(dest)))
if err != nil {
return nil, syscall.EIO
}
return fuse.ReadResultData(data), 0
}
Daemon 进程启动后(调用 fs.Mount 并运行事件循环),会持续监听 Linux 内核发起的 /dev/fuse 系统调用,并将其转译为带有 Bearer Token 的 HTTP 请求发送给 Filestore 服务端。生产实现只需在此基础上补充本地 Inode 缓存、属性失效策略以及写操作 Handlers。
总结
本文是 Agent 运行环境基建系列的第 1 篇(第 0 篇可参考之前发布的《方便 Mac 本机运行 e2b 的沙箱方案 e2b-local》)。接下来我们计划通过这个系列逐步构建完整的 Agent 运行环境基建,包括运行时、文件系统、Session 管理、监控告警、安全管控等,欢迎关注后续的实战总结。