夜雨聆风学习资料网

ARTICLE · 986191

DeepSeek Harness 源码-文件系统、策略与 Workspace

DeepSeek Harness 源码-文件系统、策略与 Workspace

DeepSeek Harness 源码解析系列

第 15 讲:文件系统、策略与 Workspace

基于 DeepSeek Harness 源码 · 2026-08-30

💡 本讲一句话:模型改文件最怕"基于过期内容盲写"。这一套文件系统能力用三个零件防住它:每个文件带一个版本令牌、每次写入前校验版本、没读过的文件根本不让写——而且全部靠事件解耦,后端可换(本地/沙箱/远程)而工具层一行不改。

一、问题:盲覆盖是怎么发生的

上一讲我们看了子进程怎么被安全地 spawn 和终止。这一讲看另一个高频危险动作:写文件。

想象这个场景:模型读了 config.json,思考了 30 秒,期间用户手动改了它。如果模型这时直接"整文件覆盖写回",用户的修改就没了——而且模型完全不知道。

更隐蔽的是:模型根本没读过这个文件就直接 write,等于对着一份它一无所知的内容做覆盖。DeepSeek Harness 的解法不是"祈祷模型别这么干",而是在文件系统能力层把这两种情况都变成显式报错。

先看这套能力的包结构——四个包各管一层:

包
职责
dsh-fs
Service Definition:抽象接口 + 类型词汇表(FsTarget/FsVersion/错误码)
dsh-fs-local
本地后端:realpath 身份、版本令牌、原子写
dsh-fs-observation-policy
观察策略插件:记录"谁读过什么文件",派生写/编辑守卫
dsh-tool-fs
模型可见工具:read / write / edit 的 schema、校验、输出格式

注意一个关键设计:策略是插件,不是接口的一部分。不装 observation-policy 插件时,工具退化为"无条件原子写"——能力层永远提供原子性,守卫由组合决定。

二、Service Definition:不透明的目标与版本令牌

所有操作都不直接吃路径字符串,而是先 resolve() 成一个 FsTarget。这是整套设计的地基:

📄 packages/fs/fs/src/types.ts (第 60-83 行)

