文件系统接缝
packages/fs/fs 定义 ctx.fs——FileSystem 抽象服务。与 shell 接缝不同,ctx.fs 横跨两个世界的多个 Provider:本地世界(fs-local、fs-sandbox)与 E2B 远程 Linux 世界(fs-e2b,基于 ctx.e2b)。观察策略(fs-observation-policy)是一个仅事件的伴生插件,不注册任何服务——它通过 fs/* 事件闸门贡献受守卫的变更语义。
tool-fs ── tool-fs-search ── tool-str-replace-editor (消费者)
│
┌──────────────▼───────────────┐
│ 服务定义:ctx.fs │
│ FileSystem(抽象类) │ @deepseek-ai/dsh-fs
└──────────────┬───────────────┘
┌─────────────┼────────────────────┐
▼ ▼ ▼
fs-local fs-sandbox fs-e2b (Provider)
(裸本地后端) (沙箱围栏) (E2B 远程,经 ctx.e2b)
└── 事件闸门 ──► fs-observation-policy (伴生,无服务)各包版本
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-fs | 服务定义(FileSystem、ctx.fs、FsError) |
@deepseek-ai/dsh-fs-local | Provider:裸本地后端 |
@deepseek-ai/dsh-fs-sandbox | Provider:按沙箱模式围栏变更的本地后端 |
@deepseek-ai/dsh-fs-observation-policy | 伴生:仅事件的已观测状态守卫 |
@deepseek-ai/dsh-tool-fs | 消费者:read/read_image/write/edit 工具 |
@deepseek-ai/dsh-tool-fs-search | 消费者:glob + grep 搜索工具 |
@deepseek-ai/dsh-tool-str-replace-editor | 消费者:str_replace_editor 工具 |
@deepseek-ai/dsh-workspace | Core:ctx.workspaceRegistry + workspace 实体 |
@deepseek-ai/dsh-atomic-write | Util:原子文件替换 + 写者锁 |
@deepseek-ai/dsh-fs-e2b | Provider:E2B 文件系统后端(见代码运行时页) |
FS 服务表面
FileSystem(packages/fs/fs/src/index.ts)是 ctx.fs 背后的抽象类。每个操作都接收一个已解析的 FsTarget——resolve() 返回的不透明身份——而且 I/O 是异步的,以便远程后端可以往返映射路径到稳定的 targetKey。
export abstract class FileSystem extends Service {
constructor(ctx: Context) { super(ctx, 'fs') }
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
abstract processPath(target: FsTarget): string // 此执行世界中的规范绝对路径
abstract fileUrl(target: FsTarget): string // 规范 file: URI
abstract contains(parent: FsTarget, child: FsTarget): boolean
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
abstract writeText(target, content, expected?, signal?, sandboxPolicy?): Promise<FsWriteOutcome>
abstract editText(target, edit, expected?, signal?, sandboxPolicy?): Promise<FsEditOutcome>
}关键设计点:
targetKey是不透明的(FsTargetKey品牌):消费者不得解析它或假定它是本地绝对路径。processPath/fileUrl是供后端控制的、把值传给另一个 OS 能力的独立通道。stat只返回元数据(FsInfo:version、type: 'file'|'directory'|'other'、可选size)。策略层用type与size决定选readText还是streamText,而不靠失败试探。lstat是路径形态、而非目标形态:它不追随后导路径组件,因此FsPathInfo.type能报'symlink',让信任边界的消费者在resolve追随后导链接之前就能拒绝仓库自持的链接。- 变更是原子的,且可选择性守卫(
expectedintent/version),外加一个sandboxPolicy围栏参数。
不透明的 FsVersion 与 FsWriteIntent
守卫基于令牌,从不「按内容比较权限」:
export type FsWriteIntent =
| { kind: 'createIfAbsent' } // 已存在则拒绝 → FS_NOT_OBSERVED
| { kind: 'replaceIfVersion'; version: FsVersion } // 缺失或不符则拒绝 → FS_STALE_VERSION
// 省略 intent = 无条件的 create-or-overwrite(并不是第三个分支)FsVersion 是带品牌的令牌,来自高分辨率 stat 身份 + 新鲜度字段。接受 version 守卫的变更会在匹配之前就拒绝过期内容(FS_STALE_VERSION)。
类型化错误分类
FsError extends HarnessError 携带稳定的、可机器路由的 FsErrorCode:
FS_NOT_FOUND、FS_NOT_DIRECTORY、FS_NOT_TEXT、FS_NOT_REGULAR_FILE、FS_TOO_LARGE、FS_PERMISSION_DENIED、FS_SANDBOX_DENIED、FS_IO_ERROR、FS_STALE_VERSION、FS_NOT_OBSERVED、FS_AMBIGUOUS_EDIT、FS_EDIT_NOT_FOUND、FS_ABORTED。
观察策略:fs/* 事件闸门
fs-observation-policy(packages/fs/fs-observation-policy/src/index.ts)不注册任何服务。它在 @deepseek-ai/dsh-fs 声明于 Context['Events'] 的事件名上挂上三个监听器:
| 事件 | 模式 | 策略做什么 |
|---|---|---|
fs/write-intent | waterfall | 推导守卫:未见/缺席 → createIfAbsent;已见 → replaceIfVersion |
fs/edit-intent | waterfall | 推导版本:未见 → FS_NOT_OBSERVED;确认缺席 → FS_NOT_FOUND |
fs/observed | emit | 以 (owner, targetKey) 为键记录权威的已见/缺席观察 |
状态是一个 WeakMap<owner, Map<targetKey, FsObservation>>,以待解释的事件 actor(通常是当前 agent 会话)推导出的观察 owner 为键,因此被回收的会话会释放自己的状态。没有此插件时,工具保留裸 Provider 的无条件变更行为——这正是接缝拆分的意义:Provider 保持简单,而「写/改前必须先读」这条纪律由伴生插件贡献。
它是模型侧引导的机制来源,编码在 write/edit 工具描述里:"Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it)."
Provider:本地、沙箱化、远程
fs-local——裸本地后端:规范化 + realpath、解码 UTF-8、拒绝二进制(FS_NOT_TEXT)、按稳定名称顺序列目录、执行原子变更。editText保留于此,以便版本检查、字面匹配与重写共享同一个临界区。fs-sandbox(@deepseek-ai/dsh-fs-sandbox)——包裹本地后端,并按ctx.sandboxPolicy的共享沙箱模式围栏变更(containment.ts)。读取不受围栏;只有 write/edit 携带sandboxPolicy参数,因此danger-full-access对workspace-write改变的是可写什么,而不是可读什么。拒绝以FS_SANDBOX_DENIED呈现(映射为模型看到的[sandbox: …]标记)。fs-e2b——E2B 后端,运行一个远程 Linux 文件系统世界(见 代码运行时)。因为ctx.fs与ctx.subprocess/ctx.e2b共享一个执行世界,processPath返回的路径在那个沙箱内有效。
面向模型的工具
tool-fs:read / read_image / write / edit
@deepseek-ai/dsh-tool-fs(包 config: readLimit、readMaxLineLength、readMaxBytes、readStreamMinSize)注册文件系统工具族。read_image 随 composition 而定:只有挂载了 attachments 存储时才注册。
read —— file_path,可选 offset(1 基,默认 1)与 limit。结果带行号与窗口 { offset, limit, maxLineLength, maxBytes }。
write —— file_path + content(空的 content 写空文件),外加升级字段(sandbox_permissions、justification),仅在存在会约束的 ctx.fs 时公布。输出返回 { path, operation: 'create'|'update', before, after };before 是 LF 规范化后、写入前的基准,UI 据此算真正的 diff。展示为 diff 卡片。
edit —— file_path、old_string、new_string、可选 replace_all。描述要求:old_string 必须精确匹配,空的 new_string 删除匹配,且默认 old_string 必须恰好出现一次(出现歧义 → FS_AMBIGUOUS_EDIT)。replace_all: true 替换所有匹配。
tool-fs-search:glob + grep
@deepseek-ai/dsh-tool-fs-search 以 subprocess 接缝的子进程方式运行 ripgrep(它不用 ctx.fs)。两个工具都经 @deepseek-ai/dsh-output-retention(ItemRetainer head)给输出设上限,并在超限时把完整结果 spill 到文件。
glob —— pattern + 可选 path。最多 caps.maxResults 个路径按修改时间序返回;含隐藏与忽略文件、排除 VCS 元数据目录;更大的结果返回采样或头部,并把完整排序列表存盘。输出:{ root, paths: string[] }。
grep —— pattern(ripgrep 语法)、可选 path 与 include glob 过滤器。返回 { matches: [{ path, lineNumber, line }] },展示时按文件分组。超限结果会报告完整匹配列表的存盘位置。
tool-str-replace-editor:兼容性编辑器
@deepseek-ai/dsh-tool-str-replace-editor 注册单个 str_replace_editor 工具(Anthropic 风格的行与字符串编辑器)。其 command 枚举为 view | create | str_replace | insert,参数为 path(按命令再加 file_text、old_str、new_str、insert_line)。按其默认描述:view 用 cat -n 显示文件,或列出目录(最多两层);create 拒绝已存在的文件;str_replace 要求在文件中唯一的精确多行匹配;insert 在某行之后插入。长输出截断并以 <response clipped> 标记。它经 ctx.fs 与同一套沙箱升级 API,并携带 sandboxDenialMarker。
workspace 概念
workspace 包(packages/workspace/workspace)不是 ctx.fs 的 Provider——它是存储域设施之上 workspace 记录的持久注册表(ctx.workspaceRegistry)。要点:
realpathNormalize(在packages/workspace/workspace/src/paths.ts)是 fs 层复用的规范化,用于会话 cwd → workspace 根。WorkspaceId品牌的记录给出稳定的会话身份,供 Host RPC 与 GUI 投影使用(apps/web)。tool-bash/tool-fs把相对workdir/file_path依会话 workspace 解析;沙箱策略的 workspace 根优先,使workdir与约束使用完全相同的逐调用身份。- 迁移/标题规则:只接受受信任的根;workspace 记录对
SessionHeader是「头部校验」的。
文件实际存放在哪里取决于 Provider 与 composition:本地 Provider 解析到真实本地文件系统;workspace 根就是相对路径的规范基准;沙箱化或 E2B 的 composition 会约束(或重定位)变更的一面。
原子写入
@deepseek-ai/dsh-atomic-write(packages/util/atomic-write)是原子文件替换背后的零依赖原语:
writeFileAtomic(filename, content, { mode, dirMode? })——先以排他创建(wx)写到随机后缀的兄弟文件,再rename覆盖目标。读者看到的要么是旧完整内容要么是新完整内容;新建 inode 把options.mode带过 rename;同目录兄弟文件保证 rename 在同一文件系统上。失败时删掉临时文件并重抛错误。withFileLock——通过wx创建的<file>.lock兄弟文件串行化同一文件的跨进程写者,使读-改-写循环永远不会复活另一个写者刚替换的状态。读者保持无锁,因为 rename 提交本身是原子的。
注意 fs Provider 用自己的原子性(临时文件 + rename),而这个 util 服务于 harness 内部状态文件(settings、session 文档)。
延伸阅读
- Shell 与终端 —— 兄弟
ctx.shell接缝,采用同样的 Provider 模式。 - 代码运行时 ——
fs-e2b背后的 E2B 底座。 - 文件系统观察与沙箱策略 —— 安全语境下的模式围栏与已观测状态策略。
- 沙箱架构 —— 三种模式与强制执行机制。
- Spill:上下文溢出 —— 超大的
glob/grep结果如何被 spill 到文件。 packages/fs/fs/src/index.ts——FileSystem定义。