dsh-plugin-wsl-env 0.15.0 → 0.17.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 (61) hide show
  1. package/CHANGELOG.md +144 -15
  2. package/CONTRIBUTING.md +25 -33
  3. package/README.md +10 -10
  4. package/SECURITY.md +6 -4
  5. package/SUPPORT.md +8 -8
  6. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  7. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  8. package/agent/wsl-agent.sh +51 -12
  9. package/cordis.patch.yml +23 -10
  10. package/docs/ARCHITECTURE.md +56 -41
  11. package/docs/COMPATIBILITY.md +121 -0
  12. package/docs/CONFIGURATION.md +275 -273
  13. package/docs/LIMITATIONS.md +114 -38
  14. package/docs/MIGRATION.md +16 -13
  15. package/docs/PARITY.md +4 -3
  16. package/docs/PERFORMANCE.md +11 -9
  17. package/docs/QUICKSTART.md +46 -21
  18. package/docs/README.zh.md +7 -7
  19. package/docs/RELEASING.md +257 -0
  20. package/docs/REQUIREMENTS.zh.md +77 -0
  21. package/docs/ROADMAP-1.0.md +105 -61
  22. package/docs/ROADMAP.md +15 -9
  23. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +3 -3
  24. package/docs/UPSTREAM-FSIO-EXPORT.md +7 -7
  25. package/docs/UPSTREAM-OPEN-IN-APP.md +57 -0
  26. package/docs/UPSTREAM-SESSION-NOTICE.md +4 -4
  27. package/docs/UPSTREAM-TERMINAL-TITLE.md +10 -12
  28. package/docs/VALUE.zh.md +144 -0
  29. package/docs/adr/0002-native-agent.md +348 -0
  30. package/docs/root-fs-routing.md +186 -0
  31. package/docs/screenshots/picker.png +0 -0
  32. package/docs/screenshots/preview.png +0 -0
  33. package/docs/screenshots/sandbox.png +0 -0
  34. package/docs/screenshots/terminal.png +0 -0
  35. package/lib/agent-exec.d.ts +7 -3
  36. package/lib/agent-protocol.d.ts +18 -5
  37. package/lib/agent-protocol.js +11 -6
  38. package/lib/agent-shared.js +1 -0
  39. package/lib/agent.d.ts +28 -9
  40. package/lib/agent.js +74 -14
  41. package/lib/fs-routing.d.ts +2 -1
  42. package/lib/fs-routing.js +13 -1
  43. package/lib/fs-substrate.d.ts +11 -9
  44. package/lib/fs-substrate.js +6 -9
  45. package/lib/fsio-agent.d.ts +2 -3
  46. package/lib/fsio-agent.js +2 -3
  47. package/lib/git-exec.d.ts +117 -0
  48. package/lib/git-exec.js +105 -0
  49. package/lib/git-route.d.ts +43 -13
  50. package/lib/git-route.js +54 -15
  51. package/lib/index.d.ts +20 -13
  52. package/lib/index.js +18 -11
  53. package/lib/session-runtime.d.ts +5 -3
  54. package/lib/session-runtime.js +5 -4
  55. package/lib/subprocess.d.ts +21 -7
  56. package/lib/subprocess.js +63 -9
  57. package/locale/en.json +1 -1
  58. package/locale/zh.json +1 -1
  59. package/package.json +13 -4
  60. package/scripts/diagnose.sh +1 -1
  61. package/scripts/doctor.mjs +23 -3
