dsh-plugin-wsl-env 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
@@ -3,41 +3,79 @@
3
3
  The agent substrate's text mechanics (`lib/fsio-text.js`) and its provider
4
4
  orchestration messages (`lib/fs-substrate.js`, `lib/fsio-agent.js`) are a
5
5
  deliberate replica of the pinned peer's fsio module, so the model reads the
6
- same refusal dialect on both substrates. This file records the verification of
7
- that claim against the real peers, which are not imported at build time.
6
+ same refusal dialect on both substrates. The **read path** holds end to end;
7
+ the **edit path** carries two declared divergences, listed at the end. This
8
+ file records the verification against the real peers.
8
9
 
9
- - Peer: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
10
+ - Peers: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
10
11
  `package.json` pins), plus `@deepseek-ai/dsh-fs@0.2.0-rc.2` for the
11
- `FsVersion` brand the local package imports.
12
- - Method: `npm pack` both pins, extract, and compare the shipped
13
- `lib/index.js` (unminified ESM) function-by-function and string-by-string
14
- against this repository. Verified 2026-10-03.
12
+ `FsVersion` brand the local package imports. Divergence 2 is measured
13
+ against a third peer, `@deepseek-ai/dsh-bash-local@0.2.0-rc.2` — the shell
14
+ executor carries the collect-budget/spill machinery; `spill` and
15
+ `maxSpillBytes` do not appear in `dsh-fs-local` at all.
16
+ - What "not imported" means: the peer's **fsio module** is not reachable from
17
+ the package's exports, so `lib/fsio-text.js` reimplements it and imports
18
+ nothing from it (the export is proposed in `docs/UPSTREAM-FSIO-EXPORT.md`).
19
+ The peer **packages** are ordinary build-time imports that stay in the
20
+ shipped artifact — `lib/fs-routing.js` and `lib/index.js` both import
21
+ `LocalFileSystem` from `@deepseek-ai/dsh-fs-local`, and `lib/index.js` also
22
+ imports `FsError` from `@deepseek-ai/dsh-fs`. Neither dependency is
23
+ removable.
24
+ - Method: `npm pack` both pins, confirm the bytes, extract, and compare the
25
+ shipped `lib/index.js` (unminified ESM) function-by-function and
26
+ string-by-string against this repository.
27
+
28
+ ```sh
29
+ mkdir -p /tmp/peer-audit && cd /tmp/peer-audit
30
+ npm pack @deepseek-ai/dsh-fs-local@0.2.0-rc.2 @deepseek-ai/dsh-fs@0.2.0-rc.2 --pack-destination .
31
+ openssl dgst -sha512 -binary deepseek-ai-dsh-fs-local-0.2.0-rc.2.tgz | base64 -w0
32
+ tar xzf deepseek-ai-dsh-fs-local-0.2.0-rc.2.tgz # -> package/lib/index.js
33
+ wc -l package/lib/index.js # expect 909
34
+ ```
35
+
36
+ The tarballs this audit compared, by content — the same values
37
+ `npm view <pkg>@0.2.0-rc.2 dist.integrity` reports:
38
+
39
+ ```
40
+ @deepseek-ai/dsh-fs-local@0.2.0-rc.2 sha512-lDzh5VlQsXf3nI+E0+T0weYdH1VAoo+htj6Kwc62P7/Lx+xjnYSLQYTeGeaJR1d8H2iQv2FvlwaDKv60AorKdA==
41
+ @deepseek-ai/dsh-fs@0.2.0-rc.2 sha512-PuVcI7drTwa19Key54DROoqnDFTTFkl8TPlyrTnS3BSnykm+jKbB13ES/PQEJoRunNdQkNZa12jtbUI4BP/EHw==
42
+ ```
43
+
44
+ Each row of the table below names the peer's `lib/index.js` line and the
45
+ literal both sides must produce. Verified 2026-10-03; the read/edit split
46
+ re-verified 2026-10-06.
15
47
 
16
48
  ## Verdict
17
49
 
18
- **The replication is faithful.** Every load-bearing line matches the peer's
19
- shipped code; the differences are the substrate's declared extensions, listed
20
- at the end.
50
+ **The read path is faithful; the edit path differs in two declared places.**
51
+ Every load-bearing line of the read path matches the peer's shipped code. The
52
+ edit path's refusal verb and NUL-sampling bound (divergence 4) and `editText`'s
53
+ missing-file split (divergence 5) are deviations rather than unknown drift:
54
+ divergence 4 is the consequence of `readForEdit` sharing `readWholeText`, whose
55
+ guard is pinned at `test/fsio-agent.test.mjs:409`, and divergence 5 is pinned
56
+ by `test/fs-substrate.test.mjs`. The remaining differences are the substrate's
57
+ declared extensions, listed at the end.
21
58
 
22
- ## Verified identical
59
+ ## Verified identical (except where marked)
23
60
 
24
61
  | Item | Evidence (peer `lib/index.js`) | Ours |
25
62
  |---|---|---|
