dsh-plugin-wsl-env 0.1.10 → 0.3.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.
@@ -3,9 +3,69 @@
3
3
  What the plugin does not do, and why. A limitation that is documented is a design
4
4
  boundary; one that is not is a bug report waiting to happen.
5
5
 
6
+ ## The filesystem substrate (`wsl-fs` `substrate`)
7
+
8
+ `substrate: "agent"` (the default) serves the file tools through the resident
9
+ in-distro agent, on ext4. `substrate: "share"` is the opt-out: the Windows-side
10
+ host stack over the distro's 9p share. What differs:
11
+
12
+ - **A new file publishes with mode 0600 on the agent substrate.** That is the
13
+ host backend's own POSIX publication semantics (a private 0600 staging file,
14
+ renamed into place), which a Linux-host DSH session has always had. The share
15
+ substrate was the outlier: the share drops mode sets, so new files took the
16
+ distro's umask default (0644). Overwrites and edits keep the original bits on
17
+ both substrates. Run `chmod` (or ask the model to) when a new file must be
18
+ executable.
19
+ - **There is no silent fallback to the share.** When the agent substrate's
20
+ resident process is out — the distro stopped, or the agent crashed past its
21
+ one rebuild — file operations fail with `FS_IO_ERROR` naming the substrate,
22
+ rather than quietly degrading to share semantics that cannot follow symlinks
23
+ or keep mode bits. The share substrate dies with the distro anyway; the
24
+ agent only adds its own failure mode, which is the one honest way to fail.
25
+ - **The mutation guard survives to the write.** `createIfAbsent` publishes with
26
+ a no-replace link inside the distro, which closes the check-then-write window
27
+ for creates outright. An overwrite or edit carries the version it was based
28
+ on into the write op, and the agent re-verifies it against a fresh stat one
29
+ syscall before the rename: a concurrent writer wins and the stale write
30
+ refuses with `FS_STALE_VERSION`. That narrows the share's window from a host
31
+ round trip to kernel-adjacent — the residual stat-then-rename pair is the
32
+ same sliver a Linux-host session's own rename has.
33
+ - **Stage two routes mutations through a confined resident.** A write or edit
34
+ under a confined policy runs on its own long-lived agent, spawned inside the
35
+ bwrap profile that binds exactly what the mode grants — so the kernel, not
36
+ just the host-side check, refuses a write outside the workspace, and the
37
+ refusal carries `FS_SANDBOX_DENIED` (the command path's dialect). Costs to
38
+ know: `bubblewrap` becomes required for confined writes (a distro without it
39
+ refuses them with the bootstrap command, rather than downgrading); a policy
40
+ change addresses a different resident, so the first write after switching the
41
+ Permissions selector pays one agent startup; and a write the policy grants to
42
+ the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
43
+ — the same hand-off rule as the commands' `/tmp`: pass files through the
44
+ session workspace.
45
+ - **`resolveSymlinks` is not consulted.** The share cannot traverse Linux
46
+ symlinks, so the share substrate retries missing reads through the distro's
47
+ canonical path; the agent substrate never misses them, so the config key has
48
+ no effect there.
49
+ - **`glob` and `grep` are unchanged on both substrates.** They are tool-layer
50
+ searches running the packaged Windows ripgrep against the share — correct
51
+ results, slow, and `.gitignore` interpreted with Windows rules. Bringing them
52
+ in-distro is future work, not part of the substrate switch.
53
+ - **`watch()` is the same poll loop on both substrates** (a `find -newer` scan
54
+ inside the distro), so the coarse-invalidation caveats below apply equally.
55
+ An inotify backend is planned, not shipped.
56
+
57
+ Everything below applies to the share substrate unless it says otherwise.
58
+
6
59
  - **The sandbox does not govern WSL interop.** A confined command can still run a
7
60
  Windows program, which escapes the Linux-side boundary. The plugin reports this as
8
61
  `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
62
+ The `maskWindowsDrive` key on `wsl-shell` and `wsl-fs` narrows the hole: the
63
+ confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be
64
+ read or exfiltrated and its executables cannot be launched. It is not a closure — a
65
+ command that can write the workspace can write an executable there and run it
66
+ (binfmt interop dispatches on file content, not location) — so enforcement stays
67
+ `partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and the only
68
+ complete closure remains distro-level (`[interop] enabled=false` in `wsl.conf`).
9
69
  - **`bubblewrap` must be installed**, or both providers fail immediately. The
10
70
  usability probe re-runs after a failure, so installing bubblewrap while the app
11
71
  runs is believed on the next command; only a success is cached.
@@ -31,13 +91,18 @@ boundary; one that is not is a bug report waiting to happen.
31
91
  checks the caller's guard itself, because the Windows backend publishes a guarded
32
92
  new file with a hard link, and the share does not support hard links. This is the
33
93
  race that `dsh-fs-sandbox` already documents.
34
- - `watch()` (file change monitoring) is not provided. Watching a 9p share is
35
- unreliable, so the plugin refuses instead of arming it.
94
+ - `watch()` observes from inside the distro (a `find -newer` poll loop, see
95
+ `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
96
+ and they arrive on the poll cadence, not instantly. A file created with an mtime
97
+ older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
98
+ else touches the tree. Watching a 9p share from the Windows side remains
99
+ unsupported — the loop runs where the kernel can actually report change.
36
100
  - `glob` and `grep` use the Windows-side ripgrep over the share: correct results, but
37
101
  not fast, and `.gitignore` is interpreted with Windows rules. `editText` reads the
38
102
  whole file into memory before writing it back.
39
- - The terminal belongs to the whole app, not to one session. A session opened on a
40
- Windows folder still gets the distro terminal, starting in `/mnt/<drive>/...`, and
41
- its shell menu is deliberately reduced to the one configured profile.
103
+ - Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
104
+ default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
105
+ `powershell.exe` in its own directory. The shell MENU remains the single configured
106
+ profile, and a directory-less launch still lands in the distro.
42
107
  - Terminal activity reporting stops at `wsl.exe`, so the controller never reclaims
43
108
  these terminals when they are idle.
package/docs/PARITY.md ADDED
@@ -0,0 +1,70 @@
1
+ # Parity with VS Code Remote-WSL
2
+
3
+ What parity means for this plugin, the seam contracts the plan was audited
4
+ against, and the phase checklist. Every phase below ends with rows of this
5
+ table ticked and this document updated in the same change.
6
+
7
+ ## What parity means here
8
+
9
+ Remote-WSL's experience rests on one thing: a resident server inside the
10
+ distro. Everything users love — native ext4 semantics, inotify, native
11
+ `rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
12
+ `wsl.exe` model can only approach it. The plan therefore lands a resident
13
+ in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
14
+ current paths as the documented fallback (`wsl.agent: 'off'` must equal
15
+ today's behaviour byte for byte).
16
+
17
+ Not applicable, recorded so the gap is a decision and not an omission:
18
+
19
+ - IntelliSense, debuggers, the extension marketplace: editor features. The
20
+ DSH analogue is tool latency and correctness (Phases 1–3).
21
+ - Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
22
+ responsibilities, not this plugin's.
23
+
24
+ ## Contract audit (Phase 0)
25
+
26
+ Findings from reading the harness packages inside the app bundle; each one
27
+ shaped a phase of the plan.
28
+
29
+ 1. **`ctx.fs.watch` is a coarse invalidation callback, not an event stream.**
30
+ `FileSystem.watch(target, changed, signal)` (dsh-fs) resolves once
31
+ observation is active and returns an async close function; `changed` takes
32
+ no event payload. So the in-distro watcher does not need to classify
33
+ rename vs. modify — any inotify (or polling) hit may call `changed()`.
34
+ Phase 1's watcher is accordingly simpler than planned.
35
+ 2. **Search is not an `fs` seam.** `dsh-tool-fs-search` spawns the PACKAGED
36
+ ripgrep binary (`@vscode/ripgrep`) through `ctx.subprocess` with a plain
37
+ argv vector and no shell; raw stdout is parsed by the tool. The correct
38
+ interception point is therefore the plugin's `subprocess-wsl` subclass:
39
+ when `argv[0]` is the packaged rg binary and the spec's cwd is inside this
40
+ distro's share, rewrite the spawn to run a distro-side `rg` (falling back
41
+ to `grep -rE` + `find` when absent) with identical stdout conventions.
42
+ Under the agent, that exec rides the agent's `exec` capability.
43
+ 3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
44
+ this plugin replaces: chokidar on 9p is the unreliability that made
45
+ `WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
46
+ 4. **`dsh-bash-local` carries the timeout machinery** (`clampTimeout`,
47
+ `deadline`, `timeoutOf`, `BASH_TIMEOUT`), which the WSL executor already
48
+ inherits. Migrating exec onto the agent keeps that shape: the deadline
49
+ arms on the host as today, but the SIGTERM→SIGKILL grace fires inside the
50
+ distro via the agent instead of against the Windows process tree.
51
+
52
+ ## Phase checklist
53
+
54
+ | Phase | Deliverable | Parity row |
55
+ | --- | --- | --- |
56
+ | 0 | This document; contract audit above | baseline recorded |
57
+ | 1 | `lib/agent/` + `wsl-agent` resident process: NDJSON-over-stdio protocol, capability handshake, lazy start, idle shutdown, crash-rebuild-once, permanent fallback | resident server |
58
+ | 2 | real `watch()` SHIPPED (in-distro poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations (and with it the workaround deletions) continues alongside Phase 3 | native file semantics, inotify — watch + publication done |
59
+ | 3 | DONE — `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved (deviation: no streaming, no spill files) | native exec latency |
60
+ | 4 | DONE (except the agent/plugin version handshake, which lands with the first agent-behaviour change after release) — `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it | open-and-it-works |
61
+ | 5 | DONE (routing half) — per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); activity-based idle reclaim of distro terminals remains a documented limitation (PTY activity stops at `wsl.exe`) | terminal per window/session — routing done |
62
+ | 6 | DONE — `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
63
+ | 7 | DONE — recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
64
+
65
+ ## Fallback invariant
66
+
67
+ Every phase must keep `wsl.agent: 'off'` (and, per capability, any runtime
68
+ fallback) behaviourally identical to the pre-phase release. Probes assert
69
+ this: `agent.sh` runs the same operation with the agent off and on and
70
+ compares outcomes, not internals.
@@ -0,0 +1,60 @@
1
+ # Peer parity — the fsio replication, verified
2
+
3
+ The agent substrate's text mechanics (`lib/fsio-text.js`) and its provider
4
+ orchestration messages (`lib/fs-substrate.js`, `lib/fsio-agent.js`) are a
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.
8
+
9
+ - Peer: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
10
+ `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.
15
+
16
+ ## Verdict
17
+
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.
21
+
22
+ ## Verified identical
23
+
24
+ | Item | Evidence (peer `lib/index.js`) | Ours |
25
+ |---|---|---|
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 |
41
+
42
+ ## Deliberate divergences (declared, not drift)
43
+
44
+ 1. **Distro-side version re-verification.** A `replace` write carrying an
45
+ expected version is re-checked by the agent against a fresh stat one
46
+ syscall before the rename (`FS_STALE_VERSION` on mismatch). The peer's
47
+ check-then-write window stops at the host; ours narrows to kernel-adjacent.
48
+ 2. **Per-stream truncation flags.** The EXEC frame carries an output budget;
49
+ 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.
52
+ 3. **The transport around the ops** (ACK, KILL, the confined resident) is
53
+ protocol this package owns; the peer has no agent.
54
+
55
+ ## Maintenance
56
+
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.
package/docs/README.zh.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm](https://img.shields.io/npm/v/dsh-plugin-wsl-env)](https://www.npmjs.com/package/dsh-plugin-wsl-env)
7
7
  [![license](https://img.shields.io/npm/l/dsh-plugin-wsl-env)](../LICENSE)
8
8
 
9
- 这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 发行版当作工作环境:命令在发行版里执行,模型的文件工具读写发行版里的真实文件,文件夹选择器可以直接打开发行版目录,GUI 终端也开在发行版里。
9
+ 这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 子系统当作工作环境:命令在子系统里执行,模型的文件工具读写子系统里的真实文件,文件夹选择器可以直接打开子系统目录,GUI 终端也开在子系统里。
10
10
 
11
11
  **只支持 Windows + WSL2。** 安装只需一条命令,插件自身没有依赖。环境按会话生效:打开 Windows 文件夹的会话继续使用原来的 Windows 环境。
12
12
 
@@ -26,9 +26,9 @@ 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
+ 然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
30
30
 
31
- 还需要在发行版里安装 **bubblewrap**:`sudo apt install bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
31
+ 还需要在子系统里安装 **bubblewrap**:运行 `npm run bootstrap -- <子系统> --install`(只读检测去掉 `--install`),或直接执行 `wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
32
32
 
33
33
  卸载:`dsh plugin --profile wsl remove dsh-plugin-wsl-env`。升级:再执行一次同样的 `add` 命令。
34
34
 
@@ -36,12 +36,13 @@ dsh --profile wsl # 4. 启动
36
36
 
37
37
  ## 使用
38
38
 
39
- 把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在发行版里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
39
+ 把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在子系统里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
40
40
 
41
- - **发行版文件夹自动获得发行版环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
42
- - **命令**以 `wsl.exe -d <发行版> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
43
- - **文件就是发行版的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
44
- - **终端**(右侧栏 → *New terminal*)在发行版里打开会话目录下的 shell。
41
+ - **子系统文件夹自动获得子系统环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
42
+ - **命令**以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
43
+ - **文件就是子系统的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
44
+ - **终端**(右侧栏 → *New terminal*)在子系统里打开会话目录下的 shell。
45
+ - **端口可见性**:子系统里监听中的端口经 `DSH_WSL_PORTS` 暴露给模型(约每 10 秒刷新);子系统内启动的 dev server 可由模型直接告知确切 URL(WSL2 的 localhost 转发是平台行为,Windows 侧直接可达)。
45
46
  - **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL` 和 `DSH_WSL_HOME`。
46
47
  - **权限与 Linux 主机一致。** 权限选择器在 `read-only`、`workspace-write`(默认)和 `danger-full-access` 之间切换。被拒绝的命令或写入会带回"放宽权限"的提议;批准后,这一次调用不加沙箱执行。
47
48
 
@@ -51,44 +52,53 @@ dsh --profile wsl # 4. 启动
51
52
 
52
53
  | 行 | 键 | 默认值 | 说明 |
53
54
  |---|---|---|---|
54
- | `wsl-shell` | `distro` | `''` | 发行版名;空表示用 WSL 的默认发行版 |
55
+ | `wsl-shell` | `distro` | `''` | 子系统名;空表示用 WSL 的默认子系统 |
55
56
  | | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
56
57
  | `wsl-fs` | `distro` | `''` | 同上 |
57
- | | `restrictToDistro` | `true` | 拒绝指向**其它发行版**共享的路径;`/mnt/c` 属于本发行版内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
58
+ | | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
58
59
  | | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
60
+ | | `substrate` | `agent` | 文件工具使用哪种 I/O 基座:`agent` —— 常驻子系统内代理,读写与路径解析直接在 ext4 上完成(原生符号链接、原生权限位,写入 guard 存活到发布时刻,受限策略下由内核强制);`share` —— 退出项:Windows 侧宿主文件栈走 9p 共享。两种基座使用同一策略围栏;差异详见 [LIMITATIONS.md](LIMITATIONS.md) |
59
61
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
60
- | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个发行版 |
62
+ | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
61
63
 
62
64
  [`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`resolveSymlinks`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
63
65
 
64
66
  ## 沙箱
65
67
 
66
- 命令由**发行版内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
68
+ 命令由**子系统内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
67
69
 
68
- | 模式 | 发行版里的命令可以做什么 |
70
+ | 模式 | 子系统里的命令可以做什么 |
69
71
  |---|---|
70
- | `read-only` | 整个发行版只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
72
+ | `read-only` | 整个子系统只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
71
73
  | `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
72
74
  | `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
73
75
 
74
76
  **bubblewrap 是必需项,而且失败是关闭的。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
75
77
 
76
- **上报的强制程度是 `partial` 而不是 `full`。** 发行版里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
78
+ **上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
77
79
 
78
80
  设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
79
81
 
82
+ ## 配方
83
+
84
+ - **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
85
+ `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
86
+ (路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
87
+ - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
88
+ - **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
89
+
80
90
  ## 常见问题
81
91
 
82
92
  | 现象 | 原因 | 处理 |
83
93
  |---|---|---|
84
- | 每条命令都报 `SANDBOX_UNAVAILABLE` | 发行版里没有 `bubblewrap` | 执行 `sudo apt install bubblewrap`,或在两个 provider 上都设 `sandbox: false` |
94
+ | 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap` | 执行 `npm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false` |
85
95
  | 命令或写入在会话目录之外被拒绝 | `workspace-write` 的预期行为 | 接受工具给出的放宽权限提示,或把会话直接开在需要的目录上 |
86
96
  | 连工作区内的写入也被拒绝 | 会处在 `read-only` 模式 | 切换权限选择器 |
87
97
  | `dsh plugin add` 提示"没有激活任何层" | 依赖之前已经装过,`add` 没有需要记录的内容 | 先执行 `dsh plugin --profile wsl remove dsh-plugin-wsl-env`,再装一次 |
88
98
  | 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
89
99
  | 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
90
- | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在发行版内开发时用运行时镜像,见[开发](#开发) |
91
- | `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用发行版内的工具 |
100
+ | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
101
+ | `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用子系统内的工具 |
92
102
  | 终端活动显示 `unknown` | 官方 shell 集成只对 POSIX 主机上直接启动的 `bash`/`zsh` 生效 | 关闭标签页以释放进程;空闲回收不会对它触发 |
93
103
  | 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
94
104
 
@@ -97,10 +107,11 @@ dsh --profile wsl # 4. 启动
97
107
  ```bash
98
108
  npm test # 风格与打包检查、语法检查、单元测试
99
109
  npm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
100
- npm run probe:sandbox # 在发行版里实测 bubblewrap 能约束什么、不能约束什么
101
- npm run probe # 文件系统探针,需要真实发行版(仅 Windows + WSL)
110
+ npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
111
+ npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
102
112
  npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
103
113
  npm run probe:terminal # 通过终端 provider 打开一个 PTY
114
+ npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
104
115
  npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
105
116
  npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
106
117
  npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
@@ -0,0 +1,87 @@
1
+ # Upstream proposal: export the fsio module from `dsh-fs-local`
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.
7
+
8
+ ## The change
9
+
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:
14
+
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
+ ```
32
+
33
+ ```diff
34
+ --- a/packages/fs/fs-local/lib/index.js
35
+ +++ b/packages/fs/fs-local/lib/index.js
36
+ @@
37
+ -export { LocalFileSystem, LocalFileSystem as default };
38
+ +export {
39
+ + LocalFileSystem,
40
+ + LocalFileSystem as default,
41
+ + applyLiteralEdit,
42
+ + detectLineEndings,
43
+ + normalizeLineEndings,
44
+ + restoreLineEndings,
45
+ +};
46
+ ```
47
+
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.)
52
+
53
+ ## Why the plugin needs it
54
+
55
+ `dsh-plugin-wsl-env`'s agent substrate (`lib/fsio-agent.js`,
56
+ `lib/fs-substrate.js`) re-runs fsio's orchestration inside a WSL distro: reads,
57
+ writes, identities and edits execute where the files live, and the host side
58
+ 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.
65
+
66
+ With the export in place the plugin deletes `lib/fsio-text.js` and imports:
67
+
68
+ ```js
69
+ import { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from "@deepseek-ai/dsh-fs-local/fsio";
70
+ ```
71
+
72
+ ## Why this is safe upstream
73
+
74
+ - No new API surface beyond re-exporting functions that already exist and are
75
+ documented in `lib/types/fsio.d.ts`.
76
+ - 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.
80
+
81
+ ## Follow-up (separate proposal, not required)
82
+
83
+ A larger seam — parameterizing `LocalFileSystem`'s fsio call sites so a
84
+ subclass can substitute an I/O substrate — would let integrations inherit the
85
+ whole backend instead of re-running its orchestration. The export above is the
86
+ prerequisite and is sufficient on its own; the parameterization is worth
87
+ discussing only after a consumer exists in the wild.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Confined residents: one long-lived agent per (distro, policy) pair, running
3
+ * its WHOLE lifetime inside the bwrap profile that policy asks for — so every
4
+ * filesystem op it performs is enforced by the kernel's mount table, not just
5
+ * by the host-side check that chose it.
6
+ *
7
+ * This is stage two of the substrate's enforcement story. Stage one routed
8
+ * every op through the plain agent with the policy check deciding what may
9
+ * pass; stage two routes *mutations* to a confined resident whose view of the
10
+ * filesystem already matches the granted rights, so a bug in the check cannot
11
+ * become a write outside the workspace — the kernel refuses it.
12
+ *
13
+ * Keying is the whole design: the profile is fixed for an agent's lifetime,
14
+ * so a change in the policy (the Permissions selector, a session move) simply
15
+ * addresses a different key, and the previous resident retires through its own
16
+ * idle timer. Escalated writes (`danger-full-access`) keep addressing the
17
+ * plain agent, which is what an approved escalation means.
18
+ *
19
+ * The argv it hands the transport is a whole command — program, profile, `--` —
20
+ * assembled by the peer-free `lib/bwrap.js`, which both confinement sites share.
21
+ * That import is why this module needs no DSH peer either: a bare checkout can
22
+ * construct the factory and assert the command it produces, which is what
23
+ * `test/agent.test.mjs` does. It used to import the sandbox's own profile
24
+ * builder and assemble the prefix itself, missing the program; the probe inlined
25
+ * a third copy, so nothing compared production's command with a working one and
26
+ * every confined mutation failed the resident's handshake.
27
+ *
28
+ * @module dsh-plugin-wsl/agent-confined
29
+ */
30
+ import { fileURLToPath } from "node:url";
31
+ import { bwrapArgvPrefix } from "./bwrap.js";
32
+ import { toLinuxPath } from "./paths.js";
33
+ import { WslAgent } from "./agent.js";
34
+
35
+ const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
36
+ const confinedAgents = new Map();
37
+
38
+ /**
39
+ * The confined agent for one distro and one resolved policy, created on first
40
+ * use and shared for the process lifetime. A success is remembered; a failed
41
+ * start is not (the agent's own rebuild rules take over from there).
42
+ *
43
+ * @param {object} request - the confinement request.
44
+ * @param {string} request.distro - the distro to run in.
45
+ * @param {string} [request.wslPath] - the `wsl.exe` path.
46
+ * @param {{mode: string, workspaceRoot: string}} request.policy - the RESOLVED
47
+ * file-effect policy: the profile binds exactly what the mode grants.
48
+ * @param {boolean} [request.maskWindowsDrive] - shadow `/mnt` with an empty
49
+ * tmpfs in the resident's profile; part of the cache key, since it changes
50
+ * the mount table the resident lives under.
51
+ * @returns {WslAgent} the confined resident for this key.
52
+ */
53
+ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false }) {
54
+ const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}`;
55
+ let agent = confinedAgents.get(key);
56
+ if (agent === undefined) {
57
+ agent = new WslAgent({
58
+ distro,
59
+ wslPath,
60
+ scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
61
+ // The prefix is a whole command from the distro's point of view — program,
62
+ // profile, `--` — because the transport inserts it between
63
+ // `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
64
+ // `spawnDefault`). Both confinement sites share that assembly in
65
+ // `lib/bwrap.js`, whose module doc records what happened when they did not.
66
+ argvPrefix: bwrapArgvPrefix(
67
+ { mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
68
+ { maskWindowsDrive },
69
+ ),
70
+ });
71
+ confinedAgents.set(key, agent);
72
+ }
73
+ return agent;
74
+ }
75
+
76
+ /** Test hook: forget every confined resident (the plain agent has no factory state). */
77
+ export function resetConfinedAgents() {
78
+ confinedAgents.clear();
79
+ }
80
+
81
+ export default confinedAgent;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The error a {@link WslAgent} rejects with when the resident process is out
3
+ * for this host process — the executor's cue to fall back to the one-shot
4
+ * `wsl.exe` path. Deliberately its own class: callers must not confuse
5
+ * "the agent cannot serve" with a failure of the command itself.
6
+ *
7
+ * @module dsh-plugin-wsl/agent-errors
8
+ */
9
+ export class AgentUnavailableError extends Error {
10
+ /**
11
+ * @param {string} message - why the agent is out, for the log.
12
+ */
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "AgentUnavailableError";
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The request's workdir could not be entered inside the distro. Carries the
21
+ * Linux path so a caller can synthesize the same diagnostic the one-shot
22
+ * `wsl.exe` path produces from the relay's `chdir(...) failed` text.
23
+ */
24
+ export class CwdError extends Error {
25
+ /**
26
+ * @param {string} linuxPath - the path that could not be entered.
27
+ */
28
+ constructor(linuxPath) {
29
+ super(`could not enter working directory "${linuxPath}"`);
30
+ this.name = "CwdError";
31
+ this.linuxPath = linuxPath;
32
+ }
33
+ }