Merge master and repair CI for bounded diff basis

This commit is contained in:
ZiyaZhang
2026-08-10 01:18:38 -07:00
2601 changed files with 59492 additions and 12345 deletions

View File

@@ -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/README.md
README.md: 162e2c75e92bc4ea6aba377c14db62e8ce83fbb9
README.zh.md: 196a2aad89203905148581d6364be7f02e2f4e35
README.md: c48039f25ce234ce8b16d9829668224156855f31
README.zh.md: 8cd7b703121d4a81fcb85b35beb89afd0cf2d7bd

View File

@@ -2,22 +2,22 @@
English | [中文](README.zh.md)
The filesystem stack: a provider seam (execution-world paths, bounded text IO, and atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages.
The filesystem stack: a provider contract (execution-world paths, bounded text IO, and atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the ripgrep-backed discovery tools. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `fs/` | Provider seam: canonical process paths/file URIs/containment, text IO, and atomic mutation primitives; owns the `fs/*` policy events | `ctx.fs` |
| `fs/` | Service Definition: canonical process paths/file URIs/containment, text IO, and atomic mutation primitives; owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| [`e2b/fs-e2b`](../e2b/fs-e2b/README.md) | E2B-backed `FileSystem` implementation sharing the remote runtime owned by `ctx.e2b` | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools backed by the packaged `@vscode/ripgrep` binary spawned through `ctx.subprocess`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote execution world shared with the E2B subprocess provider ([decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
The Service Definition lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the Service Definition, policy gate, or model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote execution world shared with the E2B subprocess provider ([decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider contract: search is a process-backed `rg` workflow (the packaged `@vscode/ripgrep` binary spawned through `ctx.subprocess`), so filesystem backends stay free of a universal search contract; its tools register unconditionally, and its results are follow-up-readable when the search workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
`read`/`write`/`edit` take **no** `timeoutMs` and the provider contract arms no deadline: file IO here runs untimed because a deadline would kill work the OS will still finish — see [the filesystem subsystem page](../../docs/subsystems/filesystem.md). Cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
The subsystem reference — targets, outcomes, guards, policy events, the error taxonomy, and why file IO takes no timeout — is [docs/subsystems/filesystem.md](../../docs/subsystems/filesystem.md); the sandbox fence in the [cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).

View File

@@ -2,22 +2,22 @@
[English](README.md) | 中文
文件系统栈包括:提供方 seam(执行世界路径、有界文本 I/O 与带可选版本防护的原子变更)、本地实现、策略门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品**包。
文件系统栈包括:提供方约定(执行世界路径、有界文本 I/O 与带可选版本防护的原子变更)、本地实现、政策门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 ripgrep 的发现工具。全部都是**产品** 包。
| 包 | 角色 | ctx 键 |
|---|---|---|
| `fs/` | 提供方 seam:规范化进程路径、文件 URI 与包含关系、文本 I/O 和原子变更原语;拥有 `fs/*` 策略事件 | `ctx.fs` |
| `fs/` | Service Definition:规范化进程路径、文件 URI 与包含关系、文本 I/O 和原子变更原语;拥有 `fs/*` 政策事件 | `ctx.fs` |
| `fs-local/` | 本地文件系统 `FileSystem` 实现 | (注册 `ctx.fs`) |
| [`e2b/fs-e2b`](../e2b/fs-e2b/README.md) | 以 E2B 为后端的 `FileSystem` 实现,共享由 `ctx.e2b` 拥有的远程运行时 | (注册 `ctx.fs`) |
| `fs-sandbox/` | 强制实施沙箱约束的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根策略约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接通过 | (注册 `ctx.fs`) |
| `fs-policy/` | 策略门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根政策约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接通过 | (注册 `ctx.fs`) |
| `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具,由经 `ctx.subprocess` spawn 的打包 `@vscode/ripgrep` 二进制文件支持,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) |
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、策略门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程执行世界中([决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md))。策略(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去策略,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索约定;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
Service Definition 位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程执行世界中([决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方约定:搜索是由进程支持的 `rg` 工作流(经 `ctx.subprocess` spawn 的打包 `@vscode/ripgrep` 二进制文件),因此文件系统后端无需承担通用搜索约定;其工具会无条件注册。如果搜索工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
## 文件 I/O 不设超时
`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不启动 deadline。这与 bash 和 web(两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs` 由 `@deepseek-ai/dsh-timeout-policy` 强制执行):这些工作由进程支持,deadline 可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的 deadline 会成为无法兑现承诺的配置项。在此添加 deadline 还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agent(Claude Code、Codex)出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。
`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方约定也不启动 deadline:这里的文件 I/O 不计时运行,因为 deadline 只会杀掉操作系统仍会完成的工作——参见[文件系统子系统页面](../../docs/subsystems/filesystem.md)。取消仍通过工具执行信号传播,在系统调用边界尽力中止。
子系统参考——目标、结果、守卫、策略事件、错误分类体系,以及文件 I/O 为何不设超时——见 [docs/subsystems/filesystem.md](../../docs/subsystems/filesystem.md);沙箱围栏见[跨家族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。
子系统参考——目标、结果、防护、策略事件、错误分类体系,以及文件 IO 为何不设超时——见 [docs/subsystems/filesystem.md](../../docs/subsystems/filesystem.md);沙箱围栏见[跨家族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。

View File

@@ -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: 5b7bcda223dd5a1110c585196e870a06c8a12bd0
README.zh.md: 20ed94b317cac763d4efe9326bf8ee52ae0c7355
README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8
README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -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, fsyncs, then renames over the target. 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` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback.
- **`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`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback.
- **`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.
@@ -39,4 +39,6 @@ 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.
- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback.
- **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** — a writer in another process is caught only by the optional version guard, never serialized.
- **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.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -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 后,以 rename 覆盖目标。现有文件的 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`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。
- **`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`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。
- **`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` 是轻量服务接线。
@@ -39,4 +39,6 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
- **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。
- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。
- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。
- **提交后清理采用尽力而为语义**:如果移除仅所有者可访问的暂存目录失败,成功发布仍视为成功,并留下私有残留供运维人员后续清理。

View File

@@ -1,13 +1,13 @@
/**
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
* stage an exclusive owner-only file in a private sibling directory and atomically rename it.
* stage an exclusive owner-only file in a private sibling directory and atomically publish it.
* @module @deepseek-ai/dsh-fs-local/fsio
*/
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import { chmod, link, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
@@ -22,6 +22,10 @@ function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
function isEEXIST(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
}
/**
* A path component that is expected to be a directory is a regular file (e.g.
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
@@ -72,7 +76,7 @@ function versionOf(info: BigIntStats): FsVersion {
}
/**
* Test seam: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without
* Test hook: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without
* a name race), override native boundaries, and observe the staged temp file before publication.
*/
export interface FsIoInternals {
@@ -86,7 +90,13 @@ export interface FsIoInternals {
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
/** Override the hard-link no-replace publication boundary. */
linkFile?: (existingPath: string, newPath: string) => Promise<void>
/** Override target inspection after guarded publication fails. */
inspectPublicationTarget?: (path: string) => Promise<BigIntStats>
/** Override staging-directory removal for commit-point failure coverage. */
removeStagingDir?: (stagingDir: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+publication. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -408,9 +418,13 @@ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal
// --- Writing ---
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
async function removeStagingDirOrThrow(
stagingDir: string,
originalError: unknown,
removeStagingDir: (path: string) => Promise<void>,
): Promise<never> {
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 })
@@ -418,6 +432,43 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
throw originalError
}
async function throwGuardedCreateFailure(
error: unknown,
absolutePath: string,
displayPath: string,
inspectPublicationTarget: (path: string) => Promise<BigIntStats>,
): Promise<never> {
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
@@ -427,8 +478,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
* @param content - the full UTF-8 text to write.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
* @param signal - cancellation checked before final publication.
* @param internals - Test hook for pinning temp names and observing the staged file.
* @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,
@@ -436,6 +490,7 @@ export async function writeFileAtomic(
mode: number | undefined,
signal: AbortSignal | undefined,
internals: FsIoInternals = {},
createIfAbsent?: { displayPath: string },
): Promise<void> {
throwIfAborted(signal, 'write')
const directory = dirname(absolutePath)
@@ -449,6 +504,11 @@ export async function writeFileAtomic(
const platform = internals.platform ?? process.platform
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<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -469,7 +529,13 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
if (platform === 'win32' && mode !== undefined) {
if (createIfAbsent !== undefined) {
try {
await linkFile(tempPath, absolutePath)
} catch (error: unknown) {
await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget)
}
} else if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
@@ -481,7 +547,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
@@ -494,7 +564,7 @@ export async function writeFileAtomic(
}
}
if (!stagingCreated) throw failure
return removeStagingDirOrThrow(stagingDir, failure)
return removeStagingDirOrThrow(stagingDir, failure, removeStagingDir)
}
}

View File

@@ -68,7 +68,7 @@ export class LocalFileSystem extends FileSystem {
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
/** Test seam forwarded to fsio for atomic-publication boundaries. */
/** Test hook forwarded to fsio for atomic-publication boundaries. */
internals: FsIoInternals = {}
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
* window can't interleave, making concurrent writes/edits deterministically
@@ -192,7 +192,14 @@ export class LocalFileSystem extends FileSystem {
const before = diffable
? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)
: null
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
await writeFileAtomic(
target.targetKey,
content,
existing?.mode,
signal,
this.internals,
expected?.kind === 'createIfAbsent' ? { displayPath: target.displayPath } : undefined,
)
const after = await probe(target.targetKey)
return {
operation: existing ? 'update' : 'create',

View File

@@ -1,5 +1,5 @@
/**
* Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
* Tests for the local backend through the `ctx.fs` Service Definition: stat, whole-
* file/streamed text reads, atomic guarded writes (createIfAbsent /
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
@@ -82,7 +82,7 @@ describe('registration', () => {
describe('resolve', () => {
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
// path there (the per-session workspace mapping — mirrors tool-bash workdir).
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
try {
await writeFile(join(other, 'x.txt'), 'in other')
@@ -325,6 +325,51 @@ describe('writeText', () => {
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
})
it('createIfAbsent preserves a competitor created after the initial probe', async () => {
const path = join(dir, 'a.txt')
const target = await fs.resolve('a.txt')
fs.internals.inspectTemp = async () => { await writeFile(path, 'competitor') }
await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' }))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
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')

View File

@@ -638,7 +638,7 @@ function daclAcePolicy(descriptor: Buffer): string[] {
for (let index = 0; index < aceCount; index++) {
const size = descriptor.readUInt16LE(offset + 2)
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
// INHERITED_ACE records provenance, not the entry's access policy.
// INHERITED_ACE records which parent ACE produced this entry, not the entry's access policy.
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
const key = ace.toString('hex')
if (!seen.has(key)) {
@@ -752,6 +752,77 @@ describe('writeFileAtomic — temp-file safety', () => {
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
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 },
}, { 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)

View File

@@ -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-policy/README.md
README.md: f6b3292bdc6e5565df0393a59c50d4e594921401
README.zh.md: 2ebd2f054ece0472c7147f6f9e740987b11c6031
README.md: 395a36e89e113dc3846a8dff62ce90601013addd
README.zh.md: 5b3b1f64de2d51f6b729392da2d4b459a9f0ca3b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
```ts
import type { Context } from 'cordis'
@@ -24,7 +24,7 @@ await ctx.plugin(FsPolicy)
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider contract | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
## How the gate participates
@@ -33,13 +33,13 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek
| Event | This plugin's listener |
|---|---|
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
| `fs/write-intent` | Unseen or observed absent → `{ kind: 'createIfAbsent' }`; observed present → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Unseen → `FS_NOT_OBSERVED`; observed absent → `FS_NOT_FOUND`; observed present → `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ kind: 'present', version }` or `{ kind: 'absent' }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
## Observed state is the prior-observation record; freshness is provider CAS
Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a metadata miss from `read` or the `str_replace_editor` `view`, `str_replace`, or `insert` command records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
## Single-slot, first-wins
@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
#### What the model sees
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`; editing a target just observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target now records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
#### Token effect

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**fs-policy 插件**:它在 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策略层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的策略。
**fs-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。
```ts
import type { Context } from 'cordis'
@@ -24,7 +24,7 @@ await ctx.plugin(FsPolicy)
|---|---|---|
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
| 策略 | `@deepseek-ai/dsh-fs-policy`(本包) | 通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑(无服务) |
| 提供方 seam | `@deepseek-ai/dsh-fs` | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方约定 | `@deepseek-ai/dsh-fs` | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方 | `@deepseek-ai/dsh-fs-local` | `ctx.fs` 的本地实现 |
## 门禁的参与方式
@@ -33,13 +33,13 @@ await ctx.plugin(FsPolicy)
| 事件 | 本插件的监听器 |
|---|---|
| `fs/write-intent` | 先前未观察 → `{ kind: 'createIfAbsent' }`;先前已观察 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
| `fs/edit-intent` | 要求该所有者先前已观察,否则抛出 `FS_NOT_OBSERVED`;返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
| `fs/observed` | 为该所有者与目标记录 `{ version }`。同步、只有副作用的 `WeakMap.set`。 |
| `fs/write-intent` | 未见或已观测为缺失 → `{ kind: 'createIfAbsent' }`;已观测为存在 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
| `fs/edit-intent` | 未见 → `FS_NOT_OBSERVED`;已观测为缺失 → `FS_NOT_FOUND`;已观测为存在 → 返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
| `fs/observed` | 为该所有者与目标记录 `{ kind: 'present', version }` 或 `{ kind: 'absent' }`。同步、只有副作用的 `WeakMap.set`。 |
## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证
已观察状态是一张以所有者为弱键、记录各目标版本的映射表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O:它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。
观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中时,都会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。
## 单槽、先到者胜
@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
#### 模型看到的内容
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。
该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝编辑;编辑刚被观测为缺失的目标会返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。
#### Token 影响

View File

@@ -1,14 +1,15 @@
/**
* Event-only filesystem observation policy; it registers no service. A weak owner/target map
* records every successful read or mutation, single-slot intent listeners supply that version,
* and the provider performs the atomic freshness check. Without this plugin, tools retain the
* bare provider's unconditional mutation behavior. See the package README for composition rules.
* records every authoritative presence/absence observation, single-slot intent listeners derive
* guards from that state, and the provider performs the atomic freshness/no-clobber check. Without
* this plugin, tools retain the bare provider's unconditional mutation behavior. See the package
* README for composition rules.
* @module @deepseek-ai/dsh-fs-policy
*/
import type { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsPolicyExec } from './types.ts'
export type { FsPolicyExec } from './types.ts'
@@ -21,9 +22,10 @@ class ObservedStateGate {
/**
* Observed-file state, keyed first by the owner object (weakly held, so a
* collected session frees its state), then by {@link FsTarget.targetKey}. An
* entry's PRESENCE is the prior-observation record.
* entry's presence is the prior-observation record; its discriminant keeps
* confirmed absence distinct from an unseen target.
*/
private observed = new WeakMap<object, Map<string, FsVersion>>()
private observed = new WeakMap<object, Map<string, FsObservation>>()
/**
* Derive the observed-state owner from the opaque event actor — normally the
@@ -38,17 +40,17 @@ class ObservedStateGate {
return (actor as FsPolicyExec | undefined)?.agent?.session
}
private get(owner: object, targetKey: string): FsVersion | undefined {
private get(owner: object, targetKey: string): FsObservation | undefined {
return this.observed.get(owner)?.get(targetKey)
}
private set(owner: object, targetKey: string, version: FsVersion): void {
private set(owner: object, targetKey: string, observation: FsObservation): void {
let byTarget = this.observed.get(owner)
if (!byTarget) {
byTarget = new Map()
this.observed.set(owner, byTarget)
}
byTarget.set(targetKey, version)
byTarget.set(targetKey, observation)
}
/** Drop all recorded state (HMR safety / disposal). */
@@ -57,33 +59,38 @@ class ObservedStateGate {
}
/**
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
* at the observed version (existing files replaced only if unchanged).
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;
* confirmed present ⇒ `replaceIfVersion` at the observed version.
*/
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
return prior?.kind === 'present'
? { kind: 'replaceIfVersion', version: prior.version }
: { kind: 'createIfAbsent' }
}
/**
* Decide the edit version guard: requires a prior observation by this owner
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the
* observed version as the CAS basis.
*/
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || !prior) {
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
return { version: prior }
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
/** Record a successful read/write/edit: this owner observed this target at this version. */
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
/** Record an authoritative present or absent observation for this owner and target. */
observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void {
const owner = this.owner(actor)
if (owner) this.set(owner, target.targetKey, version)
if (owner) this.set(owner, target.targetKey, observation)
}
}
@@ -114,9 +121,10 @@ export function apply(ctx: Context): void {
// fs/edit-intent: occupy the single decision slot — do not call next().
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
// fs/observed must remain synchronous and non-throwing: the mutation already succeeded, and
// emit does not await promises. WeakMap.set satisfies that contract.
ctx.on('fs/observed', (target, version, actor) => {
gate.observe(target, version, actor)
// fs/observed must remain synchronous and non-throwing: emit does not await
// promises, and successful mutations have already committed. WeakMap.set
// satisfies that contract for both presence and absence.
ctx.on('fs/observed', (target, observation, actor) => {
gate.observe(target, observation, actor)
})
}

View File

@@ -3,7 +3,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
@@ -11,6 +11,8 @@ function target(path: string): FsTarget {
return { targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
const absent: FsObservation = { kind: 'absent' }
/** Dispatch the write-intent waterfall with the bare default thunk. */
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
@@ -64,9 +66,16 @@ describe('write-intent decision', () => {
it('an observed target decides replaceIfVersion at the observed version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v7'), exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
it('a target observed absent decides createIfAbsent', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), absent, exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('edit-intent decision', () => {
@@ -88,16 +97,23 @@ describe('edit-intent decision', () => {
it('returns the observed version as the CAS basis after an observation', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v3'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
it('rejects editing a target observed absent with FS_NOT_FOUND', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), absent, exec)
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
})
describe('observed-state is the prior-observation record', () => {
it('a read observation authorizes an in-place write at that version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) // a read
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
@@ -105,19 +121,34 @@ describe('observed-state is the prior-observation record', () => {
const { ctx } = await setup()
const exec = ownerExec({})
// A create records v1; the follow-up edit guards against v1 with no read.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v1'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
// The edit records v2; a second edit guards against v2.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v2'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
})
it('a no-owner observation records nothing', async () => {
const { ctx } = await setup()
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
ctx.emit('fs/observed', target('a.txt'), present('v0'), undefined)
// Still unobserved for any owner.
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('supports present → absent → present transitions for one owner', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
const a = target('a.txt')
ctx.emit('fs/observed', a, present('v1'), exec)
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'replaceIfVersion', version: 'v1' })
ctx.emit('fs/observed', a, absent, exec)
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'createIfAbsent' })
await expect(editIntent(ctx, a, exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
ctx.emit('fs/observed', a, present('v2'), exec)
expect(await editIntent(ctx, a, exec)).toEqual({ version: 'v2' })
})
})
describe('multi-owner isolation', () => {
@@ -125,7 +156,7 @@ describe('multi-owner isolation', () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
ctx.emit('fs/observed', target('a.txt'), present('v0'), a)
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
})
@@ -134,7 +165,7 @@ describe('multi-owner isolation', () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
ctx.emit('fs/observed', target('a.txt'), present('v0'), a) // A observed v0
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
@@ -164,7 +195,7 @@ describe('single-slot, first-wins', () => {
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
@@ -186,7 +217,7 @@ describe('disposal releases recorded state (HMR safety)', () => {
const ctx = new Context()
const exec = ownerExec({})
const fiber = await ctx.plugin(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()

View File

@@ -1,6 +1,6 @@
/**
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
* `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
* `@deepseek-ai/dsh-fs` Service Definition. It extends `LocalFileSystem` so all
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
* write and the read-match-write edit critical section — are the local
* implementation's, verbatim; this package adds only the per-call POLICY fence
@@ -76,7 +76,7 @@ export class SandboxedFileSystem extends LocalFileSystem {
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the write outcome from the inherited backend.
@@ -97,7 +97,7 @@ export class SandboxedFileSystem extends LocalFileSystem {
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the edit outcome from the inherited backend.

View File

@@ -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/README.md
README.md: 5c58fc476b9b11a83bbe0d6c33ac782d94d6ffbc
README.zh.md: 8eb2634df2754cdcada4e48046e19eb32b1cf3af
README.md: 62d3febde82e013ace054a9e6242147c1756b0d1
README.zh.md: 137c1e8da1014bf7dda7c4bf2e667aca6d2f51b5

View File

@@ -2,18 +2,18 @@
English | [中文](README.zh.md)
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| Layer | Package | Role |
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: execution-world paths, text IO, and atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider contract | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: execution-world paths, text IO, and atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
`fs-sandbox` and `fs-e2b` implement this interface without touching the policy/tool layers.
## Service API (`ctx.fs`)
@@ -30,16 +30,16 @@ A backend subclasses `FileSystem` and implements eleven primitives.
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
## The `fs/*` policy events
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event carrying an `FsObservation` discriminated union: present with a version or confirmed absent. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
## A provider seam, not the policy layer
## A provider contract, not the policy layer
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
@@ -47,7 +47,7 @@ This package declares three events (see the generated region of [filesystem.md](
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## Model Experience

View File

@@ -2,18 +2,18 @@
[English](README.md) | 中文
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、策略插件监听的 `fs/*` 策略事件词汇。
**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
本包是四层文件系统栈中的提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| 层 | 包 | 角色 |
|---|---|---|
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 `read`/`write`/`edit` schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
| 策略 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 提供方 seam | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:执行世界路径、文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 政策 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 提供方约定 | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:执行世界路径、文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方 | `@deepseek-ai/dsh-fs-local` | 宿主文件系统实现 |
未来的沙箱化、虚拟或远程后端只需实现该接口,策略层和工具层无需改变。
`fs-sandbox` 与 `fs-e2b` 实现该接口,无需更改政策层和工具层。
## 服务 API(`ctx.fs`)
@@ -26,28 +26,28 @@
| `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 |
| `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 |
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库自有的符号链接进入目标前拒绝它。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 |
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。
## `fs/*` 策略事件
## `fs/*` 政策事件
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和策略监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖策略插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件,携带 `FsObservation` 可辨识联合:存在并带有版本,或确认缺失。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。
## 提供方 seam,不是策略层
## 提供方约定,不是政策层
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使策略层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不**负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的策略,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察策略。
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使政策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不** 负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的政策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察政策。
`editText` 留在该 seam 上,不由策略层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
`editText` 留在该 seam 上,不由政策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
## 词汇
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。
## 模型体验
@@ -55,9 +55,9 @@
#### KV Cache 影响
不会直接使缓存失效;上述消费方负责请求前缀的任何变化。
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与暂缓事项
## 已知限制与延期工作
- **约定只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有十一个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。

View File

@@ -1,5 +1,5 @@
/**
* Filesystem provider seam for one execution world. Backends own stable target
* Filesystem Service Definition for one execution world. Backends own stable target
* identity, process paths and file URIs, containment, text reads, decoding,
* binary rejection, and atomic mutations. Read windows and
* observed-state policy stay in consumer and policy plugins; `editText`
@@ -16,6 +16,7 @@ import type {
FsEditRequest,
FsInfo,
FsPathInfo,
FsObservation,
FsTarget,
FsVersion,
FsWriteIntent,
@@ -33,6 +34,7 @@ export type {
FsDirEntry,
FsErrorCode,
FsInfo,
FsObservation,
FsPathInfo,
FsTarget,
FsWriteIntent,
@@ -63,14 +65,15 @@ declare module 'cordis' {
*/
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
/**
* Record a successful observation. Listeners must be synchronous recorders:
* throws fail the tool call and returned promises are not awaited.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* Record an authoritative positive or negative observation. Listeners must
* be synchronous recorders: throws fail the tool call and returned promises
* are not awaited.
* @param target - the target whose presence or absence was observed.
* @param observation - present with its version, or confirmed absent.
* @param actor - the observing tool-execution context; undefined records nothing useful.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}
}
@@ -198,7 +201,7 @@ export abstract class FileSystem extends Service {
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -219,7 +222,7 @@ export abstract class FileSystem extends Service {
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { FsTarget, FsVersion } from './types.ts'
import type { FsObservation, FsTarget } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-fs'
@@ -24,8 +24,17 @@ const install: InvariantInstaller = (ctx, fail) => {
&& eventName !== 'fs/edit-intent'
&& eventName !== 'fs/observed') return
validateTarget(args[0] as FsTarget, fail)
if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) {
fail('fs/observed version must be non-empty')
if (eventName === 'fs/observed') {
const observation = args[1] as FsObservation
switch (observation.kind) {
case 'present':
if (observation.version.length === 0) fail('fs/observed present version must be non-empty')
break
case 'absent':
break
default:
fail('fs/observed kind must be present or absent')
}
}
}, { global: true })
}

View File

@@ -1,5 +1,5 @@
/**
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque target/version
* Vocabulary for the filesystem Service Definition (`ctx.fs`): the opaque target/version
* identities, the metadata `stat` returns, the write-intent and outcome shapes, the
* literal-edit request/outcome, and the typed error taxonomy.
* @module @deepseek-ai/dsh-fs/types
@@ -44,6 +44,15 @@ export function FsVersion(v: string): FsVersion {
return v as FsVersion
}
/**
* One authoritative observation of a target. A present observation carries the
* version used by guarded replacement; an absent observation authorizes only a
* guarded create, never an edit.
*/
export type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }
/**
* A path resolved by a backend into a stable identity. `resolve()` produces
* this; every other operation takes it.

View File

@@ -28,17 +28,28 @@ describe('filesystem invariants', () => {
ctx as never, 'fs/edit-intent', target(), undefined,
() => Promise.resolve(undefined),
)).resolves.toBeUndefined()
expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow()
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('v1') }, undefined)
}).not.toThrow()
expect(() => { ctx.emit('fs/observed', target(), { kind: 'absent' }, undefined) }).not.toThrow()
expect(() => { ctx.emit('tools/change') }).not.toThrow()
})
it('rejects empty target and version identities', async () => {
const ctx = await setup()
expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) })
expect(() => {
ctx.emit('fs/observed', target(''), { kind: 'present', version: FsVersion('v1') }, undefined)
})
.toThrow(/targetKey must be non-empty/)
expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) })
expect(() => {
ctx.emit('fs/observed', target('file:1', ''), { kind: 'present', version: FsVersion('v1') }, undefined)
})
.toThrow(/displayPath must be non-empty/)
expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) })
.toThrow(/version must be non-empty/)
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('') }, undefined)
}).toThrow(/present version must be non-empty/)
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'unknown' } as never, undefined)
}).toThrow(/kind must be present or absent/)
})
})

View File

@@ -1,5 +1,5 @@
/**
* Tests for the filesystem provider seam itself: registration, duplicate-service
* Tests for the filesystem Service Definition: registration, duplicate-service
* behavior, disposal, and the branded id factories. The provider primitives and
* policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the
* abstract service contract, so a minimal fake backend exercises it.

View File

@@ -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/tool-fs-search/README.md
README.md: 32fa61e3bb09b2166499003953a5631a93baf73b
README.zh.md: 34ffcdc9f64daba7350e4581b35e694213f24214
README.md: 7803ce52c1b9d2d858dadb718edd163585360aef
README.zh.md: 3a34f4e8a04666b72f09eca1b41fea825d5a609f

View File

@@ -16,7 +16,7 @@ Why spawn-backed: local workspace discovery is naturally a process-backed `rg` w
## Deployment requirement: no host rg, co-located workdir/filesystem
The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. That co-location requirement carries no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
## Config

View File

@@ -16,7 +16,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 部署要求:无需宿主 rg,但工作目录与文件系统需共置
二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。
二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。这项共置要求不附带运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。
## 配置

View File

@@ -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/tool-fs/README.md
README.md: 6f96970d8194c0992f9b955b95aec092185054b2
README.zh.md: 6cc8ebfe3ca4fc4e7c016aab64a0136cf4a07f14
README.md: 27b53aca50f470fe9ead4da27d87328264440ff7
README.zh.md: 5bcf9c471d933702c46d50c56c9539cb9eede3ca

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
```ts ignore-check
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
@@ -108,7 +108,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
#### What the model sees
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`. A missing read still returns `FS_NOT_FOUND`, but it records confirmed absence for the calling session; after an externally deleted file is re-read, a retried `write` can safely recreate it through the provider's no-replace guard.
#### Token effect
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
#### Token effect
@@ -148,6 +148,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam.
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。
```ts ignore-check
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
@@ -108,7 +108,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。读取缺失目标仍返回 `FS_NOT_FOUND`,但会为调用会话记录确认缺失;外部删除的文件被重新读取后,重试的 `write` 可以通过提供方的不替换防护安全地重新创建该文件。
#### Token 影响
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。
#### Token 影响
@@ -148,6 +148,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
## 已知限制与暂缓事项
- **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
- **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。

View File

@@ -137,8 +137,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
before: outcome.before,

View File

@@ -138,10 +138,13 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: type check + size routing + the version recorded as observed.
// One stat: absence observation OR type check + size routing + present version.
// A concurrent write can only make a later guarded mutation fail stale and require reread.
const info = await ctx.fs.stat(target, exec.signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (!info) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
// Stream when the file is large OR size is unknown, so a size-less backend
@@ -161,10 +164,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
lines: window.lines,
totalLines: window.totalLines,
}
// Record the observed version (a no-op when no policy plugin listens). The
// Record the present observation (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
return outcome
},
// Result-time display: a `read` card carrying the structured line window a

View File

@@ -4,7 +4,7 @@
* `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
* Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading
* `process.cwd()` at the tool seam.
* `process.cwd()` at the tool boundary.
* @module @deepseek-ai/dsh-tool-fs/session-cwd
*/

View File

@@ -118,8 +118,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
operation: outcome.operation,

View File

@@ -234,37 +234,33 @@ describe('default deployment (with dsh-fs-policy)', () => {
})
})
describe('deleted observed target (fail-closed corner)', () => {
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
describe('deleted observed target', () => {
it('a failed reread records absence so write can safely recreate the file', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
await call('read', { file_path: 'a.txt' })
await rm(join(dir, 'a.txt')) // out-of-band deletion
// Edit of the missing target: stale (the missing-target path shares the
// stale code and the re-read remedy).
// The original positive observation still protects the first mutation.
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
// observation, so the retried edit fails identically: the observed entry
// is never cleared for a deleted target.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Write cannot recreate it either: the stale observation still forces
// replaceIfVersion, which rejects a missing target ("file no longer exists").
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
const write = await call('write', { file_path: 'a.txt', content: 'premature' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The dead end lifts once the file exists again and is freshly observed.
await writeFile(join(dir, 'a.txt'), 'restored')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
// A read-not-found is an authoritative negative observation for this
// owner. It still fails as a read, but changes the next write guard.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// Absence never authorizes edit: there is no content/version to edit.
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// The retried write uses createIfAbsent; the provider remains responsible
// for rejecting a concurrent creator at publication time.
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(recovered.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
@@ -294,6 +290,20 @@ describe('default deployment (with dsh-fs-policy)', () => {
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
it('a missing read still stats once and its recovery write stats zero times', async () => {
const statSpy = vi.spyOn(ctx.fs, 'stat')
const missing = await call('read', { file_path: 'missing.txt' })
expect(missing.isError).toBe(true)
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
expect(statSpy).toHaveBeenCalledTimes(1)
statSpy.mockClear()
const created = await call('write', { file_path: 'missing.txt', content: 'fresh' })
expect(created.isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
})
})
@@ -482,7 +492,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
// Reproduce an older concurrent read winning the observation race.
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
ctx.emit('fs/observed', target, { kind: 'present', version: firstInfo.version }, { agent: { session } })
const edit = await callOwned('edit', {
file_path: 'a.txt',

View File

@@ -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/tool-str-replace-editor/README.md
README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1
README.md: 8b4772cc4eb40e23a5d6ea8e409188b5033318ba
README.zh.md: b9ccf285870f2d02a2195b70dcd24fd37794976e

View File

@@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
## Tool
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端接口组合。
## 配置
@@ -13,13 +13,13 @@
## 工具
Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
## 模型体验
### 工具 schema
#### 模型所见
#### 模型看到的内容
生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。
@@ -33,7 +33,7 @@ Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert
### 工具结果
#### 模型所见
#### 模型看到的内容
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
@@ -49,4 +49,4 @@ Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。
- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前会话的沙箱策略,并交由挂载的文件系统与策略插件执行。

View File

@@ -105,6 +105,7 @@ async function statExisting(
): Promise<FsInfo> {
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
'FS_NOT_FOUND',
@@ -231,7 +232,7 @@ async function viewPath(
throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
}
const content = await ctx.fs.readText(target, exec.signal)
ctx.emit('fs/observed', target, info.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
}
@@ -266,7 +267,7 @@ async function createFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `New file created successfully at: ${target.displayPath}`
}
@@ -317,7 +318,7 @@ async function replaceInFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
@@ -359,7 +360,7 @@ async function insertInFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `The file ${target.displayPath} has been edited successfully.`
}

View File

@@ -196,6 +196,35 @@ describe('tool-str-replace-editor', () => {
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
})
it('a failed view records absence so create can recover after external deletion', async () => {
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
const sample = join(root, 'deleted.txt')
await writeFile(sample, 'original')
expect((await call(ctx, owner, { command: 'view', path: sample })).isError).toBe(false)
await rm(sample)
const missing = await call(ctx, owner, { command: 'view', path: sample })
expect(missing.isError).toBe(true)
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const edit = await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'original',
new_str: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const created = await call(ctx, owner, {
command: 'create',
path: sample,
file_text: 'fresh',
})
expect(created.isError).toBe(false)
expect(await readFile(sample, 'utf8')).toBe('fresh')
})
it('writes replacement text literally', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'literal.txt')