@@ -0,0 +1,144 @@
1
+ # dsh-plugin-wsl-env 价值说明
2
+
3
+ 本文只回答一个问题:**在 Windows 上用 DeepSeek Harness 做 WSL 项目,为什么值得装这个插件。**
4
+ 功能、安装与逐项行为见 [README](../README.md) 和 [ARCHITECTURE.md](ARCHITECTURE.md),这里谈收益、代价与适用边界。
5
+ 文中每条主张都指向仓库内可复核的证据;基线为 v0.9.0(2026-10-17)。
6
+
7
+ > **一句话**:它把 WSL 发行版变成 DSH 会话真正的执行环境——命令、文件、终端、目录选择器都在发行版内完成,
8
+ > 同时保留 DSH 的权限模型和发行版内的 bubblewrap 沙箱;不往发行版里装自己的服务,也不开端口。
9
+
10
+ ## 1. 没有它时,三处错位
11
+
12
+ DSH 是 Windows 桌面应用,项目在 WSL 的 ext4 里。不做集成,三件事会同时错位:
13
+
14
+ | 错位 | 具体表现 | 后果 |
15
+ |---|---|---|
16
+ | 位置 | 应用只认 `\\wsl.localhost\<distro>\...`,工具却期待 `/home/...` | 路径要来回翻译;9p 共享在大量小文件时明显慢;符号链接与权限位经过共享层后不再保真 |
17
+ | 环境 | 命令在 Windows 侧执行 | 发行版的 `PATH`、`nvm`、`cargo`、`pyenv`、rc 配置、原生 `rg` 都用不上;脚本要么找不到解释器,要么静默用了另一个二进制 |
18
+ | 安全 | Windows 沙箱的受限令牌到不了 WSL(`wsl.exe` 返回 `Wsl/E_ACCESSDENIED`) | 「能操作 WSL」和「命令有边界」通常只能二选一 |
19
+
20
+ 现成的选择各有代价:把 CLI 整个装进 WSL 最干净,但 DSH 是桌面应用,搬不进去;桌面远程(VS Code Remote-WSL、ZCode 一类)成熟,但要在发行版里放一个服务目录、开一个端口,且命令没有沙箱;什么都不做,就是上面三处错位。
21
+ 本插件补的是这三者之外的组合:**应用留在 Windows,执行世界放进发行版,边界留在命令外面。**
22
+
23
+ ## 2. 六条价值
24
+
25
+ ### 价值一:第一条命令就在正确的环境里
26
+
27
+ **机制。** 会话打开发行版目录时,`auto-preset` 自动绑上 `wsl` preset;`wsl-shell` 用发行版配置的登录 shell(不写死 bash)执行 `wsl.exe --exec <shell> -lc <cmd>`。`PATH`、版本管理器、rc 文件因此全部生效。
28
+
29
+ **可感知的差别。** 不需要先 `source` 什么,也不需要把 `nvm`/`pyenv` 的路径手工告诉模型。模型说「跑一下测试」,用的就是你在那个终端里会用的 `node`、`pnpm`、`cargo`。第一条命令已经是对的,不存在「先热身一条命令把环境带起来」。
30
+
31
+ **证据。** [README「Using it」](../README.md#using-it);`test/shell.test.mjs`、`test/preset-choice.test.mjs`;`pnpm run probe:sandbox-shell`。
32
+
33
+ ### 价值二:文件是发行版的真实文件
34
+
35
+ **机制。** 读、写、搜索都经常驻 agent 落在 ext4 上:原生符号链接、原生 mode 位、发行版自己的 `rg`。GUI 文件树与预览同样由发行版内部提供。搜索参数逐字转发给发行版的 `rg`,结果、输出格式、退出码都是 rg 自己的,且遵守该目录的 `.gitignore`。
36
+
37
+ **可感知的差别。** `chmod +x`、`ln -s`、git hook、按内容分发的工具,行为与 Linux 主机一致;`glob`/`grep` 不再跨 9p 慢桥,也不再用 Windows 侧那份 rg 去读 Linux 目录。文件夹里的 `/home/you/x` 与 GUI 里的 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,不是副本。
38
+
39
+ **证据。** [ARCHITECTURE「The filesystem substrate」](ARCHITECTURE.md#the-filesystem-substrate)、[LIMITATIONS](LIMITATIONS.md);`test/search-route.test.mjs`、`test/search-exec.test.mjs`、`test/fs-substrate.test.mjs`;`pnpm run probe:substrate`(8/8)。已知极限见第 4 节:新文件 0600、含反斜杠的文件名、watch 为轮询。
40
+
41
+ ### 价值三:沙箱是默认,失败是响亮的
42
+
43
+ **机制。** 一个会话的受限命令在同一个 systemd user scope 内经 `bubblewrap` mount profile 执行(后台进程、真实 `/tmp`、`ps` 可见),scope 随会话结束停止;三档权限(`read-only` / `workspace-write` / `danger-full-access`)逐档生效;文件写入按同一份策略检查,受限策略下的写入还由内核在 bwrap 配置内二次拒绝。没有可用的 bwrap 时,命令报 `SANDBOX_UNAVAILABLE` 而不是脱箱运行。
44
+
45
+ **可感知的差别。** 受限命令和写入受到工作区策略约束,但这不是完整隔离:Windows interop 等途径仍可能越过 Linux 侧的写入边界。被拒绝时,模型收到一次「用刚好够用的权限重试」的提议,而不是直接要 `danger-full-access`。审计时能拿到当时实际执行的模式和拒绝原因;在显式 pin 发行版的配置里,文件工具遇到跨发行版路径时由 `FS_OUTSIDE_DISTRO` 拒绝,且放宽权限也解不开。
46
+
47
+ **诚实边界。** 上报是 `enforcement: partial`,因为发行版内的进程仍可通过 interop 启动 Windows 程序;`maskWindowsDrive` 收窄这个洞(`/mnt` 在受限视图里消失),但 binfmt 按内容分发,所以它仍不是闭合;完全关闭要在发行版层面设 `[interop] enabled=false`。插件不把 partial 说成 full。
48
+
49
+ **证据。** [ARCHITECTURE「Sandbox」](ARCHITECTURE.md#sandbox)、[SECURITY](../SECURITY.md);`test/sandbox.test.mjs`、`test/fs-decisions.test.mjs`;`pnpm run probe:sandbox`、`pnpm run probe:sandbox-off`。
50
+
51
+ ### 价值四:一个进程,两种会话,工具箱不变
52
+
53
+ **机制。** 组合层按路径坐标路由:发行版 UNC 路径交给 agent 实现,盘符路径留给宿主后端;GUI 终端按会话路由,WSL 目录会话得到发行版 shell,Windows 目录会话仍是 PowerShell;`wsl-shell`/`wsl-fs` 挂在 preset 层,所以环境按会话生效。
54
+
55
+ **可感知的差别。** 不需要为 WSL 单开一个应用或 profile,也不需要把整个应用切成「WSL 模式」。同一个窗口里,开 Windows 文件夹的会话不受影响,开 `\\wsl.localhost\...` 的会话自动进入发行版。换项目只是换一个打开的文件夹。模型侧尤其重要:插件不新增任何模型工具,现有工具的名字、参数、返回语义不变,变的只是它们实际作用的世界。
56
+
57
+ **证据。** [README「Architecture」](../README.md#architecture)、[docs/root-fs-routing.md](root-fs-routing.md);`test/composition.test.mjs`、`test/terminal.test.mjs`、`test/fs.boot.test.mjs`。
58
+
59
+ ### 价值五:不装服务、不开端口、没有残留
60
+
61
+ **机制。** 发行版里没有安装任何服务,也没有监听端口;插件只替换 DSH 的服务实现。文件操作与命令走发行版内按 distro 缓存的常驻 agent——它是传输层,不注册系统服务,agent 脚本在握手时校验 sha256。
62
+
63
+ **可感知的差别。** 不会多出 `~/.vscode-server` 这类目录,不会和本机端口冲突,卸载后发行版里没有需要手动清理的服务端目录。WSL 升级或发行版重启后,下一次调用重建传输;只有 agent 在「有请求在飞」时死亡才可能耗尽它的一次重建额度(见 [LIMITATIONS](LIMITATIONS.md)),这是已声明的边界,不是日常路径。
64
+
65
+ **注意区分。** 「不装服务」不等于「零依赖」:bubblewrap 是默认路径的硬依赖;会话 runtime 还需要 systemd 为 PID 1 且 user manager 已 `enable-linger`(临时 scope,不是安装的常驻服务)。ripgrep、git、inotify-tools 由 `pnpm run bootstrap` 体检。插件不把这些工具打包进来,也不在发行版里安装常驻服务。
66
+
67
+ **证据。** [README「How it compares」](../README.md#how-it-compares);`agent/wsl-agent.sh`、`test/agent.test.mjs`;`scripts/bootstrap.sh`。
68
+
69
+ ### 价值六:模型拿到事实,不用猜
70
+
71
+ **机制。** 会话环境向模型暴露 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL`、`DSH_WSL_HOME`、`DSH_WSL_PORTS`;端口按约 10 秒的节奏刷新。错误码按抛出者分门别类(`FS_OUTSIDE_DISTRO`、`SANDBOX_UNAVAILABLE`、`FS_STALE_VERSION` 等),每条都说明谁拒绝、怎么解除。会话打开时若缺工具,直接给出该发行版的安装命令。
72
+
73
+ **可感知的差别。** 模型能说出它启动的 dev server 的确切地址(WSL2 把 localhost 转发给 Windows,浏览器可直接打开);路径拒绝、沙箱缺失、文件过期各有各的码,排错不用从「命令失败了」开始猜。这减少的是往返次数,不只是错误信息的好看程度。
74
+
75
+ **证据。** [ARCHITECTURE「Error codes」](ARCHITECTURE.md#error-codes)、[README「Troubleshooting」](../README.md#troubleshooting);`test/ports.test.mjs`、`test/preflight.test.mjs`、`test/terminal.test.mjs`。
76
+
77
+ ## 3. 和替代方案的价值对照
78
+
79
+ | | 不做集成(Windows 侧开 UNC) | 桌面远程(VS Code Remote-WSL 等) | 把 CLI 装进 WSL | 本插件 |
80
+ |---|---|---|---|---|
81
+ | 命令环境 | Windows | 发行版 | 发行版 | 发行版 |
82
+ | 文件语义 | 9p 共享,慢且丢 POSIX 细节 | 发行版内,原生 | 发行版内,原生 | 发行版内,原生(ext4) |
83
+ | 命令沙箱 | Windows 沙箱到不了 WSL | 无 | 取决于 CLI | 发行版内 bubblewrap + systemd 会话 scope |
84
+ | 发行版内安装 | 无 | 一个服务目录 | 整个工具 | 无自有服务(依赖系统工具) |
85
+ | 端口 | 无 | 监听一个端口 | 无 | 无 |
86
+ | 卸载残留 | 无 | 手动删服务目录 | 删工具 | 无 |
87
+ | 适用前提 | 能接受慢与语义丢失 | 用 VS Code / 对应桌面端 | 工具能整个搬进 WSL | Windows + DSH GUI + WSL2 |
88
+
89
+ **别人更强的地方要直说。** 桌面远程重开工作区是即时的,服务常驻;VS Code 的远程有多年生产打磨。CLI 装进 WSL 则完全不需要桥接。本插件不试图在这些维度取胜,它只解决一个组合:**DSH 在 Windows 上,项目在 WSL 里,命令要有沙箱。**
90
+
91
+ ## 4. 代价、边界与不适用
92
+
93
+ 价值是收益减代价。下面这些是安装前就该知道的:
94
+
95
+ - **bubblewrap 是硬依赖。** 默认路径没有它,每条受限命令都失败(这是有意的 fail-closed);受限的文件写入同样需要它。会话 runtime 还要求 systemd 为 PID 1、user manager 已 `enable-linger`;预检失败会带修复命令拒绝。`pnpm run bootstrap <distro>` 会体检并给出安装命令。
96
+ - **enforcement 是 partial。** interop 让发行版内的进程仍能启动 Windows 程序。`maskWindowsDrive` 只是收窄;完全关闭要靠 `wsl.conf` 的 `[interop] enabled=false`。需要「对 Windows 侧也绝对隔离」的场景,本插件给不了。
97
+ - **两套 `/tmp`。** `workspace-write` 下,会话 runtime 中的命令与文件工具使用发行版真实的 `/tmp`;关闭会话 runtime 后,命令使用私有 tmpfs,文件工具仍使用真实 `/tmp`,两者互相看不见。跨边界的文件要走会话工作区。
98
+ - **watch 优先 inotify,轮询回退。** 无界目录且装了 `inotify-tools` 时用递归 inotify,按 0.5s 合并为粗粒度失效;文件目标、限深目录或缺少工具时回退轮询,原地 `cp -p`、恢复目录 mtime 的解包等极端情形仍可能漏报。
99
+ - **文件名含反斜杠无法寻址**,因为 UNC 显示形式就是身份。改名是唯一工作法。
100
+ - **新文件发布为 0600**(宿主后端的发布语义);需要可执行时自己 `chmod`。写入的发布对进程死亡持久,但不 fsync 父目录,机器崩溃可能丢 rename。
101
+ - **平台与版本。** 仅 Windows + WSL2;Node `^22.19.0 || >=24.0.0`;DSH peer 声明版本下限,开发依赖精确钉版,升级须重新验证。
102
+ - **不适合的情况。** 工作流本来就能整个装进 WSL 的 CLI;需要 VS Code 远程的编辑器能力(IntelliSense、调试器、扩展);需要企业级强隔离或多租户;项目主要在 Windows 盘上(插件能用,但价值不在这里)。
103
+
104
+ 这些不是「以后会修」的清单,而是设计边界:每一条都在 [LIMITATIONS.md](LIMITATIONS.md) 里写明原因。**把边界写清楚本身也是价值**——它让「没做到」不被误读成「已经做到」。
105
+
106
+ ## 5. 价值如何被证明
107
+
108
+ | 层面 | 证据 |
109
+ |---|---|
110
+ | 每次提交 | `pnpm test`:风格、语法、构建逐字节一致、`tsc --noEmit`、单测全过 |
111
+ | 覆盖率 | `pnpm run test:coverage`,门限 85/84/70(行/分支/函数) |
112
+ | 真机 | 探针套件(`test/probe/run-all-when-closed.sh --include-fs`),含沙箱边界演示、agent/one-shot 双路径对拍、文件基座、终端、选择器 |
113
+ | CI | Node 22 / 24 × Linux / Windows 跑 `pnpm test`;覆盖率在 Node 24 |
114
+ | 上游接缝 | peer 钉版逐字节比对,[PEER-PARITY.md](PEER-PARITY.md)、[upstream/promotion-map.md](https://github.com/Big-Dao/dsh-plugin-wsl-env/blob/main/docs/upstream/promotion-map.md) |
115
+ | 行为承诺 | [REQUIREMENTS.zh.md](REQUIREMENTS.zh.md) 的 24 条登记与追溯矩阵 |
116
+
117
+ 真机探针是手动步骤(托管 runner 没有 WSL 发行版),这是已记录的验证缺口(REQUIREMENTS 的 OI-2),不是隐性遗漏。
118
+
119
+ ## 6. 决策规则
120
+
121
+ **装它,如果同时满足:**
122
+
123
+ 1. 你在 Windows 上用 DeepSeek Harness 的 GUI;
124
+ 2. 项目在 WSL2 发行版的 ext4 上,且你希望模型用 Linux 路径工作;
125
+ 3. 你希望命令有边界,并且接受为它安装 bubblewrap。
126
+
127
+ **别装它,如果满足任意一条:**
128
+
129
+ - 你的工作流是一款能整个装进 WSL 的 CLI——直接装进去更简单;
130
+ - 你需要编辑器级远程能力(调试、IntelliSense、扩展);
131
+ - 你需要对 Windows interop 也绝对隔离;
132
+ - 你的项目主要在 Windows 盘上,且不需要 Linux 工具链。
133
+
134
+ ## 7. 证据索引
135
+
136
+ | 想知道 | 看 |
137
+ |---|---|
138
+ | 功能、安装、配置 | [README](../README.md) / [中文 README](README.zh.md) |
139
+ | 部件如何挂载、路径坐标、沙箱设计 | [ARCHITECTURE.md](ARCHITECTURE.md) |
140
+ | 不做什么、为什么 | [LIMITATIONS.md](LIMITATIONS.md) |
141
+ | 每个配置键 | [CONFIGURATION.md](CONFIGURATION.md) |
142
+ | 与 VS Code Remote-WSL 的 parity 审计 | [PARITY.md](PARITY.md) |
143
+ | 与上游 fsio 的逐字节合同 | [PEER-PARITY.md](PEER-PARITY.md) |
144
+ | 安全边界与上报渠道 | [SECURITY.md](../SECURITY.md) |
@@ -0,0 +1,348 @@
1
+ # ADR 0002 — Native in-distro agent (design)
2
+
3
+ - **Status:** accepted — the native agent is the default session-runtime
4
+ backend (`agentBackend: auto` + the packaged static-musl binary for the
5
+ distro's architecture), with the shipped shell agent as the fallback and the
6
+ `native`/`shell` overrides. The concurrency/KILL/FS surface, the copy/verify,
7
+ the HELLO digest, the packaged x86_64/aarch64 binaries and the real-machine
8
+ probe are in place. **A7 is in place** (the shared reads are native); **A8
9
+ is in place** (aarch64 18-check under qemu-user in CI; physical aarch64 WSL
10
+ is out of scope); **v6 is deferred** — the measured lock-removal gain is
11
+ marginal, and A7 removed the shared-shell batch bottleneck
12
+ - **Date:** 2026-10-18
13
+ - **Related:** [ADR 0001](https://github.com/Big-Dao/dsh-plugin-wsl-env/blob/main/docs/adr/0001-session-scoped-sandbox.md) (session runtime),
14
+ `agent/wsl-agent.sh`, `src/agent.ts`, `src/agent-protocol.ts`
15
+
16
+ ## Context
17
+
18
+ This section records the shell-only baseline and original design motivation.
19
+ The status and migration sections describe what was subsequently shipped;
20
+ v6 structured errors and concurrent FS remain deferred.
21
+
22
+ The resident peer inside the distro is a POSIX shell script,
23
+ `agent/wsl-agent.sh`. It speaks protocol v5 over the `wsl.exe` stdio pipe:
24
+ line-oriented, `|`-separated, base64 payloads, `HELLO`/`PING`/`SHUTDOWN`/
25
+ `KILL`/`SETENV`/`EXEC`/`FS`. It is the single owner of commands and the
26
+ filesystem substrate; the session runtime (ADR 0001) runs one instance per
27
+ session/policy under a systemd user scope.
28
+
29
+ The script has served well and is covered by `agent.test.mjs`, the protocol
30
+ tests, and the real-machine probes. Its hard edges are real though:
31
+
32
+ 1. **Serial requests.** The stdin loop reads one frame, runs it to completion,
33
+ then reads the next. Commands and confined file writes share the session
34
+ runtime agent, so an `npm run build` blocks an editor save on the same
35
+ session. Reads use the separate plain agent, but writes do not.
36
+ 2. **Cancellation needs a second transport.** While the shell is blocked in
37
+ `wait`, a `KILL` line sits in the pipe. The host spawns a second `wsl.exe`
38
+ and sends `USR1` to the `HELLO` pid; inside a PID namespace that is
39
+ impossible, so confined residents are not signalable at all.
40
+ 3. **Capture is shell machinery.** Bounded capture is a FIFO plus a `dd`
41
+ reader, a one-second kill grace, and a watchdog; it works, but it is the
42
+ least obvious code in the repository and every edge case costs a probe.
43
+ 4. **Protocol evolution is constrained.** FS failures ride a `dsh-fs|reason|
44
+ base64` line on stderr, because a POSIX sh response line has no structured
45
+ error slot. New features (event streaming, concurrent FS, per-request
46
+ environment) would stretch base64-over-sh further.
47
+ 5. **Lockstep releases.** Host and script must ship the same v5. A stale
48
+ deployed script is refused by digest, which is correct but means every
49
+ protocol change is a synchronized deploy and an app restart.
50
+
51
+ The original design deferred implementation because a binary adds build,
52
+ distribution, architecture and supply-chain costs. Subsequent measurements
53
+ justified shipping the native agent as described below.
54
+
55
+ ## Goals
56
+
57
+ - **Concurrency.** Multiple `EXEC`/`FS` requests in flight, each with its own
58
+ id, output buffers, deadline and process group; a long command no longer
59
+ blocks a write.
60
+ - **Precise cancellation.** `KILL` is read by the event loop while the command
61
+ runs; the request's process group is signalled directly. No `USR1`, no second
62
+ `wsl.exe`, no `signalable` special case.
63
+ - **Native capture.** `pipe` + bounded buffers per request; no FIFO, no `dd`,
64
+ no kill grace. Truncation is a byte count, not a shell heuristic.
65
+ - **Structured errors.** FS refusals become `ERR|id|reason|message` frames; the
66
+ stderr side channel is retired in the protocol version that introduces them.
67
+ - **A path to events.** A native agent can host inotify and stream watch
68
+ events to the host later, instead of the separate watcher process.
69
+ - **Same security boundary.** The agent still executes exactly the argv the
70
+ host wrapped in `bwrap`; confinement, the session scope and the FS fence are
71
+ unchanged.
72
+
73
+ ## Non-goals
74
+
75
+ - Replacing `wsl.exe` as the transport, `bwrap` as the sandbox, the systemd
76
+ user scope as the session lifetime, or the host-side FS fence.
77
+ - A Windows-side native component. The host remains Node.
78
+ - Building the agent from source on the user's machine. The distro is not
79
+ required to have a compiler.
80
+ - Retiring the shell agent. It remains the portable fallback (and the only
81
+ agent on architectures without a shipped binary).
82
+ - File watching, port scanning or terminal streaming in the first native
83
+ version; those stay where they are until the event path exists.
84
+
85
+ ## Options
86
+
87
+ ### Language
88
+
89
+ | Option | Strengths | Costs |
90
+ |---|---|---|
91
+ | **Rust** (recommended) | static musl binary, no GC, strong parsing, precise process-group/timer control; the host protocol is simple to mirror | cross-compile toolchain in CI; slightly slower iteration |
92
+ | **Go** | easiest cross-compilation, static binary, simple concurrency | larger binary, GC pauses (irrelevant at this scale), fewer compile-time guarantees for the parser |
93
+ | **C** | smallest, universally available | no memory safety; the parser and frame lifetimes become manual; not worth it for this project |
94
+
95
+ The recommendation is Rust with `x86_64-unknown-linux-musl` and
96
+ `aarch64-unknown-linux-musl`, built in CI from a committed `Cargo.lock`.
97
+
98
+ ### Distribution
99
+
100
+ | Option | Verdict |
101
+ |---|---|
102
+ | **Prebuilt binaries in the npm package**, one per Linux arch (recommended) | no network at runtime, pinned by the host, offline-installable; the package grows by a few MB per arch |
103
+ | Download on first use | rejected: a plugin install must not fetch and execute a remote binary |
104
+ | Build on first use in the distro | rejected: requires a compiler and network; the plugin's premise is no in-distro install |
105
+ | Ship only the shell | current state; acceptable until a trigger fires |
106
+
107
+ The binary runs from a **copy in the distro**, not from `/mnt/c`: DrvFs is
108
+ often mounted `noexec`, and executing from the share couples the agent's
109
+ lifetime to the Windows path. The host copies
110
+ `agent/native/linux-<arch>/dsh-wsl-agent` from the plugin package to
111
+ `~/.cache/dsh-wsl/agent/<sha256>/dsh-wsl-agent`, verifies the digest, marks it
112
+ executable, and caches per digest. The session runtime's `--ro-bind /` makes
113
+ the copy readable; `temp` is not used, so the binary survives scope restarts.
114
+
115
+ ### Protocol
116
+
117
+ Start from **v5 compatibility for the spike**, then introduce **v6** for the
118
+ features that require it:
119
+
120
+ - **v5 native** (spike): the same `HELLO|wsl-agent|5|<digest>|<pid>` and the
121
+ same frames; the host's existing codecs and the existing `agent.test.mjs`
122
+ run unchanged. This proves the parser, exec, capture and FS ops. The digest
123
+ is the binary's sha256.
124
+ - **v6** (the concurrency release):
125
+ - `HELLO|wsl-agent|6|<sha256>|<pid>|<impl>` with `impl = native|shell`.
126
+ - FS errors in `ERR|id|reason|b64 message`; the `dsh-fs|` stderr line is no
127
+ longer produced by the native agent.
128
+ - Drop `SETENV` as a sticky global (or keep it only for the single-request
129
+ compatibility path): with concurrent requests a sticky environment is a
130
+ race. Environment travels on the request (a count + base64 key/value
131
+ lines) or is fixed by the `HELLO` process environment plus the host's
132
+ bwrap wrapper.
133
+ - `KILL|id` is handled by the event loop; the host stops spawning the
134
+ `USR1` helper for native agents.
135
+ - Optional `ACK`/`RES` streaming for large outputs is a later revision; the
136
+ first v6 keeps one RES per request.
137
+ - Concurrency limits: a small per-agent cap (e.g. 8 in flight), a queue, and
138
+ per-path serialization for FS writes so two writers do not race inside the
139
+ agent. The host's per-target substrate lock already serializes its own
140
+ operations; the agent's lock is defense in depth.
141
+
142
+ Handshake identity: the host accepts `wsl-agent|5` for the shell and
143
+ `wsl-agent|6|native` for the binary; the digest is checked against the file
144
+ the host shipped (script or binary). A mismatch refuses with the existing
145
+ "resync" remedy, never silently runs a stale peer.
146
+
147
+ ## Process model
148
+
149
+ One process per agent, an event loop over stdin plus child pipes and timers:
150
+
151
+ - `EXEC`/`FS` frames are decoded, validated and queued; the event loop
152
+ acknowledges them (`ACK|id`) and spawns each request's process group (or
153
+ performs the FS syscalls directly for `stat`/`read`/`write`).
154
+ - `KILL|id` and the per-request deadline signal the group: `TERM`, then
155
+ `KILL` after a fixed grace; the event loop keeps serving other requests.
156
+ - stdout/stderr pipes are drained into per-request bounded buffers; the RES
157
+ line carries the exit code, both buffers and the cut flags.
158
+ - stdin EOF, `SHUTDOWN` or a fatal IO error runs the same cleanup as today:
159
+ kill in-flight groups, remove staging directories, exit 0.
160
+ - The agent is the session scope's main process. The scope's cgroup still
161
+ reaps background processes at session end; the agent adds no new lifecycle.
162
+ - FS operations use syscalls (stat/lstat/readlink/read/write, atomic rename);
163
+ no `sh`, `base64`, `stat`, `find` subprocesses. The write path keeps the
164
+ no-replace and expected-version semantics the host fence relies on, and the
165
+ same staging-directory cleanup for killed writes.
166
+
167
+ ## Host integration
168
+
169
+ - `agent-shared.ts` / the session runtime's factory chooses the backend:
170
+ - native when a binary for the distro architecture is present in the
171
+ package and the copy/verify succeeds;
172
+ - the shell otherwise.
173
+ - A config key (`agentBackend?: "auto" | "native" | "shell"`, default `auto`)
174
+ keeps a one-session escape hatch during rollout.
175
+ - The copy/verify step is host-side JavaScript over `wsl.exe --exec` (read the
176
+ Windows file through `/mnt/c`, write with `dd`/`cat` to the cache path,
177
+ sha256sum, chmod); it is cached by digest, so the cost is once per version
178
+ per distro user.
179
+ - `WslAgent` already carries ids, pending requests, `HELLO` digest checks and
180
+ the lease; the native agent reuses that surface. The `USR1`/`signalable`
181
+ path becomes a shell-only detail; native agents are cancelled by frame.
182
+ - The FS substrate's `DistroFs` maps `ERR|id|reason` to `FsCodedError` for
183
+ v6; v5 native keeps the stderr path so the existing tests are unchanged.
184
+
185
+ ## Security
186
+
187
+ - The binary is committed/built by CI, baked into the npm package, and pinned
188
+ by sha256 in the host; it is never downloaded at runtime.
189
+ - The protocol parser is fuzzed (frames, base64, path lengths, counts) and
190
+ never invokes a shell for request content. `EXEC` argv is the host's argv;
191
+ the agent does not interpret it.
192
+ - The agent runs as the distro user, inside the same bwrap mount profile and
193
+ systemd scope; it has no privilege the shell agent did not have.
194
+ - The copy path is the user's cache, owned by the user; a world-writable
195
+ directory is refused; the digest is re-checked before exec.
196
+
197
+ ## Testing
198
+
199
+ - **Protocol suite**: the existing `agent-protocol` unit tests stay authoritative
200
+ for both codecs; v6 adds error/env/encoding cases.
201
+ - **Agent suite**: `agent.test.mjs` parameterized over `{ shell, native }`.
202
+ The shell tests already drive the real script on Linux; the native tests do
203
+ the same with the built binary.
204
+ - **Differential matrix**: every frame shape and failure mode run against both
205
+ agents, comparing exit codes, stdout/stderr bytes, cut flags and FS errors
206
+ (allowing the v5→v6 error-channel difference where expected).
207
+ - **Concurrency suite** (new): two EXECs; EXEC + FS write; KILL one request
208
+ while another runs; timeout one; verify the untouched request's result.
209
+ - **Probes**: the real-machine suite and the session runtime; the FS-first
210
+ session; a long command with a concurrent write (the shell's known stall is
211
+ the native agent's acceptance test).
212
+ - **Fuzzing**: the frame decoder and the path/args validation.
213
+
214
+ ## Migration
215
+
216
+ 1. **Design (this ADR).** No code.
217
+ 2. **Spike (partial).** HELLO/PING/SETENV/EXEC/KILL, per-request process
218
+ groups, the FS surface and the expected-version/mode matrix are done in
219
+ `agent/native/spike` (18/18 manual checks, ~673 KB release binary). The
220
+ host wiring, the packaged static-musl x86_64/aarch64 binaries and the
221
+ default rollout are in place; v6 remains deferred. aarch64 runs under
222
+ qemu in CI (A8).
223
+ 3. **v6 (deferred).** The A6 measurement shows the lock-removal gain is
224
+ marginal (~2-3 ms per 16 ops) and the shared shell read agent is the batch
225
+ bottleneck. v6 stays unimplemented; A7 addresses the measured bottleneck
226
+ instead. Revisit v6 only if the native shared v5 still queues measurably or
227
+ the ERR/env framing is needed for another reason.
228
+ 4. **Host integration (partial).** The copy/verify, the session-runtime factory,
229
+ the `wsl-shell.agentBackend` selection, the packaged static-musl x86_64 and
230
+ aarch64 binaries (`agent/native/linux-<arch>/dsh-wsl-agent`, selected by the
231
+ distro's `uname -m`) and the running-binary HELLO digest are in place. The
232
+ native-session probe (8/8: scope, EXEC, FS, KILL, digest, teardown) is in
233
+ the real-machine suite; the default rollout is 0.10.0.
234
+ 5. **Enabled by default** on x86_64 and aarch64 (0.10.0); the shell fallback
235
+ and the flag stay. Retiring the shell is not planned; the shell remains the
236
+ fallback and the shared read path until A7.
237
+ 6. **Native shared read agent (A7, done).** The shared unconfined read agent
238
+ selects the native binary (v5) with the shell as fallback; the A6 probe is
239
+ the acceptance measurement (~30x on batch reads).
240
+ 7. **aarch64 functional validation (A8, done).** The CI `native packages` job
241
+ runs the 18-check suite under qemu-user against the freshly built aarch64
242
+ musl binary and the committed `agent/native/linux-aarch64/dsh-wsl-agent`.
243
+ Physical aarch64 WSL is out of scope ([COMPATIBILITY.md](../COMPATIBILITY.md)).
244
+
245
+ ## Spike result (stage 2, partial)
246
+
247
+ `agent/native/spike/` is a std-only Rust crate that implements `HELLO`,
248
+ `PING`, `SHUTDOWN`, `SETENV`, `EXEC` and `KILL` with per-request process
249
+ groups and bounded capture, plus `FS` `stat`/`lstat`/`read`/`write` (mode,
250
+ no-replace, replace, expected-version re-check, staging + fsync + atomic
251
+ publication) and `list`/`realpath` (nearest-existing-ancestor walk, dot-dot
252
+ refusal). The `dsh-fs` side channel rides the RES stderr. FS operations are
253
+ serialized inside the agent for the v5 error channel; EXECs run concurrently
254
+ with them.
255
+
256
+ `agent/native/spike/test.mjs` (run manually against the built binary) passes
257
+ 18/18: HELLO with the running binary's sha256; PING; EXEC exit/streams; SETENV; two concurrent EXECs with the
258
+ short one settling first; KILL without a second `wsl.exe`; a 200 ms timeout
259
+ killing the group promptly; the EXEC timeout unit (a 2 s budget does not kill
260
+ a 300 ms command — an earlier millisecond reading killed every longer
261
+ command); a capped output that is cut while the pipe is still drained; FS
262
+ stat/lstat/read matching the shell agent; a no-replace write and its refusal;
263
+ the missing-path side channel; FS list/realpath matching the shell; the
264
+ dot-dot refusal; the expected-version replace/stale path; a setuid mode; and a
265
+ shell-agent differential for the EXEC shape. The release binary is ~673 KB on
266
+ x86_64-unknown-linux-gnu and builds in ~2 s with no dependencies.
267
+
268
+ What this proves: the event loop removes the shell's serial-request stall and
269
+ the `USR1`/second-transport cancellation shape is unnecessary, and the core
270
+ FS surface (stat/lstat/read/write/list/realpath, version re-check, modes) can
271
+ be reproduced in the native process. What it does not prove: a native-session
272
+ probe on physical aarch64 WSL (qemu covers the 18-check suite in CI), v6
273
+ concurrency/env framing, or the event stream. The host copy/verify, the
274
+ session-runtime factory, the packaged binaries and the default are in place;
275
+ the shell agent remains the fallback and the shipped peer.
276
+
277
+ ## FS queue measurement (A6, 2026-10-18)
278
+
279
+ `test/probe/fs-queue.mjs` measures both FS agents on the live machine
280
+ (x86_64, 16 concurrent requests per row):
281
+
282
+ | path | wall | queue max | per-op exec |
283
+ |---|---|---|---|
284
+ | shared shell `stat` | 342 ms | 324 ms | ~20 ms |
285
+ | shared shell `read` | 418 ms | 387 ms | ~25 ms |
286
+ | shared shell `list` | 614 ms | 583 ms | ~37 ms |
287
+ | native `write` | 7.7 ms | 6.6 ms | 0.3 ms |
288
+ | native `read` | 12.2 ms | 9.4 ms | 0.2 ms |
289
+ | native `EXEC` (50 ms each) | 88 ms | 7.5 ms | 5.5 ms |
290
+
291
+ The **shared shell read agent is the batch-read bottleneck**: its serial loop
292
+ turns 16 concurrent reads into 342-614 ms with queue waits up to 583 ms. Native
293
+ FS execution is 0.2-0.3 ms; the sequential per-op is dominated by the ~22 ms
294
+ host round trip (the shell's sequential stat is 37.5 ms, the native's 22 ms).
295
+
296
+ **v6's lock removal is marginal**: it would take 16 native writes from 7.7 ms
297
+ to roughly 5 ms and 16 reads from 12.2 ms to roughly 8 ms. The high-value next
298
+ step is the **native shared read agent (v5)** — same packaged binary and
299
+ protocol, the shell as fallback, roughly 30x on batch reads — not v6. The
300
+ single-op latency (~22 ms) is the transport, not the FS backend.
301
+
302
+ ## Why it was built
303
+
304
+ Built in 0.10.0. The triggers that fired were the long-build/FS-serialization
305
+ pressure and the opportunity to make the session runtime native without
306
+ changing the shipped shell contract; the shell agent remains the fallback. The
307
+ conditions below are kept as the record of the decision:
308
+
309
+ - A long build reliably blocks model file writes often enough to be a reported
310
+ problem (the shell's serial loop).
311
+ - A feature genuinely needs the event loop: in-agent inotify/event streaming,
312
+ concurrent FS, per-request environment, output streaming.
313
+ - The shell agent causes a second maintenance incident (a capture/cancel edge
314
+ the probes did not cover).
315
+ - The distribution cost falls (e.g. the release pipeline already ships
316
+ per-arch binaries and provenance).
317
+
318
+ Until then the shell agent is the honest, dependency-free choice; the
319
+ limitations in `docs/LIMITATIONS.md` remain the declared boundary.
320
+
321
+ ## Consequences
322
+
323
+ - **Staying with the shell:** no binary supply chain; the serial write stall,
324
+ the `USR1` cancellation shape and the shell capture machinery remain; every
325
+ protocol change stays a lockstep host+script release.
326
+ - **Going native:** concurrency and precise cancellation, simpler capture and
327
+ structured errors; in exchange for per-arch binaries, a copy/verify step, a
328
+ v6 protocol migration, a dual-implementation test matrix, and a larger
329
+ security review surface.
330
+
331
+ ## Open questions
332
+
333
+ - v6 JSON instead of line/base64? The line protocol is proven and the host
334
+ parser is small; JSON would need a sh-free host but is easier to extend.
335
+ The spike should decide after measuring the v5-native parser work.
336
+ - Per-request environment: the v5 host prefixes each command with `env`, so
337
+ its variables do not mutate the agent. The low-level `SETENV` frame remains
338
+ sticky for protocol compatibility; a future v6 can remove it.
339
+ - FS path locking and concurrent write semantics (same path, two requests):
340
+ the host's per-target lock is the current answer; does the agent need its
341
+ own?
342
+ - Output streaming for very large captures: RES-only keeps MAX_FRAME_BYTES;
343
+ a chunked revision is later.
344
+ - ARM64 CI capacity; the aarch64 binary could ship after the x64 default.
345
+ - Whether the native agent hosts the watcher/inotify and the port scan, or
346
+ keeps those as separate host-driven paths.
347
+ - Binary size/package impact; whether both arches ship in one npm tarball or
348
+ the package selects at install.