export interface FsTarget {  targetKey: FsTargetKey  // 不透明键:本地后端是 realpath,远程后端可以是 URI/文件 ID  displayPath: string    // 给模型/UI 看的路径——两个字段刻意分开}export interface FsInfo {  version: FsVersion      // 目标此刻的新鲜度令牌,是写守卫的比对基准  type: 'file' | 'directory' | 'other'  // 让策略层在读之前就能拒绝目录/特殊文件  size?: number          // 字节数:据此选 readText 还是 streamText,不用靠失败探测}

为什么 targetKey 必须不透明?因为本地后端和远程后端的"身份"完全不同:本地是 realpath(符号链接解析后的真实路径),远程可能是一个 revision id。把键做成 branded string、禁止消费方解析,工具层和策略层就永远不需要知道文件到底在哪——换后端时上层零改动。

再看写操作的"意图"类型,这是防盲覆盖的核心词汇:

📄 packages/fs/fs/src/types.ts (第 123-125 行)

export type FsWriteIntent =  | { kind: 'createIfAbsent' }             // 只允许"创建":目标已存在 → FS_NOT_OBSERVED  | { kind: 'replaceIfVersion'; version: FsVersion }  // 只允许"替换":版本不符/文件消失 → FS_STALE_VERSION

注意注释里那句"省略 intent 表示无条件创建或覆盖,不是第三种 union 分支"——类型系统把"无守卫"表达为 expected?: FsWriteIntent 的可选参数,而不是一个 { kind: 'unconditional' }。这样"守卫"永远是显式加上去的东西,默认路径保持最简。

抽象类 FileSystem 把读写和变更分成两组方法,变更方法的签名值得细看:

📄 packages/fs/fs/src/index.ts (第 222-249 行,节选)

abstract writeText(  target: FsTarget,  content: string,  expected?: FsWriteIntent,        // 守卫意图;省略 = 无条件创建或覆盖  signal?: AbortSignal,  sandboxPolicy?: SandboxExecutionPolicy, // 每次调用的沙箱模式 + workspace 根): Promise<FsWriteOutcome>abstract editText(  target: FsTarget,  edit: FsEditRequest,           // 字面量 oldString/newString/replaceAll  expected?: { version: FsVersion }, // 版本守卫:先查版本再匹配字面量  signal?: AbortSignal,  sandboxPolicy?: SandboxExecutionPolicy,): Promise<FsEditOutcome>

两个细节:①editText 留在 Service Definition 里而不是拆给策略层,源码注释说得很直白——"版本检查、字面量匹配、重写必须共享同一个临界区",拆开就有竞态。②sandboxPolicy 是每次调用传进去的,不是后端构造时的固定配置——同一个后端可以按会话、按审批结果切换隔离强度。

三、本地后端:realpath 身份 + 每文件锁

LocalFileSystem 是跑在宿主机上的默认后端。它的第一件事是把"文件身份"钉死:

📄 packages/fs/fs-local/src/fsio.ts (第 73-76 行)

/** 不透明版本令牌:来自高精度身份 + 新鲜度元数据 */function versionOf(info: BigIntStats): FsVersion {  return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)}

版本令牌 = 设备号 + inode + 大小 + mtime(纳秒) + ctime(纳秒)。为什么不用单纯的 mtime?因为 mtime 精度有限、且可以被 touch 伪造;加上 inode 和 ctime,"文件被替换成另一个 inode"或"内容变了但时间戳没变"这两种情况都逃不掉。这个字符串对上层完全不透明——策略层只存、只比对,从不解析。

第二个关键零件是每文件锁。Node.js 里并发写同一个文件没有原子性保证,后端自己实现了一个按 targetKey 的 FIFO 队列:

📄 packages/fs/fs-local/src/index.ts (第 90-104 行)

/** 以 targetKey 为粒度独占执行 op(同键 FIFO) */private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {  const prior = this.locks.get(targetKey) ?? Promise.resolve() // 取该文件上一个操作(没有就空承诺)  const run = prior.then(op, op)  // 排在上一个之后执行;无论它成功还是失败都继续  const tail = run.then(() => undefined, () => undefined) // 吞掉本次结果/异常,给下一个等待者留干净的链尾  this.locks.set(targetKey, tail) // 更新队列尾部指针  try {    return await run  // 把本次操作真实的结果/异常返回给调用方  } finally {    if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey) // 只有仍是队尾才删,避免误删新排进来的队列  }}

这个锁的意义在源码注释里写得很清楚:让"读 → 守卫检查 → 写"的窗口不能交错。两个并发 write 打到同一文件时,结果确定性地变成"一个赢、另一个看到新版本后报 stale"——而不是两个都成功、互相覆盖。注意 prior.then(op, op) 的写法:前一个操作失败也会触发后一个,锁只负责排队、不负责传播错误。

🔹 为什么锁按 targetKey 而不是路径?因为同一个文件可能有多个别名(符号链接、相对/绝对路径)。realpath 之后它们共享同一个 targetKey,也就共享同一把锁——从别名 A 读到的版本守卫,能正确拦住从别名 B 发起的过期写入。

还有一个容易忽略的细节:resolveLocalTarget 对不存在的文件也要给出稳定身份——它向上找最近的已存在祖先做 realpath,再把缺失的目录段拼回去。这样"先创建父目录、再写文件"和"直接写"得到同一个 targetKey,守卫状态不会因路径是否已存在而分裂。

四、写守卫:两种意图在临界区里裁决

守卫检查发生在锁内、写入前。这是 writeText 的核心分支:

📄 packages/fs/fs-local/src/index.ts (第 172-189 行)

return this.withLock(target.targetKey, async () => {  const existing = await probe(target.targetKey) // 先 stat:存在吗?什么类型?  if (existing && existing.type !== 'file') {    throw new FsError('cannot write ...: not a regular file', 'FS_NOT_REGULAR_FILE') // 目录/特殊文件直接拒绝  }  if (expected?.kind === 'replaceIfVersion') {    // 陈旧守卫:文件必须仍存在于"所有者观察到的那个版本"    if (!existing) throw new FsError('...file no longer exists', 'FS_STALE_VERSION') // 读之后被删了    if (existing.version !== expected.version) {      throw new FsError('...file changed since it was read', 'FS_STALE_VERSION') // 版本变了 → 过期    }  } else if (expected?.kind === 'createIfAbsent' && existing) {    // createIfAbsent 撞上已存在文件 = 盲覆盖 —— 要求先读    throw new FsError('cannot overwrite existing ... without reading it first', 'FS_NOT_OBSERVED')  }  // expected 省略 = 无条件写,但依然走下面的原子发布路径

把两种意图的裁决规则浓缩成一张表:

意图
文件不存在时
文件存在且版本匹配时
文件存在但版本不符时
createIfAbsent
✅ 创建
❌ FS_NOT_OBSERVED(盲覆盖)
❌ FS_NOT_OBSERVED
replaceIfVersion(v)
❌ FS_STALE_VERSION(读后被删)
✅ 替换
❌ FS_STALE_VERSION(过期)

为什么"盲覆盖"报的是 FS_NOT_OBSERVED 而不是权限错误?因为这不是"你不许写",而是"你还没看过它,没有资格覆盖"——错误码直接告诉模型正确的补救动作是先 read 再 write。错误信息本身就是给模型的指令。

editText 的守卫顺序更讲究:先查版本、再匹配字面量。如果反过来,一个基于旧内容的 edit 会报"找不到 oldString"或"匹配多处"——模型会去改搜索词重试,而不是意识到文件已经变了。先报 stale,语义才正确。

五、原子写:staging 目录 + 硬链接发布

守卫通过后,真正的落盘由 writeFileAtomic 完成。它不用"打开原文件、截断、写入"这种危险姿势,而是经典的 staging + rename:

📄 packages/fs/fs-local/src/fsio.ts (第 533-620 行,节选)

const stagingDirName = `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`await mkdir(stagingDir, { mode: 0o700 }) // 在目标同目录建私有暂存区(同一文件系统 → rename 才是原子的)await chmod(stagingDir, 0o700)  // 仅属主可读写:半成品内容对他人不可见handle = await open(tempPath, 'wx', 0o600) // wx = 排他创建,已存在即失败;初始权限 0600await handle.writeFile(content, { encoding: 'utf8' })await handle.sync()  // fsync 落盘后才允许发布 —— 断电不会留下半截文件if (mode !== undefined) await handle.chmod(mode) // 恢复原文件的权限位(如 0644)if (createIfAbsent !== undefined) {  await linkFile(tempPath, absolutePath) // 硬链接发布:目标已存在则失败 → 天然实现"绝不覆盖"} else {  await rename(tempPath, absolutePath) // POSIX 原子替换}await removeStagingDir(stagingDir) // 清理暂存区;此时目标已提交,清理失败不影响写入结果

这里有个很妙的点:createIfAbsent 的"不覆盖"语义不是靠 if 判断实现的,而是靠硬链接的系统行为——link() 在目标已存在时直接报错。即使前面的守卫检查和这次发布之间有人抢先创建了文件(TOCTOU 竞态),硬链接也会兜底失败,而不是静默覆盖。系统调用本身就是最后一道锁。

暂存目录放在目标同目录而不是 /tmp,是因为 rename 跨文件系统(EXDEV)不是原子的——必须保证 temp 文件和最终目标在同一文件系统上。

六、观察策略:把"先读后写"变成事件流上的守卫

前面讲的守卫检查都在后端里,但"该用哪种意图"的决策被抽成了一个独立插件 fs-observation-policy。它不注册任何服务,只挂三个事件监听器:

📄 packages/fs/fs-observation-policy/src/index.ts (第 65-95 行)

writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {  const owner = this.owner(actor) // 从不透明 actor 里取出会话(agent.session)作为状态归属  const prior = owner ? this.get(owner, target.targetKey) : undefined // 查该会话对这个文件的观察记录  return prior?.kind === 'present'    ? { kind: 'replaceIfVersion', version: prior.version } // 读过 → 按"当时看到的版本"允许替换    : { kind: 'createIfAbsent' }  // 没读过/确认不存在 → 只许创建,不许覆盖}editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {  if (!owner || prior === undefined) {    throw new FsError(`edit requires reading "..." first`, 'FS_NOT_OBSERVED') // 没读过 → edit 直接拒绝  }  if (prior.kind === 'absent') {    throw new FsError('cannot edit ...: not found', 'FS_NOT_FOUND') // 确认过不存在 → 报 not found  }  return { version: prior.version } // 用观察到的版本做 CAS 基准}

状态存在一个 WeakMap<会话, Map<targetKey, 观察>> 里。用 WeakMap 持有会话意味着:会话对象被 GC 时,它的全部观察记录自动释放——不需要手动清理生命周期。

三个事件的接线方式体现了 Cordis 的事件语义:

📄 packages/fs/fs-observation-policy/src/index.ts (第 106-130 行)

export function apply(ctx: Context): void {  const gate = new ObservedStateGate() // 每次 apply() 一个实例,dispose 时可整体清空  ctx.effect(() => () => gate.clear(), 'fs-observation-policy observed-state teardown') // HMR 安全:重载即清状态  // fs/write-intent:waterfall 单槽决策 —— 占住这个槽,不调 next()  ctx.on('fs/write-intent', (target, actor) =>    Promise.resolve().then(() => gate.writeIntent(target, actor))) // 延迟到微任务:throw 变成 reject,不穿透 waterfall  // fs/edit-intent:同理占槽  ctx.on('fs/edit-intent', (target, actor) =>    Promise.resolve().then(() => gate.editIntent(target, actor)))  // fs/observed 必须同步且不抛错:emit 不 await promise,且此时变更已提交  ctx.on('fs/observed', (target, observation, actor) => {    gate.observe(target, observation, actor)  })}

注意 Promise.resolve().then(...) 这个看似多余的包装:waterfall 监听器如果同步 throw,异常会直接穿透事件分发机制;包一层微任务后,throw 变成 promise reject,被工具层的 try/catch 正常接住。这是"事件即 API"模式下必须处理的边界。

🔹 为什么策略要独立成插件?因为"先读后写"是产品决策,不是文件系统事实。同一个本地后端:给终端用户用可以强制守卫;给自动化批处理用可能想关掉它换取自由度。拆成事件 + 插件后,组合 dsh-fs-local(裸后端)和 dsh-fs-sandbox(沙箱后端)都能按需叠加同一套守卫,工具层代码一行不改。

那"观察记录"是谁写进去的?是工具自己——每次成功的读/写之后 emit fs/observed。策略插件只消费、不产生,职责单向。

七、工具层:write 工具如何把全链路串起来

dsh-tool-fs 是模型真正看到的 write / edit / read 工具。execute 函数就是整条链路的总装:

📄 packages/fs/tool-fs/src/write.ts (第 102-129 行)

async execute(args: WriteToolArgs, exec) {  const input = parseWriteArgs(args) // file_path 必须非空;content 允许为空(写空文件是合法的)  // 先解析本次调用的沙箱策略:已批准的升级 > 会话默认 > 后端默认  const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)  // 相对路径按"会话 cwd"解析,而不是服务进程的启动目录  const target = await ctx.fs.resolve(input.filePath,    sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))  // 单槽决策:策略插件给出 createIfAbsent/replaceIfVersion;没有策略则 undefined(无条件)  const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)  let outcome: FsWriteOutcome  try {    outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)  } catch (error: unknown) {    // 沙箱拒绝 → [sandbox: ...] 标记(和 bash 工具同一套词汇);stale/not-observed → 附带补救提示    throw remediateFsError(sandbox.mapError(error, sandboxPolicy))  }  // 记录"present + 新版本"观察(没有策略插件监听时是 no-op)  ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)  return { path: target.displayPath, operation: outcome.operation,    before: outcome.before, after: outcome.after }}

执行顺序值得注意:沙箱策略解析 → 路径 resolve → 意图决策 → 写入 → 记录观察。每一步都在下一步之前失败,错误在离模型最近的层被翻译成它听得懂的指令(remediateFsError 会给 stale/not-observed 附上"先读再写"的补救文案)。

返回值里的 before/after 不是给模型看的正文,而是给 UI 算 diff 卡片的原料——工具的输出 schema 同时服务两个消费者(LLM + 前端),这是"一个操作、两种呈现"的典型。

八、Workspace:每个会话有自己的工作目录

"相对路径相对于谁解析?"这个问题在多会话服务里是安全边界。答案在 session-cwd.ts,只有 46 行:

📄 packages/fs/tool-fs/src/session-cwd.ts (第 23-27 行)

export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined {  const cwd = exec.agent?.session.header.cwd // 取"发起调用的 agent 的会话"里记录的 workspace  if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd  return canonicalPath(cwd) // 路径含 .. 时先规范化:防止符号链接的 cwd 泄露文件系统身份}

文件头注释把设计意图说得很完整:每个会话的 read/write/edit 作用于它自己的 workspace,而不是服务进程的启动目录——和 bash 工具默认 workdir 取 session cwd 是同一个原则。非 agent 调用(比如 SDK 直连)返回 undefined,把回退权留给后端,而不是在工具边界偷偷读 process.cwd()。

第三行那个 canonicalPath 分支处理的是一个很刁钻的情况:如果 cwd 本身是符号链接、且请求路径里带 ..,那么"先拼路径再 realpath"和"先 realpath 再拼"可能落到不同文件。规范化 cwd 让两种顺序收敛到同一身份。

九、沙箱升级:和 bash 共用一套词汇

当挂载的是限制型后端(如 dsh-fs-sandbox,下一讲的主角)时,write/edit 的 schema 会多出两个字段:sandbox_permissions + justification。升级逻辑封装在 FsSandboxController:

📄 packages/fs/tool-fs/src/sandbox.ts (第 87-108 行,节选)

async resolvePolicy(toolName, args, exec): Promise<SandboxExecutionPolicy | undefined> {  validateEscalationArgs(args.sandbox_permissions, args.justification) // 两个字段必须成对出现  const standingPolicy = this.policy?.resolve({ ... }) // 会话的常设沙箱模式  if (args.sandbox_permissions === undefined || args.justification === undefined) {    return standingPolicy // 没请求升级 → 直接用常设策略  }  // 请求了更宽的模式:必须走用户审批,且只允许"严格更宽"  const approvedMode = await approveEscalation(    { requestedMode: args.sandbox_permissions, justification: args.justification,      effectiveMode: policy.mode, subject: 'operation' },    { approver: this.ctx.get('approval'), agent: exec.agent, callId: exec.callId, ... })  return { ...policy, mode: approvedMode } // 一次性更宽重试,不改变会话默认}

三个设计点:① 升级字段只在限制型后端挂载时才出现在 schema 里——没有沙箱的部署根本看不到这两个参数,模型无法"幻觉"出它们。② 审批是每次调用发生的(approveEscalation),批准只对本次操作生效,不污染会话默认。③ 被拒后的错误文案 [sandbox: ...] 标记和 bash 工具完全相同——模型在两个工具间学到的升级经验可以迁移,不用学两套词汇。

mapError 里还藏着一个容易踩的坑:沙箱拒绝必须包装成 FsError(带 FS_SANDBOX_DENIED code)而不是普通 Error——因为工具运行时只对 HarnessError 实例填充结构化错误字段,换成裸 Error 会把重试/观察者依赖的 code 直接抹掉。

十、总结:一次 write 调用的完整数据流

错误码
触发条件
模型的正确响应
FS_NOT_OBSERVED
没读过就 write/edit
先 read,再重试
FS_STALE_VERSION
读之后文件被改过/删了
重新 read,基于新内容操作
FS_NOT_FOUND
路径不存在 / edit 目标确认缺失
检查路径,或改用 write 创建
FS_TOO_LARGE
文件超过字节上限(readBytes)
改用带 offset/limit 的 read 分段读
FS_SANDBOX_DENIED
沙箱拒绝越界写入
带 justification 请求升级,等用户审批

write 调用全链路(一次完整的数据流)

模型 → write(file_path, content)

↓

dsh-tool-fs / executesandbox.resolvePolicy → fs.resolve(会话 cwd) → waterfall「fs/write-intent」

↓

dsh-fs-observation-policy / gate查 WeakMap[会话][targetKey] → present? replaceIfVersion(v) : createIfAbsent

↓

dsh-fs-local / writeText(withLock 临界区)probe → 版本比对 → staging(0o700) + temp(wx,0o600) + sync → link/rename 原子发布

↓

emit「fs/observed」(present, 新版本) → gate 更新观察记录返回 { path, operation, before, after } → UI diff 卡片 + 模型确认文本

回头看这一讲的架构主线:能力(fs)定义词汇、后端(local/sandbox)实现事实、策略(observation-policy)叠加决策、工具(tool-fs)面向模型。四层之间只靠类型和事件通信,任何一层都可以独立替换——这正是"everything is a plugin"在文件系统上的具体落地。

下一讲进入第三阶段的硬隔离:沙箱。bwrap、Landlock、Seatbelt、e2b 四种后端如何把"写文件"这件事关进笼子,以及升级审批的完整链路。

📚 系列导航

← 第 14 讲:Shell 与 Subprocess——spawn、管道、输出收集与终止

→ 第 16 讲:沙箱隔离(bwrap/Landlock/Seatbelt/e2b)

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料