diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml index 39e50ffe6b..6fbd47fc65 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md -2026-08-09-filesystem-absence-observation.md: e0c5c8b1845550ac6380ea4b2782cde13cba3ce9 -2026-08-09-filesystem-absence-observation.zh.md: 808698a7cb7a3575ef500dced7a646691d65c424 +2026-08-09-filesystem-absence-observation.md: e01719651f54cafc86a3972690507b1ee9b95cc9 +2026-08-09-filesystem-absence-observation.zh.md: 126f85e95b1debbccddf82fda432d31a69250388 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md index e0c5c8b184..e01719651f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md @@ -12,13 +12,13 @@ Treating a failed read as permission to create also exposes a second boundary. B ## Decision -`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a `read` or `str_replace_editor view` metadata miss emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence. +`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a metadata miss from `read` or the `str_replace_editor` `view`, `str_replace`, or `insert` command emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence. `dsh-fs-policy` stores three logical states per owner and target without injecting or calling `ctx.fs`: missing map entry is unseen, `absent` is confirmed absence, and `present(version)` is a replacement/edit basis. Write maps unseen and absent to the existing `createIfAbsent` intent and present to `replaceIfVersion`. Edit maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. A successful create or mutation replaces absence with its produced present version. -Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; an existing destination makes the no-replace link fail and preserves the competitor. `dsh-fs-e2b` uses remote `ln` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths. +Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; after a failed link it inspects the destination entry so a regular-file collision returns `FS_NOT_OBSERVED`, a non-regular entry returns `FS_NOT_REGULAR_FILE`, and a failure against a still-missing target returns `FS_IO_ERROR`. `dsh-fs-e2b` uses remote `ln -T` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths. -This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. +This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. Local guarded creation requires hard-link support; once any local publication succeeds, staging cleanup is best effort because private residue cannot make the committed write false. ## Alternatives considered @@ -29,7 +29,7 @@ This decision does not claim cross-process linearizability for `replaceIfVersion ## Consequences -The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact. +The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact; a competing directory, special entry, or dangling symbolic link instead returns `FS_NOT_REGULAR_FILE` without prescribing another read. The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the [event-gate decision](../architecture/2026-06-26-file-context-as-event-gate.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md index 808698a7cb..126f85e95b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 或 `str_replace_editor view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。 +`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中时,都会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。 `dsh-fs-policy` 按所有者与目标存储三种逻辑状态,既不注入也不调用 `ctx.fs`:映射中无条目即未见,`absent` 表示确认缺失,`present(version)` 是替换/编辑基准。写入把未见和缺失映射到现有 `createIfAbsent` 意图,把存在映射到 `replaceIfVersion`。编辑把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。 -每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;目标已存在时,不替换链接会失败,并保留竞争创建者写入的文件。`dsh-fs-e2b` 使用远程 `ln` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。 +每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;链接失败后,它会检查目标条目:与普通文件冲突时返回 `FS_NOT_OBSERVED`,条目非普通时返回 `FS_NOT_REGULAR_FILE`,目标仍然缺失时返回 `FS_IO_ERROR`。`dsh-fs-e2b` 使用远程 `ln -T` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。 -本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。 +本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。本地带防护的创建要求支持硬链接;任何本地发布一旦成功,暂存清理便采用尽力而为语义,因为私有残留无法否定已提交写入的成功。 ## 曾考虑的替代方案 @@ -29,7 +29,7 @@ Status: implemented ## 影响 -尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件。 +尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件;若竞态目标是目录、特殊条目或悬空符号链接,则改为返回 `FS_NOT_REGULAR_FILE`,且不会要求再次读取。 观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留[事件门禁决策](../architecture/2026-06-26-file-context-as-event-gate.md)确立的 read 一次 `stat`、write/edit 零次 `stat` 预算、所有者隔离、dispose 行为和可选部署边界。 diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 4e29843fad..64460f7e87 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md -README.md: 039ad72a9651a1e8907c2c63b8c83d113aedbb4e -README.zh.md: be97bbd92edb528f9560518398a0cb70f36b992b +README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9 +README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575 diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index 039ad72a96..1b66e84def 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -9,7 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide - **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. -- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln` instead, making the commit atomically no-replace; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. +- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. - **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure. The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index be97bbd92e..d9cd3ce1e1 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -9,7 +9,7 @@ - **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 -- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln` 发布,使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 +- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布,即使目标位置出现目录,也能使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 - **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。 该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。 diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 04b23a6775..b26338ffa4 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -489,7 +489,7 @@ export class E2BFileSystem extends FileSystem { assertNotAborted(signal, 'write') const targetArg = quoteE2BShellArg(targetPath) const publication = await sandbox.commands.run( - `if ln -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`, + `if ln -T -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`, commandOpts(undefined), ) if (publication.stdout === 'exists') { diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 973cf7c5c8..6ee3df6543 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -52,7 +52,10 @@ class FakeRemote { nextRemoveError: unknown canonicalOutput: string | undefined abortAfterRename: AbortController | undefined - competitorBeforeLink: { path: string; data: string } | undefined + competitorBeforeLink: + | { path: string; kind: 'file'; data: string } + | { path: string; kind: 'directory' } + | undefined guardedLinkOutput: string | undefined disappearOnInfo = new Set() private clock = 1 @@ -257,7 +260,7 @@ class FakeRemote { const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command) if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8) const guardedLink = new RegExp( - "^if ln -- '([^']+)' '([^']+)'; then printf created; " + "^if ln -T -- '([^']+)' '([^']+)'; then printf created; " + "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$", ).exec(command) if (guardedLink !== null) { @@ -269,7 +272,8 @@ class FakeRemote { return { exitCode: 0, stdout, stderr: '' } } if (this.competitorBeforeLink?.path === to) { - this.file(to, this.competitorBeforeLink.data) + if (this.competitorBeforeLink.kind === 'directory') this.dir(to) + else this.file(to, this.competitorBeforeLink.data) this.competitorBeforeLink = undefined } if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' } @@ -555,7 +559,7 @@ describe('E2BFileSystem atomic writes and edits', () => { it('preserves a competitor created after the guarded-create probe', async () => { const remote = new FakeRemote() - remote.competitorBeforeLink = { path: '/workspace/race.txt', data: 'competitor' } + remote.competitorBeforeLink = { path: '/workspace/race.txt', kind: 'file', data: 'competitor' } const { fs } = await setup(remote) await expectCode( @@ -567,6 +571,21 @@ describe('E2BFileSystem atomic writes and edits', () => { expect(remote.removals).toHaveLength(1) }) + it('preserves a competing directory during guarded-create publication', async () => { + const remote = new FakeRemote() + remote.competitorBeforeLink = { path: '/workspace/race-dir', kind: 'directory' } + const { fs } = await setup(remote) + + await expectCode( + fs.writeText(await fs.resolve('race-dir'), 'ours', { kind: 'createIfAbsent' }), + 'FS_NOT_OBSERVED', + ) + expect(remote.nodes.get('/workspace/race-dir')?.type).toBe(FileType.DIR) + expect(remote.nodes.has('/workspace/race-dir/content')).toBe(false) + expect(remote.links).toHaveLength(0) + expect(remote.removals).toHaveLength(1) + }) + it('rejects an invalid guarded-create publication response before claiming success', async () => { const remote = new FakeRemote() remote.guardedLinkOutput = 'unexpected' diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 6aaf691eef..ef76470394 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md -README.md: ac2f271ad9651797e1aceeba49e7a52533405f10 -README.zh.md: 633d55fe06b2261d9d5b52184987811c87c8f155 +README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d +README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ac2f271ad9..7b993fa123 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -40,3 +40,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. - **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized. +- **Guarded creation requires hard-link support** — filesystems or mounts that reject hard-link publication cannot serve `createIfAbsent`; the provider preserves the missing target and reports `FS_IO_ERROR`. +- **Post-commit cleanup is best effort** — a successful publication remains successful if removal of its owner-only staging directory fails, leaving private residue for later operator cleanup. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 633d55fe06..428719137f 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 -- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 - **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -40,3 +40,5 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 - **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。 +- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。 +- **提交后清理采用尽力而为语义**:如果移除仅所有者可访问的暂存目录失败,成功发布仍视为成功,并留下私有残留供运维人员后续清理。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 071ad31637..24ad9b929f 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -91,6 +91,10 @@ export interface FsIoInternals { replaceFile?: (replaced: string, replacement: string) => Promise /** Override the hard-link no-replace publication boundary. */ linkFile?: (existingPath: string, newPath: string) => Promise + /** Override target inspection after guarded publication fails. */ + inspectPublicationTarget?: (path: string) => Promise + /** Override staging-directory removal for commit-point failure coverage. */ + removeStagingDir?: (stagingDir: string) => Promise /** Test hook after the temp file is written/synced but before final chmod+publication. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise } @@ -413,9 +417,13 @@ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal // --- Writing --- -async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise { +async function removeStagingDirOrThrow( + stagingDir: string, + originalError: unknown, + removeStagingDir: (path: string) => Promise, +): Promise { try { - await rm(stagingDir, { recursive: true, force: true }) + await removeStagingDir(stagingDir) } catch (cleanupError: unknown) { /* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */ throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError }) @@ -423,6 +431,43 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow throw originalError } +async function throwGuardedCreateFailure( + error: unknown, + absolutePath: string, + displayPath: string, + inspectPublicationTarget: (path: string) => Promise, +): Promise { + let existing: BigIntStats | undefined + try { + existing = await inspectPublicationTarget(absolutePath) + } catch (metadataError: unknown) { + if (!isENOENT(metadataError) && !isENOTDIR(metadataError)) { + throw new FsError(`cannot write "${displayPath}": ${errorMessage(metadataError)}`, 'FS_IO_ERROR', { cause: metadataError }) + } + } + + // Link errno values vary by platform and filesystem. Inspect the target entry + // after failure so a collision is not confused with missing hard-link support. + if (existing !== undefined) { + if (!existing.isFile()) { + throw new FsError(`cannot write "${displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE', { cause: error }) + } + throw new FsError( + `cannot overwrite existing "${displayPath}" without reading it first`, + 'FS_NOT_OBSERVED', + { cause: error }, + ) + } + if (isEEXIST(error)) { + throw new FsError( + `cannot overwrite existing "${displayPath}" without reading it first`, + 'FS_NOT_OBSERVED', + { cause: error }, + ) + } + throw new FsError(`cannot write "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) +} + /** * Atomically replace a file through a private, synced staging file in the same directory. * POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file @@ -434,8 +479,9 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow * inert as a mode on Windows but identifies replacement security semantics. * @param signal - cancellation checked before final publication. * @param internals - Test hook for pinning temp names and observing the staged file. - * @param createIfAbsent - publish with a hard-link no-replace primitive; a - * concurrent creator is preserved and rejected with `FS_NOT_OBSERVED`. + * @param createIfAbsent - when provided, publish with a hard-link no-replace + * primitive; a concurrent creator's file is preserved and this write is + * rejected with `FS_NOT_OBSERVED` using the supplied display path. */ export async function writeFileAtomic( absolutePath: string, @@ -443,7 +489,7 @@ export async function writeFileAtomic( mode: number | undefined, signal: AbortSignal | undefined, internals: FsIoInternals = {}, - createIfAbsent = false, + createIfAbsent?: { displayPath: string }, ): Promise { throwIfAborted(signal, 'write') const directory = dirname(absolutePath) @@ -458,6 +504,10 @@ export async function writeFileAtomic( const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32 const replaceFile = internals.replaceFile ?? replaceFileWin32 const linkFile = internals.linkFile ?? link + const inspectPublicationTarget = internals.inspectPublicationTarget + ?? (path => lstat(path, { bigint: true })) + const removeStagingDir = internals.removeStagingDir + ?? (path => rm(path, { recursive: true, force: true })) let handle: Awaited> | undefined let stagingCreated = false try { @@ -478,16 +528,11 @@ export async function writeFileAtomic( handle = undefined throwIfAborted(signal, 'write') - if (createIfAbsent) { + if (createIfAbsent !== undefined) { try { await linkFile(tempPath, absolutePath) } catch (error: unknown) { - if (!isEEXIST(error)) throw error - throw new FsError( - `cannot overwrite existing "${absolutePath}" without reading it first`, - 'FS_NOT_OBSERVED', - { cause: error }, - ) + await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget) } } else if (platform === 'win32' && mode !== undefined) { try { @@ -501,7 +546,11 @@ export async function writeFileAtomic( } else { await rename(tempPath, absolutePath) } - await rm(stagingDir, { recursive: true, force: true }) + try { + await removeStagingDir(stagingDir) + } catch (_committedStagingCleanupFailure) { + // The target is committed; owner-only staging residue cannot turn that write into a failure. + } } catch (error: unknown) { /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */ let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error @@ -514,7 +563,7 @@ export async function writeFileAtomic( } } if (!stagingCreated) throw failure - return removeStagingDirOrThrow(stagingDir, failure) + return removeStagingDirOrThrow(stagingDir, failure, removeStagingDir) } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 6be6a4402b..9cd4be41b8 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -173,7 +173,7 @@ export class LocalFileSystem extends FileSystem { existing?.mode, signal, this.internals, - expected?.kind === 'createIfAbsent', + expected?.kind === 'createIfAbsent' ? { displayPath: target.displayPath } : undefined, ) const after = await probe(target.targetKey) return { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 531be25360..f6736ff9c3 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -308,6 +308,41 @@ describe('writeText', () => { expect(await readFile(path, 'utf8')).toBe('competitor') }) + it('reports a createIfAbsent race with the unresolved display path', async () => { + const realDirectory = join(dir, 'real-workspace') + const linkedDirectory = join(dir, 'linked-workspace') + await mkdir(realDirectory) + await symlink(realDirectory, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir') + const target = await fs.resolve('linked-workspace/a.txt') + fs.internals.inspectTemp = async () => { await writeFile(join(realDirectory, 'a.txt'), 'competitor') } + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${join(linkedDirectory, 'a.txt')}" without reading it first`, + }) + expect(await readFile(join(realDirectory, 'a.txt'), 'utf8')).toBe('competitor') + }) + + it('createIfAbsent rejects a competing directory as not a regular file', async () => { + const path = join(dir, 'a.txt') + const target = await fs.resolve('a.txt') + fs.internals.inspectTemp = async () => { await mkdir(path) } + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + expect((await stat(path)).isDirectory()).toBe(true) + }) + + it('createIfAbsent rejects and preserves a dangling symbolic link', async () => { + const path = join(dir, 'dangling') + await symlink(join(dir, 'missing-target'), path) + const target = await fs.resolve('dangling') + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('replaceIfVersion replaces when the version matches', async () => { await writeFile(join(dir, 'a.txt'), 'old') const target = await fs.resolve('a.txt') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 9b62caba3f..13b0edc290 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -496,17 +496,77 @@ describe('writeFileAtomic — temp-file safety', () => { expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) }) - it('surfaces a non-collision guarded-create publication failure and cleans staging', async () => { + it('maps a non-collision guarded-create publication failure and cleans staging', async () => { const file = join(dir, 'a.txt') const denied = Object.assign(new Error('link denied'), { code: 'EACCES' }) await expect(writeFileAtomic(file, 'ours', undefined, undefined, { linkFile: async () => { throw denied }, - }, true)).rejects.toBe(denied) + }, { displayPath: file })).rejects.toMatchObject({ code: 'FS_IO_ERROR', cause: denied }) await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) }) + it('maps a guarded-create target-inspection failure and cleans staging', async () => { + const file = join(dir, 'a.txt') + const linkFailure = Object.assign(new Error('link failed'), { code: 'EIO' }) + const inspectionFailure = Object.assign(new Error('inspection denied'), { code: 'EACCES' }) + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + linkFile: async () => { throw linkFailure }, + inspectPublicationTarget: async () => { throw inspectionFailure }, + }, { displayPath: file })).rejects.toMatchObject({ code: 'FS_IO_ERROR', cause: inspectionFailure }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + + it('rejects a guarded-create collision that vanishes before inspection', async () => { + const file = join(dir, 'a.txt') + const collision = Object.assign(new Error('target existed'), { code: 'EEXIST' }) + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + linkFile: async () => { throw collision }, + }, { displayPath: file })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${file}" without reading it first`, + cause: collision, + }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + + it('uses the display path and target type when guarded publication finds a competitor', async () => { + const file = join(dir, 'a.txt') + const displayPath = join(dir, 'linked-workspace', 'a.txt') + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + inspectTemp: async () => { await writeFile(file, 'competitor') }, + }, { displayPath })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${displayPath}" without reading it first`, + }) + expect(await readFile(file, 'utf8')).toBe('competitor') + + await rm(file) + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + inspectTemp: async () => { await mkdir(file) }, + }, { displayPath })).rejects.toMatchObject({ + code: 'FS_NOT_REGULAR_FILE', + message: `cannot write "${displayPath}": not a regular file`, + }) + expect((await stat(file)).isDirectory()).toBe(true) + }) + + it('does not turn post-commit staging cleanup failure into a failed guarded write', async () => { + const file = join(dir, 'a.txt') + const cleanupFailure = new Error('staging cleanup failed') + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + removeStagingDir: async () => { throw cleanupFailure }, + }, { displayPath: file })).resolves.toBeUndefined() + expect(await readFile(file, 'utf8')).toBe('ours') + }) + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined)