用 FUSE 为云端 Agent 构建文件系统

avatar
@myself

我们最近在构建开源云端托管 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 编排、虚拟命名空间、远程文件协议和凭证边界。

当前架构由五个部分组成:

  1. Runner:根据可信的 Session 配置解析用户文件与 Skill 版本,创建或更新 Filestore 命名空间,签发短期 Token,并启动 Sandbox;
  2. agent-fuse-fs:运行在 Sandbox 中,在固定目录建立 FUSE 挂载,把内核回调转换为 Filestore HTTP 请求;
  3. Filestore:校验 Token 和 filesystem 范围,处理目录、元数据与文件读写请求;
  4. PostgreSQL:保存 filesystem、目录和文件节点,决定当前 Session 能看到哪些逻辑路径;
  5. 对象存储:保存用户文件、任务产物与 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

这条命令会依次变成下面的平台操作:

  1. FUSE 接收相对于 /uploads 挂载根目录的 lookup("src/main.py");
  2. 挂载客户端将其转换为针对 /uploads/src/main.py 的 readMetadata 请求;
  3. 服务端从数据库读取 entry,并确认其属于当前 Token 绑定的 filesystem;
  4. open 和 read 根据 entry 中的 bucket 与 key 打开原对象;
  5. 文件内容通过 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

背后的请求处理过程如下:

  1. ls /root/.claude/skills 触发 readdir("/skills")。Filestore 直接从数据库返回两条 archive entry,此时不需要读取 ZIP;
  2. ls /root/.claude/skills/pdf 首次进入归档目录。Filestore 根据 /skills/pdf 找到对应 entry,并从对象存储读取压缩后的 pdf.zip;
  3. Filestore 校验对象大小和 SHA-256,确认 ZIP 结构安全,再根据目录建立内存索引;
  4. 索引中的目录和文件被转换为 FUSE 可以识别的虚拟节点,因此 references 表现为目录,SKILL.md 表现为普通文件;
  5. 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 管理、监控告警、安全管控等,欢迎关注后续的实战总结。