diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml
new file mode 100644
index 0000000000..da4e330119
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
+2026-07-30-bounded-overwrite-diff-basis.md: 7a09934bd1798059de43a092f338d37aa9ccbd9a
+2026-07-30-bounded-overwrite-diff-basis.zh.md: 1d6bdd1068d119aae859132d9f8216ca29d0dc11
diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
new file mode 100644
index 0000000000..7a09934bd1
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
@@ -0,0 +1,31 @@
+# Agent Note: Bound overwrite contextual-diff bases at the provider
+
+Status: implemented
+
+English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md)
+
+## Problem
+
+`dsh-fs-local` returned the complete prior file in `FsWriteOutcome.before` so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made the contextual hunk approach the replacement size even when the prior file was small. This closes the deferred bound recorded by [result-time applied-hunk diffs](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md).
+
+## Decision
+
+`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write.
+
+The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value.
+
+`before: null` asks consumers to use their existing whole-file fallback. The limit bounds only the extra prior-content acquisition and eligibility for a contextual pair. It does not bound the caller-owned replacement, the returned `after` value, or a consumer's fallback rendering.
+
+## Alternatives considered
+
+**Keep a hardcoded threshold equal to the read tool's streaming threshold.** Rejected because the read threshold is deployment-configurable and consumer-owned. Two same-valued constants would create an unenforced cross-package coupling, while the overwrite basis is itself a deployment memory/presentation choice.
+
+**Gate only the prior side in the provider and cap new-content diffing in `tool-fs`.** Rejected because it would acquire prior text even when the provider's configured pair limit already excludes the replacement, and it would split one `before` eligibility rule across two plugins. Consumers remain free to impose additional output limits.
+
+**Trust the initial `probe()` size before using an ordinary whole-file read.** Rejected because that size can become stale before the read. The descriptor reader must enforce the bound on the object it actually reads.
+
+**Stream a contextual diff for arbitrarily large pairs.** Rejected for this bug fix because the current filesystem seam returns complete `before`/`after` strings and the current diff implementation consumes them. A streaming diff would require a separate cross-package protocol and presentation design.
+
+## Consequences
+
+Deployments can tune the extra overwrite-basis cost without changing read routing. At or above the exclusive limit, overwrites still succeed and remain visible through the whole-file fallback, but lose contextual hunks. Below the limit, the provider can still hold almost `diffBasisMaxBytes` of prior text in addition to the caller's replacement. The bounded descriptor read adds an open/stat/read sequence for eligible overwrites, while preventing a stale path probe from turning that sequence into an unbounded allocation.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md
new file mode 100644
index 0000000000..1d6bdd1068
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 在提供方限制覆写上下文 diff 基础
+
+Status: implemented
+
+[English](2026-07-30-bounded-overwrite-diff-basis.md) | 中文
+
+## Problem
+
+`dsh-fs-local` 会在 `FsWriteOutcome.before` 中返回完整旧文件,供消费方生成覆写上下文 diff。这个仅用于展示的预读没有上限:大文件覆写可能分配整个旧文件;而仅检查较早的路径 stat 也无法真正实施上限,因为外部进程可以在 stat 与读取之间替换文件或扩大文件。即使旧文件很小,大替换内容也会使上下文 hunk 接近替换内容本身的大小。本改动关闭了 [result-time applied-hunk diff](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md) 中记录的暂缓上限事项。
+
+## Decision
+
+`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。
+
+本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。
+
+`before: null` 要求消费方使用既有的整文件回退。该上限只限制额外获取旧内容的成本,以及上下文内容对是否合格;它不限制调用方持有的替换内容、返回的 `after` 值或消费方的回退渲染。
+
+## Alternatives considered
+
+**保留一个与读取工具流式阈值相等的硬编码阈值。** 否决,因为读取阈值可由部署配置,且归消费方所有。两个同值常量会形成无法强制的一致性耦合,而覆写基础本身也是部署层面的内存与展示选择。
+
+**提供方只限制旧内容一侧,并在 `tool-fs` 中限制新内容 diff。** 否决,因为当提供方配置的成对上限已经排除替换内容时,这仍会获取旧文本;同时会把同一条 `before` 合格规则拆到两个插件中。消费方仍可自由施加额外的输出限制。
+
+**信任初次 `probe()` 的大小,再执行普通整文件读取。** 否决,因为该大小可能在读取前变旧;描述符读取必须对它真正读取的对象实施上限。
+
+**为任意大的内容对流式生成上下文 diff。** 本次缺陷修复不采用,因为当前文件系统 seam 返回完整的 `before`/`after` 字符串,当前 diff 实现也消费这两个字符串。流式 diff 需要独立的跨包协议与展示设计。
+
+## Consequences
+
+部署可以调整额外的覆写基础成本,而不改变读取路由。达到或超过排他上限时,覆写仍会成功,并通过整文件回退保持可见,但不再提供上下文 hunk。低于上限时,除调用方的替换内容外,提供方仍可能持有接近 `diffBasisMaxBytes` 的旧文本。对于合格覆写,有上限的描述符读取会增加一次 open/stat/read 序列,同时防止陈旧路径探测把该序列变成无上限分配。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 09c961ef69..889b2e24e7 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618
-config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df
+config-catalog.md: 471680f92dc44f3dd4e98ba9e946525ec79f25b0
+config-catalog.zh.md: bf78766799b02a1f6f21f935723abace108bc306
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 51c6ae46ee..471680f92d 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -525,10 +525,15 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front
export interface Config {
/** Base directory for relative paths. Defaults to `process.cwd()`. */
cwd?: string
+ /**
+ * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
+ * runtime's safe allocation/decode maximum. Defaults to 10 MiB.
+ */
+ diffBasisMaxBytes?: number
}
```
-Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts)
+Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts)
## `@deepseek-ai/dsh-fs-sandbox`
@@ -536,10 +541,10 @@ Requires: `sandboxPolicy`
```ts config-catalog
/**
- * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
- * base for relative paths). The sandbox default (mode + `workspace-write`
- * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
- * session for every enforcing capability.
+ * Plugin config: the local backend's knobs verbatim (`cwd` resolution default
+ * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
+ * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
+ * resolves each calling session for every enforcing capability.
*/
export type Config = LocalConfig
```
@@ -981,12 +986,24 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
+ /**
+ * Optional per-request output cap the replay route materializes when callers
+ * omit one, so replay reconstructs the request header a live catalog produced.
+ */
+ defaultMaxTokens?: number
+ /** Optional reasoning-effort ids the replay route accepts, in display order. */
+ reasoningEfforts?: string[]
+ /**
+ * Optional effort materialized when callers omit one; must appear in
+ * {@link reasoningEfforts} or call resolution rejects the route.
+ */
+ defaultReasoningEffort?: string
}
```
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index dc93f5b4b5..bf78766799 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -527,10 +527,15 @@ export interface Config {
export interface Config {
/** Base directory for relative paths. Defaults to `process.cwd()`. */
cwd?: string
+ /**
+ * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
+ * runtime's safe allocation/decode maximum. Defaults to 10 MiB.
+ */
+ diffBasisMaxBytes?: number
}
```
-来源:[`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts)
+来源:[`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts)
## `@deepseek-ai/dsh-fs-sandbox`
@@ -538,10 +543,10 @@ export interface Config {
```ts config-catalog
/**
- * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
- * base for relative paths). The sandbox default (mode + `workspace-write`
- * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
- * session for every enforcing capability.
+ * Plugin config: the local backend's knobs verbatim (`cwd` resolution default
+ * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
+ * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
+ * resolves each calling session for every enforcing capability.
*/
export type Config = LocalConfig
```
@@ -983,12 +988,24 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
+ /**
+ * Optional per-request output cap the replay route materializes when callers
+ * omit one, so replay reconstructs the request header a live catalog produced.
+ */
+ defaultMaxTokens?: number
+ /** Optional reasoning-effort ids the replay route accepts, in display order. */
+ reasoningEfforts?: string[]
+ /**
+ * Optional effort materialized when callers omit one; must appear in
+ * {@link reasoningEfforts} or call resolution rejects the route.
+ */
+ defaultReasoningEffort?: string
}
```
依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-来源:[`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts)
+来源:[`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml
index 34d90d18f2..783f8b8d66 100644
--- a/docs/subsystems/filesystem.i18n.yaml
+++ b/docs/subsystems/filesystem.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md
-filesystem.md: 3c154af0fa4ee6d28f2379c5392dcc9b194a2c99
-filesystem.zh.md: 5b8a136af156c63de72e260d5859d8bac7b827c5
+filesystem.md: 00e28130db9f60e6ad5f8c582ca5531482cdeb32
+filesystem.zh.md: 5cf6be12f41b36c8149a941a4d251c4497ed4738
diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md
index 3c154af0fa..00e28130db 100644
--- a/docs/subsystems/filesystem.md
+++ b/docs/subsystems/filesystem.md
@@ -136,10 +136,11 @@ interface FsWriteOutcome {
version: FsVersion
/**
* The file's content BEFORE the write, or `null` when the file did not exist
- * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
- * (the diff basis), never a diff — a consumer computes the result-time
- * contextual diff from `before`/`after` when `before` is present, else falls
- * back to a whole-file diff.
+ * (a create) or the backend declined a contextual basis (for example, a
+ * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit).
+ * LF-normalized storage text (the diff basis), never a diff — a consumer
+ * computes the result-time contextual diff from `before`/`after` when
+ * `before` is present, else falls back to a whole-file diff.
*/
before: string | null
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md
index 5b8a136af1..5cf6be12f4 100644
--- a/docs/subsystems/filesystem.zh.md
+++ b/docs/subsystems/filesystem.zh.md
@@ -136,10 +136,11 @@ interface FsWriteOutcome {
version: FsVersion
/**
* The file's content BEFORE the write, or `null` when the file did not exist
- * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
- * (the diff basis), never a diff — a consumer computes the result-time
- * contextual diff from `before`/`after` when `before` is present, else falls
- * back to a whole-file diff.
+ * (a create) or the backend declined a contextual basis (for example, a
+ * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit).
+ * LF-normalized storage text (the diff basis), never a diff — a consumer
+ * computes the result-time contextual diff from `before`/`after` when
+ * `before` is present, else falls back to a whole-file diff.
*/
before: string | null
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index 9eb71d8948..2a3de5d6b8 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -52,6 +52,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url))
const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url))
+const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
@@ -253,6 +254,21 @@ const SCENARIOS: Scenario[] = [
{ name: 'fs-write', hasModelTurn: true, recorded: true },
{ name: 'fs-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
+ // An overwrite whose replacement is at/above the configured diff-basis bound:
+ // the persisted result meta carries no contextual hunks and presentation
+ // falls back to the whole-file diff. The overlay leaves the prompt and tool
+ // sequence identical to text-turn, but the freshly recorded header carries
+ // the current adapter capability fields, so the scenario pins its own class.
+ {
+ name: 'fs-write-overwrite-bounded',
+ hasModelTurn: true,
+ recorded: true,
+ pinsHeader: true,
+ headerClass: 'fs-diff-bound',
+ systemPromptSource: 'text-turn',
+ toolSchemasSource: 'text-turn',
+ configPath: FS_DIFF_BOUND_CONFIG,
+ },
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
{ name: 'fs-delete-recreate', hasModelTurn: true, recorded: true },
diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml
new file mode 100644
index 0000000000..a216a34cfd
--- /dev/null
+++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml
@@ -0,0 +1,46 @@
+# Keyless replay counterpart to fs-diff-bound.cordis.yml. Replay patches apply
+# directly against the live cordis.yml because include patches cannot target
+# entries behind a nested include; the acp-agent restatement keeps the recorded
+# deepseek-v4-flash model and raw JSONL persistence for the harness's harvest.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ../cordis.yml
+ patches:
+ - id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ disabled: true
+ - id: acp-agent
+ name: '@deepseek-ai/dsh-acp-demo'
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+ persistenceCompression: none
+ workspaceContext:
+ maxBytes: 65536
+ persona: |
+ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+ Verify your work by running the code or tests. Keep answers brief and factual.
+ - id: fs-sandbox
+ name: '@deepseek-ai/dsh-fs-sandbox'
+ config:
+ cwd: !!js process.cwd()
+ diffBasisMaxBytes: 64
+ - insert:
+ - id: llm-replay
+ name: '@deepseek-ai/dsh-llm-replay'
+ config:
+ providers:
+ - id: deepseek-official
+ name: DeepSeek
+ models:
+ # Capability parity with the live adapter so replay
+ # reconstructs the freshly recorded request header.
+ - id: deepseek-v4-flash
+ contextWindow: 1000000
+ defaultMaxTokens: 256000
+ reasoningEfforts: ['off', 'high', 'max']
+ defaultReasoningEffort: max
+ - id: deepseek-v4-pro
diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml
new file mode 100644
index 0000000000..9be25cdb25
--- /dev/null
+++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml
@@ -0,0 +1,29 @@
+# Live counterpart for the bounded-overwrite-diff snapshot: the base stack with
+# the fs backend's overwrite diff-basis limit shrunk so a modest replacement
+# crosses the exclusive bound and the write result falls back to a whole-file
+# diff. A config patch replaces the row's whole config, so `cwd` is restated
+# verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match
+# the recorded corpus and its pinned request headers.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ../cordis.yml
+ patches:
+ - id: acp-agent
+ name: '@deepseek-ai/dsh-acp-demo'
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+ persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
+ workspaceContext:
+ maxBytes: 65536
+ persona: |
+ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+ Verify your work by running the code or tests. Keep answers brief and factual.
+ - id: fs-sandbox
+ name: '@deepseek-ai/dsh-fs-sandbox'
+ config:
+ cwd: !!js process.cwd()
+ diffBasisMaxBytes: 64
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json
new file mode 100644
index 0000000000..480c37827a
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json
@@ -0,0 +1,7 @@
+{
+ "steps": [
+ { "op": "initialize" },
+ { "op": "newSession" },
+ { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE." }
+ ]
+}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl
new file mode 100644
index 0000000000..688bf9cb70
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl
@@ -0,0 +1,42 @@
+{"type":"session","version":0,"id":"14b14f51-2428-43a0-bcc5-5f392d4faa19","createdAt":1786204699215,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"agent/inbox/spliced","seq":0,"time":1786204699218,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"}]}}
+{"type":"turn/start","seq":1,"time":1786204699219,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":2,"time":1786204699220,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":3,"time":1786204699259,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":4,"time":1786204699259,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"},"surfaceOp":"append"}
+{"type":"user/message","seq":5,"time":1786204699260,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e374fb32-1cad-4e2d-9cd3-66ac8fcf9588"},"surfaceOp":"append"}
+{"type":"session/title","seq":6,"time":1786204699261,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":7,"time":1786204699262,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786204699262,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}
+{"type":"assistant/chunk","seq":9,"time":1786204701601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":10,"time0":1786204701602,"data":{"turn":1,"step":1,"index":0,"dt":[60,22,2,1,0,1,0,19,2,1,1,17,21,2,0,20,2,1,21,0,0,0,1,21,2],"texts":["The"," user"," wants"," me"," to"," read"," data",".txt"," first",","," then"," write"," to"," replace"," its"," contents"," with"," the"," exact"," line",","," then"," reply"," D","ONE","."]}}
+{"type":"assistant/chunk","seq":36,"time":1786204701863,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":37,"time0":1786204701864,"data":{"turn":1,"step":1,"index":1,"dt":[21,2,0,21,2,1,0,26,1,0,17],"id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}}
+{"type":"assistant/chunk","seq":49,"time":1786204701981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}}
+{"type":"assistant/chunk","seq":50,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}}
+{"type":"assistant/chunk","seq":51,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}}}}
+{"type":"assistant/chunk","seq":52,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":53,"time":1786204701988,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."},{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9060e190-9971-4838-81bf-48c3e3888609"},"usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"}
+{"type":"tool/call","seq":54,"time":1786204701990,"data":{"turn":1,"step":1,"callId":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}
+{"type":"tool/result","seq":55,"time":1786204702006,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Jxz49JNt6i4oaDnzes2I0794"},"content":[{"type":"tool-result","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1406fd7d-f181-41d0-b0db-ef196010f620"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"}
+{"type":"step/end","seq":56,"time":1786204702006,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":57,"time":1786204702016,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":58,"time":1786204703539,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":59,"time0":1786204703539,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,1,0,0,1,0,12,2,0,0,0,22,35,1,0,0,0,0,0,1,0,8,2,0,0,71,1,0,0,1,0],"id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","The"," replacement"," line"," is"," deliberately"," longer"," than"," the"," configured"," sixty","-four"," byte"," diff","-b","asis"," bound",".","\"","}"]}}
+{"type":"assistant/chunk","seq":95,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}}}
+{"type":"assistant/chunk","seq":96,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","seq":97,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":98,"time":1786204703722,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"46d3792a-eded-45e7-8151-00ca0584f10b"},"usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}},"sourceEventSeqs":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"}
+{"type":"tool/call","seq":99,"time":1786204703722,"data":{"turn":1,"step":2,"callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}
+{"type":"tool/result","seq":100,"time":1786204703740,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"98c41fc1-6ce6-445f-94f7-32aa7e1c6ea7"},"meta":{"diffs":[]}},"sourceEventSeqs":[99],"surfaceOp":"append"}
+{"type":"step/end","seq":101,"time":1786204703740,"data":{"turn":1,"step":2}}
+{"type":"step/start","seq":102,"time":1786204703749,"data":{"turn":1,"step":3}}
+{"type":"assistant/chunk","seq":103,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":104,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"D"}}}
+{"type":"assistant/chunk","seq":105,"time":1786204705053,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":106,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":107,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","seq":108,"time":1786204705056,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":109,"time":1786204705057,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddf50859-b0b9-404d-a71c-a1f11ff53341"},"usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}},"sourceEventSeqs":[103,104,105,106,107,108],"surfaceOp":"append"}
+{"type":"step/end","seq":110,"time":1786204705057,"data":{"turn":1,"step":3}}
+{"type":"turn/end","seq":111,"time":1786204705058,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl
new file mode 100644
index 0000000000..82ae8907ca
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl
@@ -0,0 +1,4 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt
new file mode 100644
index 0000000000..3359a4b8d9
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt
@@ -0,0 +1 @@
+original contents
\ No newline at end of file
diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml
index ef76470394..149ee2be48 100644
--- a/packages/fs/fs-local/README.i18n.yaml
+++ b/packages/fs/fs-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
-README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d
-README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5
+README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8
+README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda
diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md
index 7b993fa123..a3239905e3 100644
--- a/packages/fs/fs-local/README.md
+++ b/packages/fs/fs-local/README.md
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
-- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a 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`).
+- **`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.
@@ -35,9 +35,9 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
-- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`).
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
- **`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** — 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`.
diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md
index 428719137f..bbd9d2f66c 100644
--- a/packages/fs/fs-local/README.zh.md
+++ b/packages/fs/fs-local/README.zh.md
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
-- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
+- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 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` 是轻量服务接线。
@@ -35,9 +35,9 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## 已知限制与延期工作
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。
-- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
+- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。
- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。
diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts
index 24ad9b929f..1ba027455d 100644
--- a/packages/fs/fs-local/src/fsio.ts
+++ b/packages/fs/fs-local/src/fsio.ts
@@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
+// Bound one non-abortable FileHandle.read so cancellation is observed between chunks.
+const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024
function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
@@ -74,9 +76,8 @@ function versionOf(info: BigIntStats): FsVersion {
}
/**
- * Test hook: lets specs pin the atomic-write temp names (to prove
- * exclusive-open behavior without a name race) and observe the staged temp
- * file before it is renamed over the target.
+ * 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 {
/** Override the host platform for native-publication unit coverage. */
@@ -634,21 +635,67 @@ export async function readForEdit(
}
/**
- * Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still
- * succeeds and presentation falls back to a whole-file diff.
- * @param absolutePath - the file to read (typically a target key); it must exist.
- * @param signal - aborts the read (`FS_ABORTED`).
- * @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
+ * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
+ * or a file deleted/made unreadable after the caller's preflight returns `null` so the write
+ * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
+ * the opened descriptor rather than a prior path stat, so concurrent external replacement or
+ * size changes cannot make this helper buffer more than `maxBytes`.
+ * @param absolutePath - the file to read (typically a target key).
+ * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
+ * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
+ * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
+ * descriptor-size-changed, or unreadable file.
*/
-export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise {
- const buffer = await readFileAbortable(absolutePath, 'read', signal)
- if (buffer.includes(0)) return null
+export async function readTextForDiff(
+ absolutePath: string,
+ maxBytes: number,
+ signal?: AbortSignal,
+): Promise {
+ throwIfAborted(signal, 'read')
try {
- return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
+ const handle = await open(absolutePath, 'r')
+ let buffer: Buffer
+ let total = 0
+ let openedSize = 0
+ try {
+ throwIfAborted(signal, 'read')
+ const info = await handle.stat()
+ throwIfAborted(signal, 'read')
+ if (!info.isFile()) return null
+ if (info.size >= maxBytes) return null
+ openedSize = info.size
+ // One extra byte detects growth after stat without retaining per-read backing buffers.
+ buffer = Buffer.allocUnsafe(openedSize + 1)
+ while (total < buffer.length) {
+ throwIfAborted(signal, 'read')
+ const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES)
+ const { bytesRead } = await handle.read(buffer, total, length, null)
+ if (bytesRead === 0) break
+ total += bytesRead
+ }
+ } finally {
+ await handle.close()
+ }
+ throwIfAborted(signal, 'read')
+ if (total !== openedSize) return null
+ const basis = buffer.subarray(0, total)
+ if (basis.includes(0)) return null
+ try {
+ return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis))
+ } catch (error: unknown) {
+ /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
+ * any other throw is an unreachable runtime fault. */
+ if (!(error instanceof TypeError)) throw error
+ return null
+ }
} catch (error: unknown) {
- /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
- if (!(error instanceof TypeError)) throw error
- return null
+ // Cancellation is the caller's intent and still propagates.
+ if (error instanceof FsError) throw error
+ // A descriptor-phase errno — deleted or made unreadable after the caller's
+ // preflight, or a faulted read — costs only the optional basis: a committed
+ // write must not fail for a presentation-only pre-read.
+ if (error instanceof Error && 'code' in error) return null
+ throw error
}
}
diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts
index 9cd4be41b8..5d1f6b2865 100644
--- a/packages/fs/fs-local/src/index.ts
+++ b/packages/fs/fs-local/src/index.ts
@@ -5,6 +5,7 @@
*/
import { Context } from 'cordis'
+import { constants as bufferConstants } from 'node:buffer'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import z from 'schemastery'
@@ -39,9 +40,19 @@ import type { FsIoInternals } from './fsio.ts'
export interface Config {
/** Base directory for relative paths. Defaults to `process.cwd()`. */
cwd?: string
+ /**
+ * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
+ * runtime's safe allocation/decode maximum. Defaults to 10 MiB.
+ */
+ diffBasisMaxBytes?: number
}
type ResolvedConfig = Required
+const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
+const MAX_DIFF_BASIS_BYTES = Math.min(
+ bufferConstants.MAX_LENGTH,
+ bufferConstants.MAX_STRING_LENGTH,
+)
/**
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
@@ -52,11 +63,12 @@ type ResolvedConfig = Required
export class LocalFileSystem extends FileSystem {
static Config: z = z.object({
cwd: z.string().default(process.cwd()),
+ diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
})
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
- /** Test hook forwarded to fsio (force streaming path, pin temp names). */
+ /** 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
@@ -65,7 +77,13 @@ export class LocalFileSystem extends FileSystem {
constructor(ctx: Context, config: Config) {
super(ctx)
- this.config = config as ResolvedConfig
+ const resolved = config as ResolvedConfig
+ if (!Number.isSafeInteger(resolved.diffBasisMaxBytes)
+ || resolved.diffBasisMaxBytes <= 0
+ || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) {
+ throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`)
+ }
+ this.config = resolved
}
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
@@ -164,9 +182,16 @@ export class LocalFileSystem extends FileSystem {
}
// No expectation means an unconditional but still atomic write.
- // Preserve prior text for contextual diffs; null falls back to a whole-file diff.
- // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
- const before = existing ? await readTextForDiff(target.targetKey, signal) : null
+ // Capture an optional contextual-diff basis before the write. The bounded
+ // reader checks the opened file itself, so an external replacement after
+ // `probe()` cannot turn this best-effort presentation read into an
+ // unbounded allocation. Either side at/above the configured limit yields
+ // `before: null`; consumers retain their whole-file fallback.
+ const diffable = existing !== null
+ && Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes
+ const before = diffable
+ ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)
+ : null
await writeFileAtomic(
target.targetKey,
content,
diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts
index f6736ff9c3..d70581f28e 100644
--- a/packages/fs/fs-local/tests/filesystem.spec.ts
+++ b/packages/fs/fs-local/tests/filesystem.spec.ts
@@ -7,6 +7,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { constants as bufferConstants } from 'node:buffer'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -43,13 +44,39 @@ async function versionOf(target: FsTarget): Promise {
return info.version
}
+async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise {
+ await fiber.dispose()
+ fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes })
+ fs = ctx.fs as LocalFileSystem
+}
+
describe('registration', () => {
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
const bare = new Context()
const bareFiber = await bare.plugin(LocalFileSystem)
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
+ expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024)
await bareFiber.dispose()
})
+
+ it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => {
+ const maxDiffBasisBytes = Math.min(
+ bufferConstants.MAX_LENGTH,
+ bufferConstants.MAX_STRING_LENGTH,
+ )
+ const valid = new Context()
+ const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes })
+ expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes)
+ await validFiber.dispose()
+
+ for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) {
+ const invalid = new Context()
+ await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow(
+ `fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`,
+ )
+ await invalid.fiber.dispose()
+ }
+ })
})
describe('resolve', () => {
@@ -444,6 +471,54 @@ describe('writeText', () => {
expect(outcome.after).toBe('now valid')
})
+ it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => {
+ // The configured bound keeps the fixture small; 8 bytes at a bound of 8
+ // pins the exclusive edge without coupling this provider to a read tool.
+ await remountWithDiffLimit(8)
+ await writeFile(join(dir, 'big.txt'), '12345678')
+ const target = await fs.resolve('big.txt')
+ const outcome = await fs.writeText(target, 'tiny')
+ expect(outcome.operation).toBe('update')
+ expect(outcome.before).toBeNull()
+ expect(outcome.after).toBe('tiny')
+ })
+
+ it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => {
+ // The bound gates BOTH sides of the diff pair: a small prior file rewritten
+ // with at/above-bound content yields no contextual-hunk basis either, since
+ // a small-to-huge rewrite's hunk is as large as the new content — the
+ // consumer must fall back to the whole-file diff card, exactly like a
+ // create of the same size.
+ await remountWithDiffLimit(8)
+ await writeFile(join(dir, 'grow.txt'), 'tiny')
+ const target = await fs.resolve('grow.txt')
+ const outcome = await fs.writeText(target, '12345678')
+ expect(outcome.operation).toBe('update')
+ expect(outcome.before).toBeNull()
+ expect(outcome.after).toBe('12345678')
+ })
+
+ it('gates the NEW content by UTF-8 byte length, not character count', async () => {
+ // Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by
+ // characters but at/above it by bytes, so the basis must be declined.
+ await remountWithDiffLimit(8)
+ await writeFile(join(dir, 'cjk.txt'), 'tiny')
+ const target = await fs.resolve('cjk.txt')
+ const outcome = await fs.writeText(target, '你好吗')
+ expect(outcome.operation).toBe('update')
+ expect(outcome.before).toBeNull()
+ expect(outcome.after).toBe('你好吗')
+ })
+
+ it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => {
+ await remountWithDiffLimit(8)
+ await writeFile(join(dir, 'small.txt'), '1234567')
+ const target = await fs.resolve('small.txt')
+ const outcome = await fs.writeText(target, 'new')
+ expect(outcome.before).toBe('1234567')
+ expect(outcome.after).toBe('new')
+ })
+
it('releases per-target mutation locks after success and failure', async () => {
const target = await fs.resolve('a.txt')
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts
index 13b0edc290..397aa819a7 100644
--- a/packages/fs/fs-local/tests/fsio.spec.ts
+++ b/packages/fs/fs-local/tests/fsio.spec.ts
@@ -5,7 +5,7 @@
* policy and lives in `dsh-fs-policy`, so it is not tested here.
*/
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -16,6 +16,7 @@ import {
probe,
probeNoFollow,
readForEdit,
+ readTextForDiff,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
@@ -316,6 +317,261 @@ describe('readWholeText', () => {
})
})
+describe('readTextForDiff', () => {
+ it('returns normalized text only when the opened file is strictly below the limit', async () => {
+ const file = join(dir, 'basis.txt')
+ await writeFile(file, 'a\r\nb')
+ expect(await readTextForDiff(file, 5)).toBe('a\nb')
+ expect(await readTextForDiff(file, 4)).toBeNull()
+ })
+
+ it('bounds the actual opened file rather than trusting an earlier path size', async () => {
+ const file = join(dir, 'replaced.txt')
+ await writeFile(file, 'tiny')
+ const earlierSize = (await stat(file)).size
+ await writeFile(file, '123456789')
+ expect(earlierSize).toBeLessThan(8)
+ expect(await readTextForDiff(file, 8)).toBeNull()
+ })
+
+ it('returns null when the opened file shrinks after descriptor stat', async () => {
+ const file = join(dir, 'shrinking.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ return {
+ close: handle.close.bind(handle),
+ read: handle.read.bind(handle),
+ async stat(...statArgs: Parameters) {
+ const info = await handle.stat(...statArgs)
+ await writeFile(file, 'abc')
+ return info
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ expect(await isolatedReadTextForDiff(file, 8)).toBeNull()
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('returns null when the opened file grows after descriptor stat', async () => {
+ const file = join(dir, 'growing.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ return {
+ close: handle.close.bind(handle),
+ read: handle.read.bind(handle),
+ async stat(...statArgs: Parameters) {
+ const info = await handle.stat(...statArgs)
+ await writeFile(file, 'abcdef-grown')
+ return info
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('returns null when the file vanishes before the basis open (deletion race)', async () => {
+ expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull()
+ })
+
+ it('returns null when the opened descriptor is no longer a regular file', async () => {
+ const file = join(dir, 'swapped.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ return {
+ close: handle.close.bind(handle),
+ read: handle.read.bind(handle),
+ async stat(...statArgs: Parameters) {
+ const info = await handle.stat(...statArgs)
+ return Object.assign(info, { isFile: () => false })
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('propagates a non-errno fault instead of masking it as a null basis', async () => {
+ const file = join(dir, 'faulted.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open() {
+ throw new TypeError('forged programming fault')
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault')
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => {
+ await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
+ await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
+ expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull()
+ expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull()
+ })
+
+ it('honors a pre-aborted signal', async () => {
+ const file = join(dir, 'basis.txt')
+ await writeFile(file, 'text')
+ await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
+ })
+
+ it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => {
+ const file = join(dir, 'basis.txt')
+ await writeFile(file, 'text')
+ const reached = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ let statCalls = 0
+ const allocate = vi.spyOn(Buffer, 'allocUnsafe')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ if (stage === 'open') {
+ reached.resolve(undefined)
+ await release.promise
+ }
+ return {
+ close: handle.close.bind(handle),
+ read: handle.read.bind(handle),
+ async stat(...statArgs: Parameters) {
+ statCalls += 1
+ const info = await handle.stat(...statArgs)
+ if (stage === 'stat') {
+ reached.resolve(undefined)
+ await release.promise
+ }
+ return info
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ const controller = new AbortController()
+ const pending = isolatedReadTextForDiff(file, 8, controller.signal)
+ await reached.promise
+ const allocationCalls = allocate.mock.calls.length
+ controller.abort()
+ release.resolve(undefined)
+ await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
+ expect(statCalls).toBe(stage === 'open' ? 0 : 1)
+ expect(allocate).toHaveBeenCalledTimes(allocationCalls)
+ } finally {
+ release.resolve(undefined)
+ allocate.mockRestore()
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('bounds descriptor reads and observes cancellation before the next chunk', async () => {
+ const file = join(dir, 'large-basis.txt')
+ const fileBytes = 200 * 1024
+ await writeFile(file, 'x'.repeat(fileBytes))
+ const firstRead = Promise.withResolvers()
+ const releaseFirstRead = Promise.withResolvers()
+ const readLengths: number[] = []
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ return {
+ stat: handle.stat.bind(handle),
+ close: handle.close.bind(handle),
+ async read(buffer: Buffer, offset: number, length: number, position: number | null) {
+ readLengths.push(length)
+ const result = await handle.read(buffer, offset, length, position)
+ if (readLengths.length === 1) {
+ firstRead.resolve(undefined)
+ await releaseFirstRead.promise
+ }
+ return result
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ const controller = new AbortController()
+ const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal)
+ await firstRead.promise
+ expect(readLengths).toEqual([64 * 1024])
+ controller.abort()
+ releaseFirstRead.resolve(undefined)
+ await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
+ expect(readLengths).toHaveLength(1)
+ } finally {
+ releaseFirstRead.resolve(undefined)
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+})
+
describe('streamWholeText', () => {
it('streams the whole file as decoded text', async () => {
const file = join(dir, 'a.txt')
diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml
index 35a33d135b..4f7771cd3c 100644
--- a/packages/fs/fs-sandbox/README.i18n.yaml
+++ b/packages/fs/fs-sandbox/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md
-README.md: c40fc7999ab85a70702f65a5675208163f5fc351
-README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2
+README.md: ae1fd746c711a86e308a02e0054ba478e8d913c0
+README.zh.md: e25a3467c06fbd93de9bb75d6b364e8da5a451fe
diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md
index c40fc7999a..ae1fd746c7 100644
--- a/packages/fs/fs-sandbox/README.md
+++ b/packages/fs/fs-sandbox/README.md
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
+Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis.
+
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots.
## The fence
diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md
index 15db5abbfc..e25a3467c0 100644
--- a/packages/fs/fs-sandbox/README.zh.md
+++ b/packages/fs/fs-sandbox/README.zh.md
@@ -4,6 +4,8 @@
`SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。
+它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。
+
只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。
## 围栏
diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts
index 9884cc5fbd..8fa3654d62 100644
--- a/packages/fs/fs-sandbox/src/index.ts
+++ b/packages/fs/fs-sandbox/src/index.ts
@@ -41,10 +41,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { isPathUnder } from './containment.ts'
/**
- * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
- * base for relative paths). The sandbox default (mode + `workspace-write`
- * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
- * session for every enforcing capability.
+ * Plugin config: the local backend's knobs verbatim (`cwd` resolution default
+ * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
+ * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
+ * resolves each calling session for every enforcing capability.
*/
export type Config = LocalConfig
diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts
index e842d0d574..ddef6f117d 100644
--- a/packages/fs/fs/src/types.ts
+++ b/packages/fs/fs/src/types.ts
@@ -132,10 +132,11 @@ export interface FsWriteOutcome {
version: FsVersion
/**
* The file's content BEFORE the write, or `null` when the file did not exist
- * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
- * (the diff basis), never a diff — a consumer computes the result-time
- * contextual diff from `before`/`after` when `before` is present, else falls
- * back to a whole-file diff.
+ * (a create) or the backend declined a contextual basis (for example, a
+ * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit).
+ * LF-normalized storage text (the diff basis), never a diff — a consumer
+ * computes the result-time contextual diff from `before`/`after` when
+ * `before` is present, else falls back to a whole-file diff.
*/
before: string | null
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts
index 5ac4e55874..8a40879a24 100644
--- a/packages/preset/agent-presets/src/authoring.ts
+++ b/packages/preset/agent-presets/src/authoring.ts
@@ -105,6 +105,8 @@ async function tightenModes(dir: string): Promise {
if (entry.isDirectory()) {
await tightenModes(target)
} else {
+ /* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state;
+ * the Windows native gate preserves the DACL while the POSIX suite covers this branch. */
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
}
}
diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts
index 9f18c18271..868751d969 100644
--- a/packages/support/llm-replay/src/index.ts
+++ b/packages/support/llm-replay/src/index.ts
@@ -24,7 +24,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
-import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
/**
* One recorded model call. `throw` may replay prefix chunks before failing;
@@ -51,6 +51,18 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
+ /**
+ * Optional per-request output cap the replay route materializes when callers
+ * omit one, so replay reconstructs the request header a live catalog produced.
+ */
+ defaultMaxTokens?: number
+ /** Optional reasoning-effort ids the replay route accepts, in display order. */
+ reasoningEfforts?: string[]
+ /**
+ * Optional effort materialized when callers omit one; must appear in
+ * {@link reasoningEfforts} or call resolution rejects the route.
+ */
+ defaultReasoningEffort?: string
}
/** One provider route exposed by the replay adapter. */
@@ -585,6 +597,19 @@ class ReplayAdapter extends LlmAdapter {
...configuredModel?.contextWindow === undefined
? {}
: { context: { contextWindow: configuredModel.contextWindow } },
+ ...configuredModel?.defaultMaxTokens === undefined
+ ? {}
+ : { defaultMaxTokens: configuredModel.defaultMaxTokens },
+ ...configuredModel?.reasoningEfforts === undefined
+ ? {}
+ : {
+ reasoning: {
+ efforts: configuredModel.reasoningEfforts.map(id => ({ id: ReasoningEffortId(id), name: id })),
+ ...configuredModel.defaultReasoningEffort === undefined
+ ? {}
+ : { defaultEffort: ReasoningEffortId(configuredModel.defaultReasoningEffort) },
+ },
+ },
})
}
diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts
index f9aa66bce5..4b163fdd95 100644
--- a/packages/support/llm-replay/tests/llm-replay.spec.ts
+++ b/packages/support/llm-replay/tests/llm-replay.spec.ts
@@ -592,8 +592,14 @@ describe('installLlmReplay (through the real LlmService)', () => {
backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
},
models: [
- { id: 'flash', contextWindow: 128_000 },
- { id: 'pro', name: 'Pro', description: 'Larger model' },
+ {
+ id: 'flash',
+ contextWindow: 128_000,
+ defaultMaxTokens: 64_000,
+ reasoningEfforts: ['off', 'max'],
+ defaultReasoningEffort: 'max',
+ },
+ { id: 'pro', name: 'Pro', description: 'Larger model', reasoningEfforts: ['high'] },
],
},
{ id: 'empty' },
@@ -611,8 +617,18 @@ describe('installLlmReplay (through the real LlmService)', () => {
await expect(ctx.llm.listModels('empty')).resolves.toEqual([])
await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({
context: { contextWindow: 128_000 },
+ defaultMaxTokens: 64_000,
+ reasoning: {
+ efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }],
+ defaultEffort: 'max',
+ },
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context')
+ // Efforts without a configured default preserve the provider's own default.
+ await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({
+ reasoning: { efforts: [{ id: 'high', name: 'high' }] },
+ })
+ await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('defaultMaxTokens')
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context')
await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context')
expect(ctx.llm.providerRetryPolicy('deepseek')).toMatchObject({
diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh
index 00a513db8f..e5f0902750 100755
--- a/scripts/prepare-ci-bubblewrap.sh
+++ b/scripts/prepare-ci-bubblewrap.sh
@@ -19,7 +19,7 @@ fi
archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
root="${RUNNER_TEMP}/dsh-bubblewrap"
-curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL"
+curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output "$archive" "$BUBBLEWRAP_URL"
printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status
mkdir -p "$root"
dpkg-deb --extract "$archive" "$root"
diff --git a/vitest.config.ts b/vitest.config.ts
index 75c1e64941..c5fed1597f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -37,15 +37,14 @@ const windowsUnsupportedPackages = process.platform === 'win32'
]
: []
-// Windows-only packages: their sources execute exclusively on win32 (koffi
-// loads Win32 libraries), so the Linux coverage lane can never cover them.
-// The Windows dev/CI lane exercises them through the probe/runner suites; the
-// per-file 100% gate must not fail on their Linux-uncovered paths.
-const windowsOnlyCoverageExclusions = process.platform !== 'win32'
- ? [
- 'packages/sandbox/sandbox-windows-acl/src/**/*.ts',
- ]
- : []
+// The ACL package is covered by native functional probes, not aggregate V8:
+// runner.ts executes in a child process and the remaining Win32 error paths
+// depend on external kernel behavior. Keeping those sources out of the
+// in-process per-file gate avoids false negatives while the native Windows
+// job remains blocking and runs the complete package test inventory.
+const sandboxIntegrationCoverageExclusions = [
+ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts',
+]
// pwsh-local's run/start/lifecycle suites self-skip without a real pwsh
// (executor.spec.ts hasPwsh), leaving this file
@@ -233,7 +232,7 @@ export default defineConfig({
'packages/interaction/commands/src/invariant.ts',
'packages/session/session-projection/src/index.ts',
...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
- ...windowsOnlyCoverageExclusions,
+ ...sandboxIntegrationCoverageExclusions,
...pwshCoverageExclusions,
],
// 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).