26
- | `normalizeLineEndings` | `content.replaceAll("\r\n", "\n")` | identical |
27
- | `detectLineEndings` | 4096-char sample; `crlf > lf - crlf` | identical |
28
- | `restoreLineEndings` | LF unchanged; CRLF re-normalizes then `\n` → `\r\n` | identical (`replaceAll` vs `split/join` — equivalent) |
29
- | occurrence counting | non-overlapping left-to-right | `split(needle).length - 1` — equivalent |
30
- | `applyLiteralEdit` | empty needle, not-found, and ambiguous refusals; single-pass `split/join` without rescanning | identical |
31
- | edit refusal strings | `old_string must be a non-empty string` (`FS_EDIT_NOT_FOUND`); ``old_string was not found in "${displayPath}"``; ``old_string matched ${n} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`` (`FS_AMBIGUOUS_EDIT`) | identical |
32
- | invalid UTF-8 | ``cannot ${verb} "${displayPath}": invalid UTF-8 text`` (`FS_NOT_TEXT`) | identical |
33
- | binary detection | 8192-byte sample, NUL check; ``cannot read "${displayPath}": binary file`` (`FS_NOT_TEXT`) | identical |
34
- | size caps | ``cannot read "${displayPath}": ${size} bytes exceeds the ${maxBytes}-byte limit`` and ``content exceeds the ${maxBytes}-byte limit`` (`FS_TOO_LARGE`) | identical |
35
- | `writeText` guards | not-regular (`FS_NOT_REGULAR_FILE`), `file no longer exists` / `file changed since it was read` (`FS_STALE_VERSION`), ``cannot overwrite existing "${displayPath}" without reading it first`` (`FS_NOT_OBSERVED`) | identical |
36
- | `editText` guards | missing file refuses with `FS_STALE_VERSION` "file changed since it was read" (NOT `FS_NOT_FOUND`); not-regular; stale-version | identical — an earlier draft of this audit suspected a divergence here; the peer matches us |
37
- | `listDir` refusal | ``cannot list "${displayPath}": not a directory`` (`FS_NOT_DIRECTORY`) | identical |
38
- | diff-basis gate | `existing !== null && Buffer.byteLength(content) < diffBasisMaxBytes` — keyed on the NEW content's size, `null` above the cap | identical — the gate's new-size keying is inherited by design, not a divergence |
39
- | outcome shapes | `operation: "update" \| "create"`, the `missing:${targetKey}` sentinel version, `before`/`after` | identical |
40
- | version join | `` `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}` `` (local `index.js:146`, branded by `dsh-fs`'s `FsVersion`) | `versionOf` — identical shape |
63
+ | `normalizeLineEndings` | `:584` `content.replaceAll("\r\n", "\n")` | identical |
64
+ | `detectLineEndings` | `:587` 4096-char sample; `crlf > lf - crlf` | identical |
65
+ | `restoreLineEndings` | `:600` LF unchanged; CRLF re-normalizes then `\n` → `\r\n` | identical (`replaceAll` vs `split/join` — equivalent) |
66
+ | occurrence counting | `:603` non-overlapping left-to-right | `split(needle).length - 1` — equivalent |
67
+ | `applyLiteralEdit` | `:698` empty needle, not-found, and ambiguous refusals; single-pass `split/join` without rescanning | identical |
68
+ | edit refusal strings | `:700`, `:703`, `:704` — `old_string must be a non-empty string` (`FS_EDIT_NOT_FOUND`); ``old_string was not found in "${displayPath}"``; ``old_string matched ${n} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`` (`FS_AMBIGUOUS_EDIT`) | identical |
69
+ | invalid UTF-8, read path | `:328`, `:331` — ``cannot ${verb} "${displayPath}": invalid UTF-8 text`` (`FS_NOT_TEXT`), verb `read` | identical (the edit path's verb differs; see 4) |
70
+ | binary detection, read path | `:369` (guard at `:373`), stream at `:457` — 8192-byte sample, NUL check; ``cannot read "${displayPath}": binary file`` (`FS_NOT_TEXT`) | identical — both accept a NUL at offset 8500, past the sample; the edit path's bound differs, see 4 |
71
+ | size caps | `:389`, `:400` — ``cannot read "${displayPath}": ${size} bytes exceeds the ${maxBytes}-byte limit`` and ``content exceeds the ${maxBytes}-byte limit`` (`FS_TOO_LARGE`) | identical |
72
+ | `writeText` guards | `:867`, `:869-870`, `:871` — not-regular (`FS_NOT_REGULAR_FILE`), `file no longer exists` / `file changed since it was read` (`FS_STALE_VERSION`), ``cannot overwrite existing "${displayPath}" without reading it first`` (`FS_NOT_OBSERVED`) | identical |
73
+ | `editText` guards — not-regular and stale version | `:887`, `:888` — ``cannot edit "${displayPath}": not a regular file`` (`FS_NOT_REGULAR_FILE`); `file changed since it was read` (`FS_STALE_VERSION`) when the expected version differs | identical |
74
+ | `editText` guards — missing file | `:886` — one refusal for every caller: `file changed since it was read` (`FS_STALE_VERSION`), with or without an expected version | **diverges by design** — we split on the caller's evidence: with an expected version `file no longer exists` (`FS_STALE_VERSION`), without one `file does not exist` (`FS_NOT_FOUND`). See 5 |
75
+ | `listDir` refusal | `:296` — ``cannot list "${displayPath}": not a directory`` (`FS_NOT_DIRECTORY`) | identical |
76
+ | diff-basis gate | `:872` — `existing !== null && Buffer.byteLength(content) < diffBasisMaxBytes` — keyed on the NEW content's size, `null` above the cap | identical — the gate's new-size keying is inherited by design, not a divergence |
77
+ | outcome shapes | `:876`, `:905` — `operation: "update" \| "create"`, the `missing:${targetKey}` sentinel version, `before`/`after` | identical |
78
+ | version join | `:145` — `` `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}` `` (branded by `dsh-fs`'s `FsVersion`) | `versionOf` — identical shape |
41
79
 
42
80
  ## Deliberate divergences (declared, not drift)
43
81
 
@@ -47,14 +85,39 @@ at the end.
47
85
  check-then-write window stops at the host; ours narrows to kernel-adjacent.
48
86
  2. **Per-stream truncation flags.** The EXEC frame carries an output budget;
49
87
  the RES line reports which streams the agent cut, surfaced as honest
50
- `truncated` flags. The peer's collect-budget machinery is the spill-file
51
- path we deliberately do not run.
88
+ `truncated` flags. The collect-budget/spill machinery of the shell peer,
89
+ `@deepseek-ai/dsh-bash-local@0.2.0-rc.2` (`lib/index.js:30`, `:110`), is the
90
+ spill-file path we deliberately do not run.
52
91
  3. **The transport around the ops** (ACK, KILL, the confined resident) is
53
92
  protocol this package owns; the peer has no agent.
93
+ 4. **The edit path's read dialect.** `readForEdit` delegates to `readWholeText`
94
+ (`src/fsio-agent.ts:526`). That is a shared guard rather than a chosen
95
+ dialect, but a divergence all the same, so it is declared: an edit refused
96
+ for its bytes reads ``cannot read "…": binary file`` or
97
+ ``cannot read "…": invalid UTF-8 text`` — verb `read`, where the peer's
98
+ `readForEdit` (`lib/index.js:621`) says ``cannot edit "…": …``. The NUL
99
+ check is likewise the read path's 8192-byte sample
100
+ (`src/fsio-agent.ts:377-384`, guard at `:380`), where the peer scans the
101
+ whole buffer (`lib/index.js:625`): a file whose first 8192 bytes are clean
102
+ but that carries a NUL later is edited by us and refused by the peer.
103
+ Measured on a 9000-byte fixture with a NUL at offset 8500 — ours `EDITED`,
104
+ the peer `FS_NOT_TEXT: cannot edit "…"`.
105
+ 5. **`editText` on a missing file splits by evidence.** An edit that carries an
106
+ expected version was grounded in a read, so a vanished file is a stale
107
+ guard (`file no longer exists`, `FS_STALE_VERSION`); an edit with no read
108
+ behind it names a file that never answered the stat, so it takes the read
109
+ path's missing-file dialect (`cannot edit "…": file does not exist`,
110
+ `FS_NOT_FOUND`). The peer throws `file changed since it was read`
111
+ (`FS_STALE_VERSION`, `lib/index.js:886`) for both. Pinned by
112
+ `test/fs-substrate.test.mjs`.
54
113
 
55
114
  ## Maintenance
56
115
 
57
- When `package.json` bumps the peer pins, re-run this comparison — extract the
58
- new tarballs and diff the regions above. The fastest canary: stat a file, list
59
- its directory, and compare the versions (`test/fsio-agent.test.mjs` asserts the
60
- intra-distro half of this); the strings above are the other half.
116
+ When `package.json` bumps the peer pins, re-run the comparison above — pack the
117
+ new tarballs, check the sha512, and walk the table's peer line references
118
+ against the replicas. The fastest canary: stat a file, list its directory, and
119
+ compare the versions (`test/fsio-agent.test.mjs` asserts the intra-distro half
120
+ of this); the strings above are the other half. The split dialect has its own
121
+ regression: `test/fs-substrate.test.mjs` asserts the missing-file refusal, and
122
+ `test/fsio-agent.test.mjs:409` pins the read-path guard that `readForEdit`
123
+ shares with `readWholeText`.
package/docs/README.zh.md CHANGED
@@ -26,24 +26,30 @@ dsh --profile wsl # 4. 启动
26
26
 
27
27
  第 3 步应该打印出名为 `# == dsh-plugin-wsl-env` 的层,并且 `- id: terminal-controller` 上应出现 `shell: { path: wsl.exe, name: WSL }`。这个包是 DSH bundle,所以第 2 步会把 [`cordis.patch.yml`](../cordis.patch.yml) 作为配置层施加进去,没有任何需要手工合并的补丁。
28
28
 
29
- 然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
29
+ **第一条命令之前,先给子系统装上 bubblewrap。** 每条命令都由 [bubblewrap](#沙箱) 约束执行,而多数子系统不预装它;没有它每条命令都会失败关闭——这是设计,绝不放行为不受限执行:
30
+
31
+ ```powershell
32
+ wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap # Debian/Ubuntu
33
+ ```
30
34
 
31
- 还需要在子系统里安装 **bubblewrap**:运行 `npm run bootstrap -- <子系统> --install`(只读检测去掉 `--install`),或直接执行 `wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
35
+ 在本插件的检出目录里,`pnpm run bootstrap -- <子系统>` 会检查插件用到的全部四个工具(bubblewrap、ripgrep、git、inotify-tools),并按子系统自己的包管理器家族打印对应的安装命令;加 `--install` 则直接执行(`bootstrap` 是包内 pnpm 脚本,需要 `pnpm` 与 `bash`)。漏了这一步它会自己找上门:distro 会话打开时插件会探测 bubblewrap 并带完整修复指引发出警告——第一条命令不必再充当发现时刻。
36
+
37
+ 然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
32
38
 
33
39
  卸载:`dsh plugin --profile wsl remove dsh-plugin-wsl-env`。升级:再执行一次同样的 `add` 命令。
34
40
 
35
- > **改了 `lib/` 下的任何文件?必须重启应用。** 运行中的进程会缓存 ES module,否则会继续用旧代码。
41
+ > **改了 `lib/` 下的任何文件?先 `pnpm run sync:windows`,再重启应用。** 应用加载的是 Windows 侧运行时镜像,不先同步,重启也只是重新载入旧代码;运行中的进程还会缓存 ES module。
36
42
 
37
43
  ## 使用
38
44
 
39
45
  把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在子系统里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
40
46
 
41
47
  - **子系统文件夹自动获得子系统环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
42
- - **命令**以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
48
+ - **命令**在子系统里由你的登录 shell 执行,所以 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效;shell 不是硬编码的 bash。默认走常驻子系统内代理(每个子系统一个长驻进程);代理不可用时(或设了 `agent: false`)同一条命令回退为一次性 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 进程。
43
49
  - **文件就是子系统的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
44
50
  - **终端**(右侧栏 → *New terminal*)在子系统里打开会话目录下的 shell。
45
51
  - **端口可见性**:子系统里监听中的端口经 `DSH_WSL_PORTS` 暴露给模型(约每 10 秒刷新);子系统内启动的 dev server 可由模型直接告知确切 URL(WSL2 的 localhost 转发是平台行为,Windows 侧直接可达)。
46
- - **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL` 和 `DSH_WSL_HOME`。
52
+ - **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL`、`DSH_WSL_HOME` 和端口快照 `DSH_WSL_PORTS`。
47
53
  - **权限与 Linux 主机一致。** 权限选择器在 `read-only`、`workspace-write`(默认)和 `danger-full-access` 之间切换。被拒绝的命令或写入会带回"放宽权限"的提议;批准后,这一次调用不加沙箱执行。
48
54
 
49
55
  ## 配置
@@ -54,6 +60,7 @@ dsh --profile wsl # 4. 启动
54
60
  |---|---|---|---|
55
61
  | `wsl-shell` | `distro` | `''` | 子系统名;空表示用 WSL 的默认子系统 |
56
62
  | | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
63
+ | | `maskWindowsDrive` | `false` | 用空 tmpfs 遮蔽 `/mnt`,收窄 interop 洞(`wsl-fs`、`fs-routing` 上同名);只是收窄、不是关闭,见[沙箱](#沙箱) |
57
64
  | `wsl-fs` | `distro` | `''` | 同上 |
58
65
  | | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
59
66
  | | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
@@ -61,14 +68,16 @@ dsh --profile wsl # 4. 启动
61
68
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
62
69
  | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
63
70
 
71
+ `wsl-shell` 与 `wsl-fs` 恰恰是**按 id 覆盖不到**的两行:它们是 `wsl` agent preset 的插件,嵌在 `preset-wsl` 行的 `config.plugins` 里,所以在自己的层里写 `- id: wsl-shell` 不会生效——loader 只会警告 `patch: entry "wsl-shell" not found`,实际值仍是随包的那份。要改就整块改写 `preset-wsl` 行;配方见 [docs/CONFIGURATION.md](CONFIGURATION.md#overriding-the-rows-inside-preset-wsl)。
72
+
64
73
  [`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
65
74
 
66
75
  ## 配方
67
76
 
68
77
  - **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
69
78
  `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
70
- (路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
71
- - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep、git、inotifywait(搜索、快照与监视的后端)是否就位。
79
+ (路径按 Windows 侧 Git 的安装位置调整。)
80
+ - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`pnpm run bootstrap <子系统>` 会一并报告 ripgrep、git、inotifywait(搜索、快照与监视的后端)是否就位。
72
81
  - **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
73
82
 
74
83
  ## 架构
@@ -81,6 +90,9 @@ dsh --profile wsl # 4. 启动
81
90
  │ 子系统目录会话 → 子系统 shell,落在会话的 Linux 目录
82
91
  │ Windows 目录会话 → powershell.exe,落在会话自己的目录
83
92
  ├─ directory-picker-wsl 在 Windows 家目录旁列出已安装的子系统
93
+ ├─ workspace-files-wsl GUI 文件树与预览由子系统内提供,供子系统工作区使用
94
+ ├─ fs-routing 根级文件系统,按坐标路由
95
+ │ 子系统 UNC → 常驻代理;盘符路径 → 宿主后端
84
96
  ├─ wsl-shell-env 向模型暴露 DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS
85
97
  └─ auto-preset 会话打开子系统目录时自动绑定 wsl preset
86
98
 
@@ -89,9 +101,9 @@ preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
89
101
  └─ wsl-fs ctx.fs — 子系统的真实文件;常驻代理基座落在 ext4
90
102
  ```
91
103
 
92
- **为什么分两层。** `wsl-shell` 和 `wsl-fs` 位于 `wsl` agent preset 内,`auto-preset` 在会话工作区位于子系统内时自动绑定它:Windows 目录的会话继续使用原生 provider,子系统会话拿到 WSL provider——环境是会话的属性,而不是进程的。终端控制器是例外:它经根上下文解析执行世界,永远看不到 preset 的 isolate realm,所以 `subprocess-wsl` 必须挂在组合层。
104
+ **为什么分两层。** `wsl-shell` 和 `wsl-fs` 位于 `wsl` agent preset 内,`auto-preset` 在会话工作区位于子系统内时自动绑定它:Windows 目录的会话继续使用原生 provider,子系统会话拿到 WSL provider——环境是会话的属性,而不是进程的。终端控制器是例外:它经根上下文解析执行世界,永远看不到 preset 的 isolate realm,所以 `subprocess-wsl` 必须挂在组合层。反过来,`workspace-files-wsl` 与 `fs-routing` 挂在组合层是同样的道理:GUI 文件树与根级 `ctx.fs` 没有会话,无法从 preset 内提供服务。二者直接替换随包的 `workspace-files` 与根级 `fs-sandbox` 行;根级路由的设计见 [docs/root-fs-routing.md](root-fs-routing.md)。
93
105
 
94
- **命令与文件。** 命令以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <登录 shell> -lc <命令>` 执行,外层是子系统内的 bubblewrap profile,参数与 DSH 自家 Linux runner 完全一致——约束语义与报错文案都和 Linux 主机相同。文件工具读写子系统的真实文件,基座只有常驻子系统内代理一种——读写与路径解析落在 ext4,原生符号链接与权限位;文件搜索的 spawn 以同样的方式改写:子系统工作区的搜索跑子系统自己的 `rg`(`wsl.exe --exec`),绝不让 Windows 侧二进制走 9p 共享。写入与命令沙箱使用同一份策略检查。
106
+ **命令与文件。** 默认情况下命令走常驻子系统内代理——每个子系统一个长驻进程,cwd 随请求下发,不再逐命令 `--cd`。它执行的仍是 `<登录 shell> -lc <命令>`,外层是子系统内的 bubblewrap profile,参数与 DSH 自家 Linux runner 完全一致——约束语义与报错文案都和 Linux 主机相同;代理不可用时(或设了 `agent: false`)回退为一次性 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <登录 shell> -lc <命令>` 进程。文件工具读写子系统的真实文件,基座只有常驻子系统内代理一种——读写与路径解析落在 ext4,原生符号链接与权限位;文件搜索的 spawn 以同样的方式改写:子系统工作区的搜索跑子系统自己的 `rg`(`wsl.exe --exec`),绝不让 Windows 侧二进制走 9p 共享。写入与命令沙箱使用同一份策略检查。
95
107
 
96
108
  ## 沙箱
97
109
 
@@ -103,9 +115,11 @@ preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
103
115
  | `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
104
116
  | `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
105
117
 
106
- **bubblewrap 是必需项,缺失即失败关闭。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
118
+ **bubblewrap 是必需项,缺失即失败关闭。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束:把 `sandbox` 设为 `false` 的行要选对——命令归 `wsl-shell`,写入栅栏归 `wsl-fs`,根级路由的 `fs-routing` 行另有自己的一份;注意前两行要经 `preset-wsl` 整块改写,按 id 改不到(见[配置](#配置))。工具层会如实告诉模型这些操作没有沙箱。
119
+
120
+ **上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`pnpm run probe:sandbox` 会在你的机器上演示这条边界。
107
121
 
108
- **上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
122
+ `maskWindowsDrive`(在 `wsl-shell`、`wsl-fs` 与 `fs-routing` 三个行上,默认 `false`)是这套 profile 能给出的唯一收窄手段:它为每个受限 profile 用空 tmpfs 遮蔽 `/mnt`,Windows 磁盘上的文件读不到也带不走,其上的可执行文件也无法经 interop 启动;但这只是收窄而非关闭——命令仍可把可执行文件写进工作区再运行(`binfmt` 按内容分发),所以 enforcement 仍是 `partial`,结果里如实上报 `windowsDrive: "masked"`。完全关闭是子系统级的:`wsl.conf` 里的 `[interop] enabled=false`,见 [docs/CONFIGURATION.md](CONFIGURATION.md)。
109
123
 
110
124
  设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
111
125
 
@@ -113,39 +127,43 @@ preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
113
127
 
114
128
  | 现象 | 原因 | 处理 |
115
129
  |---|---|---|
116
- | 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap` | 执行 `npm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false` |
130
+ | 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap`(或存在但不可用,错误消息会区分这两种情况并给出对应处置) | 按 `wsl-shell`(命令)与 `wsl-fs`(写入)错误消息里的指引安装,或 `pnpm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false`(根级路由的 `fs-routing` 行另有自己的一份;前两行怎么改见[配置](#配置)) |
117
131
  | 命令或写入在会话目录之外被拒绝 | `workspace-write` 的预期行为 | 接受工具给出的放宽权限提示,或把会话直接开在需要的目录上 |
118
132
  | 连工作区内的写入也被拒绝 | 会处在 `read-only` 模式 | 切换权限选择器 |
119
- | `dsh plugin add` 提示"没有激活任何层" | 依赖之前已经装过,`add` 没有需要记录的内容 | 先执行 `dsh plugin --profile wsl remove dsh-plugin-wsl-env`,再装一次 |
133
+ | 重复执行 `dsh plugin add` 没有任何输出,`--dump-config` 里也没有变化 | 依赖之前已经装过;`add` 对已存在的依赖是静默 no-op,没有需要记录的内容 | 无需任何操作——层已经就位,`dsh --profile wsl --dump-config` 仍能看到 `# == dsh-plugin-wsl-env` |
120
134
  | 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
121
- | 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
135
+ | 改了 `lib/` 但不生效 | 应用加载的是 Windows 侧运行时镜像,且运行中的进程会缓存 ES module | 先 `pnpm run sync:windows` 同步到镜像,再重启应用 |
122
136
  | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
123
- | `glob`/`grep` 很慢 | 子系统内没有 rg 时,搜索只能报 rg 自己的 "command not found";Windows 目录的搜索走宿主原生 rg,不受影响 | 运行 `npm run bootstrap -- <子系统> --install`(安装 ripgrep);子系统工作区的搜索始终跑子系统内的 rg,绝不遍历 9p 共享 |
137
+ | `glob`/`grep` 很慢 | 子系统内没有 rg 时,搜索只能报 rg 自己的 "command not found";Windows 目录的搜索走宿主原生 rg,不受影响 | 运行 `pnpm run bootstrap -- <子系统> --install`(安装 ripgrep);子系统工作区的搜索始终跑子系统内的 rg,绝不遍历 9p 共享 |
124
138
  | 终端活动显示 `unknown` | 仅在常驻代理不可用时出现——distro 终端从子系统内部观测(`/proc` 中扫描 `DSH_TERMINAL_ID` 标记:shell 独处为 `idle`,运行任何命令为 `busy`) | 确认子系统在运行;空闲终端会在控制器的无人值守超时(默认 2 小时)后自动回收,`terminalIdleReclaim: false` 恢复手动关闭 |
125
139
  | 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
126
140
 
127
141
  ## 开发
128
142
 
129
143
  ```bash
130
- npm test # 风格与打包检查、语法检查、单元测试
131
- npm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
132
- npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
133
- npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
134
- npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
135
- npm run probe:terminal # 通过终端 provider 打开一个 PTY
136
- npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
137
- npm run probe:watch # 在真实目录上装上 distro 内轮询 watcher(在子系统内运行)
138
- npm run probe:agent # resident 与 one-shot 的回退不变量对比腿(在子系统内运行)
139
- npm run probe:exec # agent 执行句柄:超时、杀停、cwd 失败(在子系统内运行)
140
- npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
141
- npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
142
- npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
143
- npm run probe:sandbox-off # 验证 sandbox: false 确实解除两侧约束(仅 Windows + WSL)
144
+ pnpm run build # 从 src/ 生成 lib/(产物提交进仓,改 src/ 后必须重新构建)
145
+ pnpm test # 风格、语法、构建一致性、类型检查与单元测试
146
+ pnpm run sync:windows # 把 lib/ 同步到 Windows 侧运行时镜像(改 lib/ 后必跑,再重启应用)
147
+ pnpm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
148
+ pnpm run diagnose # 只读诊断报告(可直接贴进 issue):版本、工具、bwrap 探测
149
+ pnpm run bootstrap -- <子系统> # 检查四个子系统侧工具;加 --install 安装缺失项
150
+ pnpm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
151
+ pnpm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
152
+ pnpm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
153
+ pnpm run probe:terminal # 通过终端 provider 打开一个 PTY
154
+ pnpm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
155
+ pnpm run probe:watch # 在真实目录上装上 distro 内轮询 watcher(在子系统内运行)
156
+ pnpm run probe:agent # resident 与 one-shot 的回退不变量对比腿(在子系统内运行)
157
+ pnpm run probe:exec # agent 执行句柄:超时、杀停、cwd 失败(在子系统内运行)
158
+ pnpm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
159
+ pnpm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
160
+ pnpm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(需 Windows node 与已安装的 harness,不启动 profile)
161
+ pnpm run probe:sandbox-off # 验证 sandbox: false 确实解除两侧约束(仅 Windows + WSL)
144
162
  ```
145
163
 
146
- 这个包没有依赖,被测模块只 import Node 内置模块,所以不需要先安装任何东西。CI 在 Linux 和 Windows 上用 Node 20、22、24 运行 `npm test`,并在 Node 24 上运行 `npm run test:coverage`。
164
+ 应用加载的是 Windows 侧运行时镜像,不是仓库检出:改了 `lib/` 要先 `pnpm run sync:windows` 同步过去,再重启应用才会生效(`pnpm run build` 负责从 `src/` 生成 `lib/`,`pnpm test` 里的构建一致性检查会校验它)。首次运行前先 `pnpm install` 一次,复原钉版的开发依赖:测试会 import 钉版的 `@deepseek-ai/*` 包(组合与启动测试跑的是 loader 的真实 patch 算法),运行时包本身仍然保持零依赖。pnpm 由 `packageManager` 字段解析,任何开了 corepack 的 Node 都能直接用。CI 在 Linux 和 Windows 上用 Node 22、24 运行 `pnpm test`,并在 Node 24 上运行 `pnpm run test:coverage`;`engines` 与 harness 宿主自身的下限(`^22.19.0 || >=24.0.0`)保持一致。
147
165
 
148
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) 有文件布局、挂载方式和沙箱设计;[CONTRIBUTING.md](../CONTRIBUTING.md) 有开发循环、完整质量门清单和已验证内容。
166
+ [docs/ARCHITECTURE.md](ARCHITECTURE.md) 有文件布局、挂载方式和沙箱设计;[CONTRIBUTING.md](../CONTRIBUTING.md) 有开发循环、运行时镜像、完整质量门清单和已验证内容。
149
167
 
150
168
  ## 文档
151
169
 
@@ -1,54 +1,90 @@
1
- # Upstream proposal: export the fsio module from `dsh-fs-local`
1
+ # Upstream proposal: export the fsio text helpers from `dsh-fs-local`
2
2
 
3
- Status: draft, targeting `deepseek-harness` @ `packages/fs/fs-local` (version
4
- line `0.2.0-rc.x`). This is the Shape-A upstream half of the in-distro I/O
5
- refactor: with the fsio module exported, `dsh-plugin-wsl-env` deletes its
6
- in-plugin replicas (`lib/fsio-text.js`) and imports the originals.
3
+ Status: draft, not yet filed upstream — no discussion link yet (the filing
4
+ convention is [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md)).
5
+ Targeting `deepseek-harness` @ `packages/fs/fs-local`, the `0.2.0-rc.x` line
6
+ this plugin pins exactly (`@deepseek-ai/dsh-fs-local@0.2.0-rc.2`, in both the
7
+ dev and peer dependency blocks of `package.json`). This is the Shape-A
8
+ upstream half of the in-distro I/O refactor: with the fsio text mechanics
9
+ importable, `dsh-plugin-wsl-env` deletes its copies of them.
7
10
 
8
11
  ## The change
9
12
 
10
- The fsio module already exists, is fully documented, and is pure (no peers
11
- beyond types). It is only not reachable: `package.json` exports `"."` and
12
- `"./package.json"`, and `lib/index.js` does not re-export the helpers. Two
13
- lines of surface, no behaviour change:
13
+ The fsio module already exists and is fully documented in the shipped
14
+ declarations (`lib/types/fsio.d.ts`). Two facts keep it out of reach:
14
15
 
15
- ```diff
16
- --- a/packages/fs/fs-local/package.json
17
- +++ b/packages/fs/fs-local/package.json
18
- @@
19
- "exports": {
20
- ".": {
21
- "types": "./lib/types/index.d.ts",
22
- "default": "./lib/index.js"
23
- },
24
- + "./fsio": {
25
- + "types": "./lib/types/fsio.d.ts",
26
- + "default": "./lib/fsio.js"
27
- + },
28
- "./src/*": "./src/*",
29
- "./package.json": "./package.json"
30
- },
31
- ```
16
+ - `package.json` exports exactly three entries — `"."`, `"./src/*"` and
17
+ `"./package.json"` (lines 16–23). `"./src/*"` is not a way in: `files` is
18
+ `["lib/index.js", "lib/types/**/*.d.ts"]` (lines 24–27), so the sources are
19
+ in the repository but not in the tarball (`npm pack --dry-run` lists nine
20
+ files, none of them under `src/`).
21
+ - The published runtime is one bundled file. The fsio code is a region of
22
+ `lib/index.js` (`//#region lib/types/fsio.js`, line 90) — there is no
23
+ `lib/fsio.js` — and that file's only export statement is
24
+ `export { LocalFileSystem, LocalFileSystem as default };` (line 909).
25
+
26
+ So the reachable seam is the package entry, not a new subpath: re-export the
27
+ four text helpers from it, in the runtime and in the declarations.
28
+
29
+ (A `"./fsio"` subpath does not work as-is — the runtime file it would name does
30
+ not exist. Pointing its `types` at `lib/types/fsio.d.ts` instead would publish
31
+ the whole fsio surface — `readWholeText`, `readWholeBytes`, `readByteWindow`,
32
+ `streamWholeText`, `writeFileAtomic`, `probe`, `probeNoFollow`,
33
+ `listDirectory`, `resolveLocalTarget`, `readForEdit`, `readTextForDiff`, … —
34
+ while the runtime side offered four names. Types and runtime must promise the
35
+ same surface.)
32
36
 
33
37
  ```diff
34
38
  --- a/packages/fs/fs-local/lib/index.js
35
39
  +++ b/packages/fs/fs-local/lib/index.js
36
- @@
40
+ @@ -906,4 +906,11 @@
41
+ }
42
+ };
43
+ //#endregion
37
44
  -export { LocalFileSystem, LocalFileSystem as default };
38
45
  +export {
39
- + LocalFileSystem,
40
- + LocalFileSystem as default,
41
- + applyLiteralEdit,
42
- + detectLineEndings,
43
- + normalizeLineEndings,
44
- + restoreLineEndings,
46
+ + LocalFileSystem,
47
+ + LocalFileSystem as default,
48
+ + applyLiteralEdit,
49
+ + detectLineEndings,
50
+ + normalizeLineEndings,
51
+ + restoreLineEndings,
45
52
  +};
46
53
  ```
47
54
 
48
- (The module layout differs between the repo's `src/` and the built `lib/`;
49
- the export list is what matters. `readForEdit` and `readTextForDiff` stay
50
- internal — they are I/O orchestration, which the plugin drives against its own
51
- substrate — only the pure text mechanics are needed.)
55
+ ```diff
56
+ --- a/packages/fs/fs-local/lib/types/index.d.ts
57
+ +++ b/packages/fs/fs-local/lib/types/index.d.ts
58
+ @@ -66,4 +66,5 @@
59
+ private versionAfterWrite;
60
+ }
61
+ export default LocalFileSystem;
62
+ +export { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from './fsio.ts';
63
+ //# sourceMappingURL=index.d.ts.map
64
+
65
+ ```
66
+
67
+ Both files are generated; the change itself belongs in the sources that emit
68
+ them (`src/index.ts` re-exporting from the fsio module, and that module
69
+ exporting `detectLineEndings`). The artifact diffs are shown because they are
70
+ what a consumer resolves — and they apply as-is to the published `0.2.0-rc.2`
71
+ files.
72
+
73
+ `detectLineEndings` is the one genuinely new name: it is not in
74
+ `lib/types/fsio.d.ts` at all — in the bundle it is a non-exported function
75
+ (`function detectLineEndings(raw)`, line 587). Declaring it is a
76
+ `declare function detectLineEndings(raw: string): LineEndings;` beside its two
77
+ siblings, plus the name in the export list at `fsio.d.ts:223` (`LineEndings`
78
+ itself is already exported, `:166`). The other three are already declared
79
+ there: `normalizeLineEndings` (`:173`), `restoreLineEndings` (`:182`),
80
+ `applyLiteralEdit` (`:219`), with the first two exported at `:223`. If upstream
81
+ would rather not widen the surface at all, leave `detectLineEndings` out and the
82
+ consumer keeps its four-line local copy.
83
+
84
+ `readForEdit` and `readTextForDiff` are not part of this. They are exported by
85
+ the fsio module (`fsio.d.ts:191`, `:207`), but they are I/O orchestration —
86
+ which the plugin drives against its own substrate — so the proposal simply does
87
+ not re-export them.
52
88
 
53
89
  ## Why the plugin needs it
54
90
 
@@ -56,27 +92,100 @@ substrate — only the pure text mechanics are needed.)
56
92
  `lib/fs-substrate.js`) re-runs fsio's orchestration inside a WSL distro: reads,
57
93
  writes, identities and edits execute where the files live, and the host side
58
94
  keeps only the validation and edit mechanics. Those mechanics —
59
- `applyLiteralEdit`, the line-ending trio, the binary NUL sample, the fatal UTF-8
60
- decode — are replicated today (`lib/fsio-text.js`) because the package does not
61
- export them. The replica is byte-for-byte faithful, including error messages,
62
- because the tool layer matches on both the code and the text the model reads;
63
- but a replica is a fork surface: every upstream change to the edit algorithm
64
- has to be re-ported by hand.
95
+ `applyLiteralEdit`, the line-ending trio, the binary NUL sample, the fatal
96
+ UTF-8 decode — are replicated today in `src/fsio-text.ts` (built to
97
+ `lib/fsio-text.js`) because the package does not export them. The replica is
98
+ byte-for-byte faithful, including error messages, because the tool layer
99
+ matches on both the code and the text the model reads — and that faithfulness
100
+ is verified, not assumed: [PEER-PARITY.md](PEER-PARITY.md) records the
101
+ function-by-function and string-by-string comparison against the pinned peer's
102
+ shipped code (verified 2026-10-03; the read/edit split re-verified
103
+ 2026-10-06). But a replica is a fork surface: every upstream change to the edit
104
+ algorithm has to be re-ported by hand.
65
105
 
66
- With the export in place the plugin deletes `lib/fsio-text.js` and imports:
106
+ With the export in place the plugin imports the four functions and deletes its
107
+ copies:
67
108
 
68
109
  ```js
69
- import { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from "@deepseek-ai/dsh-fs-local/fsio";
110
+ import { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from "@deepseek-ai/dsh-fs-local";
70
111
  ```
71
112
 
113
+ The rest of the module stays. `BINARY_SAMPLE_BYTES`, `FsCodedError`,
114
+ `notTextError`, `decodeUtf8`, `decodeUtf8Stream` and `throwIfAborted`
115
+ (`src/fsio-text.ts:23`, `:30`, `:53`, `:67`, `:86`, `:179`) have no counterpart
116
+ in the fsio declarations, and the module's other consumers keep importing them
117
+ locally (`src/fsio-agent.ts:27-36`, `src/fs-substrate.ts:37`,
118
+ `src/index.ts:57`). `lib/fsio-text.js` is not deleted; four of its ten exports
119
+ are.
120
+
121
+ Importing from the entry costs this consumer nothing new: it already imports
122
+ `LocalFileSystem` from the same entry (`src/index.ts:45`,
123
+ `src/fs-routing.ts:35`) and `@deepseek-ai/dsh-fs` (`src/index.ts:46`). The
124
+ entry's non-builtin static imports are `chokidar` (`lib/index.js:3`),
125
+ `@deepseek-ai/schemastery` (`:6`) and the `@deepseek-ai/dsh-fs` peer (`:7`) —
126
+ all three already in this consumer's graph; `koffi` loads lazily (`await
127
+ import("koffi")`, `:25`), so no native dependency is evaluated at import time.
128
+
72
129
  ## Why this is safe upstream
73
130
 
74
- - No new API surface beyond re-exporting functions that already exist and are
75
- documented in `lib/types/fsio.d.ts`.
131
+ - Additive: three of the four names are already declared in the shipped
132
+ `lib/types/fsio.d.ts`; `detectLineEndings` is one new pure function the edit
133
+ path already calls (`readForEdit`, `lib/index.js:629` — the class reaches it
134
+ through `editText`).
76
135
  - No behaviour change: the class's own call sites are untouched.
77
- - The functions are pure (text in, text out, structured errors), so exporting
78
- them cannot widen the I/O contract.
79
- - Semver: additive export, a minor bump.
136
+ - The functions do no I/O and hold no state — text in, text out, structured
137
+ errors — so exporting them cannot widen the I/O contract.
138
+ - They are pure but **not peer-free**: the module they live in imports
139
+ `FsError`, `FsTargetKey` and `FsVersion` from the `@deepseek-ai/dsh-fs` peer
140
+ (`lib/index.js:7`) and uses them throughout (`FsError` thrown at `:700`,
141
+ `:703`, `:704`; `FsTargetKey` at `:175`, `:196`, `:207`; `FsVersion` at
142
+ `:146`), so importers of these helpers take that peer at runtime. They do not
143
+ take cordis — the bundle imports none. The plugin accepts the peer
144
+ dependency: both packages are already runtime imports of it, and its tool
145
+ layer maps the `code` token and the message, not the error class.
146
+ - Semver: an additive export. On the `0.2.0-rc.x` line it ships in the next
147
+ pre-release — the `0.2` position does not move, so this is not a minor bump
148
+ of a released line; and because the consuming plugin pins the exact version,
149
+ adoption is an explicit pin bump, never an automatic upgrade.
150
+
151
+ ## Landing it (consumer side)
152
+
153
+ 1. Bump both pins in `package.json` (`devDependencies` and `peerDependencies`)
154
+ to the peer that carries the export.
155
+ 2. `pnpm run build` — `lib/` is committed, and `test/build-freshness.mjs`
156
+ enforces that it is a byte-identical, closed image of `src/`.
157
+ 3. Re-run the comparison in [PEER-PARITY.md](PEER-PARITY.md) (its Maintenance
158
+ section) against the new pin before trusting anything else.
159
+ 4. Delete the four replicas and point the call sites at the peer, then
160
+ `pnpm test`.
161
+
162
+ Files that move with it:
163
+
164
+ - `src/fsio-text.ts` — drop `applyLiteralEdit`, `detectLineEndings`,
165
+ `normalizeLineEndings`, `restoreLineEndings`; keep the rest.
166
+ - `src/fsio-agent.ts` (imports `:27-36`, `detectLineEndings` used at `:528`)
167
+ and `src/fs-substrate.ts` (`:37`) — take the four from the peer. Where they
168
+ land decides the module's peer story: importing them directly keeps
169
+ `src/fsio-text.ts` peer-free — and `test/fsio-text.test.mjs`, which imports
170
+ nothing but node builtins and the local module (`:9-18`), runnable without
171
+ the peer — while re-exporting them through it does not. Either way the
172
+ "peer-free" self-descriptions in `src/fsio-agent.ts:14-16` and
173
+ `src/fs-substrate.ts:25` need a pass.
174
+ - `lib/*` — the rebuilt artifacts.
175
+ - `test/fsio-text.test.mjs` (the replica's tests, including the
176
+ `detectLineEndings` checks at `:69-72`) and `test/fsio-agent.test.mjs:22`,
177
+ which imports two of the four from `lib/fsio-text.js`. If that test file is
178
+ dropped or renamed, also the `test:coverage` and `test:unit` lists at
179
+ `package.json:114` and `:116`.
180
+ - The documents that describe the replica as current: `docs/ARCHITECTURE.md:173`
181
+ and `:416` (the two entries marked "replicated … pending the … export"),
182
+ `docs/PEER-PARITY.md:3` and `:17` (plus its Maintenance section),
183
+ `docs/upstream/promotion-map.md:82` — and this file, whose status then becomes
184
+ "landed".
185
+
186
+ Done when: the pinned peer resolves the four names from both its runtime entry
187
+ and its declarations, the plugin imports them there, `pnpm test` passes, and no
188
+ document still calls `lib/fsio-text.js` a replica of them.
80
189
 
81
190
  ## Follow-up (separate proposal, not required)
82
191